mirror of https://github.com/openclaw/openclaw.git
435 lines
13 KiB
TypeScript
435 lines
13 KiB
TypeScript
import { collectTextContentBlocks } from "../../agents/content-blocks.js";
|
|
import { createOpenClawTools } from "../../agents/openclaw-tools.js";
|
|
import type { BlockReplyChunking } from "../../agents/pi-embedded-block-chunker.js";
|
|
import type { SkillCommandSpec } from "../../agents/skills.js";
|
|
import { applyOwnerOnlyToolPolicy } from "../../agents/tool-policy.js";
|
|
import { getChannelDock } from "../../channels/dock.js";
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
|
import type { SessionEntry } from "../../config/sessions.js";
|
|
import { logVerbose } from "../../globals.js";
|
|
import { generateSecureToken } from "../../infra/secure-random.js";
|
|
import { resolveGatewayMessageChannel } from "../../utils/message-channel.js";
|
|
import {
|
|
listReservedChatSlashCommandNames,
|
|
listSkillCommandsForWorkspace,
|
|
resolveSkillCommandInvocation,
|
|
} from "../skill-commands.js";
|
|
import type { MsgContext, TemplateContext } from "../templating.js";
|
|
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
|
|
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
|
import {
|
|
clearAbortCutoffInSession,
|
|
readAbortCutoffFromSessionEntry,
|
|
resolveAbortCutoffFromContext,
|
|
shouldSkipMessageByAbortCutoff,
|
|
} from "./abort-cutoff.js";
|
|
import { getAbortMemory, isAbortRequestText } from "./abort.js";
|
|
import { buildStatusReply, handleCommands } from "./commands.js";
|
|
import type { InlineDirectives } from "./directive-handling.js";
|
|
import { isDirectiveOnly } from "./directive-handling.js";
|
|
import type { createModelSelectionState } from "./model-selection.js";
|
|
import { extractInlineSimpleCommand } from "./reply-inline.js";
|
|
import type { TypingController } from "./typing.js";
|
|
|
|
let builtinSlashCommands: Set<string> | null = null;
|
|
|
|
function getBuiltinSlashCommands(): Set<string> {
|
|
if (builtinSlashCommands) {
|
|
return builtinSlashCommands;
|
|
}
|
|
builtinSlashCommands = listReservedChatSlashCommandNames([
|
|
"btw",
|
|
"think",
|
|
"verbose",
|
|
"reasoning",
|
|
"elevated",
|
|
"exec",
|
|
"model",
|
|
"status",
|
|
"queue",
|
|
]);
|
|
return builtinSlashCommands;
|
|
}
|
|
|
|
function resolveSlashCommandName(commandBodyNormalized: string): string | null {
|
|
const trimmed = commandBodyNormalized.trim();
|
|
if (!trimmed.startsWith("/")) {
|
|
return null;
|
|
}
|
|
const match = trimmed.match(/^\/([^\s:]+)(?::|\s|$)/);
|
|
const name = match?.[1]?.trim().toLowerCase() ?? "";
|
|
return name ? name : null;
|
|
}
|
|
|
|
export type InlineActionResult =
|
|
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
|
|
| {
|
|
kind: "continue";
|
|
directives: InlineDirectives;
|
|
abortedLastRun: boolean;
|
|
};
|
|
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
function extractTextFromToolResult(result: any): string | null {
|
|
if (!result || typeof result !== "object") {
|
|
return null;
|
|
}
|
|
const content = (result as { content?: unknown }).content;
|
|
if (typeof content === "string") {
|
|
const trimmed = content.trim();
|
|
return trimmed ? trimmed : null;
|
|
}
|
|
const parts = collectTextContentBlocks(content);
|
|
const out = parts.join("");
|
|
const trimmed = out.trim();
|
|
return trimmed ? trimmed : null;
|
|
}
|
|
|
|
export async function handleInlineActions(params: {
|
|
ctx: MsgContext;
|
|
sessionCtx: TemplateContext;
|
|
cfg: OpenClawConfig;
|
|
agentId: string;
|
|
agentDir?: string;
|
|
sessionEntry?: SessionEntry;
|
|
previousSessionEntry?: SessionEntry;
|
|
sessionStore?: Record<string, SessionEntry>;
|
|
sessionKey: string;
|
|
storePath?: string;
|
|
sessionScope: Parameters<typeof buildStatusReply>[0]["sessionScope"];
|
|
workspaceDir: string;
|
|
isGroup: boolean;
|
|
opts?: GetReplyOptions;
|
|
typing: TypingController;
|
|
allowTextCommands: boolean;
|
|
inlineStatusRequested: boolean;
|
|
command: Parameters<typeof handleCommands>[0]["command"];
|
|
skillCommands?: SkillCommandSpec[];
|
|
directives: InlineDirectives;
|
|
cleanedBody: string;
|
|
elevatedEnabled: boolean;
|
|
elevatedAllowed: boolean;
|
|
elevatedFailures: Array<{ gate: string; key: string }>;
|
|
defaultActivation: Parameters<typeof buildStatusReply>[0]["defaultGroupActivation"];
|
|
resolvedThinkLevel: ThinkLevel | undefined;
|
|
resolvedVerboseLevel: VerboseLevel | undefined;
|
|
resolvedReasoningLevel: ReasoningLevel;
|
|
resolvedElevatedLevel: ElevatedLevel;
|
|
blockReplyChunking?: BlockReplyChunking;
|
|
resolvedBlockStreamingBreak?: "text_end" | "message_end";
|
|
resolveDefaultThinkingLevel: Awaited<
|
|
ReturnType<typeof createModelSelectionState>
|
|
>["resolveDefaultThinkingLevel"];
|
|
provider: string;
|
|
model: string;
|
|
contextTokens: number;
|
|
directiveAck?: ReplyPayload;
|
|
abortedLastRun: boolean;
|
|
skillFilter?: string[];
|
|
}): Promise<InlineActionResult> {
|
|
const {
|
|
ctx,
|
|
sessionCtx,
|
|
cfg,
|
|
agentId,
|
|
agentDir,
|
|
sessionEntry,
|
|
previousSessionEntry,
|
|
sessionStore,
|
|
sessionKey,
|
|
storePath,
|
|
sessionScope,
|
|
workspaceDir,
|
|
isGroup,
|
|
opts,
|
|
typing,
|
|
allowTextCommands,
|
|
inlineStatusRequested,
|
|
command,
|
|
directives: initialDirectives,
|
|
cleanedBody: initialCleanedBody,
|
|
elevatedEnabled,
|
|
elevatedAllowed,
|
|
elevatedFailures,
|
|
defaultActivation,
|
|
resolvedThinkLevel,
|
|
resolvedVerboseLevel,
|
|
resolvedReasoningLevel,
|
|
resolvedElevatedLevel,
|
|
blockReplyChunking,
|
|
resolvedBlockStreamingBreak,
|
|
resolveDefaultThinkingLevel,
|
|
provider,
|
|
model,
|
|
contextTokens,
|
|
directiveAck,
|
|
abortedLastRun: initialAbortedLastRun,
|
|
skillFilter,
|
|
} = params;
|
|
|
|
let directives = initialDirectives;
|
|
let cleanedBody = initialCleanedBody;
|
|
|
|
const slashCommandName = resolveSlashCommandName(command.commandBodyNormalized);
|
|
const shouldLoadSkillCommands =
|
|
allowTextCommands &&
|
|
slashCommandName !== null &&
|
|
// `/skill …` needs the full skill command list.
|
|
(slashCommandName === "skill" || !getBuiltinSlashCommands().has(slashCommandName));
|
|
const skillCommands =
|
|
shouldLoadSkillCommands && params.skillCommands
|
|
? params.skillCommands
|
|
: shouldLoadSkillCommands
|
|
? listSkillCommandsForWorkspace({
|
|
workspaceDir,
|
|
cfg,
|
|
skillFilter,
|
|
})
|
|
: [];
|
|
|
|
const skillInvocation =
|
|
allowTextCommands && skillCommands.length > 0
|
|
? resolveSkillCommandInvocation({
|
|
commandBodyNormalized: command.commandBodyNormalized,
|
|
skillCommands,
|
|
})
|
|
: null;
|
|
if (skillInvocation) {
|
|
if (!command.isAuthorizedSender) {
|
|
logVerbose(
|
|
`Ignoring /${skillInvocation.command.name} from unauthorized sender: ${command.senderId || "<unknown>"}`,
|
|
);
|
|
typing.cleanup();
|
|
return { kind: "reply", reply: undefined };
|
|
}
|
|
|
|
const dispatch = skillInvocation.command.dispatch;
|
|
if (dispatch?.kind === "tool") {
|
|
const rawArgs = (skillInvocation.args ?? "").trim();
|
|
const channel =
|
|
resolveGatewayMessageChannel(ctx.Surface) ??
|
|
resolveGatewayMessageChannel(ctx.Provider) ??
|
|
undefined;
|
|
|
|
const tools = createOpenClawTools({
|
|
agentSessionKey: sessionKey,
|
|
agentChannel: channel,
|
|
agentAccountId: (ctx as { AccountId?: string }).AccountId,
|
|
agentTo: ctx.OriginatingTo ?? ctx.To,
|
|
agentThreadId: ctx.MessageThreadId ?? undefined,
|
|
agentDir,
|
|
workspaceDir,
|
|
config: cfg,
|
|
});
|
|
const authorizedTools = applyOwnerOnlyToolPolicy(tools, command.senderIsOwner);
|
|
|
|
const tool = authorizedTools.find((candidate) => candidate.name === dispatch.toolName);
|
|
if (!tool) {
|
|
typing.cleanup();
|
|
return { kind: "reply", reply: { text: `❌ Tool not available: ${dispatch.toolName}` } };
|
|
}
|
|
|
|
const toolCallId = `cmd_${generateSecureToken(8)}`;
|
|
try {
|
|
const result = await tool.execute(toolCallId, {
|
|
command: rawArgs,
|
|
commandName: skillInvocation.command.name,
|
|
skillName: skillInvocation.command.skillName,
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
} as any);
|
|
const text = extractTextFromToolResult(result) ?? "✅ Done.";
|
|
typing.cleanup();
|
|
return { kind: "reply", reply: { text } };
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
typing.cleanup();
|
|
return { kind: "reply", reply: { text: `❌ ${message}` } };
|
|
}
|
|
}
|
|
|
|
const promptParts = [
|
|
`Use the "${skillInvocation.command.skillName}" skill for this request.`,
|
|
skillInvocation.args ? `User input:\n${skillInvocation.args}` : null,
|
|
].filter((entry): entry is string => Boolean(entry));
|
|
const rewrittenBody = promptParts.join("\n\n");
|
|
ctx.Body = rewrittenBody;
|
|
ctx.BodyForAgent = rewrittenBody;
|
|
sessionCtx.Body = rewrittenBody;
|
|
sessionCtx.BodyForAgent = rewrittenBody;
|
|
sessionCtx.BodyStripped = rewrittenBody;
|
|
cleanedBody = rewrittenBody;
|
|
}
|
|
|
|
const sendInlineReply = async (reply?: ReplyPayload) => {
|
|
if (!reply) {
|
|
return;
|
|
}
|
|
if (!opts?.onBlockReply) {
|
|
return;
|
|
}
|
|
await opts.onBlockReply(reply);
|
|
};
|
|
|
|
const isStopLikeInbound = isAbortRequestText(command.rawBodyNormalized);
|
|
if (!isStopLikeInbound && sessionEntry) {
|
|
const cutoff = readAbortCutoffFromSessionEntry(sessionEntry);
|
|
const incoming = resolveAbortCutoffFromContext(ctx);
|
|
const shouldSkip = cutoff
|
|
? shouldSkipMessageByAbortCutoff({
|
|
cutoffMessageSid: cutoff.messageSid,
|
|
cutoffTimestamp: cutoff.timestamp,
|
|
messageSid: incoming?.messageSid,
|
|
timestamp: incoming?.timestamp,
|
|
})
|
|
: false;
|
|
if (shouldSkip) {
|
|
typing.cleanup();
|
|
return { kind: "reply", reply: undefined };
|
|
}
|
|
if (cutoff) {
|
|
await clearAbortCutoffInSession({
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey,
|
|
storePath,
|
|
});
|
|
}
|
|
}
|
|
|
|
const inlineCommand =
|
|
allowTextCommands && command.isAuthorizedSender
|
|
? extractInlineSimpleCommand(cleanedBody)
|
|
: null;
|
|
if (inlineCommand) {
|
|
cleanedBody = inlineCommand.cleaned;
|
|
sessionCtx.Body = cleanedBody;
|
|
sessionCtx.BodyForAgent = cleanedBody;
|
|
sessionCtx.BodyStripped = cleanedBody;
|
|
}
|
|
|
|
const handleInlineStatus =
|
|
!isDirectiveOnly({
|
|
directives,
|
|
cleanedBody: directives.cleaned,
|
|
ctx,
|
|
cfg,
|
|
agentId,
|
|
isGroup,
|
|
}) && inlineStatusRequested;
|
|
if (handleInlineStatus) {
|
|
const inlineStatusReply = await buildStatusReply({
|
|
cfg,
|
|
command,
|
|
sessionEntry,
|
|
sessionKey,
|
|
parentSessionKey: ctx.ParentSessionKey,
|
|
sessionScope,
|
|
provider,
|
|
model,
|
|
contextTokens,
|
|
resolvedThinkLevel,
|
|
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
|
|
resolvedReasoningLevel,
|
|
resolvedElevatedLevel,
|
|
resolveDefaultThinkingLevel,
|
|
isGroup,
|
|
defaultGroupActivation: defaultActivation,
|
|
mediaDecisions: ctx.MediaUnderstandingDecisions,
|
|
});
|
|
await sendInlineReply(inlineStatusReply);
|
|
directives = { ...directives, hasStatusDirective: false };
|
|
}
|
|
|
|
const runCommands = (commandInput: typeof command) =>
|
|
handleCommands({
|
|
// Pass sessionCtx so command handlers can mutate stripped body for same-turn continuation.
|
|
ctx: sessionCtx,
|
|
// Keep original finalized context in sync when command handlers need outer-dispatch side effects.
|
|
rootCtx: ctx,
|
|
cfg,
|
|
command: commandInput,
|
|
agentId,
|
|
agentDir,
|
|
directives,
|
|
elevated: {
|
|
enabled: elevatedEnabled,
|
|
allowed: elevatedAllowed,
|
|
failures: elevatedFailures,
|
|
},
|
|
sessionEntry,
|
|
previousSessionEntry,
|
|
sessionStore,
|
|
sessionKey,
|
|
storePath,
|
|
sessionScope,
|
|
workspaceDir,
|
|
opts,
|
|
defaultGroupActivation: defaultActivation,
|
|
resolvedThinkLevel,
|
|
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
|
|
resolvedReasoningLevel,
|
|
resolvedElevatedLevel,
|
|
blockReplyChunking,
|
|
resolvedBlockStreamingBreak,
|
|
resolveDefaultThinkingLevel,
|
|
provider,
|
|
model,
|
|
contextTokens,
|
|
isGroup,
|
|
skillCommands,
|
|
typing,
|
|
});
|
|
|
|
if (inlineCommand) {
|
|
const inlineCommandContext = {
|
|
...command,
|
|
rawBodyNormalized: inlineCommand.command,
|
|
commandBodyNormalized: inlineCommand.command,
|
|
};
|
|
const inlineResult = await runCommands(inlineCommandContext);
|
|
if (inlineResult.reply) {
|
|
if (!inlineCommand.cleaned) {
|
|
typing.cleanup();
|
|
return { kind: "reply", reply: inlineResult.reply };
|
|
}
|
|
await sendInlineReply(inlineResult.reply);
|
|
}
|
|
}
|
|
|
|
if (directiveAck) {
|
|
await sendInlineReply(directiveAck);
|
|
}
|
|
|
|
const isEmptyConfig = Object.keys(cfg).length === 0;
|
|
const skipWhenConfigEmpty = command.channelId
|
|
? Boolean(getChannelDock(command.channelId)?.commands?.skipWhenConfigEmpty)
|
|
: false;
|
|
if (
|
|
skipWhenConfigEmpty &&
|
|
isEmptyConfig &&
|
|
command.from &&
|
|
command.to &&
|
|
command.from !== command.to
|
|
) {
|
|
typing.cleanup();
|
|
return { kind: "reply", reply: undefined };
|
|
}
|
|
|
|
let abortedLastRun = initialAbortedLastRun;
|
|
if (!sessionEntry && command.abortKey) {
|
|
abortedLastRun = getAbortMemory(command.abortKey) ?? false;
|
|
}
|
|
|
|
const commandResult = await runCommands(command);
|
|
if (!commandResult.shouldContinue) {
|
|
typing.cleanup();
|
|
return { kind: "reply", reply: commandResult.reply };
|
|
}
|
|
|
|
return {
|
|
kind: "continue",
|
|
directives,
|
|
abortedLastRun,
|
|
};
|
|
}
|