Files
openclaw/src/agents/embedded-agent-subscribe.tools.ts
Peter Steinberger bb46b79d3c refactor: internalize OpenClaw agent runtime (#85341)
* refactor: extract agent core package

Introduce packages/agent-core as the OpenClaw-owned home for reusable agent loop, harness, session, prompt, and runtime dependency contracts.

* refactor: extract shared llm runtime

Move provider model registries, stream wrappers, OAuth helpers, and LLM utilities into src/llm with plugin-sdk barrels instead of depending on the old embedded runtime layout.

* refactor: remove pi runtime internals

Rename remaining Pi-shaped agent surfaces to OpenClaw agent runtime names, delete obsolete Pi docs and package graph checks, and add the third-party notice for incorporated code.

* refactor: tighten agent session runtime

Make agent-core/runtime dependencies explicit, consolidate compaction and session transcript helpers, and move model/session helpers behind OpenClaw-owned contracts.

* refactor: remove static model and pi auth paths

Drop static model catalogs and Pi auth bridges, move model/provider facts to manifest-owned runtime contracts, and harden internal embedded-agent utilities.

* refactor: remove legacy provider compat paths

* docs: remove agent parity notes

* fix: skip provider wildcard metadata parsing

* refactor: share session extension sdk loading

* refactor: inline acpx proxy error formatter

* refactor: fold edit recovery into edit tool

* fix: accept extension batch separator

* test: align startup provider plugin expectations

* fix: restore provider-scoped release discovery

* test: align static asset packaging expectations

* fix: run static provider catalogs during scoped discovery

* fix: add provider entry catalogs for scoped live discovery

* fix: load lightweight provider catalog entries

* fix: refresh provider-scoped plugin metadata

* fix: keep provider catalog entries on release live path

* fix: keep static manifest models in release live checks

* fix: harden release model discovery

* fix: reduce OpenAI live cache probe reasoning

* fix: disable OpenAI cache probe reasoning

* ci: extend OpenAI gateway live timeout

* fix: extend live gateway model budget

* fix: stabilize release validation regressions

* fix: honor provider aliases in model rows

* fix: stabilize release validation lanes

* fix: stabilize release memory qa

* ci: stabilize release validation lanes

* ci: prefer ipv4 for live docker node calls

* fix: restore shared tool-call stream wrapper

* ci: remove legacy pi test shard alias

* fix: clean up embedded agent test drift

* fix: stabilize runtime alias status

* fix: clean up embedded agent ci drift

* fix: restore release ci invariants

* fix: clean up post-rebase runtime drift

* fix: restore release ci checks

* fix: restore release ci after rebase

* fix: remove stale pi runtime path

* test: align compaction runtime expectations

* test: update plugin prerelease expectations

* fix: handle claude live tool approvals

* fix: stabilize release validation gates

* fix: finish agent runtime import

* test: finish post-rebase agent runtime mocks

* fix: keep codex compaction native

* fix: stabilize codex app-server hook tests

* test: isolate codex diagnostic active run

* test: remove codex diagnostic completion race

# Conflicts:
#	extensions/codex/src/app-server/run-attempt.test.ts

* ci: fix full release manifest performance run id

* refactor: narrow llm plugin sdk boundary

* chore: drop generated google boundary stamps

* fix: repair rebase fallout

* fix: clean up rebased runtime references

* fix: decode codex jwt payloads as base64url

* fix: preserve shipped pi runtime alias

* fix: add scoped sdk virtual modules

* fix: decode llm codex oauth jwt as base64url

* fix: avoid stale vertex adc negative cache

* fix: harden tool arg decoding and codeql path

* fix: keep vertex adc negative checks live

* refactor: consolidate codex jwt and edit helpers

* fix: await codex oauth node runtime imports

* fix: preserve sdk tool and notice contracts

* fix: preserve shipped compat config boundaries

* fix: align codex oauth callback host

* fix: terminate agent-core loop streams on failure

* fix: keep codex oauth callback alive during fallback

* ci: include session tools in critical codeql scans

* fix: keep Cloudflare Anthropic provider auth header

* docs: redirect legacy pi runtime pages

* fix: honor bundled web provider compat discovery

* fix: protect session output spill files

* fix: keep legacy agent dir env blocked

* fix: contain auto-discovered skill symlinks

* fix: harden agent core sdk proxy surfaces

* fix: restore approval reaction sdk compat

* fix: keep live docker runs bounded

* fix: keep codex oauth redirect host aligned

* fix: resolve post-rebase agent runtime drift

* fix: redact anthropic oauth parse failures

* fix: preserve responses strict tool shaping

* fix: repair agent runtime rebase cleanup

* docs: redirect retired parity pages

* fix: bound auto-discovered resources to roots

* fix: repair post-rebase agent test drift

* fix: preserve bundled provider allowlist migration

* fix: preserve manifest-owned provider aliases

* fix: declare photon image dependency

* fix: keep provider headers out of proxy body

* fix: preserve shipped env aliases

* fix: refresh control ui i18n generated state

* fix: quote read fallback paths

* fix: preview edits through configured backend

* test: satisfy core test typecheck

* fix: preserve ZAI usage auth fallback

* test: repair codex diagnostic test

* fix: repair agent runtime rebase drift

* test: finish embedded runner import rename

* fix: repair agent runtime rebase integrations

* test: align compaction oauth fallback expectations

* fix: allow sdk-auth session models

* fix: update doctor tool schema import

* fix: preserve bedrock plugin region

* fix: stream harmony-like prose immediately

* ci: include session runtime in codeql shards

* fix: repair latest rebase integrations

* fix: honor explicit codex websocket transport

* fix: keep openai-compatible credentials provider-scoped

* fix: refresh sdk api baseline after rebase

* fix: route cli runtime aliases through openclaw harness

* test: rename stale harness mock expectation

* test: rename embedded agent overflow calls

* test: clean embedded auth test wording

* test: use openclaw stream types in deepinfra cache test

* fix: refresh sdk api baseline on latest main

* fix: honor bundled discovery compat allowlists

* fix: refresh sdk api baseline after latest rebase

* fix: remove stale rebase imports

* test: rename stale model catalog mock

* test: mock renamed doctor runtime modules

* fix: map canonical kimi env auth

* fix: use internal model registry in bench script

* fix: migrate deepinfra provider catalog entry

* fix: enforce builtin tool suppression

* fix: route compaction auth and proxy payloads safely

* refactor: prune unused llm registry leftovers

* test: update codex hooks session import

* test: fix model picker ci coverage

* test: align model picker auth mock types
2026-05-27 19:24:04 +01:00

705 lines
21 KiB
TypeScript

import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js";
import { redactSensitiveFieldValue, redactToolPayloadText } from "../logging/redact.js";
import { splitMediaFromOutput } from "../media/parse.js";
import { asOptionalRecord as readRecord } from "../shared/record-coerce.js";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
readStringValue,
} from "../shared/string-coerce.js";
import { uniqueStrings } from "../shared/string-normalization.js";
import { truncateUtf16Safe } from "../utils.js";
import { collectTextContentBlocks } from "./content-blocks.js";
import { isMessageToolSendActionName } from "./embedded-agent-messaging.js";
import type { MessagingToolSend } from "./embedded-agent-messaging.types.js";
import { normalizeToolName } from "./tool-policy.js";
const TOOL_RESULT_MAX_CHARS = 8000;
const TOOL_ERROR_MAX_CHARS = 400;
const TOOL_DENIAL_ERROR_CODES = ["SYSTEM_RUN_DENIED", "INVALID_REQUEST"] as const;
function truncateToolText(text: string): string {
if (text.length <= TOOL_RESULT_MAX_CHARS) {
return text;
}
return `${truncateUtf16Safe(text, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`;
}
function normalizeToolErrorText(text: string): string | undefined {
const trimmed = text.trim();
if (!trimmed) {
return undefined;
}
const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? "";
if (!firstLine) {
return undefined;
}
return firstLine.length > TOOL_ERROR_MAX_CHARS
? `${truncateUtf16Safe(firstLine, TOOL_ERROR_MAX_CHARS)}`
: firstLine;
}
function isErrorLikeStatus(status: string): boolean {
const normalized = normalizeOptionalLowercaseString(status);
if (!normalized) {
return false;
}
if (
normalized === "0" ||
normalized === "ok" ||
normalized === "success" ||
normalized === "completed" ||
normalized === "running"
) {
return false;
}
return /error|fail|timeout|timed[_\s-]?out|denied|cancel|invalid|forbidden/.test(normalized);
}
function readErrorCandidate(value: unknown): string | undefined {
if (typeof value === "string") {
return normalizeToolErrorText(value);
}
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Record<string, unknown>;
if (typeof record.message === "string") {
return normalizeToolErrorText(record.message);
}
if (typeof record.error === "string") {
return normalizeToolErrorText(record.error);
}
return undefined;
}
function extractErrorField(value: unknown): string | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Record<string, unknown>;
const direct = extractDirectErrorField(record);
if (direct) {
return direct;
}
const status = normalizeOptionalString(record.status) ?? "";
if (!status || !isErrorLikeStatus(status)) {
return undefined;
}
return normalizeToolErrorText(status);
}
function extractDirectErrorField(value: unknown): string | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Record<string, unknown>;
return (
readErrorCandidate(record.error) ??
readErrorCandidate(record.message) ??
readErrorCandidate(record.reason)
);
}
function readErrorCodeField(value: unknown): string | undefined {
return typeof value === "string" ? normalizeOptionalString(value) : undefined;
}
function readDenialErrorCodeFromMessage(value: unknown): string | undefined {
const message = typeof value === "string" ? normalizeOptionalString(value) : undefined;
if (!message) {
return undefined;
}
for (const code of TOOL_DENIAL_ERROR_CODES) {
if (message === code || message.startsWith(`${code}:`)) {
return code;
}
}
return undefined;
}
function readNestedErrorCodeField(value: unknown): string | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Record<string, unknown>;
return (
readDenialErrorCodeFromMessage(record.message) ??
readDenialErrorCodeFromMessage(record.error) ??
readErrorCodeField(record.code) ??
readErrorCodeField(record.gatewayCode)
);
}
function extractDirectErrorCodeField(value: unknown): string | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Record<string, unknown>;
return (
readNestedErrorCodeField(record.error) ??
readNestedErrorCodeField(record.nodeError) ??
readErrorCodeField(record.code) ??
readErrorCodeField(record.gatewayCode)
);
}
export function buildToolLifecycleErrorResult(error: unknown): {
details: Record<string, unknown>;
} {
const errorRecord = readRecord(error);
const rawDetails = readRecord(errorRecord?.details);
const nodeError = readRecord(rawDetails?.nodeError);
const gatewayCode =
readErrorCodeField(errorRecord?.gatewayCode) ?? readErrorCodeField(errorRecord?.code);
const message = error instanceof Error ? error.message : String(error);
return {
details: {
status: "error",
error: message,
...(gatewayCode ? { gatewayCode } : {}),
...(nodeError ? { nodeError } : {}),
},
};
}
function extractAggregatedErrorField(value: unknown): string | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Record<string, unknown>;
return readErrorCandidate(record.aggregated);
}
function redactStringsDeep(value: unknown, seen = new WeakSet<object>()): unknown {
if (typeof value === "string") {
return redactToolPayloadText(value);
}
if (Array.isArray(value)) {
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
return value.map((item) => redactStringsDeep(item, seen));
}
if (value && typeof value === "object") {
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
const out: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
out[key] =
typeof child === "string"
? redactSensitiveFieldValue(key, child)
: redactStringsDeep(child, seen);
}
return out;
}
return value;
}
export function sanitizeToolArgs(args: unknown): unknown {
return redactStringsDeep(args);
}
export function sanitizeToolResult(result: unknown): unknown {
if (typeof result === "string") {
return redactToolPayloadText(result);
}
if (Array.isArray(result)) {
return redactStringsDeep(result);
}
if (!result || typeof result !== "object") {
return result;
}
const record = result as Record<string, unknown>;
// Strip image data first so the deep redaction pass doesn't waste work
// scanning base64 payloads (and so we capture the original byte counts).
const preCleaned: Record<string, unknown> = { ...record };
const originalContent = Array.isArray(record.content) ? record.content : null;
if (originalContent) {
preCleaned.content = originalContent.map((item) => {
if (!item || typeof item !== "object") {
return item;
}
const entry = item as Record<string, unknown>;
if (readStringValue(entry.type) === "image") {
const data = readStringValue(entry.data);
const bytes = data ? data.length : undefined;
const cleaned = { ...entry };
delete cleaned.data;
return Object.assign({}, cleaned, { bytes, omitted: true });
}
return entry;
});
}
// Deep-redact the entire result so any top-level or nested string is
// protected, not just `details` and text content blocks.
const baseline = redactStringsDeep(preCleaned) as Record<string, unknown>;
const out: Record<string, unknown> = { ...baseline };
const content = Array.isArray(baseline.content) ? baseline.content : null;
if (content) {
out.content = content.map((item) => {
if (!item || typeof item !== "object") {
return item;
}
const entry = item as Record<string, unknown>;
if (readStringValue(entry.type) === "text" && typeof entry.text === "string") {
return Object.assign({}, entry, { text: truncateToolText(entry.text) });
}
return entry;
});
}
return out;
}
export function extractToolResultText(result: unknown): string | undefined {
if (!result || typeof result !== "object") {
return undefined;
}
const record = result as Record<string, unknown>;
const texts = collectTextContentBlocks(record.content)
.map((item) => {
const trimmed = item.trim();
return trimmed ? trimmed : undefined;
})
.filter((value): value is string => Boolean(value));
if (texts.length === 0) {
return undefined;
}
return texts.join("\n");
}
// Core tool names that are allowed to emit local MEDIA: paths. Plugin tools
// must be explicitly passed as trusted run-local names by the caller.
const TRUSTED_TOOL_RESULT_MEDIA = new Set([
"agents_list",
"apply_patch",
"browser",
"canvas",
"cron",
"edit",
"exec",
"gateway",
"image",
"image_generate",
"memory_get",
"memory_search",
"message",
"music_generate",
"nodes",
"process",
"read",
"session_status",
"sessions_history",
"sessions_list",
"sessions_send",
"sessions_spawn",
"subagents",
"tts",
"video_generate",
"web_fetch",
"web_search",
"x_search",
"write",
]);
const HTTP_URL_RE = /^https?:\/\//i;
export function isCoreToolResultMediaTrustedName(toolName?: string): boolean {
if (!toolName) {
return false;
}
return TRUSTED_TOOL_RESULT_MEDIA.has(normalizeToolName(toolName));
}
function readToolResultDetails(result: unknown): Record<string, unknown> | undefined {
if (!result || typeof result !== "object") {
return undefined;
}
const record = result as Record<string, unknown>;
return record.details && typeof record.details === "object" && !Array.isArray(record.details)
? (record.details as Record<string, unknown>)
: undefined;
}
function readToolResultStatus(result: unknown): string | undefined {
const status = readToolResultDetails(result)?.status;
return normalizeOptionalLowercaseString(status);
}
function isExternalToolResult(result: unknown): boolean {
const details = readToolResultDetails(result);
if (!details) {
return false;
}
return typeof details.mcpServer === "string" || typeof details.mcpTool === "string";
}
export function isToolResultMediaTrusted(
toolName?: string,
result?: unknown,
trustedLocalMediaToolNames?: ReadonlySet<string>,
): boolean {
if (!toolName || isExternalToolResult(result)) {
return false;
}
const registeredName = toolName.trim();
if (registeredName && trustedLocalMediaToolNames?.has(registeredName) === true) {
return true;
}
return isCoreToolResultMediaTrustedName(toolName);
}
function isTrustedOwnedTtsLocalMedia(
toolName: string | undefined,
result: unknown,
trustedLocalMediaToolNames?: ReadonlySet<string>,
): boolean {
if (
!toolName ||
!isToolResultMediaTrusted(toolName, result, trustedLocalMediaToolNames) ||
normalizeToolName(toolName) !== "tts"
) {
return false;
}
const media = readToolResultDetails(result)?.media;
if (!media || typeof media !== "object" || Array.isArray(media)) {
return false;
}
return (media as Record<string, unknown>).trustedLocalMedia === true;
}
export function filterToolResultMediaUrls(
toolName: string | undefined,
mediaUrls: string[],
result?: unknown,
trustedLocalMediaToolNames?: ReadonlySet<string>,
): string[] {
if (mediaUrls.length === 0) {
return mediaUrls;
}
const trustedOwnedTtsLocalMedia = isTrustedOwnedTtsLocalMedia(
toolName,
result,
trustedLocalMediaToolNames,
);
if (isToolResultMediaTrusted(toolName, result, trustedLocalMediaToolNames)) {
// When the current run provides its exact trusted local-media tool names,
// require the raw emitted tool name to match one of them before allowing
// local MEDIA: paths.
// This blocks normalized aliases and case-variant collisions such as
// "Bash" -> "bash" or "Web_Search" -> "web_search" from inheriting a
// registered tool's media trust. TTS-generated local files carry a
// separate trusted-media flag from the owned tool result, so they can
// survive runs whose exact trusted set omitted the raw tts name.
if (trustedLocalMediaToolNames !== undefined) {
if (!trustedOwnedTtsLocalMedia) {
const registeredName = toolName?.trim();
if (!registeredName || !trustedLocalMediaToolNames.has(registeredName)) {
return mediaUrls.filter((url) => HTTP_URL_RE.test(url.trim()));
}
}
}
return mediaUrls;
}
return mediaUrls.filter((url) => HTTP_URL_RE.test(url.trim()));
}
/**
* Extract media file paths from a tool result.
*
* Strategy (first match wins):
* 1. Read structured `details.media` attachments from tool details.
* 2. Parse `MEDIA:` directive tokens from text content blocks.
* 3. Fall back to `details.path` when image content exists (legacy imageResult).
*
* Returns an empty array when no media is found (e.g. embedded `read` tool
* returns base64 image data but no file path; those need a different delivery
* path like saving to a temp file).
*/
type ToolResultMediaArtifact = {
mediaUrls: string[];
audioAsVoice?: boolean;
trustedLocalMedia?: boolean;
};
function readToolResultDetailsMedia(
result: Record<string, unknown>,
): Record<string, unknown> | undefined {
const details = readToolResultDetails(result);
const media =
details?.media && typeof details.media === "object" && !Array.isArray(details.media)
? (details.media as Record<string, unknown>)
: undefined;
return media;
}
function collectStructuredMediaUrls(media: Record<string, unknown>): string[] {
const urls: string[] = [];
const pushString = (value: unknown) => {
if (typeof value !== "string") {
return;
}
const normalized = value.trim();
if (normalized) {
urls.push(normalized);
}
};
const pushAttachment = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return;
}
const attachment = value as Record<string, unknown>;
pushString(attachment.media);
pushString(attachment.path);
pushString(attachment.url);
pushString(attachment.mediaUrl);
pushString(attachment.filePath);
pushString(attachment.fileUrl);
};
if (typeof media.mediaUrl === "string" && media.mediaUrl.trim()) {
urls.push(media.mediaUrl.trim());
}
if (Array.isArray(media.mediaUrls)) {
for (const value of media.mediaUrls) {
pushString(value);
}
}
if (Array.isArray(media.attachments)) {
for (const attachment of media.attachments) {
pushAttachment(attachment);
}
}
return uniqueStrings(urls);
}
function isNonOutboundToolResultMedia(media: Record<string, unknown>): boolean {
return media.outbound === false;
}
function extractTextContentMediaArtifact(content: unknown[]): {
mediaUrls: string[];
audioAsVoice?: boolean;
hasImageContent: boolean;
} {
const mediaUrls: string[] = [];
let audioAsVoice = false;
let hasImageContent = false;
for (const item of content) {
if (!item || typeof item !== "object") {
continue;
}
const entry = item as Record<string, unknown>;
if (entry.type === "image") {
hasImageContent = true;
continue;
}
if (entry.type !== "text" || typeof entry.text !== "string") {
continue;
}
const parsed = splitMediaFromOutput(entry.text);
if (parsed.audioAsVoice) {
audioAsVoice = true;
}
if (parsed.mediaUrls?.length) {
mediaUrls.push(...parsed.mediaUrls);
}
}
return {
mediaUrls,
...(audioAsVoice ? { audioAsVoice: true } : {}),
hasImageContent,
};
}
export function extractToolResultMediaArtifact(
result: unknown,
): ToolResultMediaArtifact | undefined {
if (!result || typeof result !== "object") {
return undefined;
}
const record = result as Record<string, unknown>;
const detailsMedia = readToolResultDetailsMedia(record);
if (detailsMedia) {
if (isNonOutboundToolResultMedia(detailsMedia)) {
return undefined;
}
const mediaUrls = collectStructuredMediaUrls(detailsMedia);
if (mediaUrls.length > 0) {
return {
mediaUrls,
...(detailsMedia.audioAsVoice === true ? { audioAsVoice: true } : {}),
...(detailsMedia.trustedLocalMedia === true ? { trustedLocalMedia: true } : {}),
};
}
}
const content = Array.isArray(record.content) ? record.content : null;
if (!content) {
return undefined;
}
const textMedia = extractTextContentMediaArtifact(content);
if (textMedia.mediaUrls.length > 0) {
return {
mediaUrls: textMedia.mediaUrls,
...(textMedia.audioAsVoice ? { audioAsVoice: true } : {}),
};
}
// Fall back to legacy details.path when image content exists but no
// structured media details or MEDIA: text.
if (textMedia.hasImageContent) {
const details = record.details as Record<string, unknown> | undefined;
const p = normalizeOptionalString(details?.path) ?? "";
if (p) {
return { mediaUrls: [p] };
}
}
return undefined;
}
export function extractToolResultMediaPaths(result: unknown): string[] {
return extractToolResultMediaArtifact(result)?.mediaUrls ?? [];
}
export function isToolResultError(result: unknown): boolean {
const normalized = readToolResultStatus(result);
if (!normalized) {
return false;
}
return normalized === "error" || normalized === "timeout";
}
export function extractToolErrorCode(result: unknown): string | undefined {
if (!result || typeof result !== "object") {
return undefined;
}
const record = result as Record<string, unknown>;
return extractDirectErrorCodeField(record.details) ?? extractDirectErrorCodeField(record);
}
export function isToolResultTimedOut(result: unknown): boolean {
const normalizedStatus = readToolResultStatus(result);
if (normalizedStatus === "timeout") {
return true;
}
return readToolResultDetails(result)?.timedOut === true;
}
export function extractToolErrorMessage(result: unknown): string | undefined {
if (!result || typeof result !== "object") {
return undefined;
}
const record = result as Record<string, unknown>;
const fromDetails = extractDirectErrorField(record.details);
if (fromDetails) {
return fromDetails;
}
const fromDetailsAggregated = extractAggregatedErrorField(record.details);
if (fromDetailsAggregated) {
return fromDetailsAggregated;
}
const fromRoot = extractDirectErrorField(record);
if (fromRoot) {
return fromRoot;
}
const text = extractToolResultText(result);
if (text) {
try {
const parsed = JSON.parse(text) as unknown;
const fromJson = extractErrorField(parsed);
if (fromJson) {
return fromJson;
}
} catch {
// Fall through to status/text fallback.
}
}
const fromDetailsStatus = extractErrorField(record.details);
if (fromDetailsStatus) {
return fromDetailsStatus;
}
const fromRootStatus = extractErrorField(record);
if (fromRootStatus) {
return fromRootStatus;
}
return text ? normalizeToolErrorText(text) : undefined;
}
function resolveMessageToolTarget(args: Record<string, unknown>): string | undefined {
const toRaw = readStringValue(args.to);
if (toRaw) {
return toRaw;
}
return readStringValue(args.target);
}
export function extractMessagingToolSend(
toolName: string,
args: Record<string, unknown>,
): MessagingToolSend | undefined {
// Provider docking: new provider tools must implement plugin.actions.extractToolSend.
const action = normalizeOptionalString(args.action) ?? "";
const accountId = normalizeOptionalString(args.accountId);
if (toolName === "message") {
if (!isMessageToolSendActionName(action)) {
return undefined;
}
const toRaw = resolveMessageToolTarget(args);
if (!toRaw) {
return undefined;
}
const providerRaw = normalizeOptionalString(args.provider) ?? "";
const channelRaw = normalizeOptionalString(args.channel) ?? "";
const providerHint = providerRaw || channelRaw;
const providerId = providerHint ? normalizeChannelId(providerHint) : null;
const provider = providerId ?? normalizeOptionalLowercaseString(providerHint) ?? "message";
const to = normalizeTargetForProvider(provider, toRaw);
const threadId = normalizeOptionalString(args.threadId);
const threadSuppressed = args.topLevel === true || args.threadId === null;
const threadImplicit =
!threadId &&
!threadSuppressed &&
Boolean(providerId && getChannelPlugin(providerId)?.threading?.resolveAutoThreadId);
return to
? {
tool: toolName,
provider,
accountId,
to,
...(threadId ? { threadId } : {}),
...(threadImplicit ? { threadImplicit: true } : {}),
...(threadSuppressed ? { threadSuppressed: true } : {}),
}
: undefined;
}
const providerId = normalizeChannelId(toolName);
if (!providerId) {
return undefined;
}
const plugin = getChannelPlugin(providerId);
const extracted = plugin?.actions?.extractToolSend?.({ args });
if (!extracted?.to) {
return undefined;
}
const to = normalizeTargetForProvider(providerId, extracted.to);
const threadId = normalizeOptionalString(extracted.threadId);
return to
? {
tool: toolName,
provider: providerId,
accountId: extracted.accountId ?? accountId,
to,
...(threadId ? { threadId } : {}),
}
: undefined;
}