Files
clawdbot/src/auto-reply/reply/get-reply-inline-actions.ts
Peter Steinberger 02ca148583 fix: preserve subagent thread routing (#1241)
Thanks @gnarco.

Co-authored-by: gnarco <gnarco@users.noreply.github.com>
2026-01-20 17:22:07 +00:00

373 lines
11 KiB
TypeScript

import { getChannelDock } from "../../channels/dock.js";
import type { SkillCommandSpec } from "../../agents/skills.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.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 { getAbortMemory } 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";
import { listSkillCommandsForWorkspace, resolveSkillCommandInvocation } from "../skill-commands.js";
import { logVerbose } from "../../globals.js";
import { createClawdbotTools } from "../../agents/clawdbot-tools.js";
import { resolveGatewayMessageChannel } from "../../utils/message-channel.js";
export type InlineActionResult =
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
| {
kind: "continue";
directives: InlineDirectives;
abortedLastRun: boolean;
};
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;
}
if (!Array.isArray(content)) return null;
const parts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") continue;
const rec = block as { type?: unknown; text?: unknown };
if (rec.type === "text" && typeof rec.text === "string") {
parts.push(rec.text);
}
}
const out = parts.join("");
const trimmed = out.trim();
return trimmed ? trimmed : null;
}
export async function handleInlineActions(params: {
ctx: MsgContext;
sessionCtx: TemplateContext;
cfg: ClawdbotConfig;
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;
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,
resolveDefaultThinkingLevel,
provider,
model,
contextTokens,
directiveAck,
abortedLastRun: initialAbortedLastRun,
skillFilter,
} = params;
let directives = initialDirectives;
let cleanedBody = initialCleanedBody;
const shouldLoadSkillCommands = command.commandBodyNormalized.startsWith("/");
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 = createClawdbotTools({
agentSessionKey: sessionKey,
agentChannel: channel,
agentAccountId: (ctx as { AccountId?: string }).AccountId,
agentTo: ctx.OriginatingTo ?? ctx.To,
agentThreadId: ctx.MessageThreadId ?? undefined,
agentDir,
workspaceDir,
config: cfg,
});
const tool = tools.find((candidate) => candidate.name === dispatch.toolName);
if (!tool) {
typing.cleanup();
return { kind: "reply", reply: { text: `❌ Tool not available: ${dispatch.toolName}` } };
}
const toolCallId = `cmd_${Date.now()}_${Math.random().toString(16).slice(2)}`;
try {
const result = await tool.execute(toolCallId, {
command: rawArgs,
commandName: skillInvocation.command.name,
skillName: skillInvocation.command.skillName,
} 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 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,
sessionScope,
provider,
model,
contextTokens,
resolvedThinkLevel,
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
resolvedReasoningLevel,
resolvedElevatedLevel,
resolveDefaultThinkingLevel,
isGroup,
defaultGroupActivation: defaultActivation,
mediaDecisions: ctx.MediaUnderstandingDecisions,
});
await sendInlineReply(inlineStatusReply);
directives = { ...directives, hasStatusDirective: false };
}
if (inlineCommand) {
const inlineCommandContext = {
...command,
rawBodyNormalized: inlineCommand.command,
commandBodyNormalized: inlineCommand.command,
};
const inlineResult = await handleCommands({
ctx,
cfg,
command: inlineCommandContext,
agentId,
directives,
elevated: {
enabled: elevatedEnabled,
allowed: elevatedAllowed,
failures: elevatedFailures,
},
sessionEntry,
previousSessionEntry,
sessionStore,
sessionKey,
storePath,
sessionScope,
workspaceDir,
defaultGroupActivation: defaultActivation,
resolvedThinkLevel,
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
resolvedReasoningLevel,
resolvedElevatedLevel,
resolveDefaultThinkingLevel,
provider,
model,
contextTokens,
isGroup,
skillCommands,
});
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 handleCommands({
ctx,
cfg,
command,
agentId,
directives,
elevated: {
enabled: elevatedEnabled,
allowed: elevatedAllowed,
failures: elevatedFailures,
},
sessionEntry,
previousSessionEntry,
sessionStore,
sessionKey,
storePath,
sessionScope,
workspaceDir,
defaultGroupActivation: defaultActivation,
resolvedThinkLevel,
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
resolvedReasoningLevel,
resolvedElevatedLevel,
resolveDefaultThinkingLevel,
provider,
model,
contextTokens,
isGroup,
skillCommands,
});
if (!commandResult.shouldContinue) {
typing.cleanup();
return { kind: "reply", reply: commandResult.reply };
}
return {
kind: "continue",
directives,
abortedLastRun,
};
}