refactor(auto-reply): split reply pipeline
This commit is contained in:
File diff suppressed because it is too large
Load Diff
475
src/auto-reply/reply/agent-runner-execution.ts
Normal file
475
src/auto-reply/reply/agent-runner-execution.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js";
|
||||
import { runCliAgent } from "../../agents/cli-runner.js";
|
||||
import { getCliSessionId } from "../../agents/cli-session.js";
|
||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||
import { isCliProvider } from "../../agents/model-selection.js";
|
||||
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
||||
import {
|
||||
isCompactionFailureError,
|
||||
isContextOverflowError,
|
||||
} from "../../agents/pi-embedded-helpers.js";
|
||||
import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveSessionTranscriptPath,
|
||||
type SessionEntry,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import {
|
||||
emitAgentEvent,
|
||||
registerAgentRunContext,
|
||||
} from "../../infra/agent-events.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import type { VerboseLevel } from "../thinking.js";
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import {
|
||||
buildThreadingToolContext,
|
||||
resolveEnforceFinalTag,
|
||||
} from "./agent-runner-utils.js";
|
||||
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||
import type { FollowupRun } from "./queue.js";
|
||||
import { parseReplyDirectives } from "./reply-directives.js";
|
||||
import {
|
||||
applyReplyTagsToPayload,
|
||||
isRenderablePayload,
|
||||
} from "./reply-payloads.js";
|
||||
import type { TypingSignaler } from "./typing-mode.js";
|
||||
|
||||
export type AgentRunLoopResult =
|
||||
| {
|
||||
kind: "success";
|
||||
runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
fallbackProvider?: string;
|
||||
fallbackModel?: string;
|
||||
didLogHeartbeatStrip: boolean;
|
||||
autoCompactionCompleted: boolean;
|
||||
}
|
||||
| { kind: "final"; payload: ReplyPayload };
|
||||
|
||||
export async function runAgentTurnWithFallback(params: {
|
||||
commandBody: string;
|
||||
followupRun: FollowupRun;
|
||||
sessionCtx: TemplateContext;
|
||||
opts?: GetReplyOptions;
|
||||
typingSignals: TypingSignaler;
|
||||
blockReplyPipeline: BlockReplyPipeline | null;
|
||||
blockStreamingEnabled: boolean;
|
||||
blockReplyChunking?: {
|
||||
minChars: number;
|
||||
maxChars: number;
|
||||
breakPreference: "paragraph" | "newline" | "sentence";
|
||||
};
|
||||
resolvedBlockStreamingBreak: "text_end" | "message_end";
|
||||
applyReplyToMode: (payload: ReplyPayload) => ReplyPayload;
|
||||
shouldEmitToolResult: () => boolean;
|
||||
pendingToolTasks: Set<Promise<void>>;
|
||||
resetSessionAfterCompactionFailure: (reason: string) => Promise<boolean>;
|
||||
isHeartbeat: boolean;
|
||||
sessionKey?: string;
|
||||
getActiveSessionEntry: () => SessionEntry | undefined;
|
||||
activeSessionStore?: Record<string, SessionEntry>;
|
||||
storePath?: string;
|
||||
resolvedVerboseLevel: VerboseLevel;
|
||||
}): Promise<AgentRunLoopResult> {
|
||||
let didLogHeartbeatStrip = false;
|
||||
let autoCompactionCompleted = false;
|
||||
|
||||
const runId = crypto.randomUUID();
|
||||
if (params.sessionKey) {
|
||||
registerAgentRunContext(runId, {
|
||||
sessionKey: params.sessionKey,
|
||||
verboseLevel: params.resolvedVerboseLevel,
|
||||
});
|
||||
}
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
let fallbackProvider = params.followupRun.run.provider;
|
||||
let fallbackModel = params.followupRun.run.model;
|
||||
let didResetAfterCompactionFailure = false;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const allowPartialStream = !(
|
||||
params.followupRun.run.reasoningLevel === "stream" &&
|
||||
params.opts?.onReasoningStream
|
||||
);
|
||||
const normalizeStreamingText = (
|
||||
payload: ReplyPayload,
|
||||
): { text?: string; skip: boolean } => {
|
||||
if (!allowPartialStream) return { skip: true };
|
||||
let text = payload.text;
|
||||
if (!params.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||
const stripped = stripHeartbeatToken(text, {
|
||||
mode: "message",
|
||||
});
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||
}
|
||||
if (stripped.shouldSkip && (payload.mediaUrls?.length ?? 0) === 0) {
|
||||
return { skip: true };
|
||||
}
|
||||
text = stripped.text;
|
||||
}
|
||||
if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) {
|
||||
return { skip: true };
|
||||
}
|
||||
return { text, skip: false };
|
||||
};
|
||||
const handlePartialForTyping = async (
|
||||
payload: ReplyPayload,
|
||||
): Promise<string | undefined> => {
|
||||
const { text, skip } = normalizeStreamingText(payload);
|
||||
if (skip || !text) return undefined;
|
||||
await params.typingSignals.signalTextDelta(text);
|
||||
return text;
|
||||
};
|
||||
const blockReplyPipeline = params.blockReplyPipeline;
|
||||
const onToolResult = params.opts?.onToolResult;
|
||||
const fallbackResult = await runWithModelFallback({
|
||||
cfg: params.followupRun.run.config,
|
||||
provider: params.followupRun.run.provider,
|
||||
model: params.followupRun.run.model,
|
||||
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
||||
params.followupRun.run.config,
|
||||
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
|
||||
),
|
||||
run: (provider, model) => {
|
||||
if (isCliProvider(provider, params.followupRun.run.config)) {
|
||||
const startedAt = Date.now();
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "start",
|
||||
startedAt,
|
||||
},
|
||||
});
|
||||
const cliSessionId = getCliSessionId(
|
||||
params.getActiveSessionEntry(),
|
||||
provider,
|
||||
);
|
||||
return runCliAgent({
|
||||
sessionId: params.followupRun.run.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionFile: params.followupRun.run.sessionFile,
|
||||
workspaceDir: params.followupRun.run.workspaceDir,
|
||||
config: params.followupRun.run.config,
|
||||
prompt: params.commandBody,
|
||||
provider,
|
||||
model,
|
||||
thinkLevel: params.followupRun.run.thinkLevel,
|
||||
timeoutMs: params.followupRun.run.timeoutMs,
|
||||
runId,
|
||||
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
|
||||
ownerNumbers: params.followupRun.run.ownerNumbers,
|
||||
cliSessionId,
|
||||
})
|
||||
.then((result) => {
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "end",
|
||||
startedAt,
|
||||
endedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
return result;
|
||||
})
|
||||
.catch((err) => {
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "error",
|
||||
startedAt,
|
||||
endedAt: Date.now(),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return runEmbeddedPiAgent({
|
||||
sessionId: params.followupRun.run.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider:
|
||||
params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
agentAccountId: params.sessionCtx.AccountId,
|
||||
// Provider threading context for tool auto-injection
|
||||
...buildThreadingToolContext({
|
||||
sessionCtx: params.sessionCtx,
|
||||
config: params.followupRun.run.config,
|
||||
hasRepliedRef: params.opts?.hasRepliedRef,
|
||||
}),
|
||||
sessionFile: params.followupRun.run.sessionFile,
|
||||
workspaceDir: params.followupRun.run.workspaceDir,
|
||||
agentDir: params.followupRun.run.agentDir,
|
||||
config: params.followupRun.run.config,
|
||||
skillsSnapshot: params.followupRun.run.skillsSnapshot,
|
||||
prompt: params.commandBody,
|
||||
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
|
||||
ownerNumbers: params.followupRun.run.ownerNumbers,
|
||||
enforceFinalTag: resolveEnforceFinalTag(
|
||||
params.followupRun.run,
|
||||
provider,
|
||||
),
|
||||
provider,
|
||||
model,
|
||||
authProfileId: params.followupRun.run.authProfileId,
|
||||
thinkLevel: params.followupRun.run.thinkLevel,
|
||||
verboseLevel: params.followupRun.run.verboseLevel,
|
||||
reasoningLevel: params.followupRun.run.reasoningLevel,
|
||||
bashElevated: params.followupRun.run.bashElevated,
|
||||
timeoutMs: params.followupRun.run.timeoutMs,
|
||||
runId,
|
||||
blockReplyBreak: params.resolvedBlockStreamingBreak,
|
||||
blockReplyChunking: params.blockReplyChunking,
|
||||
onPartialReply: allowPartialStream
|
||||
? async (payload) => {
|
||||
const textForTyping = await handlePartialForTyping(payload);
|
||||
if (
|
||||
!params.opts?.onPartialReply ||
|
||||
textForTyping === undefined
|
||||
)
|
||||
return;
|
||||
await params.opts.onPartialReply({
|
||||
text: textForTyping,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onAssistantMessageStart: async () => {
|
||||
await params.typingSignals.signalMessageStart();
|
||||
},
|
||||
onReasoningStream:
|
||||
params.typingSignals.shouldStartOnReasoning ||
|
||||
params.opts?.onReasoningStream
|
||||
? async (payload) => {
|
||||
await params.typingSignals.signalReasoningDelta();
|
||||
await params.opts?.onReasoningStream?.({
|
||||
text: payload.text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onAgentEvent: (evt) => {
|
||||
// Trigger typing when tools start executing
|
||||
if (evt.stream === "tool") {
|
||||
const phase =
|
||||
typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
if (phase === "start" || phase === "update") {
|
||||
void params.typingSignals.signalToolStart();
|
||||
}
|
||||
}
|
||||
// Track auto-compaction completion
|
||||
if (evt.stream === "compaction") {
|
||||
const phase =
|
||||
typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
autoCompactionCompleted = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
onBlockReply:
|
||||
params.blockStreamingEnabled && params.opts?.onBlockReply
|
||||
? async (payload) => {
|
||||
const { text, skip } = normalizeStreamingText(payload);
|
||||
const hasPayloadMedia =
|
||||
(payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (skip && !hasPayloadMedia) return;
|
||||
const taggedPayload = applyReplyTagsToPayload(
|
||||
{
|
||||
text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
mediaUrl: payload.mediaUrls?.[0],
|
||||
},
|
||||
params.sessionCtx.MessageSid,
|
||||
);
|
||||
// Let through payloads with audioAsVoice flag even if empty (need to track it)
|
||||
if (
|
||||
!isRenderablePayload(taggedPayload) &&
|
||||
!payload.audioAsVoice
|
||||
)
|
||||
return;
|
||||
const parsed = parseReplyDirectives(
|
||||
taggedPayload.text ?? "",
|
||||
{
|
||||
currentMessageId: params.sessionCtx.MessageSid,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
},
|
||||
);
|
||||
const cleaned = parsed.text || undefined;
|
||||
const hasRenderableMedia =
|
||||
Boolean(taggedPayload.mediaUrl) ||
|
||||
(taggedPayload.mediaUrls?.length ?? 0) > 0;
|
||||
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
|
||||
if (
|
||||
!cleaned &&
|
||||
!hasRenderableMedia &&
|
||||
!payload.audioAsVoice &&
|
||||
!parsed.audioAsVoice
|
||||
)
|
||||
return;
|
||||
if (parsed.isSilent && !hasRenderableMedia) return;
|
||||
|
||||
const blockPayload: ReplyPayload = params.applyReplyToMode({
|
||||
...taggedPayload,
|
||||
text: cleaned,
|
||||
audioAsVoice: Boolean(
|
||||
parsed.audioAsVoice || payload.audioAsVoice,
|
||||
),
|
||||
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
|
||||
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
|
||||
replyToCurrent:
|
||||
taggedPayload.replyToCurrent || parsed.replyToCurrent,
|
||||
});
|
||||
|
||||
void params.typingSignals
|
||||
.signalTextDelta(cleaned ?? taggedPayload.text)
|
||||
.catch((err) => {
|
||||
logVerbose(
|
||||
`block reply typing signal failed: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
|
||||
params.blockReplyPipeline?.enqueue(blockPayload);
|
||||
}
|
||||
: undefined,
|
||||
onBlockReplyFlush:
|
||||
params.blockStreamingEnabled && blockReplyPipeline
|
||||
? async () => {
|
||||
await blockReplyPipeline.flush({ force: true });
|
||||
}
|
||||
: undefined,
|
||||
shouldEmitToolResult: params.shouldEmitToolResult,
|
||||
onToolResult: onToolResult
|
||||
? (payload) => {
|
||||
// `subscribeEmbeddedPiSession` may invoke tool callbacks without awaiting them.
|
||||
// If a tool callback starts typing after the run finalized, we can end up with
|
||||
// a typing loop that never sees a matching markRunComplete(). Track and drain.
|
||||
const task = (async () => {
|
||||
const { text, skip } = normalizeStreamingText(payload);
|
||||
if (skip) return;
|
||||
await params.typingSignals.signalTextDelta(text);
|
||||
await onToolResult({
|
||||
text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
})()
|
||||
.catch((err) => {
|
||||
logVerbose(`tool result delivery failed: ${String(err)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
params.pendingToolTasks.delete(task);
|
||||
});
|
||||
params.pendingToolTasks.add(task);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
runResult = fallbackResult.result;
|
||||
fallbackProvider = fallbackResult.provider;
|
||||
fallbackModel = fallbackResult.model;
|
||||
|
||||
// Some embedded runs surface context overflow as an error payload instead of throwing.
|
||||
// Treat those as a session-level failure and auto-recover by starting a fresh session.
|
||||
const embeddedError = runResult.meta?.error;
|
||||
if (
|
||||
embeddedError &&
|
||||
isContextOverflowError(embeddedError.message) &&
|
||||
!didResetAfterCompactionFailure &&
|
||||
(await params.resetSessionAfterCompactionFailure(embeddedError.message))
|
||||
) {
|
||||
didResetAfterCompactionFailure = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const isContextOverflow =
|
||||
isContextOverflowError(message) ||
|
||||
/context.*overflow|too large|context window/i.test(message);
|
||||
const isCompactionFailure = isCompactionFailureError(message);
|
||||
const isSessionCorruption =
|
||||
/function call turn comes immediately after/i.test(message);
|
||||
|
||||
if (
|
||||
isCompactionFailure &&
|
||||
!didResetAfterCompactionFailure &&
|
||||
(await params.resetSessionAfterCompactionFailure(message))
|
||||
) {
|
||||
didResetAfterCompactionFailure = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-recover from Gemini session corruption by resetting the session
|
||||
if (
|
||||
isSessionCorruption &&
|
||||
params.sessionKey &&
|
||||
params.activeSessionStore &&
|
||||
params.storePath
|
||||
) {
|
||||
const corruptedSessionId = params.getActiveSessionEntry()?.sessionId;
|
||||
defaultRuntime.error(
|
||||
`Session history corrupted (Gemini function call ordering). Resetting session: ${params.sessionKey}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Delete transcript file if it exists
|
||||
if (corruptedSessionId) {
|
||||
const transcriptPath =
|
||||
resolveSessionTranscriptPath(corruptedSessionId);
|
||||
try {
|
||||
fs.unlinkSync(transcriptPath);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
// Remove session entry from store
|
||||
delete params.activeSessionStore[params.sessionKey];
|
||||
await saveSessionStore(params.storePath, params.activeSessionStore);
|
||||
} catch (cleanupErr) {
|
||||
defaultRuntime.error(
|
||||
`Failed to reset corrupted session ${params.sessionKey}: ${String(cleanupErr)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "final",
|
||||
payload: {
|
||||
text: "⚠️ Session history was corrupted. I've reset the conversation - please try again!",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
defaultRuntime.error(`Embedded agent failed before reply: ${message}`);
|
||||
return {
|
||||
kind: "final",
|
||||
payload: {
|
||||
text: isContextOverflow
|
||||
? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
|
||||
: `⚠️ Agent failed before reply: ${message}. Check gateway logs for details.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "success",
|
||||
runResult,
|
||||
fallbackProvider,
|
||||
fallbackModel,
|
||||
didLogHeartbeatStrip,
|
||||
autoCompactionCompleted,
|
||||
};
|
||||
}
|
||||
60
src/auto-reply/reply/agent-runner-helpers.ts
Normal file
60
src/auto-reply/reply/agent-runner-helpers.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { loadSessionStore } from "../../config/sessions.js";
|
||||
import { isAudioFileName } from "../../media/mime.js";
|
||||
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { scheduleFollowupDrain } from "./queue.js";
|
||||
import type { TypingSignaler } from "./typing-mode.js";
|
||||
|
||||
const hasAudioMedia = (urls?: string[]): boolean =>
|
||||
Boolean(urls?.some((url) => isAudioFileName(url)));
|
||||
|
||||
export const isAudioPayload = (payload: ReplyPayload): boolean =>
|
||||
hasAudioMedia(
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined),
|
||||
);
|
||||
|
||||
export const createShouldEmitToolResult = (params: {
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
resolvedVerboseLevel: VerboseLevel;
|
||||
}): (() => boolean) => {
|
||||
return () => {
|
||||
if (!params.sessionKey || !params.storePath) {
|
||||
return params.resolvedVerboseLevel === "on";
|
||||
}
|
||||
try {
|
||||
const store = loadSessionStore(params.storePath);
|
||||
const entry = store[params.sessionKey];
|
||||
const current = normalizeVerboseLevel(entry?.verboseLevel);
|
||||
if (current) return current === "on";
|
||||
} catch {
|
||||
// ignore store read failures
|
||||
}
|
||||
return params.resolvedVerboseLevel === "on";
|
||||
};
|
||||
};
|
||||
|
||||
export const finalizeWithFollowup = <T>(
|
||||
value: T,
|
||||
queueKey: string,
|
||||
runFollowupTurn: Parameters<typeof scheduleFollowupDrain>[1],
|
||||
): T => {
|
||||
scheduleFollowupDrain(queueKey, runFollowupTurn);
|
||||
return value;
|
||||
};
|
||||
|
||||
export const signalTypingIfNeeded = async (
|
||||
payloads: ReplyPayload[],
|
||||
typingSignals: TypingSignaler,
|
||||
): Promise<void> => {
|
||||
const shouldSignalTyping = payloads.some((payload) => {
|
||||
const trimmed = payload.text?.trim();
|
||||
if (trimmed) return true;
|
||||
if (payload.mediaUrl) return true;
|
||||
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
|
||||
return false;
|
||||
});
|
||||
if (shouldSignalTyping) {
|
||||
await typingSignals.signalRunStart();
|
||||
}
|
||||
};
|
||||
195
src/auto-reply/reply/agent-runner-memory.ts
Normal file
195
src/auto-reply/reply/agent-runner-memory.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import crypto from "node:crypto";
|
||||
import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js";
|
||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||
import { isCliProvider } from "../../agents/model-selection.js";
|
||||
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
||||
import {
|
||||
resolveSandboxConfigForAgent,
|
||||
resolveSandboxRuntimeStatus,
|
||||
} from "../../agents/sandbox.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
type SessionEntry,
|
||||
updateSessionStoreEntry,
|
||||
} from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import type { VerboseLevel } from "../thinking.js";
|
||||
import type { GetReplyOptions } from "../types.js";
|
||||
import {
|
||||
buildThreadingToolContext,
|
||||
resolveEnforceFinalTag,
|
||||
} from "./agent-runner-utils.js";
|
||||
import {
|
||||
resolveMemoryFlushContextWindowTokens,
|
||||
resolveMemoryFlushSettings,
|
||||
shouldRunMemoryFlush,
|
||||
} from "./memory-flush.js";
|
||||
import type { FollowupRun } from "./queue.js";
|
||||
import { incrementCompactionCount } from "./session-updates.js";
|
||||
|
||||
export async function runMemoryFlushIfNeeded(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
followupRun: FollowupRun;
|
||||
sessionCtx: TemplateContext;
|
||||
opts?: GetReplyOptions;
|
||||
defaultModel: string;
|
||||
agentCfgContextTokens?: number;
|
||||
resolvedVerboseLevel: VerboseLevel;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
isHeartbeat: boolean;
|
||||
}): Promise<SessionEntry | undefined> {
|
||||
const memoryFlushSettings = resolveMemoryFlushSettings(params.cfg);
|
||||
if (!memoryFlushSettings) return params.sessionEntry;
|
||||
|
||||
const memoryFlushWritable = (() => {
|
||||
if (!params.sessionKey) return true;
|
||||
const runtime = resolveSandboxRuntimeStatus({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
if (!runtime.sandboxed) return true;
|
||||
const sandboxCfg = resolveSandboxConfigForAgent(
|
||||
params.cfg,
|
||||
runtime.agentId,
|
||||
);
|
||||
return sandboxCfg.workspaceAccess === "rw";
|
||||
})();
|
||||
|
||||
const shouldFlushMemory =
|
||||
memoryFlushSettings &&
|
||||
memoryFlushWritable &&
|
||||
!params.isHeartbeat &&
|
||||
!isCliProvider(params.followupRun.run.provider, params.cfg) &&
|
||||
shouldRunMemoryFlush({
|
||||
entry:
|
||||
params.sessionEntry ??
|
||||
(params.sessionKey
|
||||
? params.sessionStore?.[params.sessionKey]
|
||||
: undefined),
|
||||
contextWindowTokens: resolveMemoryFlushContextWindowTokens({
|
||||
modelId: params.followupRun.run.model ?? params.defaultModel,
|
||||
agentCfgContextTokens: params.agentCfgContextTokens,
|
||||
}),
|
||||
reserveTokensFloor: memoryFlushSettings.reserveTokensFloor,
|
||||
softThresholdTokens: memoryFlushSettings.softThresholdTokens,
|
||||
});
|
||||
|
||||
if (!shouldFlushMemory) return params.sessionEntry;
|
||||
|
||||
let activeSessionEntry = params.sessionEntry;
|
||||
const activeSessionStore = params.sessionStore;
|
||||
const flushRunId = crypto.randomUUID();
|
||||
if (params.sessionKey) {
|
||||
registerAgentRunContext(flushRunId, {
|
||||
sessionKey: params.sessionKey,
|
||||
verboseLevel: params.resolvedVerboseLevel,
|
||||
});
|
||||
}
|
||||
let memoryCompactionCompleted = false;
|
||||
const flushSystemPrompt = [
|
||||
params.followupRun.run.extraSystemPrompt,
|
||||
memoryFlushSettings.systemPrompt,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
try {
|
||||
await runWithModelFallback({
|
||||
cfg: params.followupRun.run.config,
|
||||
provider: params.followupRun.run.provider,
|
||||
model: params.followupRun.run.model,
|
||||
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
||||
params.followupRun.run.config,
|
||||
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
|
||||
),
|
||||
run: (provider, model) =>
|
||||
runEmbeddedPiAgent({
|
||||
sessionId: params.followupRun.run.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider:
|
||||
params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
agentAccountId: params.sessionCtx.AccountId,
|
||||
// Provider threading context for tool auto-injection
|
||||
...buildThreadingToolContext({
|
||||
sessionCtx: params.sessionCtx,
|
||||
config: params.followupRun.run.config,
|
||||
hasRepliedRef: params.opts?.hasRepliedRef,
|
||||
}),
|
||||
sessionFile: params.followupRun.run.sessionFile,
|
||||
workspaceDir: params.followupRun.run.workspaceDir,
|
||||
agentDir: params.followupRun.run.agentDir,
|
||||
config: params.followupRun.run.config,
|
||||
skillsSnapshot: params.followupRun.run.skillsSnapshot,
|
||||
prompt: memoryFlushSettings.prompt,
|
||||
extraSystemPrompt: flushSystemPrompt,
|
||||
ownerNumbers: params.followupRun.run.ownerNumbers,
|
||||
enforceFinalTag: resolveEnforceFinalTag(
|
||||
params.followupRun.run,
|
||||
provider,
|
||||
),
|
||||
provider,
|
||||
model,
|
||||
authProfileId: params.followupRun.run.authProfileId,
|
||||
thinkLevel: params.followupRun.run.thinkLevel,
|
||||
verboseLevel: params.followupRun.run.verboseLevel,
|
||||
reasoningLevel: params.followupRun.run.reasoningLevel,
|
||||
bashElevated: params.followupRun.run.bashElevated,
|
||||
timeoutMs: params.followupRun.run.timeoutMs,
|
||||
runId: flushRunId,
|
||||
onAgentEvent: (evt) => {
|
||||
if (evt.stream === "compaction") {
|
||||
const phase =
|
||||
typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
memoryCompactionCompleted = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
let memoryFlushCompactionCount =
|
||||
activeSessionEntry?.compactionCount ??
|
||||
(params.sessionKey
|
||||
? activeSessionStore?.[params.sessionKey]?.compactionCount
|
||||
: 0) ??
|
||||
0;
|
||||
if (memoryCompactionCompleted) {
|
||||
const nextCount = await incrementCompactionCount({
|
||||
sessionEntry: activeSessionEntry,
|
||||
sessionStore: activeSessionStore,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: params.storePath,
|
||||
});
|
||||
if (typeof nextCount === "number") {
|
||||
memoryFlushCompactionCount = nextCount;
|
||||
}
|
||||
}
|
||||
if (params.storePath && params.sessionKey) {
|
||||
try {
|
||||
const updatedEntry = await updateSessionStoreEntry({
|
||||
storePath: params.storePath,
|
||||
sessionKey: params.sessionKey,
|
||||
update: async () => ({
|
||||
memoryFlushAt: Date.now(),
|
||||
memoryFlushCompactionCount,
|
||||
}),
|
||||
});
|
||||
if (updatedEntry) {
|
||||
activeSessionEntry = updatedEntry;
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`failed to persist memory flush metadata: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`memory flush run failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
return activeSessionEntry;
|
||||
}
|
||||
118
src/auto-reply/reply/agent-runner-payloads.ts
Normal file
118
src/auto-reply/reply/agent-runner-payloads.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { ReplyToMode } from "../../config/types.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import type { OriginatingChannelType } from "../templating.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import {
|
||||
formatBunFetchSocketError,
|
||||
isBunFetchSocketError,
|
||||
} from "./agent-runner-utils.js";
|
||||
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||
import { parseReplyDirectives } from "./reply-directives.js";
|
||||
import {
|
||||
applyReplyThreading,
|
||||
filterMessagingToolDuplicates,
|
||||
isRenderablePayload,
|
||||
shouldSuppressMessagingToolReplies,
|
||||
} from "./reply-payloads.js";
|
||||
|
||||
export function buildReplyPayloads(params: {
|
||||
payloads: ReplyPayload[];
|
||||
isHeartbeat: boolean;
|
||||
didLogHeartbeatStrip: boolean;
|
||||
blockStreamingEnabled: boolean;
|
||||
blockReplyPipeline: BlockReplyPipeline | null;
|
||||
replyToMode: ReplyToMode;
|
||||
replyToChannel?: OriginatingChannelType;
|
||||
currentMessageId?: string;
|
||||
messageProvider?: string;
|
||||
messagingToolSentTexts?: string[];
|
||||
messagingToolSentTargets?: Parameters<
|
||||
typeof shouldSuppressMessagingToolReplies
|
||||
>[0]["messagingToolSentTargets"];
|
||||
originatingTo?: string;
|
||||
accountId?: string;
|
||||
}): { replyPayloads: ReplyPayload[]; didLogHeartbeatStrip: boolean } {
|
||||
let didLogHeartbeatStrip = params.didLogHeartbeatStrip;
|
||||
const sanitizedPayloads = params.isHeartbeat
|
||||
? params.payloads
|
||||
: params.payloads.flatMap((payload) => {
|
||||
let text = payload.text;
|
||||
|
||||
if (payload.isError && text && isBunFetchSocketError(text)) {
|
||||
text = formatBunFetchSocketError(text);
|
||||
}
|
||||
|
||||
if (!text || !text.includes("HEARTBEAT_OK")) {
|
||||
return [{ ...payload, text }];
|
||||
}
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||
}
|
||||
const hasMedia =
|
||||
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (stripped.shouldSkip && !hasMedia) return [];
|
||||
return [{ ...payload, text: stripped.text }];
|
||||
});
|
||||
|
||||
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
|
||||
payloads: sanitizedPayloads,
|
||||
replyToMode: params.replyToMode,
|
||||
replyToChannel: params.replyToChannel,
|
||||
currentMessageId: params.currentMessageId,
|
||||
})
|
||||
.map((payload) => {
|
||||
const parsed = parseReplyDirectives(payload.text ?? "", {
|
||||
currentMessageId: params.currentMessageId,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
});
|
||||
const mediaUrls = payload.mediaUrls ?? parsed.mediaUrls;
|
||||
const mediaUrl = payload.mediaUrl ?? parsed.mediaUrl ?? mediaUrls?.[0];
|
||||
return {
|
||||
...payload,
|
||||
text: parsed.text ? parsed.text : undefined,
|
||||
mediaUrls,
|
||||
mediaUrl,
|
||||
replyToId: payload.replyToId ?? parsed.replyToId,
|
||||
replyToTag: payload.replyToTag || parsed.replyToTag,
|
||||
replyToCurrent: payload.replyToCurrent || parsed.replyToCurrent,
|
||||
audioAsVoice: Boolean(payload.audioAsVoice || parsed.audioAsVoice),
|
||||
};
|
||||
})
|
||||
.filter(isRenderablePayload);
|
||||
|
||||
// Drop final payloads only when block streaming succeeded end-to-end.
|
||||
// If streaming aborted (e.g., timeout), fall back to final payloads.
|
||||
const shouldDropFinalPayloads =
|
||||
params.blockStreamingEnabled &&
|
||||
Boolean(params.blockReplyPipeline?.didStream()) &&
|
||||
!params.blockReplyPipeline?.isAborted();
|
||||
const messagingToolSentTexts = params.messagingToolSentTexts ?? [];
|
||||
const messagingToolSentTargets = params.messagingToolSentTargets ?? [];
|
||||
const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({
|
||||
messageProvider: params.messageProvider,
|
||||
messagingToolSentTargets,
|
||||
originatingTo: params.originatingTo,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const dedupedPayloads = filterMessagingToolDuplicates({
|
||||
payloads: replyTaggedPayloads,
|
||||
sentTexts: messagingToolSentTexts,
|
||||
});
|
||||
const filteredPayloads = shouldDropFinalPayloads
|
||||
? []
|
||||
: params.blockStreamingEnabled
|
||||
? dedupedPayloads.filter(
|
||||
(payload) => !params.blockReplyPipeline?.hasSentPayload(payload),
|
||||
)
|
||||
: dedupedPayloads;
|
||||
const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads;
|
||||
|
||||
return {
|
||||
replyPayloads,
|
||||
didLogHeartbeatStrip,
|
||||
};
|
||||
}
|
||||
122
src/auto-reply/reply/agent-runner-utils.ts
Normal file
122
src/auto-reply/reply/agent-runner-utils.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { NormalizedUsage } from "../../agents/usage.js";
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js";
|
||||
import { normalizeChannelId } from "../../channels/registry.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
||||
import {
|
||||
estimateUsageCost,
|
||||
formatTokenCount,
|
||||
formatUsd,
|
||||
} from "../../utils/usage-format.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import type { FollowupRun } from "./queue.js";
|
||||
|
||||
const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i;
|
||||
|
||||
/**
|
||||
* Build provider-specific threading context for tool auto-injection.
|
||||
*/
|
||||
export function buildThreadingToolContext(params: {
|
||||
sessionCtx: TemplateContext;
|
||||
config: ClawdbotConfig | undefined;
|
||||
hasRepliedRef: { value: boolean } | undefined;
|
||||
}): ChannelThreadingToolContext {
|
||||
const { sessionCtx, config, hasRepliedRef } = params;
|
||||
if (!config) return {};
|
||||
const provider = normalizeChannelId(sessionCtx.Provider);
|
||||
if (!provider) return {};
|
||||
const dock = getChannelDock(provider);
|
||||
if (!dock?.threading?.buildToolContext) return {};
|
||||
return (
|
||||
dock.threading.buildToolContext({
|
||||
cfg: config,
|
||||
accountId: sessionCtx.AccountId,
|
||||
context: {
|
||||
Channel: sessionCtx.Provider,
|
||||
To: sessionCtx.To,
|
||||
ReplyToId: sessionCtx.ReplyToId,
|
||||
ThreadLabel: sessionCtx.ThreadLabel,
|
||||
},
|
||||
hasRepliedRef,
|
||||
}) ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
export const isBunFetchSocketError = (message?: string) =>
|
||||
Boolean(message && BUN_FETCH_SOCKET_ERROR_RE.test(message));
|
||||
|
||||
export const formatBunFetchSocketError = (message: string) => {
|
||||
const trimmed = message.trim();
|
||||
return [
|
||||
"⚠️ LLM connection failed. This could be due to server issues, network problems, or context length exceeded (e.g., with local LLMs like LM Studio). Original error:",
|
||||
"```",
|
||||
trimmed || "Unknown error",
|
||||
"```",
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
export const formatResponseUsageLine = (params: {
|
||||
usage?: NormalizedUsage;
|
||||
showCost: boolean;
|
||||
costConfig?: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
};
|
||||
}): string | null => {
|
||||
const usage = params.usage;
|
||||
if (!usage) return null;
|
||||
const input = usage.input;
|
||||
const output = usage.output;
|
||||
if (typeof input !== "number" && typeof output !== "number") return null;
|
||||
const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?";
|
||||
const outputLabel =
|
||||
typeof output === "number" ? formatTokenCount(output) : "?";
|
||||
const cost =
|
||||
params.showCost && typeof input === "number" && typeof output === "number"
|
||||
? estimateUsageCost({
|
||||
usage: {
|
||||
input,
|
||||
output,
|
||||
cacheRead: usage.cacheRead,
|
||||
cacheWrite: usage.cacheWrite,
|
||||
},
|
||||
cost: params.costConfig,
|
||||
})
|
||||
: undefined;
|
||||
const costLabel = params.showCost ? formatUsd(cost) : undefined;
|
||||
const suffix = costLabel ? ` · est ${costLabel}` : "";
|
||||
return `Usage: ${inputLabel} in / ${outputLabel} out${suffix}`;
|
||||
};
|
||||
|
||||
export const appendUsageLine = (
|
||||
payloads: ReplyPayload[],
|
||||
line: string,
|
||||
): ReplyPayload[] => {
|
||||
let index = -1;
|
||||
for (let i = payloads.length - 1; i >= 0; i -= 1) {
|
||||
if (payloads[i]?.text) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (index === -1) return [...payloads, { text: line }];
|
||||
const existing = payloads[index];
|
||||
const existingText = existing.text ?? "";
|
||||
const separator = existingText.endsWith("\n") ? "" : "\n";
|
||||
const next = {
|
||||
...existing,
|
||||
text: `${existingText}${separator}${line}`,
|
||||
};
|
||||
const updated = payloads.slice();
|
||||
updated[index] = next;
|
||||
return updated;
|
||||
};
|
||||
|
||||
export const resolveEnforceFinalTag = (
|
||||
run: FollowupRun["run"],
|
||||
provider: string,
|
||||
) => Boolean(run.enforceFinalTag || isReasoningTagProvider(provider));
|
||||
@@ -1,32 +1,12 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js";
|
||||
import { runCliAgent } from "../../agents/cli-runner.js";
|
||||
import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js";
|
||||
import { setCliSessionId } from "../../agents/cli-session.js";
|
||||
import { lookupContextTokens } from "../../agents/context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||
import { resolveModelAuthMode } from "../../agents/model-auth.js";
|
||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||
import { isCliProvider } from "../../agents/model-selection.js";
|
||||
import { queueEmbeddedPiMessage } from "../../agents/pi-embedded.js";
|
||||
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||
import {
|
||||
queueEmbeddedPiMessage,
|
||||
runEmbeddedPiAgent,
|
||||
} from "../../agents/pi-embedded.js";
|
||||
import {
|
||||
isCompactionFailureError,
|
||||
isContextOverflowError,
|
||||
} from "../../agents/pi-embedded-helpers.js";
|
||||
import {
|
||||
resolveSandboxConfigForAgent,
|
||||
resolveSandboxRuntimeStatus,
|
||||
} from "../../agents/sandbox.js";
|
||||
import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js";
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js";
|
||||
import { normalizeChannelId } from "../../channels/registry.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveSessionTranscriptPath,
|
||||
type SessionEntry,
|
||||
@@ -35,49 +15,35 @@ import {
|
||||
} from "../../config/sessions.js";
|
||||
import type { TypingMode } from "../../config/types.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import {
|
||||
emitAgentEvent,
|
||||
registerAgentRunContext,
|
||||
} from "../../infra/agent-events.js";
|
||||
import { isAudioFileName } from "../../media/mime.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
||||
import {
|
||||
estimateUsageCost,
|
||||
formatTokenCount,
|
||||
formatUsd,
|
||||
resolveModelCostConfig,
|
||||
} from "../../utils/usage-format.js";
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import { resolveModelCostConfig } from "../../utils/usage-format.js";
|
||||
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
|
||||
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { VerboseLevel } from "../thinking.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { runAgentTurnWithFallback } from "./agent-runner-execution.js";
|
||||
import {
|
||||
createShouldEmitToolResult,
|
||||
finalizeWithFollowup,
|
||||
isAudioPayload,
|
||||
signalTypingIfNeeded,
|
||||
} from "./agent-runner-helpers.js";
|
||||
import { runMemoryFlushIfNeeded } from "./agent-runner-memory.js";
|
||||
import { buildReplyPayloads } from "./agent-runner-payloads.js";
|
||||
import {
|
||||
appendUsageLine,
|
||||
formatResponseUsageLine,
|
||||
} from "./agent-runner-utils.js";
|
||||
import {
|
||||
createAudioAsVoiceBuffer,
|
||||
createBlockReplyPipeline,
|
||||
} from "./block-reply-pipeline.js";
|
||||
import { resolveBlockStreamingCoalescing } from "./block-streaming.js";
|
||||
import { createFollowupRunner } from "./followup-runner.js";
|
||||
import {
|
||||
resolveMemoryFlushContextWindowTokens,
|
||||
resolveMemoryFlushSettings,
|
||||
shouldRunMemoryFlush,
|
||||
} from "./memory-flush.js";
|
||||
import {
|
||||
enqueueFollowupRun,
|
||||
type FollowupRun,
|
||||
type QueueSettings,
|
||||
scheduleFollowupDrain,
|
||||
} from "./queue.js";
|
||||
import { parseReplyDirectives } from "./reply-directives.js";
|
||||
import {
|
||||
applyReplyTagsToPayload,
|
||||
applyReplyThreading,
|
||||
filterMessagingToolDuplicates,
|
||||
isRenderablePayload,
|
||||
shouldSuppressMessagingToolReplies,
|
||||
} from "./reply-payloads.js";
|
||||
import {
|
||||
createReplyToModeFilterForChannel,
|
||||
resolveReplyToMode,
|
||||
@@ -86,113 +52,8 @@ import { incrementCompactionCount } from "./session-updates.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
import { createTypingSignaler } from "./typing-mode.js";
|
||||
|
||||
const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i;
|
||||
const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Build provider-specific threading context for tool auto-injection.
|
||||
*/
|
||||
function buildThreadingToolContext(params: {
|
||||
sessionCtx: TemplateContext;
|
||||
config: ClawdbotConfig | undefined;
|
||||
hasRepliedRef: { value: boolean } | undefined;
|
||||
}): ChannelThreadingToolContext {
|
||||
const { sessionCtx, config, hasRepliedRef } = params;
|
||||
if (!config) return {};
|
||||
const provider = normalizeChannelId(sessionCtx.Provider);
|
||||
if (!provider) return {};
|
||||
const dock = getChannelDock(provider);
|
||||
if (!dock?.threading?.buildToolContext) return {};
|
||||
return (
|
||||
dock.threading.buildToolContext({
|
||||
cfg: config,
|
||||
accountId: sessionCtx.AccountId,
|
||||
context: {
|
||||
Channel: sessionCtx.Provider,
|
||||
To: sessionCtx.To,
|
||||
ReplyToId: sessionCtx.ReplyToId,
|
||||
ThreadLabel: sessionCtx.ThreadLabel,
|
||||
},
|
||||
hasRepliedRef,
|
||||
}) ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
const isBunFetchSocketError = (message?: string) =>
|
||||
Boolean(message && BUN_FETCH_SOCKET_ERROR_RE.test(message));
|
||||
|
||||
const formatBunFetchSocketError = (message: string) => {
|
||||
const trimmed = message.trim();
|
||||
return [
|
||||
"⚠️ LLM connection failed. This could be due to server issues, network problems, or context length exceeded (e.g., with local LLMs like LM Studio). Original error:",
|
||||
"```",
|
||||
trimmed || "Unknown error",
|
||||
"```",
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
const formatResponseUsageLine = (params: {
|
||||
usage?: NormalizedUsage;
|
||||
showCost: boolean;
|
||||
costConfig?: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
};
|
||||
}): string | null => {
|
||||
const usage = params.usage;
|
||||
if (!usage) return null;
|
||||
const input = usage.input;
|
||||
const output = usage.output;
|
||||
if (typeof input !== "number" && typeof output !== "number") return null;
|
||||
const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?";
|
||||
const outputLabel =
|
||||
typeof output === "number" ? formatTokenCount(output) : "?";
|
||||
const cost =
|
||||
params.showCost && typeof input === "number" && typeof output === "number"
|
||||
? estimateUsageCost({
|
||||
usage: {
|
||||
input,
|
||||
output,
|
||||
cacheRead: usage.cacheRead,
|
||||
cacheWrite: usage.cacheWrite,
|
||||
},
|
||||
cost: params.costConfig,
|
||||
})
|
||||
: undefined;
|
||||
const costLabel = params.showCost ? formatUsd(cost) : undefined;
|
||||
const suffix = costLabel ? ` · est ${costLabel}` : "";
|
||||
return `Usage: ${inputLabel} in / ${outputLabel} out${suffix}`;
|
||||
};
|
||||
|
||||
const appendUsageLine = (
|
||||
payloads: ReplyPayload[],
|
||||
line: string,
|
||||
): ReplyPayload[] => {
|
||||
let index = -1;
|
||||
for (let i = payloads.length - 1; i >= 0; i -= 1) {
|
||||
if (payloads[i]?.text) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (index === -1) return [...payloads, { text: line }];
|
||||
const existing = payloads[index];
|
||||
const existingText = existing.text ?? "";
|
||||
const separator = existingText.endsWith("\n") ? "" : "\n";
|
||||
const next = {
|
||||
...existing,
|
||||
text: `${existingText}${separator}${line}`,
|
||||
};
|
||||
const updated = payloads.slice();
|
||||
updated[index] = next;
|
||||
return updated;
|
||||
};
|
||||
|
||||
const resolveEnforceFinalTag = (run: FollowupRun["run"], provider: string) =>
|
||||
Boolean(run.enforceFinalTag || isReasoningTagProvider(provider));
|
||||
|
||||
export async function runReplyAgent(params: {
|
||||
commandBody: string;
|
||||
followupRun: FollowupRun;
|
||||
@@ -261,31 +122,16 @@ export async function runReplyAgent(params: {
|
||||
isHeartbeat,
|
||||
});
|
||||
|
||||
const shouldEmitToolResult = () => {
|
||||
if (!sessionKey || !storePath) {
|
||||
return resolvedVerboseLevel === "on";
|
||||
}
|
||||
try {
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[sessionKey];
|
||||
const current = normalizeVerboseLevel(entry?.verboseLevel);
|
||||
if (current) return current === "on";
|
||||
} catch {
|
||||
// ignore store read failures
|
||||
}
|
||||
return resolvedVerboseLevel === "on";
|
||||
};
|
||||
const shouldEmitToolResult = createShouldEmitToolResult({
|
||||
sessionKey,
|
||||
storePath,
|
||||
resolvedVerboseLevel,
|
||||
});
|
||||
|
||||
const pendingToolTasks = new Set<Promise<void>>();
|
||||
const blockReplyTimeoutMs =
|
||||
opts?.blockReplyTimeoutMs ?? BLOCK_REPLY_SEND_TIMEOUT_MS;
|
||||
|
||||
const hasAudioMedia = (urls?: string[]): boolean =>
|
||||
Boolean(urls?.some((u) => isAudioFileName(u)));
|
||||
const isAudioPayload = (payload: ReplyPayload) =>
|
||||
hasAudioMedia(
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined),
|
||||
);
|
||||
const replyToChannel =
|
||||
sessionCtx.OriginatingChannel ??
|
||||
((sessionCtx.Surface ?? sessionCtx.Provider)?.toLowerCase() as
|
||||
@@ -351,133 +197,20 @@ export async function runReplyAgent(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const memoryFlushSettings = resolveMemoryFlushSettings(cfg);
|
||||
const memoryFlushWritable = (() => {
|
||||
if (!sessionKey) return true;
|
||||
const runtime = resolveSandboxRuntimeStatus({ cfg, sessionKey });
|
||||
if (!runtime.sandboxed) return true;
|
||||
const sandboxCfg = resolveSandboxConfigForAgent(cfg, runtime.agentId);
|
||||
return sandboxCfg.workspaceAccess === "rw";
|
||||
})();
|
||||
const shouldFlushMemory =
|
||||
memoryFlushSettings &&
|
||||
memoryFlushWritable &&
|
||||
!isHeartbeat &&
|
||||
!isCliProvider(followupRun.run.provider, cfg) &&
|
||||
shouldRunMemoryFlush({
|
||||
entry:
|
||||
activeSessionEntry ??
|
||||
(sessionKey ? activeSessionStore?.[sessionKey] : undefined),
|
||||
contextWindowTokens: resolveMemoryFlushContextWindowTokens({
|
||||
modelId: followupRun.run.model ?? defaultModel,
|
||||
agentCfgContextTokens,
|
||||
}),
|
||||
reserveTokensFloor: memoryFlushSettings.reserveTokensFloor,
|
||||
softThresholdTokens: memoryFlushSettings.softThresholdTokens,
|
||||
});
|
||||
if (shouldFlushMemory) {
|
||||
const flushRunId = crypto.randomUUID();
|
||||
if (sessionKey) {
|
||||
registerAgentRunContext(flushRunId, {
|
||||
sessionKey,
|
||||
verboseLevel: resolvedVerboseLevel,
|
||||
});
|
||||
}
|
||||
let memoryCompactionCompleted = false;
|
||||
const flushSystemPrompt = [
|
||||
followupRun.run.extraSystemPrompt,
|
||||
memoryFlushSettings.systemPrompt,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
try {
|
||||
await runWithModelFallback({
|
||||
cfg: followupRun.run.config,
|
||||
provider: followupRun.run.provider,
|
||||
model: followupRun.run.model,
|
||||
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
||||
followupRun.run.config,
|
||||
resolveAgentIdFromSessionKey(followupRun.run.sessionKey),
|
||||
),
|
||||
run: (provider, model) =>
|
||||
runEmbeddedPiAgent({
|
||||
sessionId: followupRun.run.sessionId,
|
||||
sessionKey,
|
||||
messageProvider:
|
||||
sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
agentAccountId: sessionCtx.AccountId,
|
||||
// Provider threading context for tool auto-injection
|
||||
...buildThreadingToolContext({
|
||||
sessionCtx,
|
||||
config: followupRun.run.config,
|
||||
hasRepliedRef: opts?.hasRepliedRef,
|
||||
}),
|
||||
sessionFile: followupRun.run.sessionFile,
|
||||
workspaceDir: followupRun.run.workspaceDir,
|
||||
agentDir: followupRun.run.agentDir,
|
||||
config: followupRun.run.config,
|
||||
skillsSnapshot: followupRun.run.skillsSnapshot,
|
||||
prompt: memoryFlushSettings.prompt,
|
||||
extraSystemPrompt: flushSystemPrompt,
|
||||
ownerNumbers: followupRun.run.ownerNumbers,
|
||||
enforceFinalTag: resolveEnforceFinalTag(followupRun.run, provider),
|
||||
provider,
|
||||
model,
|
||||
authProfileId: followupRun.run.authProfileId,
|
||||
thinkLevel: followupRun.run.thinkLevel,
|
||||
verboseLevel: followupRun.run.verboseLevel,
|
||||
reasoningLevel: followupRun.run.reasoningLevel,
|
||||
bashElevated: followupRun.run.bashElevated,
|
||||
timeoutMs: followupRun.run.timeoutMs,
|
||||
runId: flushRunId,
|
||||
onAgentEvent: (evt) => {
|
||||
if (evt.stream === "compaction") {
|
||||
const phase =
|
||||
typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
memoryCompactionCompleted = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
let memoryFlushCompactionCount =
|
||||
activeSessionEntry?.compactionCount ??
|
||||
(sessionKey ? activeSessionStore?.[sessionKey]?.compactionCount : 0) ??
|
||||
0;
|
||||
if (memoryCompactionCompleted) {
|
||||
const nextCount = await incrementCompactionCount({
|
||||
sessionEntry: activeSessionEntry,
|
||||
sessionStore: activeSessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
if (typeof nextCount === "number") {
|
||||
memoryFlushCompactionCount = nextCount;
|
||||
}
|
||||
}
|
||||
if (storePath && sessionKey) {
|
||||
try {
|
||||
const updatedEntry = await updateSessionStoreEntry({
|
||||
storePath,
|
||||
sessionKey,
|
||||
update: async () => ({
|
||||
memoryFlushAt: Date.now(),
|
||||
memoryFlushCompactionCount,
|
||||
}),
|
||||
});
|
||||
if (updatedEntry) {
|
||||
activeSessionEntry = updatedEntry;
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`failed to persist memory flush metadata: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`memory flush run failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
activeSessionEntry = await runMemoryFlushIfNeeded({
|
||||
cfg,
|
||||
followupRun,
|
||||
sessionCtx,
|
||||
opts,
|
||||
defaultModel,
|
||||
agentCfgContextTokens,
|
||||
resolvedVerboseLevel,
|
||||
sessionEntry: activeSessionEntry,
|
||||
sessionStore: activeSessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
isHeartbeat,
|
||||
});
|
||||
|
||||
const runFollowupTurn = createFollowupRunner({
|
||||
opts,
|
||||
@@ -491,13 +224,6 @@ export async function runReplyAgent(params: {
|
||||
agentCfgContextTokens,
|
||||
});
|
||||
|
||||
const finalizeWithFollowup = <T>(value: T): T => {
|
||||
scheduleFollowupDrain(queueKey, runFollowupTurn);
|
||||
return value;
|
||||
};
|
||||
|
||||
let didLogHeartbeatStrip = false;
|
||||
let autoCompactionCompleted = false;
|
||||
let responseUsageLine: string | undefined;
|
||||
const resetSessionAfterCompactionFailure = async (
|
||||
reason: string,
|
||||
@@ -540,379 +266,38 @@ export async function runReplyAgent(params: {
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
const runId = crypto.randomUUID();
|
||||
if (sessionKey) {
|
||||
registerAgentRunContext(runId, {
|
||||
sessionKey,
|
||||
verboseLevel: resolvedVerboseLevel,
|
||||
});
|
||||
const runOutcome = await runAgentTurnWithFallback({
|
||||
commandBody,
|
||||
followupRun,
|
||||
sessionCtx,
|
||||
opts,
|
||||
typingSignals,
|
||||
blockReplyPipeline,
|
||||
blockStreamingEnabled,
|
||||
blockReplyChunking,
|
||||
resolvedBlockStreamingBreak,
|
||||
applyReplyToMode,
|
||||
shouldEmitToolResult,
|
||||
pendingToolTasks,
|
||||
resetSessionAfterCompactionFailure,
|
||||
isHeartbeat,
|
||||
sessionKey,
|
||||
getActiveSessionEntry: () => activeSessionEntry,
|
||||
activeSessionStore,
|
||||
storePath,
|
||||
resolvedVerboseLevel,
|
||||
});
|
||||
|
||||
if (runOutcome.kind === "final") {
|
||||
return finalizeWithFollowup(
|
||||
runOutcome.payload,
|
||||
queueKey,
|
||||
runFollowupTurn,
|
||||
);
|
||||
}
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
let fallbackProvider = followupRun.run.provider;
|
||||
let fallbackModel = followupRun.run.model;
|
||||
let didResetAfterCompactionFailure = false;
|
||||
while (true) {
|
||||
try {
|
||||
const allowPartialStream = !(
|
||||
followupRun.run.reasoningLevel === "stream" && opts?.onReasoningStream
|
||||
);
|
||||
const normalizeStreamingText = (
|
||||
payload: ReplyPayload,
|
||||
): { text?: string; skip: boolean } => {
|
||||
if (!allowPartialStream) return { skip: true };
|
||||
let text = payload.text;
|
||||
if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||
const stripped = stripHeartbeatToken(text, {
|
||||
mode: "message",
|
||||
});
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||
}
|
||||
if (stripped.shouldSkip && (payload.mediaUrls?.length ?? 0) === 0) {
|
||||
return { skip: true };
|
||||
}
|
||||
text = stripped.text;
|
||||
}
|
||||
if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) {
|
||||
return { skip: true };
|
||||
}
|
||||
return { text, skip: false };
|
||||
};
|
||||
const handlePartialForTyping = async (
|
||||
payload: ReplyPayload,
|
||||
): Promise<string | undefined> => {
|
||||
const { text, skip } = normalizeStreamingText(payload);
|
||||
if (skip || !text) return undefined;
|
||||
await typingSignals.signalTextDelta(text);
|
||||
return text;
|
||||
};
|
||||
const fallbackResult = await runWithModelFallback({
|
||||
cfg: followupRun.run.config,
|
||||
provider: followupRun.run.provider,
|
||||
model: followupRun.run.model,
|
||||
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
||||
followupRun.run.config,
|
||||
resolveAgentIdFromSessionKey(followupRun.run.sessionKey),
|
||||
),
|
||||
run: (provider, model) => {
|
||||
if (isCliProvider(provider, followupRun.run.config)) {
|
||||
const startedAt = Date.now();
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "start",
|
||||
startedAt,
|
||||
},
|
||||
});
|
||||
const cliSessionId = getCliSessionId(
|
||||
activeSessionEntry,
|
||||
provider,
|
||||
);
|
||||
return runCliAgent({
|
||||
sessionId: followupRun.run.sessionId,
|
||||
sessionKey,
|
||||
sessionFile: followupRun.run.sessionFile,
|
||||
workspaceDir: followupRun.run.workspaceDir,
|
||||
config: followupRun.run.config,
|
||||
prompt: commandBody,
|
||||
provider,
|
||||
model,
|
||||
thinkLevel: followupRun.run.thinkLevel,
|
||||
timeoutMs: followupRun.run.timeoutMs,
|
||||
runId,
|
||||
extraSystemPrompt: followupRun.run.extraSystemPrompt,
|
||||
ownerNumbers: followupRun.run.ownerNumbers,
|
||||
cliSessionId,
|
||||
})
|
||||
.then((result) => {
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "end",
|
||||
startedAt,
|
||||
endedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
return result;
|
||||
})
|
||||
.catch((err) => {
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "error",
|
||||
startedAt,
|
||||
endedAt: Date.now(),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return runEmbeddedPiAgent({
|
||||
sessionId: followupRun.run.sessionId,
|
||||
sessionKey,
|
||||
messageProvider:
|
||||
sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
agentAccountId: sessionCtx.AccountId,
|
||||
// Provider threading context for tool auto-injection
|
||||
...buildThreadingToolContext({
|
||||
sessionCtx,
|
||||
config: followupRun.run.config,
|
||||
hasRepliedRef: opts?.hasRepliedRef,
|
||||
}),
|
||||
sessionFile: followupRun.run.sessionFile,
|
||||
workspaceDir: followupRun.run.workspaceDir,
|
||||
agentDir: followupRun.run.agentDir,
|
||||
config: followupRun.run.config,
|
||||
skillsSnapshot: followupRun.run.skillsSnapshot,
|
||||
prompt: commandBody,
|
||||
extraSystemPrompt: followupRun.run.extraSystemPrompt,
|
||||
ownerNumbers: followupRun.run.ownerNumbers,
|
||||
enforceFinalTag: resolveEnforceFinalTag(
|
||||
followupRun.run,
|
||||
provider,
|
||||
),
|
||||
provider,
|
||||
model,
|
||||
authProfileId: followupRun.run.authProfileId,
|
||||
thinkLevel: followupRun.run.thinkLevel,
|
||||
verboseLevel: followupRun.run.verboseLevel,
|
||||
reasoningLevel: followupRun.run.reasoningLevel,
|
||||
bashElevated: followupRun.run.bashElevated,
|
||||
timeoutMs: followupRun.run.timeoutMs,
|
||||
runId,
|
||||
blockReplyBreak: resolvedBlockStreamingBreak,
|
||||
blockReplyChunking,
|
||||
onPartialReply: allowPartialStream
|
||||
? async (payload) => {
|
||||
const textForTyping = await handlePartialForTyping(payload);
|
||||
if (!opts?.onPartialReply || textForTyping === undefined)
|
||||
return;
|
||||
await opts.onPartialReply({
|
||||
text: textForTyping,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onAssistantMessageStart: async () => {
|
||||
await typingSignals.signalMessageStart();
|
||||
},
|
||||
onReasoningStream:
|
||||
typingSignals.shouldStartOnReasoning || opts?.onReasoningStream
|
||||
? async (payload) => {
|
||||
await typingSignals.signalReasoningDelta();
|
||||
await opts?.onReasoningStream?.({
|
||||
text: payload.text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onAgentEvent: (evt) => {
|
||||
// Trigger typing when tools start executing
|
||||
if (evt.stream === "tool") {
|
||||
const phase =
|
||||
typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
if (phase === "start" || phase === "update") {
|
||||
void typingSignals.signalToolStart();
|
||||
}
|
||||
}
|
||||
// Track auto-compaction completion
|
||||
if (evt.stream === "compaction") {
|
||||
const phase =
|
||||
typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
autoCompactionCompleted = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
onBlockReply:
|
||||
blockStreamingEnabled && opts?.onBlockReply
|
||||
? async (payload) => {
|
||||
const { text, skip } = normalizeStreamingText(payload);
|
||||
const hasPayloadMedia =
|
||||
(payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (skip && !hasPayloadMedia) return;
|
||||
const taggedPayload = applyReplyTagsToPayload(
|
||||
{
|
||||
text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
mediaUrl: payload.mediaUrls?.[0],
|
||||
},
|
||||
sessionCtx.MessageSid,
|
||||
);
|
||||
// Let through payloads with audioAsVoice flag even if empty (need to track it)
|
||||
if (
|
||||
!isRenderablePayload(taggedPayload) &&
|
||||
!payload.audioAsVoice
|
||||
)
|
||||
return;
|
||||
const parsed = parseReplyDirectives(
|
||||
taggedPayload.text ?? "",
|
||||
{
|
||||
currentMessageId: sessionCtx.MessageSid,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
},
|
||||
);
|
||||
const cleaned = parsed.text || undefined;
|
||||
const hasRenderableMedia =
|
||||
Boolean(taggedPayload.mediaUrl) ||
|
||||
(taggedPayload.mediaUrls?.length ?? 0) > 0;
|
||||
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
|
||||
if (
|
||||
!cleaned &&
|
||||
!hasRenderableMedia &&
|
||||
!payload.audioAsVoice &&
|
||||
!parsed.audioAsVoice
|
||||
)
|
||||
return;
|
||||
if (parsed.isSilent && !hasRenderableMedia) return;
|
||||
|
||||
const blockPayload: ReplyPayload = applyReplyToMode({
|
||||
...taggedPayload,
|
||||
text: cleaned,
|
||||
audioAsVoice: Boolean(
|
||||
parsed.audioAsVoice || payload.audioAsVoice,
|
||||
),
|
||||
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
|
||||
replyToTag:
|
||||
taggedPayload.replyToTag || parsed.replyToTag,
|
||||
replyToCurrent:
|
||||
taggedPayload.replyToCurrent || parsed.replyToCurrent,
|
||||
});
|
||||
|
||||
void typingSignals
|
||||
.signalTextDelta(cleaned ?? taggedPayload.text)
|
||||
.catch((err) => {
|
||||
logVerbose(
|
||||
`block reply typing signal failed: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
|
||||
blockReplyPipeline?.enqueue(blockPayload);
|
||||
}
|
||||
: undefined,
|
||||
onBlockReplyFlush:
|
||||
blockStreamingEnabled && blockReplyPipeline
|
||||
? async () => {
|
||||
await blockReplyPipeline.flush({ force: true });
|
||||
}
|
||||
: undefined,
|
||||
shouldEmitToolResult,
|
||||
onToolResult: opts?.onToolResult
|
||||
? (payload) => {
|
||||
// `subscribeEmbeddedPiSession` may invoke tool callbacks without awaiting them.
|
||||
// If a tool callback starts typing after the run finalized, we can end up with
|
||||
// a typing loop that never sees a matching markRunComplete(). Track and drain.
|
||||
const task = (async () => {
|
||||
const { text, skip } = normalizeStreamingText(payload);
|
||||
if (skip) return;
|
||||
await typingSignals.signalTextDelta(text);
|
||||
await opts.onToolResult?.({
|
||||
text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
})()
|
||||
.catch((err) => {
|
||||
logVerbose(
|
||||
`tool result delivery failed: ${String(err)}`,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingToolTasks.delete(task);
|
||||
});
|
||||
pendingToolTasks.add(task);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
runResult = fallbackResult.result;
|
||||
fallbackProvider = fallbackResult.provider;
|
||||
fallbackModel = fallbackResult.model;
|
||||
|
||||
// Some embedded runs surface context overflow as an error payload instead of throwing.
|
||||
// Treat those as a session-level failure and auto-recover by starting a fresh session.
|
||||
const embeddedError = runResult.meta?.error;
|
||||
if (
|
||||
embeddedError &&
|
||||
isContextOverflowError(embeddedError.message) &&
|
||||
!didResetAfterCompactionFailure &&
|
||||
(await resetSessionAfterCompactionFailure(embeddedError.message))
|
||||
) {
|
||||
didResetAfterCompactionFailure = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const isContextOverflow =
|
||||
isContextOverflowError(message) ||
|
||||
/context.*overflow|too large|context window/i.test(message);
|
||||
const isCompactionFailure = isCompactionFailureError(message);
|
||||
const isSessionCorruption =
|
||||
/function call turn comes immediately after/i.test(message);
|
||||
|
||||
if (
|
||||
isCompactionFailure &&
|
||||
!didResetAfterCompactionFailure &&
|
||||
(await resetSessionAfterCompactionFailure(message))
|
||||
) {
|
||||
didResetAfterCompactionFailure = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-recover from Gemini session corruption by resetting the session
|
||||
if (
|
||||
isSessionCorruption &&
|
||||
sessionKey &&
|
||||
activeSessionStore &&
|
||||
storePath
|
||||
) {
|
||||
const corruptedSessionId = activeSessionEntry?.sessionId;
|
||||
defaultRuntime.error(
|
||||
`Session history corrupted (Gemini function call ordering). Resetting session: ${sessionKey}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Delete transcript file if it exists
|
||||
if (corruptedSessionId) {
|
||||
const transcriptPath =
|
||||
resolveSessionTranscriptPath(corruptedSessionId);
|
||||
try {
|
||||
fs.unlinkSync(transcriptPath);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
// Remove session entry from store
|
||||
delete activeSessionStore[sessionKey];
|
||||
await saveSessionStore(storePath, activeSessionStore);
|
||||
} catch (cleanupErr) {
|
||||
defaultRuntime.error(
|
||||
`Failed to reset corrupted session ${sessionKey}: ${String(cleanupErr)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return finalizeWithFollowup({
|
||||
text: "⚠️ Session history was corrupted. I've reset the conversation - please try again!",
|
||||
});
|
||||
}
|
||||
|
||||
defaultRuntime.error(`Embedded agent failed before reply: ${message}`);
|
||||
return finalizeWithFollowup({
|
||||
text: isContextOverflow
|
||||
? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
|
||||
: `⚠️ Agent failed before reply: ${message}. Check gateway logs for details.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const { runResult, fallbackProvider, fallbackModel } = runOutcome;
|
||||
let { didLogHeartbeatStrip, autoCompactionCompleted } = runOutcome;
|
||||
|
||||
if (
|
||||
shouldInjectGroupIntro &&
|
||||
@@ -942,95 +327,31 @@ export async function runReplyAgent(params: {
|
||||
// Drain any late tool/block deliveries before deciding there's "nothing to send".
|
||||
// Otherwise, a late typing trigger (e.g. from a tool callback) can outlive the run and
|
||||
// keep the typing indicator stuck.
|
||||
if (payloadArray.length === 0) return finalizeWithFollowup(undefined);
|
||||
if (payloadArray.length === 0)
|
||||
return finalizeWithFollowup(undefined, queueKey, runFollowupTurn);
|
||||
|
||||
const sanitizedPayloads = isHeartbeat
|
||||
? payloadArray
|
||||
: payloadArray.flatMap((payload) => {
|
||||
let text = payload.text;
|
||||
|
||||
if (payload.isError && text && isBunFetchSocketError(text)) {
|
||||
text = formatBunFetchSocketError(text);
|
||||
}
|
||||
|
||||
if (!text || !text.includes("HEARTBEAT_OK"))
|
||||
return [{ ...payload, text }];
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||
}
|
||||
const hasMedia =
|
||||
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (stripped.shouldSkip && !hasMedia) return [];
|
||||
return [{ ...payload, text: stripped.text }];
|
||||
});
|
||||
|
||||
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
|
||||
payloads: sanitizedPayloads,
|
||||
const payloadResult = buildReplyPayloads({
|
||||
payloads: payloadArray,
|
||||
isHeartbeat,
|
||||
didLogHeartbeatStrip,
|
||||
blockStreamingEnabled,
|
||||
blockReplyPipeline,
|
||||
replyToMode,
|
||||
replyToChannel,
|
||||
currentMessageId: sessionCtx.MessageSid,
|
||||
})
|
||||
.map((payload) => {
|
||||
const parsed = parseReplyDirectives(payload.text ?? "", {
|
||||
currentMessageId: sessionCtx.MessageSid,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
});
|
||||
const mediaUrls = payload.mediaUrls ?? parsed.mediaUrls;
|
||||
const mediaUrl = payload.mediaUrl ?? parsed.mediaUrl ?? mediaUrls?.[0];
|
||||
return {
|
||||
...payload,
|
||||
text: parsed.text ? parsed.text : undefined,
|
||||
mediaUrls,
|
||||
mediaUrl,
|
||||
replyToId: payload.replyToId ?? parsed.replyToId,
|
||||
replyToTag: payload.replyToTag || parsed.replyToTag,
|
||||
replyToCurrent: payload.replyToCurrent || parsed.replyToCurrent,
|
||||
audioAsVoice: Boolean(payload.audioAsVoice || parsed.audioAsVoice),
|
||||
};
|
||||
})
|
||||
.filter(isRenderablePayload);
|
||||
|
||||
// Drop final payloads only when block streaming succeeded end-to-end.
|
||||
// If streaming aborted (e.g., timeout), fall back to final payloads.
|
||||
const shouldDropFinalPayloads =
|
||||
blockStreamingEnabled &&
|
||||
Boolean(blockReplyPipeline?.didStream()) &&
|
||||
!blockReplyPipeline?.isAborted();
|
||||
const messagingToolSentTexts = runResult.messagingToolSentTexts ?? [];
|
||||
const messagingToolSentTargets = runResult.messagingToolSentTargets ?? [];
|
||||
const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({
|
||||
messageProvider: followupRun.run.messageProvider,
|
||||
messagingToolSentTargets,
|
||||
messagingToolSentTexts: runResult.messagingToolSentTexts,
|
||||
messagingToolSentTargets: runResult.messagingToolSentTargets,
|
||||
originatingTo: sessionCtx.OriginatingTo ?? sessionCtx.To,
|
||||
accountId: sessionCtx.AccountId,
|
||||
});
|
||||
const dedupedPayloads = filterMessagingToolDuplicates({
|
||||
payloads: replyTaggedPayloads,
|
||||
sentTexts: messagingToolSentTexts,
|
||||
});
|
||||
const filteredPayloads = shouldDropFinalPayloads
|
||||
? []
|
||||
: blockStreamingEnabled
|
||||
? dedupedPayloads.filter(
|
||||
(payload) => !blockReplyPipeline?.hasSentPayload(payload),
|
||||
)
|
||||
: dedupedPayloads;
|
||||
const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads;
|
||||
const { replyPayloads } = payloadResult;
|
||||
didLogHeartbeatStrip = payloadResult.didLogHeartbeatStrip;
|
||||
|
||||
if (replyPayloads.length === 0) return finalizeWithFollowup(undefined);
|
||||
if (replyPayloads.length === 0)
|
||||
return finalizeWithFollowup(undefined, queueKey, runFollowupTurn);
|
||||
|
||||
const shouldSignalTyping = replyPayloads.some((payload) => {
|
||||
const trimmed = payload.text?.trim();
|
||||
if (trimmed) return true;
|
||||
if (payload.mediaUrl) return true;
|
||||
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
|
||||
return false;
|
||||
});
|
||||
if (shouldSignalTyping) {
|
||||
await typingSignals.signalRunStart();
|
||||
}
|
||||
await signalTypingIfNeeded(replyPayloads, typingSignals);
|
||||
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed =
|
||||
@@ -1166,6 +487,8 @@ export async function runReplyAgent(params: {
|
||||
|
||||
return finalizeWithFollowup(
|
||||
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
|
||||
queueKey,
|
||||
runFollowupTurn,
|
||||
);
|
||||
} finally {
|
||||
blockReplyPipeline?.stop();
|
||||
|
||||
36
src/auto-reply/reply/commands-bash.ts
Normal file
36
src/auto-reply/reply/commands-bash.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { handleBashChatCommand } from "./bash-command.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
|
||||
export const handleBashCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
if (!allowTextCommands) return null;
|
||||
const { command } = params;
|
||||
const bashSlashRequested =
|
||||
command.commandBodyNormalized === "/bash" ||
|
||||
command.commandBodyNormalized.startsWith("/bash ");
|
||||
const bashBangRequested = command.commandBodyNormalized.startsWith("!");
|
||||
if (
|
||||
!bashSlashRequested &&
|
||||
!(bashBangRequested && command.isAuthorizedSender)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /bash from unauthorized sender: ${command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
const reply = await handleBashChatCommand({
|
||||
ctx: params.ctx,
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
isGroup: params.isGroup,
|
||||
elevated: params.elevated,
|
||||
});
|
||||
return { shouldContinue: false, reply };
|
||||
};
|
||||
119
src/auto-reply/reply/commands-compact.ts
Normal file
119
src/auto-reply/reply/commands-compact.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
abortEmbeddedPiRun,
|
||||
compactEmbeddedPiSession,
|
||||
isEmbeddedPiRunActive,
|
||||
waitForEmbeddedPiRunEnd,
|
||||
} from "../../agents/pi-embedded.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { resolveSessionFilePath } from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { formatContextUsageShort, formatTokenCount } from "../status.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
import { incrementCompactionCount } from "./session-updates.js";
|
||||
|
||||
function extractCompactInstructions(params: {
|
||||
rawBody?: string;
|
||||
ctx: import("../templating.js").MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
agentId?: string;
|
||||
isGroup: boolean;
|
||||
}): string | undefined {
|
||||
const raw = stripStructuralPrefixes(params.rawBody ?? "");
|
||||
const stripped = params.isGroup
|
||||
? stripMentions(raw, params.ctx, params.cfg, params.agentId)
|
||||
: raw;
|
||||
const trimmed = stripped.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const lowered = trimmed.toLowerCase();
|
||||
const prefix = lowered.startsWith("/compact") ? "/compact" : null;
|
||||
if (!prefix) return undefined;
|
||||
let rest = trimmed.slice(prefix.length).trimStart();
|
||||
if (rest.startsWith(":")) rest = rest.slice(1).trimStart();
|
||||
return rest.length ? rest : undefined;
|
||||
}
|
||||
|
||||
export const handleCompactCommand: CommandHandler = async (params) => {
|
||||
const compactRequested =
|
||||
params.command.commandBodyNormalized === "/compact" ||
|
||||
params.command.commandBodyNormalized.startsWith("/compact ");
|
||||
if (!compactRequested) return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /compact from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
if (!params.sessionEntry?.sessionId) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Compaction unavailable (missing session id)." },
|
||||
};
|
||||
}
|
||||
const sessionId = params.sessionEntry.sessionId;
|
||||
if (isEmbeddedPiRunActive(sessionId)) {
|
||||
abortEmbeddedPiRun(sessionId);
|
||||
await waitForEmbeddedPiRunEnd(sessionId, 15_000);
|
||||
}
|
||||
const customInstructions = extractCompactInstructions({
|
||||
rawBody: params.ctx.CommandBody ?? params.ctx.RawBody ?? params.ctx.Body,
|
||||
ctx: params.ctx,
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
isGroup: params.isGroup,
|
||||
});
|
||||
const result = await compactEmbeddedPiSession({
|
||||
sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
messageChannel: params.command.channel,
|
||||
sessionFile: resolveSessionFilePath(sessionId, params.sessionEntry),
|
||||
workspaceDir: params.workspaceDir,
|
||||
config: params.cfg,
|
||||
skillsSnapshot: params.sessionEntry.skillsSnapshot,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
thinkLevel:
|
||||
params.resolvedThinkLevel ?? (await params.resolveDefaultThinkingLevel()),
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
customInstructions,
|
||||
ownerNumbers:
|
||||
params.command.ownerList.length > 0
|
||||
? params.command.ownerList
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const totalTokens =
|
||||
params.sessionEntry.totalTokens ??
|
||||
(params.sessionEntry.inputTokens ?? 0) +
|
||||
(params.sessionEntry.outputTokens ?? 0);
|
||||
const contextSummary = formatContextUsageShort(
|
||||
totalTokens > 0 ? totalTokens : null,
|
||||
params.contextTokens ?? params.sessionEntry.contextTokens ?? null,
|
||||
);
|
||||
const compactLabel = result.ok
|
||||
? result.compacted
|
||||
? result.result?.tokensBefore
|
||||
? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)`
|
||||
: "Compacted"
|
||||
: "Compaction skipped"
|
||||
: "Compaction failed";
|
||||
if (result.ok && result.compacted) {
|
||||
await incrementCompactionCount({
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionStore: params.sessionStore,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: params.storePath,
|
||||
});
|
||||
}
|
||||
const reason = result.reason?.trim();
|
||||
const line = reason
|
||||
? `${compactLabel}: ${reason} • ${contextSummary}`
|
||||
: `${compactLabel} • ${contextSummary}`;
|
||||
enqueueSystemEvent(line, { sessionKey: params.sessionKey });
|
||||
return { shouldContinue: false, reply: { text: `⚙️ ${line}` } };
|
||||
};
|
||||
255
src/auto-reply/reply/commands-config.ts
Normal file
255
src/auto-reply/reply/commands-config.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
readConfigFileSnapshot,
|
||||
validateConfigObject,
|
||||
writeConfigFile,
|
||||
} from "../../config/config.js";
|
||||
import {
|
||||
getConfigValueAtPath,
|
||||
parseConfigPath,
|
||||
setConfigValueAtPath,
|
||||
unsetConfigValueAtPath,
|
||||
} from "../../config/config-paths.js";
|
||||
import {
|
||||
getConfigOverrides,
|
||||
resetConfigOverrides,
|
||||
setConfigOverride,
|
||||
unsetConfigOverride,
|
||||
} from "../../config/runtime-overrides.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
import { parseConfigCommand } from "./config-commands.js";
|
||||
import { parseDebugCommand } from "./debug-commands.js";
|
||||
|
||||
export const handleConfigCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
if (!allowTextCommands) return null;
|
||||
const configCommand = parseConfigCommand(
|
||||
params.command.commandBodyNormalized,
|
||||
);
|
||||
if (!configCommand) return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /config from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
if (params.cfg.commands?.config !== true) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚠️ /config is disabled. Set commands.config=true to enable.",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (configCommand.action === "error") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${configCommand.message}` },
|
||||
};
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (
|
||||
!snapshot.valid ||
|
||||
!snapshot.parsed ||
|
||||
typeof snapshot.parsed !== "object"
|
||||
) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚠️ Config file is invalid; fix it before using /config.",
|
||||
},
|
||||
};
|
||||
}
|
||||
const parsedBase = structuredClone(
|
||||
snapshot.parsed as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (configCommand.action === "show") {
|
||||
const pathRaw = configCommand.path?.trim();
|
||||
if (pathRaw) {
|
||||
const parsedPath = parseConfigPath(pathRaw);
|
||||
if (!parsedPath.ok || !parsedPath.path) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
|
||||
};
|
||||
}
|
||||
const value = getConfigValueAtPath(parsedBase, parsedPath.path);
|
||||
const rendered = JSON.stringify(value ?? null, null, 2);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ Config ${pathRaw}:\n\`\`\`json\n${rendered}\n\`\`\``,
|
||||
},
|
||||
};
|
||||
}
|
||||
const json = JSON.stringify(parsedBase, null, 2);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚙️ Config (raw):\n\`\`\`json\n${json}\n\`\`\`` },
|
||||
};
|
||||
}
|
||||
|
||||
if (configCommand.action === "unset") {
|
||||
const parsedPath = parseConfigPath(configCommand.path);
|
||||
if (!parsedPath.ok || !parsedPath.path) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
|
||||
};
|
||||
}
|
||||
const removed = unsetConfigValueAtPath(parsedBase, parsedPath.path);
|
||||
if (!removed) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚙️ No config value found for ${configCommand.path}.` },
|
||||
};
|
||||
}
|
||||
const validated = validateConfigObject(parsedBase);
|
||||
if (!validated.ok) {
|
||||
const issue = validated.issues[0];
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚠️ Config invalid after unset (${issue.path}: ${issue.message}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
await writeConfigFile(validated.config);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚙️ Config updated: ${configCommand.path} removed.` },
|
||||
};
|
||||
}
|
||||
|
||||
if (configCommand.action === "set") {
|
||||
const parsedPath = parseConfigPath(configCommand.path);
|
||||
if (!parsedPath.ok || !parsedPath.path) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
|
||||
};
|
||||
}
|
||||
setConfigValueAtPath(parsedBase, parsedPath.path, configCommand.value);
|
||||
const validated = validateConfigObject(parsedBase);
|
||||
if (!validated.ok) {
|
||||
const issue = validated.issues[0];
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚠️ Config invalid after set (${issue.path}: ${issue.message}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
await writeConfigFile(validated.config);
|
||||
const valueLabel =
|
||||
typeof configCommand.value === "string"
|
||||
? `"${configCommand.value}"`
|
||||
: JSON.stringify(configCommand.value);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ Config updated: ${configCommand.path}=${valueLabel ?? "null"}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const handleDebugCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
if (!allowTextCommands) return null;
|
||||
const debugCommand = parseDebugCommand(params.command.commandBodyNormalized);
|
||||
if (!debugCommand) return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /debug from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
if (params.cfg.commands?.debug !== true) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚠️ /debug is disabled. Set commands.debug=true to enable.",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (debugCommand.action === "error") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${debugCommand.message}` },
|
||||
};
|
||||
}
|
||||
if (debugCommand.action === "show") {
|
||||
const overrides = getConfigOverrides();
|
||||
const hasOverrides = Object.keys(overrides).length > 0;
|
||||
if (!hasOverrides) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Debug overrides: (none)" },
|
||||
};
|
||||
}
|
||||
const json = JSON.stringify(overrides, null, 2);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ Debug overrides (memory-only):\n\`\`\`json\n${json}\n\`\`\``,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (debugCommand.action === "reset") {
|
||||
resetConfigOverrides();
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Debug overrides cleared; using config on disk." },
|
||||
};
|
||||
}
|
||||
if (debugCommand.action === "unset") {
|
||||
const result = unsetConfigOverride(debugCommand.path);
|
||||
if (!result.ok) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${result.error ?? "Invalid path."}` },
|
||||
};
|
||||
}
|
||||
if (!result.removed) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ No debug override found for ${debugCommand.path}.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚙️ Debug override removed for ${debugCommand.path}.` },
|
||||
};
|
||||
}
|
||||
if (debugCommand.action === "set") {
|
||||
const result = setConfigOverride(debugCommand.path, debugCommand.value);
|
||||
if (!result.ok) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${result.error ?? "Invalid override."}` },
|
||||
};
|
||||
}
|
||||
const valueLabel =
|
||||
typeof debugCommand.value === "string"
|
||||
? `"${debugCommand.value}"`
|
||||
: JSON.stringify(debugCommand.value);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ Debug override set: ${debugCommand.path}=${valueLabel ?? "null"}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
48
src/auto-reply/reply/commands-context.ts
Normal file
48
src/auto-reply/reply/commands-context.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
import { normalizeCommandBody } from "../commands-registry.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type { CommandContext } from "./commands-types.js";
|
||||
import { stripMentions } from "./mentions.js";
|
||||
|
||||
export function buildCommandContext(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
isGroup: boolean;
|
||||
triggerBodyNormalized: string;
|
||||
commandAuthorized: boolean;
|
||||
}): CommandContext {
|
||||
const { ctx, cfg, agentId, sessionKey, isGroup, triggerBodyNormalized } =
|
||||
params;
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: params.commandAuthorized,
|
||||
});
|
||||
const surface = (ctx.Surface ?? ctx.Provider ?? "").trim().toLowerCase();
|
||||
const channel = (ctx.Provider ?? surface).trim().toLowerCase();
|
||||
const abortKey =
|
||||
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
|
||||
const rawBodyNormalized = triggerBodyNormalized;
|
||||
const commandBodyNormalized = normalizeCommandBody(
|
||||
isGroup
|
||||
? stripMentions(rawBodyNormalized, ctx, cfg, agentId)
|
||||
: rawBodyNormalized,
|
||||
);
|
||||
|
||||
return {
|
||||
surface,
|
||||
channel,
|
||||
channelId: auth.providerId,
|
||||
ownerList: auth.ownerList,
|
||||
isAuthorizedSender: auth.isAuthorizedSender,
|
||||
senderId: auth.senderId,
|
||||
abortKey,
|
||||
rawBodyNormalized,
|
||||
commandBodyNormalized,
|
||||
from: auth.from,
|
||||
to: auth.to,
|
||||
};
|
||||
}
|
||||
81
src/auto-reply/reply/commands-core.ts
Normal file
81
src/auto-reply/reply/commands-core.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
import { shouldHandleTextCommands } from "../commands-registry.js";
|
||||
import { handleBashCommand } from "./commands-bash.js";
|
||||
import { handleCompactCommand } from "./commands-compact.js";
|
||||
import { handleConfigCommand, handleDebugCommand } from "./commands-config.js";
|
||||
import {
|
||||
handleCommandsListCommand,
|
||||
handleHelpCommand,
|
||||
handleStatusCommand,
|
||||
handleWhoamiCommand,
|
||||
} from "./commands-info.js";
|
||||
import {
|
||||
handleAbortTrigger,
|
||||
handleActivationCommand,
|
||||
handleRestartCommand,
|
||||
handleSendPolicyCommand,
|
||||
handleStopCommand,
|
||||
} from "./commands-session.js";
|
||||
import type {
|
||||
CommandHandler,
|
||||
CommandHandlerResult,
|
||||
HandleCommandsParams,
|
||||
} from "./commands-types.js";
|
||||
|
||||
const HANDLERS: CommandHandler[] = [
|
||||
handleBashCommand,
|
||||
handleActivationCommand,
|
||||
handleSendPolicyCommand,
|
||||
handleRestartCommand,
|
||||
handleHelpCommand,
|
||||
handleCommandsListCommand,
|
||||
handleStatusCommand,
|
||||
handleWhoamiCommand,
|
||||
handleConfigCommand,
|
||||
handleDebugCommand,
|
||||
handleStopCommand,
|
||||
handleCompactCommand,
|
||||
handleAbortTrigger,
|
||||
];
|
||||
|
||||
export async function handleCommands(
|
||||
params: HandleCommandsParams,
|
||||
): Promise<CommandHandlerResult> {
|
||||
const resetRequested =
|
||||
params.command.commandBodyNormalized === "/reset" ||
|
||||
params.command.commandBodyNormalized === "/new";
|
||||
if (resetRequested && !params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /reset from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
const allowTextCommands = shouldHandleTextCommands({
|
||||
cfg: params.cfg,
|
||||
surface: params.command.surface,
|
||||
commandSource: params.ctx.CommandSource,
|
||||
});
|
||||
|
||||
for (const handler of HANDLERS) {
|
||||
const result = await handler(params, allowTextCommands);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
const sendPolicy = resolveSendPolicy({
|
||||
cfg: params.cfg,
|
||||
entry: params.sessionEntry,
|
||||
sessionKey: params.sessionKey,
|
||||
channel: params.sessionEntry?.channel ?? params.command.channel,
|
||||
chatType: params.sessionEntry?.chatType,
|
||||
});
|
||||
if (sendPolicy === "deny") {
|
||||
logVerbose(
|
||||
`Send blocked by policy for session ${params.sessionKey ?? "unknown"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
return { shouldContinue: true };
|
||||
}
|
||||
109
src/auto-reply/reply/commands-info.ts
Normal file
109
src/auto-reply/reply/commands-info.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { buildCommandsMessage, buildHelpMessage } from "../status.js";
|
||||
import { buildStatusReply } from "./commands-status.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
|
||||
export const handleHelpCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (params.command.commandBodyNormalized !== "/help") return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /help from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: buildHelpMessage(params.cfg) },
|
||||
};
|
||||
};
|
||||
|
||||
export const handleCommandsListCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (params.command.commandBodyNormalized !== "/commands") return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /commands from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: buildCommandsMessage(params.cfg) },
|
||||
};
|
||||
};
|
||||
|
||||
export const handleStatusCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
if (!allowTextCommands) return null;
|
||||
const statusRequested =
|
||||
params.directives.hasStatusDirective ||
|
||||
params.command.commandBodyNormalized === "/status";
|
||||
if (!statusRequested) return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /status from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
const reply = await buildStatusReply({
|
||||
cfg: params.cfg,
|
||||
command: params.command,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionScope: params.sessionScope,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
contextTokens: params.contextTokens,
|
||||
resolvedThinkLevel: params.resolvedThinkLevel,
|
||||
resolvedVerboseLevel: params.resolvedVerboseLevel,
|
||||
resolvedReasoningLevel: params.resolvedReasoningLevel,
|
||||
resolvedElevatedLevel: params.resolvedElevatedLevel,
|
||||
resolveDefaultThinkingLevel: params.resolveDefaultThinkingLevel,
|
||||
isGroup: params.isGroup,
|
||||
defaultGroupActivation: params.defaultGroupActivation,
|
||||
});
|
||||
return { shouldContinue: false, reply };
|
||||
};
|
||||
|
||||
export const handleWhoamiCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (params.command.commandBodyNormalized !== "/whoami") return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /whoami from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
const senderId = params.ctx.SenderId ?? "";
|
||||
const senderUsername = params.ctx.SenderUsername ?? "";
|
||||
const lines = ["🧭 Identity", `Channel: ${params.command.channel}`];
|
||||
if (senderId) lines.push(`User id: ${senderId}`);
|
||||
if (senderUsername) {
|
||||
const handle = senderUsername.startsWith("@")
|
||||
? senderUsername
|
||||
: `@${senderUsername}`;
|
||||
lines.push(`Username: ${handle}`);
|
||||
}
|
||||
if (params.ctx.ChatType === "group" && params.ctx.From) {
|
||||
lines.push(`Chat: ${params.ctx.From}`);
|
||||
}
|
||||
if (params.ctx.MessageThreadId != null) {
|
||||
lines.push(`Thread: ${params.ctx.MessageThreadId}`);
|
||||
}
|
||||
if (senderId) {
|
||||
lines.push(`AllowFrom: ${senderId}`);
|
||||
}
|
||||
return { shouldContinue: false, reply: { text: lines.join("\n") } };
|
||||
};
|
||||
252
src/auto-reply/reply/commands-session.ts
Normal file
252
src/auto-reply/reply/commands-session.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { saveSessionStore } from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import {
|
||||
scheduleGatewaySigusr1Restart,
|
||||
triggerClawdbotRestart,
|
||||
} from "../../infra/restart.js";
|
||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { parseActivationCommand } from "../group-activation.js";
|
||||
import { parseSendPolicyCommand } from "../send-policy.js";
|
||||
import { isAbortTrigger, setAbortMemory } from "./abort.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
|
||||
function resolveSessionEntryForKey(
|
||||
store: Record<string, SessionEntry> | undefined,
|
||||
sessionKey: string | undefined,
|
||||
) {
|
||||
if (!store || !sessionKey) return {};
|
||||
const direct = store[sessionKey];
|
||||
if (direct) return { entry: direct, key: sessionKey };
|
||||
const parsed = parseAgentSessionKey(sessionKey);
|
||||
const legacyKey = parsed?.rest;
|
||||
if (legacyKey && store[legacyKey]) {
|
||||
return { entry: store[legacyKey], key: legacyKey };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function resolveAbortTarget(params: {
|
||||
ctx: { CommandTargetSessionKey?: string | null };
|
||||
sessionKey?: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
}) {
|
||||
const targetSessionKey =
|
||||
params.ctx.CommandTargetSessionKey?.trim() || params.sessionKey;
|
||||
const { entry, key } = resolveSessionEntryForKey(
|
||||
params.sessionStore,
|
||||
targetSessionKey,
|
||||
);
|
||||
if (entry && key) return { entry, key, sessionId: entry.sessionId };
|
||||
if (params.sessionEntry && params.sessionKey) {
|
||||
return {
|
||||
entry: params.sessionEntry,
|
||||
key: params.sessionKey,
|
||||
sessionId: params.sessionEntry.sessionId,
|
||||
};
|
||||
}
|
||||
return { entry: undefined, key: targetSessionKey, sessionId: undefined };
|
||||
}
|
||||
|
||||
export const handleActivationCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
if (!allowTextCommands) return null;
|
||||
const activationCommand = parseActivationCommand(
|
||||
params.command.commandBodyNormalized,
|
||||
);
|
||||
if (!activationCommand.hasCommand) return null;
|
||||
if (!params.isGroup) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Group activation only applies to group chats." },
|
||||
};
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /activation from unauthorized sender in group: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
if (!activationCommand.mode) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Usage: /activation mention|always" },
|
||||
};
|
||||
}
|
||||
if (params.sessionEntry && params.sessionStore && params.sessionKey) {
|
||||
params.sessionEntry.groupActivation = activationCommand.mode;
|
||||
params.sessionEntry.groupActivationNeedsSystemIntro = true;
|
||||
params.sessionEntry.updatedAt = Date.now();
|
||||
params.sessionStore[params.sessionKey] = params.sessionEntry;
|
||||
if (params.storePath) {
|
||||
await saveSessionStore(params.storePath, params.sessionStore);
|
||||
}
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ Group activation set to ${activationCommand.mode}.`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const handleSendPolicyCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
if (!allowTextCommands) return null;
|
||||
const sendPolicyCommand = parseSendPolicyCommand(
|
||||
params.command.commandBodyNormalized,
|
||||
);
|
||||
if (!sendPolicyCommand.hasCommand) return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /send from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
if (!sendPolicyCommand.mode) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Usage: /send on|off|inherit" },
|
||||
};
|
||||
}
|
||||
if (params.sessionEntry && params.sessionStore && params.sessionKey) {
|
||||
if (sendPolicyCommand.mode === "inherit") {
|
||||
delete params.sessionEntry.sendPolicy;
|
||||
} else {
|
||||
params.sessionEntry.sendPolicy = sendPolicyCommand.mode;
|
||||
}
|
||||
params.sessionEntry.updatedAt = Date.now();
|
||||
params.sessionStore[params.sessionKey] = params.sessionEntry;
|
||||
if (params.storePath) {
|
||||
await saveSessionStore(params.storePath, params.sessionStore);
|
||||
}
|
||||
}
|
||||
const label =
|
||||
sendPolicyCommand.mode === "inherit"
|
||||
? "inherit"
|
||||
: sendPolicyCommand.mode === "allow"
|
||||
? "on"
|
||||
: "off";
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚙️ Send policy set to ${label}.` },
|
||||
};
|
||||
};
|
||||
|
||||
export const handleRestartCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (params.command.commandBodyNormalized !== "/restart") return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /restart from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
if (params.cfg.commands?.restart !== true) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚠️ /restart is disabled. Set commands.restart=true to enable.",
|
||||
},
|
||||
};
|
||||
}
|
||||
const hasSigusr1Listener = process.listenerCount("SIGUSR1") > 0;
|
||||
if (hasSigusr1Listener) {
|
||||
scheduleGatewaySigusr1Restart({ reason: "/restart" });
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚙️ Restarting clawdbot in-process (SIGUSR1); back in a few seconds.",
|
||||
},
|
||||
};
|
||||
}
|
||||
const restartMethod = triggerClawdbotRestart();
|
||||
if (!restartMethod.ok) {
|
||||
const detail = restartMethod.detail
|
||||
? ` Details: ${restartMethod.detail}`
|
||||
: "";
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚠️ Restart failed (${restartMethod.method}).${detail}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ Restarting clawdbot via ${restartMethod.method}; give me a few seconds to come back online.`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const handleStopCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (params.command.commandBodyNormalized !== "/stop") return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /stop from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
const abortTarget = resolveAbortTarget({
|
||||
ctx: params.ctx,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionStore: params.sessionStore,
|
||||
});
|
||||
if (abortTarget.sessionId) {
|
||||
abortEmbeddedPiRun(abortTarget.sessionId);
|
||||
}
|
||||
if (abortTarget.entry && params.sessionStore && abortTarget.key) {
|
||||
abortTarget.entry.abortedLastRun = true;
|
||||
abortTarget.entry.updatedAt = Date.now();
|
||||
params.sessionStore[abortTarget.key] = abortTarget.entry;
|
||||
if (params.storePath) {
|
||||
await saveSessionStore(params.storePath, params.sessionStore);
|
||||
}
|
||||
} else if (params.command.abortKey) {
|
||||
setAbortMemory(params.command.abortKey, true);
|
||||
}
|
||||
return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } };
|
||||
};
|
||||
|
||||
export const handleAbortTrigger: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (!isAbortTrigger(params.command.rawBodyNormalized)) return null;
|
||||
const abortTarget = resolveAbortTarget({
|
||||
ctx: params.ctx,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionStore: params.sessionStore,
|
||||
});
|
||||
if (abortTarget.sessionId) {
|
||||
abortEmbeddedPiRun(abortTarget.sessionId);
|
||||
}
|
||||
if (abortTarget.entry && params.sessionStore && abortTarget.key) {
|
||||
abortTarget.entry.abortedLastRun = true;
|
||||
abortTarget.entry.updatedAt = Date.now();
|
||||
params.sessionStore[abortTarget.key] = abortTarget.entry;
|
||||
if (params.storePath) {
|
||||
await saveSessionStore(params.storePath, params.sessionStore);
|
||||
}
|
||||
} else if (params.command.abortKey) {
|
||||
setAbortMemory(params.command.abortKey, true);
|
||||
}
|
||||
return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } };
|
||||
};
|
||||
223
src/auto-reply/reply/commands-status.ts
Normal file
223
src/auto-reply/reply/commands-status.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
resolveAgentDir,
|
||||
resolveDefaultAgentId,
|
||||
resolveSessionAgentId,
|
||||
} from "../../agents/agent-scope.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
resolveAuthProfileDisplayLabel,
|
||||
resolveAuthProfileOrder,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import {
|
||||
getCustomProviderApiKey,
|
||||
resolveEnvApiKey,
|
||||
} from "../../agents/model-auth.js";
|
||||
import { normalizeProviderId } from "../../agents/model-selection.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { SessionEntry, SessionScope } from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import {
|
||||
formatUsageSummaryLine,
|
||||
loadProviderUsageSummary,
|
||||
resolveUsageProviderId,
|
||||
} from "../../infra/provider-usage.js";
|
||||
import { normalizeGroupActivation } from "../group-activation.js";
|
||||
import { buildStatusMessage } from "../status.js";
|
||||
import type {
|
||||
ElevatedLevel,
|
||||
ReasoningLevel,
|
||||
ThinkLevel,
|
||||
VerboseLevel,
|
||||
} from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import type { CommandContext } from "./commands-types.js";
|
||||
import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js";
|
||||
|
||||
function formatApiKeySnippet(apiKey: string): string {
|
||||
const compact = apiKey.replace(/\s+/g, "");
|
||||
if (!compact) return "unknown";
|
||||
const edge = compact.length >= 12 ? 6 : 4;
|
||||
const head = compact.slice(0, edge);
|
||||
const tail = compact.slice(-edge);
|
||||
return `${head}…${tail}`;
|
||||
}
|
||||
|
||||
function resolveModelAuthLabel(
|
||||
provider?: string,
|
||||
cfg?: ClawdbotConfig,
|
||||
sessionEntry?: SessionEntry,
|
||||
agentDir?: string,
|
||||
): string | undefined {
|
||||
const resolved = provider?.trim();
|
||||
if (!resolved) return undefined;
|
||||
|
||||
const providerKey = normalizeProviderId(resolved);
|
||||
const store = ensureAuthProfileStore(agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const profileOverride = sessionEntry?.authProfileOverride?.trim();
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg,
|
||||
store,
|
||||
provider: providerKey,
|
||||
preferredProfile: profileOverride,
|
||||
});
|
||||
const candidates = [profileOverride, ...order].filter(Boolean) as string[];
|
||||
|
||||
for (const profileId of candidates) {
|
||||
const profile = store.profiles[profileId];
|
||||
if (!profile || normalizeProviderId(profile.provider) !== providerKey) {
|
||||
continue;
|
||||
}
|
||||
const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
|
||||
if (profile.type === "oauth") {
|
||||
return `oauth${label ? ` (${label})` : ""}`;
|
||||
}
|
||||
if (profile.type === "token") {
|
||||
const snippet = formatApiKeySnippet(profile.token);
|
||||
return `token ${snippet}${label ? ` (${label})` : ""}`;
|
||||
}
|
||||
const snippet = formatApiKeySnippet(profile.key);
|
||||
return `api-key ${snippet}${label ? ` (${label})` : ""}`;
|
||||
}
|
||||
|
||||
const envKey = resolveEnvApiKey(providerKey);
|
||||
if (envKey?.apiKey) {
|
||||
if (envKey.source.includes("OAUTH_TOKEN")) {
|
||||
return `oauth (${envKey.source})`;
|
||||
}
|
||||
return `api-key ${formatApiKeySnippet(envKey.apiKey)} (${envKey.source})`;
|
||||
}
|
||||
|
||||
const customKey = getCustomProviderApiKey(cfg, providerKey);
|
||||
if (customKey) {
|
||||
return `api-key ${formatApiKeySnippet(customKey)} (models.json)`;
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export async function buildStatusReply(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
command: CommandContext;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionKey: string;
|
||||
sessionScope?: SessionScope;
|
||||
provider: string;
|
||||
model: string;
|
||||
contextTokens: number;
|
||||
resolvedThinkLevel?: ThinkLevel;
|
||||
resolvedVerboseLevel: VerboseLevel;
|
||||
resolvedReasoningLevel: ReasoningLevel;
|
||||
resolvedElevatedLevel?: ElevatedLevel;
|
||||
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
|
||||
isGroup: boolean;
|
||||
defaultGroupActivation: () => "always" | "mention";
|
||||
}): Promise<ReplyPayload | undefined> {
|
||||
const {
|
||||
cfg,
|
||||
command,
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
sessionScope,
|
||||
provider,
|
||||
model,
|
||||
contextTokens,
|
||||
resolvedThinkLevel,
|
||||
resolvedVerboseLevel,
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
resolveDefaultThinkingLevel,
|
||||
isGroup,
|
||||
defaultGroupActivation,
|
||||
} = params;
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /status from unauthorized sender: ${command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const statusAgentId = sessionKey
|
||||
? resolveSessionAgentId({ sessionKey, config: cfg })
|
||||
: resolveDefaultAgentId(cfg);
|
||||
const statusAgentDir = resolveAgentDir(cfg, statusAgentId);
|
||||
let usageLine: string | null = null;
|
||||
try {
|
||||
const usageProvider = resolveUsageProviderId(provider);
|
||||
if (usageProvider) {
|
||||
const usageSummary = await loadProviderUsageSummary({
|
||||
timeoutMs: 3500,
|
||||
providers: [usageProvider],
|
||||
agentDir: statusAgentDir,
|
||||
});
|
||||
usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() });
|
||||
if (
|
||||
!usageLine &&
|
||||
(resolvedVerboseLevel === "on" || resolvedElevatedLevel === "on")
|
||||
) {
|
||||
const entry = usageSummary.providers[0];
|
||||
if (entry?.error) {
|
||||
usageLine = `📊 Usage: ${entry.displayName} (${entry.error})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
usageLine = null;
|
||||
}
|
||||
const queueSettings = resolveQueueSettings({
|
||||
cfg,
|
||||
channel: command.channel,
|
||||
sessionEntry,
|
||||
});
|
||||
const queueKey = sessionKey ?? sessionEntry?.sessionId;
|
||||
const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0;
|
||||
const queueOverrides = Boolean(
|
||||
sessionEntry?.queueDebounceMs ??
|
||||
sessionEntry?.queueCap ??
|
||||
sessionEntry?.queueDrop,
|
||||
);
|
||||
const groupActivation = isGroup
|
||||
? (normalizeGroupActivation(sessionEntry?.groupActivation) ??
|
||||
defaultGroupActivation())
|
||||
: undefined;
|
||||
const agentDefaults = cfg.agents?.defaults ?? {};
|
||||
const statusText = buildStatusMessage({
|
||||
config: cfg,
|
||||
agent: {
|
||||
...agentDefaults,
|
||||
model: {
|
||||
...agentDefaults.model,
|
||||
primary: `${provider}/${model}`,
|
||||
},
|
||||
contextTokens,
|
||||
thinkingDefault: agentDefaults.thinkingDefault,
|
||||
verboseDefault: agentDefaults.verboseDefault,
|
||||
elevatedDefault: agentDefaults.elevatedDefault,
|
||||
},
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
sessionScope,
|
||||
groupActivation,
|
||||
resolvedThink: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
|
||||
resolvedVerbose: resolvedVerboseLevel,
|
||||
resolvedReasoning: resolvedReasoningLevel,
|
||||
resolvedElevated: resolvedElevatedLevel,
|
||||
modelAuth: resolveModelAuthLabel(
|
||||
provider,
|
||||
cfg,
|
||||
sessionEntry,
|
||||
statusAgentDir,
|
||||
),
|
||||
usageLine: usageLine ?? undefined,
|
||||
queue: {
|
||||
mode: queueSettings.mode,
|
||||
depth: queueDepth,
|
||||
debounceMs: queueSettings.debounceMs,
|
||||
cap: queueSettings.cap,
|
||||
dropPolicy: queueSettings.dropPolicy,
|
||||
showDetails: queueOverrides,
|
||||
},
|
||||
includeTranscriptUsage: false,
|
||||
});
|
||||
return { text: statusText };
|
||||
}
|
||||
65
src/auto-reply/reply/commands-types.ts
Normal file
65
src/auto-reply/reply/commands-types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { SessionEntry, SessionScope } from "../../config/sessions.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type {
|
||||
ElevatedLevel,
|
||||
ReasoningLevel,
|
||||
ThinkLevel,
|
||||
VerboseLevel,
|
||||
} from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import type { InlineDirectives } from "./directive-handling.js";
|
||||
|
||||
export type CommandContext = {
|
||||
surface: string;
|
||||
channel: string;
|
||||
channelId?: ChannelId;
|
||||
ownerList: string[];
|
||||
isAuthorizedSender: boolean;
|
||||
senderId?: string;
|
||||
abortKey?: string;
|
||||
rawBodyNormalized: string;
|
||||
commandBodyNormalized: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export type HandleCommandsParams = {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
command: CommandContext;
|
||||
agentId?: string;
|
||||
directives: InlineDirectives;
|
||||
elevated: {
|
||||
enabled: boolean;
|
||||
allowed: boolean;
|
||||
failures: Array<{ gate: string; key: string }>;
|
||||
};
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
sessionScope?: SessionScope;
|
||||
workspaceDir: string;
|
||||
defaultGroupActivation: () => "always" | "mention";
|
||||
resolvedThinkLevel?: ThinkLevel;
|
||||
resolvedVerboseLevel: VerboseLevel;
|
||||
resolvedReasoningLevel: ReasoningLevel;
|
||||
resolvedElevatedLevel?: ElevatedLevel;
|
||||
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
|
||||
provider: string;
|
||||
model: string;
|
||||
contextTokens: number;
|
||||
isGroup: boolean;
|
||||
};
|
||||
|
||||
export type CommandHandlerResult = {
|
||||
reply?: ReplyPayload;
|
||||
shouldContinue: boolean;
|
||||
};
|
||||
|
||||
export type CommandHandler = (
|
||||
params: HandleCommandsParams,
|
||||
allowTextCommands: boolean,
|
||||
) => Promise<CommandHandlerResult | null>;
|
||||
File diff suppressed because it is too large
Load Diff
309
src/auto-reply/reply/get-reply-directives-apply.ts
Normal file
309
src/auto-reply/reply/get-reply-directives-apply.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type {
|
||||
ElevatedLevel,
|
||||
ReasoningLevel,
|
||||
ThinkLevel,
|
||||
VerboseLevel,
|
||||
} from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { buildStatusReply } from "./commands.js";
|
||||
import {
|
||||
applyInlineDirectivesFastLane,
|
||||
handleDirectiveOnly,
|
||||
type InlineDirectives,
|
||||
isDirectiveOnly,
|
||||
persistInlineDirectives,
|
||||
} from "./directive-handling.js";
|
||||
import type { createModelSelectionState } from "./model-selection.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
type AgentDefaults = NonNullable<ClawdbotConfig["agents"]>["defaults"];
|
||||
|
||||
export type ApplyDirectiveResult =
|
||||
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
|
||||
| {
|
||||
kind: "continue";
|
||||
directives: InlineDirectives;
|
||||
provider: string;
|
||||
model: string;
|
||||
contextTokens: number;
|
||||
directiveAck?: ReplyPayload;
|
||||
perMessageQueueMode?: InlineDirectives["queueMode"];
|
||||
perMessageQueueOptions?: {
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
dropPolicy?: InlineDirectives["dropPolicy"];
|
||||
};
|
||||
};
|
||||
|
||||
export async function applyInlineDirectiveOverrides(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
agentDir: string;
|
||||
agentCfg: AgentDefaults;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
sessionScope: Parameters<typeof buildStatusReply>[0]["sessionScope"];
|
||||
isGroup: boolean;
|
||||
allowTextCommands: boolean;
|
||||
command: Parameters<typeof buildStatusReply>[0]["command"];
|
||||
directives: InlineDirectives;
|
||||
messageProviderKey: string;
|
||||
elevatedEnabled: boolean;
|
||||
elevatedAllowed: boolean;
|
||||
elevatedFailures: Array<{ gate: string; key: string }>;
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
aliasIndex: Parameters<typeof applyInlineDirectivesFastLane>[0]["aliasIndex"];
|
||||
provider: string;
|
||||
model: string;
|
||||
modelState: Awaited<ReturnType<typeof createModelSelectionState>>;
|
||||
initialModelLabel: string;
|
||||
formatModelSwitchEvent: (label: string, alias?: string) => string;
|
||||
resolvedElevatedLevel: ElevatedLevel;
|
||||
defaultActivation: () => ReturnType<
|
||||
Parameters<typeof buildStatusReply>[0]["defaultGroupActivation"]
|
||||
>;
|
||||
contextTokens: number;
|
||||
effectiveModelDirective?: string;
|
||||
typing: TypingController;
|
||||
}): Promise<ApplyDirectiveResult> {
|
||||
const {
|
||||
ctx,
|
||||
cfg,
|
||||
agentId,
|
||||
agentDir,
|
||||
agentCfg,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionScope,
|
||||
isGroup,
|
||||
allowTextCommands,
|
||||
command,
|
||||
messageProviderKey,
|
||||
elevatedEnabled,
|
||||
elevatedAllowed,
|
||||
elevatedFailures,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
aliasIndex,
|
||||
modelState,
|
||||
initialModelLabel,
|
||||
formatModelSwitchEvent,
|
||||
resolvedElevatedLevel,
|
||||
defaultActivation,
|
||||
typing,
|
||||
effectiveModelDirective,
|
||||
} = params;
|
||||
let { directives } = params;
|
||||
let { provider, model } = params;
|
||||
let { contextTokens } = params;
|
||||
|
||||
let directiveAck: ReplyPayload | undefined;
|
||||
|
||||
if (!command.isAuthorizedSender) {
|
||||
directives = {
|
||||
...directives,
|
||||
hasThinkDirective: false,
|
||||
hasVerboseDirective: false,
|
||||
hasReasoningDirective: false,
|
||||
hasElevatedDirective: false,
|
||||
hasStatusDirective: false,
|
||||
hasModelDirective: false,
|
||||
hasQueueDirective: false,
|
||||
queueReset: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
isDirectiveOnly({
|
||||
directives,
|
||||
cleanedBody: directives.cleaned,
|
||||
ctx,
|
||||
cfg,
|
||||
agentId,
|
||||
isGroup,
|
||||
})
|
||||
) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
typing.cleanup();
|
||||
return { kind: "reply", reply: undefined };
|
||||
}
|
||||
const resolvedDefaultThinkLevel =
|
||||
(sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
|
||||
(agentCfg?.thinkingDefault as ThinkLevel | undefined) ??
|
||||
(await modelState.resolveDefaultThinkingLevel());
|
||||
const currentThinkLevel = resolvedDefaultThinkLevel;
|
||||
const currentVerboseLevel =
|
||||
(sessionEntry?.verboseLevel as VerboseLevel | undefined) ??
|
||||
(agentCfg?.verboseDefault as VerboseLevel | undefined);
|
||||
const currentReasoningLevel =
|
||||
(sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? "off";
|
||||
const currentElevatedLevel =
|
||||
(sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ??
|
||||
(agentCfg?.elevatedDefault as ElevatedLevel | undefined);
|
||||
const directiveReply = await handleDirectiveOnly({
|
||||
cfg,
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
elevatedEnabled,
|
||||
elevatedAllowed,
|
||||
elevatedFailures,
|
||||
messageProviderKey,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
aliasIndex,
|
||||
allowedModelKeys: modelState.allowedModelKeys,
|
||||
allowedModelCatalog: modelState.allowedModelCatalog,
|
||||
resetModelOverride: modelState.resetModelOverride,
|
||||
provider,
|
||||
model,
|
||||
initialModelLabel,
|
||||
formatModelSwitchEvent,
|
||||
currentThinkLevel,
|
||||
currentVerboseLevel,
|
||||
currentReasoningLevel,
|
||||
currentElevatedLevel,
|
||||
});
|
||||
let statusReply: ReplyPayload | undefined;
|
||||
if (
|
||||
directives.hasStatusDirective &&
|
||||
allowTextCommands &&
|
||||
command.isAuthorizedSender
|
||||
) {
|
||||
statusReply = await buildStatusReply({
|
||||
cfg,
|
||||
command,
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
sessionScope,
|
||||
provider,
|
||||
model,
|
||||
contextTokens,
|
||||
resolvedThinkLevel: resolvedDefaultThinkLevel,
|
||||
resolvedVerboseLevel: (currentVerboseLevel ?? "off") as VerboseLevel,
|
||||
resolvedReasoningLevel: (currentReasoningLevel ??
|
||||
"off") as ReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
resolveDefaultThinkingLevel: async () => resolvedDefaultThinkLevel,
|
||||
isGroup,
|
||||
defaultGroupActivation: defaultActivation,
|
||||
});
|
||||
}
|
||||
typing.cleanup();
|
||||
if (statusReply?.text && directiveReply?.text) {
|
||||
return {
|
||||
kind: "reply",
|
||||
reply: { text: `${directiveReply.text}\n${statusReply.text}` },
|
||||
};
|
||||
}
|
||||
return { kind: "reply", reply: statusReply ?? directiveReply };
|
||||
}
|
||||
|
||||
const hasAnyDirective =
|
||||
directives.hasThinkDirective ||
|
||||
directives.hasVerboseDirective ||
|
||||
directives.hasReasoningDirective ||
|
||||
directives.hasElevatedDirective ||
|
||||
directives.hasModelDirective ||
|
||||
directives.hasQueueDirective ||
|
||||
directives.hasStatusDirective;
|
||||
|
||||
if (hasAnyDirective && command.isAuthorizedSender) {
|
||||
const fastLane = await applyInlineDirectivesFastLane({
|
||||
directives,
|
||||
commandAuthorized: command.isAuthorizedSender,
|
||||
ctx,
|
||||
cfg,
|
||||
agentId,
|
||||
isGroup,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
elevatedEnabled,
|
||||
elevatedAllowed,
|
||||
elevatedFailures,
|
||||
messageProviderKey,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
aliasIndex,
|
||||
allowedModelKeys: modelState.allowedModelKeys,
|
||||
allowedModelCatalog: modelState.allowedModelCatalog,
|
||||
resetModelOverride: modelState.resetModelOverride,
|
||||
provider,
|
||||
model,
|
||||
initialModelLabel,
|
||||
formatModelSwitchEvent,
|
||||
agentCfg,
|
||||
modelState: {
|
||||
resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel,
|
||||
allowedModelKeys: modelState.allowedModelKeys,
|
||||
allowedModelCatalog: modelState.allowedModelCatalog,
|
||||
resetModelOverride: modelState.resetModelOverride,
|
||||
},
|
||||
});
|
||||
directiveAck = fastLane.directiveAck;
|
||||
provider = fastLane.provider;
|
||||
model = fastLane.model;
|
||||
}
|
||||
|
||||
const persisted = await persistInlineDirectives({
|
||||
directives,
|
||||
effectiveModelDirective,
|
||||
cfg,
|
||||
agentDir,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
elevatedEnabled,
|
||||
elevatedAllowed,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
aliasIndex,
|
||||
allowedModelKeys: modelState.allowedModelKeys,
|
||||
provider,
|
||||
model,
|
||||
initialModelLabel,
|
||||
formatModelSwitchEvent,
|
||||
agentCfg,
|
||||
});
|
||||
provider = persisted.provider;
|
||||
model = persisted.model;
|
||||
contextTokens = persisted.contextTokens;
|
||||
|
||||
const perMessageQueueMode =
|
||||
directives.hasQueueDirective && !directives.queueReset
|
||||
? directives.queueMode
|
||||
: undefined;
|
||||
const perMessageQueueOptions =
|
||||
directives.hasQueueDirective && !directives.queueReset
|
||||
? {
|
||||
debounceMs: directives.debounceMs,
|
||||
cap: directives.cap,
|
||||
dropPolicy: directives.dropPolicy,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
kind: "continue",
|
||||
directives,
|
||||
provider,
|
||||
model,
|
||||
contextTokens,
|
||||
directiveAck,
|
||||
perMessageQueueMode,
|
||||
perMessageQueueOptions,
|
||||
};
|
||||
}
|
||||
33
src/auto-reply/reply/get-reply-directives-utils.ts
Normal file
33
src/auto-reply/reply/get-reply-directives-utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { InlineDirectives } from "./directive-handling.js";
|
||||
|
||||
export function clearInlineDirectives(cleaned: string): InlineDirectives {
|
||||
return {
|
||||
cleaned,
|
||||
hasThinkDirective: false,
|
||||
thinkLevel: undefined,
|
||||
rawThinkLevel: undefined,
|
||||
hasVerboseDirective: false,
|
||||
verboseLevel: undefined,
|
||||
rawVerboseLevel: undefined,
|
||||
hasReasoningDirective: false,
|
||||
reasoningLevel: undefined,
|
||||
rawReasoningLevel: undefined,
|
||||
hasElevatedDirective: false,
|
||||
elevatedLevel: undefined,
|
||||
rawElevatedLevel: undefined,
|
||||
hasStatusDirective: false,
|
||||
hasModelDirective: false,
|
||||
rawModelDirective: undefined,
|
||||
hasQueueDirective: false,
|
||||
queueMode: undefined,
|
||||
queueReset: false,
|
||||
rawQueueMode: undefined,
|
||||
debounceMs: undefined,
|
||||
cap: undefined,
|
||||
dropPolicy: undefined,
|
||||
rawDebounce: undefined,
|
||||
rawCap: undefined,
|
||||
rawDrop: undefined,
|
||||
hasQueueOptions: false,
|
||||
};
|
||||
}
|
||||
473
src/auto-reply/reply/get-reply-directives.ts
Normal file
473
src/auto-reply/reply/get-reply-directives.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import type { ModelAliasIndex } from "../../agents/model-selection.js";
|
||||
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import {
|
||||
listChatCommands,
|
||||
shouldHandleTextCommands,
|
||||
} from "../commands-registry.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 { resolveBlockStreamingChunking } from "./block-streaming.js";
|
||||
import { buildCommandContext } from "./commands.js";
|
||||
import {
|
||||
type InlineDirectives,
|
||||
parseInlineDirectives,
|
||||
} from "./directive-handling.js";
|
||||
import { applyInlineDirectiveOverrides } from "./get-reply-directives-apply.js";
|
||||
import { clearInlineDirectives } from "./get-reply-directives-utils.js";
|
||||
import {
|
||||
defaultGroupActivation,
|
||||
resolveGroupRequireMention,
|
||||
} from "./groups.js";
|
||||
import {
|
||||
CURRENT_MESSAGE_MARKER,
|
||||
stripMentions,
|
||||
stripStructuralPrefixes,
|
||||
} from "./mentions.js";
|
||||
import {
|
||||
createModelSelectionState,
|
||||
resolveContextTokens,
|
||||
} from "./model-selection.js";
|
||||
import {
|
||||
formatElevatedUnavailableMessage,
|
||||
resolveElevatedPermissions,
|
||||
} from "./reply-elevated.js";
|
||||
import { stripInlineStatus } from "./reply-inline.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
type AgentDefaults = NonNullable<ClawdbotConfig["agents"]>["defaults"];
|
||||
|
||||
export type ReplyDirectiveContinuation = {
|
||||
commandSource: string;
|
||||
command: ReturnType<typeof buildCommandContext>;
|
||||
allowTextCommands: boolean;
|
||||
directives: InlineDirectives;
|
||||
cleanedBody: string;
|
||||
messageProviderKey: string;
|
||||
elevatedEnabled: boolean;
|
||||
elevatedAllowed: boolean;
|
||||
elevatedFailures: Array<{ gate: string; key: string }>;
|
||||
defaultActivation: ReturnType<typeof defaultGroupActivation>;
|
||||
resolvedThinkLevel: ThinkLevel | undefined;
|
||||
resolvedVerboseLevel: VerboseLevel | undefined;
|
||||
resolvedReasoningLevel: ReasoningLevel;
|
||||
resolvedElevatedLevel: ElevatedLevel;
|
||||
blockStreamingEnabled: boolean;
|
||||
blockReplyChunking?: {
|
||||
minChars: number;
|
||||
maxChars: number;
|
||||
breakPreference: "paragraph" | "newline" | "sentence";
|
||||
};
|
||||
resolvedBlockStreamingBreak: "text_end" | "message_end";
|
||||
provider: string;
|
||||
model: string;
|
||||
modelState: Awaited<ReturnType<typeof createModelSelectionState>>;
|
||||
contextTokens: number;
|
||||
inlineStatusRequested: boolean;
|
||||
directiveAck?: ReplyPayload;
|
||||
perMessageQueueMode?: InlineDirectives["queueMode"];
|
||||
perMessageQueueOptions?: {
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
dropPolicy?: InlineDirectives["dropPolicy"];
|
||||
};
|
||||
};
|
||||
|
||||
export type ReplyDirectiveResult =
|
||||
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
|
||||
| { kind: "continue"; result: ReplyDirectiveContinuation };
|
||||
|
||||
export async function resolveReplyDirectives(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
agentDir: string;
|
||||
agentCfg: AgentDefaults;
|
||||
sessionCtx: TemplateContext;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
sessionScope: Parameters<
|
||||
typeof applyInlineDirectiveOverrides
|
||||
>[0]["sessionScope"];
|
||||
groupResolution: Parameters<
|
||||
typeof resolveGroupRequireMention
|
||||
>[0]["groupResolution"];
|
||||
isGroup: boolean;
|
||||
triggerBodyNormalized: string;
|
||||
commandAuthorized: boolean;
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
aliasIndex: ModelAliasIndex;
|
||||
provider: string;
|
||||
model: string;
|
||||
typing: TypingController;
|
||||
opts?: GetReplyOptions;
|
||||
}): Promise<ReplyDirectiveResult> {
|
||||
const {
|
||||
ctx,
|
||||
cfg,
|
||||
agentId,
|
||||
agentCfg,
|
||||
agentDir,
|
||||
sessionCtx,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionScope,
|
||||
groupResolution,
|
||||
isGroup,
|
||||
triggerBodyNormalized,
|
||||
commandAuthorized,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
provider: initialProvider,
|
||||
model: initialModel,
|
||||
typing,
|
||||
opts,
|
||||
} = params;
|
||||
let provider = initialProvider;
|
||||
let model = initialModel;
|
||||
|
||||
// Prefer CommandBody/RawBody (clean message without structural context) for directive parsing.
|
||||
// Keep `Body`/`BodyStripped` as the best-available prompt text (may include context).
|
||||
const commandSource =
|
||||
sessionCtx.CommandBody ??
|
||||
sessionCtx.RawBody ??
|
||||
sessionCtx.BodyStripped ??
|
||||
sessionCtx.Body ??
|
||||
"";
|
||||
const command = buildCommandContext({
|
||||
ctx,
|
||||
cfg,
|
||||
agentId,
|
||||
sessionKey,
|
||||
isGroup,
|
||||
triggerBodyNormalized,
|
||||
commandAuthorized,
|
||||
});
|
||||
const allowTextCommands = shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: command.surface,
|
||||
commandSource: ctx.CommandSource,
|
||||
});
|
||||
const reservedCommands = new Set(
|
||||
listChatCommands().flatMap((cmd) =>
|
||||
cmd.textAliases.map((a) => a.replace(/^\//, "").toLowerCase()),
|
||||
),
|
||||
);
|
||||
const configuredAliases = Object.values(cfg.agents?.defaults?.models ?? {})
|
||||
.map((entry) => entry.alias?.trim())
|
||||
.filter((alias): alias is string => Boolean(alias))
|
||||
.filter((alias) => !reservedCommands.has(alias.toLowerCase()));
|
||||
const allowStatusDirective = allowTextCommands && command.isAuthorizedSender;
|
||||
let parsedDirectives = parseInlineDirectives(commandSource, {
|
||||
modelAliases: configuredAliases,
|
||||
allowStatusDirective,
|
||||
});
|
||||
const hasInlineStatus =
|
||||
parsedDirectives.hasStatusDirective &&
|
||||
parsedDirectives.cleaned.trim().length > 0;
|
||||
if (hasInlineStatus) {
|
||||
parsedDirectives = {
|
||||
...parsedDirectives,
|
||||
hasStatusDirective: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
isGroup &&
|
||||
ctx.WasMentioned !== true &&
|
||||
parsedDirectives.hasElevatedDirective
|
||||
) {
|
||||
if (parsedDirectives.elevatedLevel !== "off") {
|
||||
parsedDirectives = {
|
||||
...parsedDirectives,
|
||||
hasElevatedDirective: false,
|
||||
elevatedLevel: undefined,
|
||||
rawElevatedLevel: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
const hasInlineDirective =
|
||||
parsedDirectives.hasThinkDirective ||
|
||||
parsedDirectives.hasVerboseDirective ||
|
||||
parsedDirectives.hasReasoningDirective ||
|
||||
parsedDirectives.hasElevatedDirective ||
|
||||
parsedDirectives.hasModelDirective ||
|
||||
parsedDirectives.hasQueueDirective;
|
||||
if (hasInlineDirective) {
|
||||
const stripped = stripStructuralPrefixes(parsedDirectives.cleaned);
|
||||
const noMentions = isGroup
|
||||
? stripMentions(stripped, ctx, cfg, agentId)
|
||||
: stripped;
|
||||
if (noMentions.trim().length > 0) {
|
||||
const directiveOnlyCheck = parseInlineDirectives(noMentions, {
|
||||
modelAliases: configuredAliases,
|
||||
});
|
||||
if (directiveOnlyCheck.cleaned.trim().length > 0) {
|
||||
const allowInlineStatus =
|
||||
parsedDirectives.hasStatusDirective &&
|
||||
allowTextCommands &&
|
||||
command.isAuthorizedSender;
|
||||
parsedDirectives = allowInlineStatus
|
||||
? {
|
||||
...clearInlineDirectives(parsedDirectives.cleaned),
|
||||
hasStatusDirective: true,
|
||||
}
|
||||
: clearInlineDirectives(parsedDirectives.cleaned);
|
||||
}
|
||||
}
|
||||
}
|
||||
let directives = commandAuthorized
|
||||
? parsedDirectives
|
||||
: {
|
||||
...parsedDirectives,
|
||||
hasThinkDirective: false,
|
||||
hasVerboseDirective: false,
|
||||
hasReasoningDirective: false,
|
||||
hasStatusDirective: false,
|
||||
hasModelDirective: false,
|
||||
hasQueueDirective: false,
|
||||
queueReset: false,
|
||||
};
|
||||
const existingBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
let cleanedBody = (() => {
|
||||
if (!existingBody) return parsedDirectives.cleaned;
|
||||
if (!sessionCtx.CommandBody && !sessionCtx.RawBody) {
|
||||
return parseInlineDirectives(existingBody, {
|
||||
modelAliases: configuredAliases,
|
||||
allowStatusDirective,
|
||||
}).cleaned;
|
||||
}
|
||||
|
||||
const markerIndex = existingBody.indexOf(CURRENT_MESSAGE_MARKER);
|
||||
if (markerIndex < 0) {
|
||||
return parseInlineDirectives(existingBody, {
|
||||
modelAliases: configuredAliases,
|
||||
allowStatusDirective,
|
||||
}).cleaned;
|
||||
}
|
||||
|
||||
const head = existingBody.slice(
|
||||
0,
|
||||
markerIndex + CURRENT_MESSAGE_MARKER.length,
|
||||
);
|
||||
const tail = existingBody.slice(
|
||||
markerIndex + CURRENT_MESSAGE_MARKER.length,
|
||||
);
|
||||
const cleanedTail = parseInlineDirectives(tail, {
|
||||
modelAliases: configuredAliases,
|
||||
allowStatusDirective,
|
||||
}).cleaned;
|
||||
return `${head}${cleanedTail}`;
|
||||
})();
|
||||
|
||||
if (allowStatusDirective) {
|
||||
cleanedBody = stripInlineStatus(cleanedBody).cleaned;
|
||||
}
|
||||
|
||||
sessionCtx.Body = cleanedBody;
|
||||
sessionCtx.BodyStripped = cleanedBody;
|
||||
|
||||
const messageProviderKey =
|
||||
sessionCtx.Provider?.trim().toLowerCase() ??
|
||||
ctx.Provider?.trim().toLowerCase() ??
|
||||
"";
|
||||
const elevated = resolveElevatedPermissions({
|
||||
cfg,
|
||||
agentId,
|
||||
ctx,
|
||||
provider: messageProviderKey,
|
||||
});
|
||||
const elevatedEnabled = elevated.enabled;
|
||||
const elevatedAllowed = elevated.allowed;
|
||||
const elevatedFailures = elevated.failures;
|
||||
if (
|
||||
directives.hasElevatedDirective &&
|
||||
(!elevatedEnabled || !elevatedAllowed)
|
||||
) {
|
||||
typing.cleanup();
|
||||
const runtimeSandboxed = resolveSandboxRuntimeStatus({
|
||||
cfg,
|
||||
sessionKey: ctx.SessionKey,
|
||||
}).sandboxed;
|
||||
return {
|
||||
kind: "reply",
|
||||
reply: {
|
||||
text: formatElevatedUnavailableMessage({
|
||||
runtimeSandboxed,
|
||||
failures: elevatedFailures,
|
||||
sessionKey: ctx.SessionKey,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const requireMention = resolveGroupRequireMention({
|
||||
cfg,
|
||||
ctx: sessionCtx,
|
||||
groupResolution,
|
||||
});
|
||||
const defaultActivation = defaultGroupActivation(requireMention);
|
||||
const resolvedThinkLevel =
|
||||
(directives.thinkLevel as ThinkLevel | undefined) ??
|
||||
(sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
|
||||
(agentCfg?.thinkingDefault as ThinkLevel | undefined);
|
||||
|
||||
const resolvedVerboseLevel =
|
||||
(directives.verboseLevel as VerboseLevel | undefined) ??
|
||||
(sessionEntry?.verboseLevel as VerboseLevel | undefined) ??
|
||||
(agentCfg?.verboseDefault as VerboseLevel | undefined);
|
||||
const resolvedReasoningLevel: ReasoningLevel =
|
||||
(directives.reasoningLevel as ReasoningLevel | undefined) ??
|
||||
(sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ??
|
||||
"off";
|
||||
const resolvedElevatedLevel = elevatedAllowed
|
||||
? ((directives.elevatedLevel as ElevatedLevel | undefined) ??
|
||||
(sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ??
|
||||
(agentCfg?.elevatedDefault as ElevatedLevel | undefined) ??
|
||||
"on")
|
||||
: "off";
|
||||
const resolvedBlockStreaming =
|
||||
opts?.disableBlockStreaming === true
|
||||
? "off"
|
||||
: opts?.disableBlockStreaming === false
|
||||
? "on"
|
||||
: agentCfg?.blockStreamingDefault === "on"
|
||||
? "on"
|
||||
: "off";
|
||||
const resolvedBlockStreamingBreak: "text_end" | "message_end" =
|
||||
agentCfg?.blockStreamingBreak === "message_end"
|
||||
? "message_end"
|
||||
: "text_end";
|
||||
const blockStreamingEnabled =
|
||||
resolvedBlockStreaming === "on" && opts?.disableBlockStreaming !== true;
|
||||
const blockReplyChunking = blockStreamingEnabled
|
||||
? resolveBlockStreamingChunking(
|
||||
cfg,
|
||||
sessionCtx.Provider,
|
||||
sessionCtx.AccountId,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const modelState = await createModelSelectionState({
|
||||
cfg,
|
||||
agentCfg,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
provider,
|
||||
model,
|
||||
hasModelDirective: directives.hasModelDirective,
|
||||
});
|
||||
provider = modelState.provider;
|
||||
model = modelState.model;
|
||||
|
||||
let contextTokens = resolveContextTokens({
|
||||
agentCfg,
|
||||
model,
|
||||
});
|
||||
|
||||
const initialModelLabel = `${provider}/${model}`;
|
||||
const formatModelSwitchEvent = (label: string, alias?: string) =>
|
||||
alias
|
||||
? `Model switched to ${alias} (${label}).`
|
||||
: `Model switched to ${label}.`;
|
||||
const isModelListAlias =
|
||||
directives.hasModelDirective &&
|
||||
["status", "list"].includes(
|
||||
directives.rawModelDirective?.trim().toLowerCase() ?? "",
|
||||
);
|
||||
const effectiveModelDirective = isModelListAlias
|
||||
? undefined
|
||||
: directives.rawModelDirective;
|
||||
|
||||
const inlineStatusRequested =
|
||||
hasInlineStatus && allowTextCommands && command.isAuthorizedSender;
|
||||
|
||||
const applyResult = await applyInlineDirectiveOverrides({
|
||||
ctx,
|
||||
cfg,
|
||||
agentId,
|
||||
agentDir,
|
||||
agentCfg,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionScope,
|
||||
isGroup,
|
||||
allowTextCommands,
|
||||
command,
|
||||
directives,
|
||||
messageProviderKey,
|
||||
elevatedEnabled,
|
||||
elevatedAllowed,
|
||||
elevatedFailures,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
aliasIndex: params.aliasIndex,
|
||||
provider,
|
||||
model,
|
||||
modelState,
|
||||
initialModelLabel,
|
||||
formatModelSwitchEvent,
|
||||
resolvedElevatedLevel,
|
||||
defaultActivation: () => defaultActivation,
|
||||
contextTokens,
|
||||
effectiveModelDirective,
|
||||
typing,
|
||||
});
|
||||
if (applyResult.kind === "reply") {
|
||||
return { kind: "reply", reply: applyResult.reply };
|
||||
}
|
||||
directives = applyResult.directives;
|
||||
provider = applyResult.provider;
|
||||
model = applyResult.model;
|
||||
contextTokens = applyResult.contextTokens;
|
||||
const { directiveAck, perMessageQueueMode, perMessageQueueOptions } =
|
||||
applyResult;
|
||||
|
||||
return {
|
||||
kind: "continue",
|
||||
result: {
|
||||
commandSource,
|
||||
command,
|
||||
allowTextCommands,
|
||||
directives,
|
||||
cleanedBody,
|
||||
messageProviderKey,
|
||||
elevatedEnabled,
|
||||
elevatedAllowed,
|
||||
elevatedFailures,
|
||||
defaultActivation,
|
||||
resolvedThinkLevel,
|
||||
resolvedVerboseLevel,
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
blockStreamingEnabled,
|
||||
blockReplyChunking,
|
||||
resolvedBlockStreamingBreak,
|
||||
provider,
|
||||
model,
|
||||
modelState,
|
||||
contextTokens,
|
||||
inlineStatusRequested,
|
||||
directiveAck,
|
||||
perMessageQueueMode,
|
||||
perMessageQueueOptions,
|
||||
},
|
||||
};
|
||||
}
|
||||
256
src/auto-reply/reply/get-reply-inline-actions.ts
Normal file
256
src/auto-reply/reply/get-reply-inline-actions.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { getChannelDock } from "../../channels/dock.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";
|
||||
|
||||
export type InlineActionResult =
|
||||
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
|
||||
| {
|
||||
kind: "continue";
|
||||
directives: InlineDirectives;
|
||||
abortedLastRun: boolean;
|
||||
};
|
||||
|
||||
export async function handleInlineActions(params: {
|
||||
ctx: MsgContext;
|
||||
sessionCtx: TemplateContext;
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
sessionEntry?: 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"];
|
||||
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;
|
||||
}): Promise<InlineActionResult> {
|
||||
const {
|
||||
ctx,
|
||||
sessionCtx,
|
||||
cfg,
|
||||
agentId,
|
||||
sessionEntry,
|
||||
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,
|
||||
} = params;
|
||||
|
||||
let directives = initialDirectives;
|
||||
let cleanedBody = initialCleanedBody;
|
||||
|
||||
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.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,
|
||||
});
|
||||
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,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionScope,
|
||||
workspaceDir,
|
||||
defaultGroupActivation: defaultActivation,
|
||||
resolvedThinkLevel,
|
||||
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
resolveDefaultThinkingLevel,
|
||||
provider,
|
||||
model,
|
||||
contextTokens,
|
||||
isGroup,
|
||||
});
|
||||
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,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionScope,
|
||||
workspaceDir,
|
||||
defaultGroupActivation: defaultActivation,
|
||||
resolvedThinkLevel,
|
||||
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
resolveDefaultThinkingLevel,
|
||||
provider,
|
||||
model,
|
||||
contextTokens,
|
||||
isGroup,
|
||||
});
|
||||
if (!commandResult.shouldContinue) {
|
||||
typing.cleanup();
|
||||
return { kind: "reply", reply: commandResult.reply };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "continue",
|
||||
directives,
|
||||
abortedLastRun,
|
||||
};
|
||||
}
|
||||
425
src/auto-reply/reply/get-reply-run.ts
Normal file
425
src/auto-reply/reply/get-reply-run.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
abortEmbeddedPiRun,
|
||||
isEmbeddedPiRunActive,
|
||||
isEmbeddedPiRunStreaming,
|
||||
resolveEmbeddedSessionLane,
|
||||
} from "../../agents/pi-embedded.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
resolveSessionFilePath,
|
||||
type SessionEntry,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { clearCommandLane, getQueueSize } from "../../process/command-queue.js";
|
||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
||||
import { hasControlCommand } from "../command-detection.js";
|
||||
import { buildInboundMediaNote } from "../media-note.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import {
|
||||
type ElevatedLevel,
|
||||
formatXHighModelHint,
|
||||
normalizeThinkLevel,
|
||||
type ReasoningLevel,
|
||||
supportsXHighThinking,
|
||||
type ThinkLevel,
|
||||
type VerboseLevel,
|
||||
} from "../thinking.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { runReplyAgent } from "./agent-runner.js";
|
||||
import { applySessionHints } from "./body.js";
|
||||
import type { buildCommandContext } from "./commands.js";
|
||||
import type { InlineDirectives } from "./directive-handling.js";
|
||||
import { buildGroupIntro } from "./groups.js";
|
||||
import type { createModelSelectionState } from "./model-selection.js";
|
||||
import { resolveQueueSettings } from "./queue.js";
|
||||
import { ensureSkillSnapshot, prependSystemEvents } from "./session-updates.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js";
|
||||
|
||||
type AgentDefaults = NonNullable<ClawdbotConfig["agents"]>["defaults"];
|
||||
|
||||
const BARE_SESSION_RESET_PROMPT =
|
||||
"A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning.";
|
||||
|
||||
type RunPreparedReplyParams = {
|
||||
ctx: MsgContext;
|
||||
sessionCtx: TemplateContext;
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
agentDir: string;
|
||||
agentCfg: AgentDefaults;
|
||||
sessionCfg: ClawdbotConfig["session"];
|
||||
commandAuthorized: boolean;
|
||||
command: ReturnType<typeof buildCommandContext>;
|
||||
commandSource: string;
|
||||
allowTextCommands: boolean;
|
||||
directives: InlineDirectives;
|
||||
defaultActivation: Parameters<typeof buildGroupIntro>[0]["defaultActivation"];
|
||||
resolvedThinkLevel: ThinkLevel | undefined;
|
||||
resolvedVerboseLevel: VerboseLevel | undefined;
|
||||
resolvedReasoningLevel: ReasoningLevel;
|
||||
resolvedElevatedLevel: ElevatedLevel;
|
||||
elevatedEnabled: boolean;
|
||||
elevatedAllowed: boolean;
|
||||
blockStreamingEnabled: boolean;
|
||||
blockReplyChunking?: {
|
||||
minChars: number;
|
||||
maxChars: number;
|
||||
breakPreference: "paragraph" | "newline" | "sentence";
|
||||
};
|
||||
resolvedBlockStreamingBreak: "text_end" | "message_end";
|
||||
modelState: Awaited<ReturnType<typeof createModelSelectionState>>;
|
||||
provider: string;
|
||||
model: string;
|
||||
perMessageQueueMode?: InlineDirectives["queueMode"];
|
||||
perMessageQueueOptions?: {
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
dropPolicy?: InlineDirectives["dropPolicy"];
|
||||
};
|
||||
transcribedText?: string;
|
||||
typing: TypingController;
|
||||
opts?: GetReplyOptions;
|
||||
defaultModel: string;
|
||||
timeoutMs: number;
|
||||
isNewSession: boolean;
|
||||
systemSent: boolean;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
sessionId?: string;
|
||||
storePath?: string;
|
||||
workspaceDir: string;
|
||||
abortedLastRun: boolean;
|
||||
};
|
||||
|
||||
export async function runPreparedReply(
|
||||
params: RunPreparedReplyParams,
|
||||
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
||||
const {
|
||||
ctx,
|
||||
sessionCtx,
|
||||
cfg,
|
||||
agentId,
|
||||
agentDir,
|
||||
agentCfg,
|
||||
sessionCfg,
|
||||
commandAuthorized,
|
||||
command,
|
||||
commandSource,
|
||||
allowTextCommands,
|
||||
directives,
|
||||
defaultActivation,
|
||||
elevatedEnabled,
|
||||
elevatedAllowed,
|
||||
blockStreamingEnabled,
|
||||
blockReplyChunking,
|
||||
resolvedBlockStreamingBreak,
|
||||
modelState,
|
||||
provider,
|
||||
model,
|
||||
perMessageQueueMode,
|
||||
perMessageQueueOptions,
|
||||
transcribedText,
|
||||
typing,
|
||||
opts,
|
||||
defaultModel,
|
||||
timeoutMs,
|
||||
isNewSession,
|
||||
systemSent,
|
||||
sessionKey,
|
||||
sessionId,
|
||||
storePath,
|
||||
workspaceDir,
|
||||
sessionStore,
|
||||
} = params;
|
||||
let {
|
||||
sessionEntry,
|
||||
resolvedThinkLevel,
|
||||
resolvedVerboseLevel,
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
abortedLastRun,
|
||||
} = params;
|
||||
let currentSystemSent = systemSent;
|
||||
|
||||
const isFirstTurnInSession = isNewSession || !currentSystemSent;
|
||||
const isGroupChat = sessionCtx.ChatType === "group";
|
||||
const wasMentioned = ctx.WasMentioned === true;
|
||||
const isHeartbeat = opts?.isHeartbeat === true;
|
||||
const typingMode = resolveTypingMode({
|
||||
configured: sessionCfg?.typingMode ?? agentCfg?.typingMode,
|
||||
isGroupChat,
|
||||
wasMentioned,
|
||||
isHeartbeat,
|
||||
});
|
||||
const typingSignals = createTypingSignaler({
|
||||
typing,
|
||||
mode: typingMode,
|
||||
isHeartbeat,
|
||||
});
|
||||
const shouldInjectGroupIntro = Boolean(
|
||||
isGroupChat &&
|
||||
(isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro),
|
||||
);
|
||||
const groupIntro = shouldInjectGroupIntro
|
||||
? buildGroupIntro({
|
||||
cfg,
|
||||
sessionCtx,
|
||||
sessionEntry,
|
||||
defaultActivation,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
})
|
||||
: "";
|
||||
const groupSystemPrompt = sessionCtx.GroupSystemPrompt?.trim() ?? "";
|
||||
const extraSystemPrompt = [groupIntro, groupSystemPrompt]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
// Use CommandBody/RawBody for bare reset detection (clean message without structural context).
|
||||
const rawBodyTrimmed = (
|
||||
ctx.CommandBody ??
|
||||
ctx.RawBody ??
|
||||
ctx.Body ??
|
||||
""
|
||||
).trim();
|
||||
const baseBodyTrimmedRaw = baseBody.trim();
|
||||
if (
|
||||
allowTextCommands &&
|
||||
(!commandAuthorized || !command.isAuthorizedSender) &&
|
||||
!baseBodyTrimmedRaw &&
|
||||
hasControlCommand(commandSource, cfg)
|
||||
) {
|
||||
typing.cleanup();
|
||||
return undefined;
|
||||
}
|
||||
const isBareSessionReset =
|
||||
isNewSession &&
|
||||
baseBodyTrimmedRaw.length === 0 &&
|
||||
rawBodyTrimmed.length > 0;
|
||||
const baseBodyFinal = isBareSessionReset
|
||||
? BARE_SESSION_RESET_PROMPT
|
||||
: baseBody;
|
||||
const baseBodyTrimmed = baseBodyFinal.trim();
|
||||
if (!baseBodyTrimmed) {
|
||||
await typing.onReplyStart();
|
||||
logVerbose("Inbound body empty after normalization; skipping agent run");
|
||||
typing.cleanup();
|
||||
return {
|
||||
text: "I didn't receive any text in your message. Please resend or add a caption.",
|
||||
};
|
||||
}
|
||||
let prefixedBodyBase = await applySessionHints({
|
||||
baseBody: baseBodyFinal,
|
||||
abortedLastRun,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
abortKey: command.abortKey,
|
||||
messageId: sessionCtx.MessageSid,
|
||||
});
|
||||
const isGroupSession =
|
||||
sessionEntry?.chatType === "group" || sessionEntry?.chatType === "room";
|
||||
const isMainSession =
|
||||
!isGroupSession && sessionKey === normalizeMainKey(sessionCfg?.mainKey);
|
||||
prefixedBodyBase = await prependSystemEvents({
|
||||
cfg,
|
||||
sessionKey,
|
||||
isMainSession,
|
||||
isNewSession,
|
||||
prefixedBodyBase,
|
||||
});
|
||||
const threadStarterBody = ctx.ThreadStarterBody?.trim();
|
||||
const threadStarterNote =
|
||||
isNewSession && threadStarterBody
|
||||
? `[Thread starter - for context]\n${threadStarterBody}`
|
||||
: undefined;
|
||||
const skillResult = await ensureSkillSnapshot({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionId,
|
||||
isFirstTurnInSession,
|
||||
workspaceDir,
|
||||
cfg,
|
||||
skillFilter: opts?.skillFilter,
|
||||
});
|
||||
sessionEntry = skillResult.sessionEntry ?? sessionEntry;
|
||||
currentSystemSent = skillResult.systemSent;
|
||||
const skillsSnapshot = skillResult.skillsSnapshot;
|
||||
const prefixedBody = transcribedText
|
||||
? [threadStarterNote, prefixedBodyBase, `Transcript:\n${transcribedText}`]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
: [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n");
|
||||
const mediaNote = buildInboundMediaNote(ctx);
|
||||
const mediaReplyHint = mediaNote
|
||||
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
|
||||
: undefined;
|
||||
let prefixedCommandBody = mediaNote
|
||||
? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim()
|
||||
: prefixedBody;
|
||||
if (!resolvedThinkLevel && prefixedCommandBody) {
|
||||
const parts = prefixedCommandBody.split(/\s+/);
|
||||
const maybeLevel = normalizeThinkLevel(parts[0]);
|
||||
if (
|
||||
maybeLevel &&
|
||||
(maybeLevel !== "xhigh" || supportsXHighThinking(provider, model))
|
||||
) {
|
||||
resolvedThinkLevel = maybeLevel;
|
||||
prefixedCommandBody = parts.slice(1).join(" ").trim();
|
||||
}
|
||||
}
|
||||
if (!resolvedThinkLevel) {
|
||||
resolvedThinkLevel = await modelState.resolveDefaultThinkingLevel();
|
||||
}
|
||||
if (
|
||||
resolvedThinkLevel === "xhigh" &&
|
||||
!supportsXHighThinking(provider, model)
|
||||
) {
|
||||
const explicitThink =
|
||||
directives.hasThinkDirective && directives.thinkLevel !== undefined;
|
||||
if (explicitThink) {
|
||||
typing.cleanup();
|
||||
return {
|
||||
text: `Thinking level "xhigh" is only supported for ${formatXHighModelHint()}. Use /think high or switch to one of those models.`,
|
||||
};
|
||||
}
|
||||
resolvedThinkLevel = "high";
|
||||
if (
|
||||
sessionEntry &&
|
||||
sessionStore &&
|
||||
sessionKey &&
|
||||
sessionEntry.thinkingLevel === "xhigh"
|
||||
) {
|
||||
sessionEntry.thinkingLevel = "high";
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sessionIdFinal = sessionId ?? crypto.randomUUID();
|
||||
const sessionFile = resolveSessionFilePath(sessionIdFinal, sessionEntry);
|
||||
const queueBodyBase = transcribedText
|
||||
? [threadStarterNote, baseBodyFinal, `Transcript:\n${transcribedText}`]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
: [threadStarterNote, baseBodyFinal].filter(Boolean).join("\n\n");
|
||||
const queuedBody = mediaNote
|
||||
? [mediaNote, mediaReplyHint, queueBodyBase]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim()
|
||||
: queueBodyBase;
|
||||
const resolvedQueue = resolveQueueSettings({
|
||||
cfg,
|
||||
channel: sessionCtx.Provider,
|
||||
sessionEntry,
|
||||
inlineMode: perMessageQueueMode,
|
||||
inlineOptions: perMessageQueueOptions,
|
||||
});
|
||||
const sessionLaneKey = resolveEmbeddedSessionLane(
|
||||
sessionKey ?? sessionIdFinal,
|
||||
);
|
||||
const laneSize = getQueueSize(sessionLaneKey);
|
||||
if (resolvedQueue.mode === "interrupt" && laneSize > 0) {
|
||||
const cleared = clearCommandLane(sessionLaneKey);
|
||||
const aborted = abortEmbeddedPiRun(sessionIdFinal);
|
||||
logVerbose(
|
||||
`Interrupting ${sessionLaneKey} (cleared ${cleared}, aborted=${aborted})`,
|
||||
);
|
||||
}
|
||||
const queueKey = sessionKey ?? sessionIdFinal;
|
||||
const isActive = isEmbeddedPiRunActive(sessionIdFinal);
|
||||
const isStreaming = isEmbeddedPiRunStreaming(sessionIdFinal);
|
||||
const shouldSteer =
|
||||
resolvedQueue.mode === "steer" || resolvedQueue.mode === "steer-backlog";
|
||||
const shouldFollowup =
|
||||
resolvedQueue.mode === "followup" ||
|
||||
resolvedQueue.mode === "collect" ||
|
||||
resolvedQueue.mode === "steer-backlog";
|
||||
const authProfileId = sessionEntry?.authProfileOverride;
|
||||
const followupRun = {
|
||||
prompt: queuedBody,
|
||||
messageId: sessionCtx.MessageSid,
|
||||
summaryLine: baseBodyTrimmedRaw,
|
||||
enqueuedAt: Date.now(),
|
||||
// Originating channel for reply routing.
|
||||
originatingChannel: ctx.OriginatingChannel,
|
||||
originatingTo: ctx.OriginatingTo,
|
||||
originatingAccountId: ctx.AccountId,
|
||||
originatingThreadId: ctx.MessageThreadId,
|
||||
run: {
|
||||
agentId,
|
||||
agentDir,
|
||||
sessionId: sessionIdFinal,
|
||||
sessionKey,
|
||||
messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
agentAccountId: sessionCtx.AccountId,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
skillsSnapshot,
|
||||
provider,
|
||||
model,
|
||||
authProfileId,
|
||||
thinkLevel: resolvedThinkLevel,
|
||||
verboseLevel: resolvedVerboseLevel,
|
||||
reasoningLevel: resolvedReasoningLevel,
|
||||
elevatedLevel: resolvedElevatedLevel,
|
||||
bashElevated: {
|
||||
enabled: elevatedEnabled,
|
||||
allowed: elevatedAllowed,
|
||||
defaultLevel: resolvedElevatedLevel ?? "off",
|
||||
},
|
||||
timeoutMs,
|
||||
blockReplyBreak: resolvedBlockStreamingBreak,
|
||||
ownerNumbers:
|
||||
command.ownerList.length > 0 ? command.ownerList : undefined,
|
||||
extraSystemPrompt: extraSystemPrompt || undefined,
|
||||
...(isReasoningTagProvider(provider) ? { enforceFinalTag: true } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
if (typingSignals.shouldStartImmediately) {
|
||||
await typingSignals.signalRunStart();
|
||||
}
|
||||
|
||||
return runReplyAgent({
|
||||
commandBody: prefixedCommandBody,
|
||||
followupRun,
|
||||
queueKey,
|
||||
resolvedQueue,
|
||||
shouldSteer,
|
||||
shouldFollowup,
|
||||
isActive,
|
||||
isStreaming,
|
||||
opts,
|
||||
typing,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
defaultModel,
|
||||
agentCfgContextTokens: agentCfg?.contextTokens,
|
||||
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
|
||||
isNewSession,
|
||||
blockStreamingEnabled,
|
||||
blockReplyChunking,
|
||||
resolvedBlockStreamingBreak,
|
||||
sessionCtx,
|
||||
shouldInjectGroupIntro,
|
||||
typingMode,
|
||||
});
|
||||
}
|
||||
272
src/auto-reply/reply/get-reply.ts
Normal file
272
src/auto-reply/reply/get-reply.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveSessionAgentId,
|
||||
} from "../../agents/agent-scope.js";
|
||||
import { resolveModelRefFromString } from "../../agents/model-selection.js";
|
||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||
import {
|
||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||
ensureAgentWorkspace,
|
||||
} from "../../agents/workspace.js";
|
||||
import { type ClawdbotConfig, loadConfig } from "../../config/config.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import {
|
||||
hasAudioTranscriptionConfig,
|
||||
isAudio,
|
||||
transcribeInboundAudio,
|
||||
} from "../transcription.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { resolveDefaultModel } from "./directive-handling.js";
|
||||
import { resolveReplyDirectives } from "./get-reply-directives.js";
|
||||
import { handleInlineActions } from "./get-reply-inline-actions.js";
|
||||
import { runPreparedReply } from "./get-reply-run.js";
|
||||
import { initSessionState } from "./session.js";
|
||||
import { stageSandboxMedia } from "./stage-sandbox-media.js";
|
||||
import { createTypingController } from "./typing.js";
|
||||
|
||||
export async function getReplyFromConfig(
|
||||
ctx: MsgContext,
|
||||
opts?: GetReplyOptions,
|
||||
configOverride?: ClawdbotConfig,
|
||||
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
||||
const cfg = configOverride ?? loadConfig();
|
||||
const agentId = resolveSessionAgentId({
|
||||
sessionKey: ctx.SessionKey,
|
||||
config: cfg,
|
||||
});
|
||||
const agentCfg = cfg.agents?.defaults;
|
||||
const sessionCfg = cfg.session;
|
||||
const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({
|
||||
cfg,
|
||||
agentId,
|
||||
});
|
||||
let provider = defaultProvider;
|
||||
let model = defaultModel;
|
||||
if (opts?.isHeartbeat) {
|
||||
const heartbeatRaw = agentCfg?.heartbeat?.model?.trim() ?? "";
|
||||
const heartbeatRef = heartbeatRaw
|
||||
? resolveModelRefFromString({
|
||||
raw: heartbeatRaw,
|
||||
defaultProvider,
|
||||
aliasIndex,
|
||||
})
|
||||
: null;
|
||||
if (heartbeatRef) {
|
||||
provider = heartbeatRef.ref.provider;
|
||||
model = heartbeatRef.ref.model;
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceDirRaw =
|
||||
resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
|
||||
const workspace = await ensureAgentWorkspace({
|
||||
dir: workspaceDirRaw,
|
||||
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
|
||||
});
|
||||
const workspaceDir = workspace.dir;
|
||||
const agentDir = resolveAgentDir(cfg, agentId);
|
||||
const timeoutMs = resolveAgentTimeoutMs({ cfg });
|
||||
const configuredTypingSeconds =
|
||||
agentCfg?.typingIntervalSeconds ?? sessionCfg?.typingIntervalSeconds;
|
||||
const typingIntervalSeconds =
|
||||
typeof configuredTypingSeconds === "number" ? configuredTypingSeconds : 6;
|
||||
const typing = createTypingController({
|
||||
onReplyStart: opts?.onReplyStart,
|
||||
typingIntervalSeconds,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
log: defaultRuntime.log,
|
||||
});
|
||||
opts?.onTypingController?.(typing);
|
||||
|
||||
let transcribedText: string | undefined;
|
||||
if (hasAudioTranscriptionConfig(cfg) && isAudio(ctx.MediaType)) {
|
||||
const transcribed = await transcribeInboundAudio(cfg, ctx, defaultRuntime);
|
||||
if (transcribed?.text) {
|
||||
transcribedText = transcribed.text;
|
||||
ctx.Body = transcribed.text;
|
||||
ctx.Transcript = transcribed.text;
|
||||
logVerbose("Replaced Body with audio transcript for reply flow");
|
||||
}
|
||||
}
|
||||
|
||||
const commandAuthorized = ctx.CommandAuthorized ?? true;
|
||||
resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized,
|
||||
});
|
||||
const sessionState = await initSessionState({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized,
|
||||
});
|
||||
let {
|
||||
sessionCtx,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
sessionId,
|
||||
isNewSession,
|
||||
systemSent,
|
||||
abortedLastRun,
|
||||
storePath,
|
||||
sessionScope,
|
||||
groupResolution,
|
||||
isGroup,
|
||||
triggerBodyNormalized,
|
||||
} = sessionState;
|
||||
|
||||
const directiveResult = await resolveReplyDirectives({
|
||||
ctx,
|
||||
cfg,
|
||||
agentId,
|
||||
agentDir,
|
||||
agentCfg,
|
||||
sessionCtx,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionScope,
|
||||
groupResolution,
|
||||
isGroup,
|
||||
triggerBodyNormalized,
|
||||
commandAuthorized,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
aliasIndex,
|
||||
provider,
|
||||
model,
|
||||
typing,
|
||||
opts,
|
||||
});
|
||||
if (directiveResult.kind === "reply") {
|
||||
return directiveResult.reply;
|
||||
}
|
||||
|
||||
let {
|
||||
commandSource,
|
||||
command,
|
||||
allowTextCommands,
|
||||
directives,
|
||||
cleanedBody,
|
||||
elevatedEnabled,
|
||||
elevatedAllowed,
|
||||
elevatedFailures,
|
||||
defaultActivation,
|
||||
resolvedThinkLevel,
|
||||
resolvedVerboseLevel,
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
blockStreamingEnabled,
|
||||
blockReplyChunking,
|
||||
resolvedBlockStreamingBreak,
|
||||
provider: resolvedProvider,
|
||||
model: resolvedModel,
|
||||
modelState,
|
||||
contextTokens,
|
||||
inlineStatusRequested,
|
||||
directiveAck,
|
||||
perMessageQueueMode,
|
||||
perMessageQueueOptions,
|
||||
} = directiveResult.result;
|
||||
provider = resolvedProvider;
|
||||
model = resolvedModel;
|
||||
|
||||
const inlineActionResult = await handleInlineActions({
|
||||
ctx,
|
||||
sessionCtx,
|
||||
cfg,
|
||||
agentId,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionScope,
|
||||
workspaceDir,
|
||||
isGroup,
|
||||
opts,
|
||||
typing,
|
||||
allowTextCommands,
|
||||
inlineStatusRequested,
|
||||
command,
|
||||
directives,
|
||||
cleanedBody,
|
||||
elevatedEnabled,
|
||||
elevatedAllowed,
|
||||
elevatedFailures,
|
||||
defaultActivation: () => defaultActivation,
|
||||
resolvedThinkLevel,
|
||||
resolvedVerboseLevel,
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel,
|
||||
provider,
|
||||
model,
|
||||
contextTokens,
|
||||
directiveAck,
|
||||
abortedLastRun,
|
||||
});
|
||||
if (inlineActionResult.kind === "reply") {
|
||||
return inlineActionResult.reply;
|
||||
}
|
||||
directives = inlineActionResult.directives;
|
||||
abortedLastRun = inlineActionResult.abortedLastRun ?? abortedLastRun;
|
||||
|
||||
await stageSandboxMedia({
|
||||
ctx,
|
||||
sessionCtx,
|
||||
cfg,
|
||||
sessionKey,
|
||||
workspaceDir,
|
||||
});
|
||||
|
||||
return runPreparedReply({
|
||||
ctx,
|
||||
sessionCtx,
|
||||
cfg,
|
||||
agentId,
|
||||
agentDir,
|
||||
agentCfg,
|
||||
sessionCfg,
|
||||
commandAuthorized,
|
||||
command,
|
||||
commandSource,
|
||||
allowTextCommands,
|
||||
directives,
|
||||
defaultActivation,
|
||||
resolvedThinkLevel,
|
||||
resolvedVerboseLevel,
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
elevatedEnabled,
|
||||
elevatedAllowed,
|
||||
blockStreamingEnabled,
|
||||
blockReplyChunking,
|
||||
resolvedBlockStreamingBreak,
|
||||
modelState,
|
||||
provider,
|
||||
model,
|
||||
perMessageQueueMode,
|
||||
perMessageQueueOptions,
|
||||
transcribedText,
|
||||
typing,
|
||||
opts,
|
||||
defaultModel,
|
||||
timeoutMs,
|
||||
isNewSession,
|
||||
systemSent,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
sessionId,
|
||||
storePath,
|
||||
workspaceDir,
|
||||
abortedLastRun,
|
||||
});
|
||||
}
|
||||
206
src/auto-reply/reply/reply-elevated.ts
Normal file
206
src/auto-reply/reply/reply-elevated.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { resolveAgentConfig } from "../../agents/agent-scope.js";
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import {
|
||||
CHAT_CHANNEL_ORDER,
|
||||
normalizeChannelId,
|
||||
} from "../../channels/registry.js";
|
||||
import type {
|
||||
AgentElevatedAllowFromConfig,
|
||||
ClawdbotConfig,
|
||||
} from "../../config/config.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
|
||||
function normalizeAllowToken(value?: string) {
|
||||
if (!value) return "";
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function slugAllowToken(value?: string) {
|
||||
if (!value) return "";
|
||||
let text = value.trim().toLowerCase();
|
||||
if (!text) return "";
|
||||
text = text.replace(/^[@#]+/, "");
|
||||
text = text.replace(/[\s_]+/g, "-");
|
||||
text = text.replace(/[^a-z0-9-]+/g, "-");
|
||||
return text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
const SENDER_PREFIXES = [
|
||||
...CHAT_CHANNEL_ORDER,
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
"user",
|
||||
"group",
|
||||
"channel",
|
||||
];
|
||||
const SENDER_PREFIX_RE = new RegExp(`^(${SENDER_PREFIXES.join("|")}):`, "i");
|
||||
|
||||
function stripSenderPrefix(value?: string) {
|
||||
if (!value) return "";
|
||||
const trimmed = value.trim();
|
||||
return trimmed.replace(SENDER_PREFIX_RE, "");
|
||||
}
|
||||
|
||||
function resolveElevatedAllowList(
|
||||
allowFrom: AgentElevatedAllowFromConfig | undefined,
|
||||
provider: string,
|
||||
fallbackAllowFrom?: Array<string | number>,
|
||||
): Array<string | number> | undefined {
|
||||
if (!allowFrom) return fallbackAllowFrom;
|
||||
const value = allowFrom[provider];
|
||||
return Array.isArray(value) ? value : fallbackAllowFrom;
|
||||
}
|
||||
|
||||
function isApprovedElevatedSender(params: {
|
||||
provider: string;
|
||||
ctx: MsgContext;
|
||||
allowFrom?: AgentElevatedAllowFromConfig;
|
||||
fallbackAllowFrom?: Array<string | number>;
|
||||
}): boolean {
|
||||
const rawAllow = resolveElevatedAllowList(
|
||||
params.allowFrom,
|
||||
params.provider,
|
||||
params.fallbackAllowFrom,
|
||||
);
|
||||
if (!rawAllow || rawAllow.length === 0) return false;
|
||||
|
||||
const allowTokens = rawAllow
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean);
|
||||
if (allowTokens.length === 0) return false;
|
||||
if (allowTokens.some((entry) => entry === "*")) return true;
|
||||
|
||||
const tokens = new Set<string>();
|
||||
const addToken = (value?: string) => {
|
||||
if (!value) return;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
tokens.add(trimmed);
|
||||
const normalized = normalizeAllowToken(trimmed);
|
||||
if (normalized) tokens.add(normalized);
|
||||
const slugged = slugAllowToken(trimmed);
|
||||
if (slugged) tokens.add(slugged);
|
||||
};
|
||||
|
||||
addToken(params.ctx.SenderName);
|
||||
addToken(params.ctx.SenderUsername);
|
||||
addToken(params.ctx.SenderTag);
|
||||
addToken(params.ctx.SenderE164);
|
||||
addToken(params.ctx.From);
|
||||
addToken(stripSenderPrefix(params.ctx.From));
|
||||
addToken(params.ctx.To);
|
||||
addToken(stripSenderPrefix(params.ctx.To));
|
||||
|
||||
for (const rawEntry of allowTokens) {
|
||||
const entry = rawEntry.trim();
|
||||
if (!entry) continue;
|
||||
const stripped = stripSenderPrefix(entry);
|
||||
if (tokens.has(entry) || tokens.has(stripped)) return true;
|
||||
const normalized = normalizeAllowToken(stripped);
|
||||
if (normalized && tokens.has(normalized)) return true;
|
||||
const slugged = slugAllowToken(stripped);
|
||||
if (slugged && tokens.has(slugged)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function resolveElevatedPermissions(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
ctx: MsgContext;
|
||||
provider: string;
|
||||
}): {
|
||||
enabled: boolean;
|
||||
allowed: boolean;
|
||||
failures: Array<{ gate: string; key: string }>;
|
||||
} {
|
||||
const globalConfig = params.cfg.tools?.elevated;
|
||||
const agentConfig = resolveAgentConfig(params.cfg, params.agentId)?.tools
|
||||
?.elevated;
|
||||
const globalEnabled = globalConfig?.enabled !== false;
|
||||
const agentEnabled = agentConfig?.enabled !== false;
|
||||
const enabled = globalEnabled && agentEnabled;
|
||||
const failures: Array<{ gate: string; key: string }> = [];
|
||||
if (!globalEnabled)
|
||||
failures.push({ gate: "enabled", key: "tools.elevated.enabled" });
|
||||
if (!agentEnabled)
|
||||
failures.push({
|
||||
gate: "enabled",
|
||||
key: "agents.list[].tools.elevated.enabled",
|
||||
});
|
||||
if (!enabled) return { enabled, allowed: false, failures };
|
||||
if (!params.provider) {
|
||||
failures.push({ gate: "provider", key: "ctx.Provider" });
|
||||
return { enabled, allowed: false, failures };
|
||||
}
|
||||
|
||||
const normalizedProvider = normalizeChannelId(params.provider);
|
||||
const dockFallbackAllowFrom = normalizedProvider
|
||||
? getChannelDock(normalizedProvider)?.elevated?.allowFromFallback?.({
|
||||
cfg: params.cfg,
|
||||
accountId: params.ctx.AccountId,
|
||||
})
|
||||
: undefined;
|
||||
const fallbackAllowFrom = dockFallbackAllowFrom;
|
||||
const globalAllowed = isApprovedElevatedSender({
|
||||
provider: params.provider,
|
||||
ctx: params.ctx,
|
||||
allowFrom: globalConfig?.allowFrom,
|
||||
fallbackAllowFrom,
|
||||
});
|
||||
if (!globalAllowed) {
|
||||
failures.push({
|
||||
gate: "allowFrom",
|
||||
key: `tools.elevated.allowFrom.${params.provider}`,
|
||||
});
|
||||
return { enabled, allowed: false, failures };
|
||||
}
|
||||
|
||||
const agentAllowed = agentConfig?.allowFrom
|
||||
? isApprovedElevatedSender({
|
||||
provider: params.provider,
|
||||
ctx: params.ctx,
|
||||
allowFrom: agentConfig.allowFrom,
|
||||
fallbackAllowFrom,
|
||||
})
|
||||
: true;
|
||||
if (!agentAllowed) {
|
||||
failures.push({
|
||||
gate: "allowFrom",
|
||||
key: `agents.list[].tools.elevated.allowFrom.${params.provider}`,
|
||||
});
|
||||
}
|
||||
return { enabled, allowed: globalAllowed && agentAllowed, failures };
|
||||
}
|
||||
|
||||
export function formatElevatedUnavailableMessage(params: {
|
||||
runtimeSandboxed: boolean;
|
||||
failures: Array<{ gate: string; key: string }>;
|
||||
sessionKey?: string;
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
`elevated is not available right now (runtime=${params.runtimeSandboxed ? "sandboxed" : "direct"}).`,
|
||||
);
|
||||
if (params.failures.length > 0) {
|
||||
lines.push(
|
||||
`Failing gates: ${params.failures
|
||||
.map((f) => `${f.gate} (${f.key})`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
"Failing gates: enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled), allowFrom (tools.elevated.allowFrom.<provider>).",
|
||||
);
|
||||
}
|
||||
lines.push("Fix-it keys:");
|
||||
lines.push("- tools.elevated.enabled");
|
||||
lines.push("- tools.elevated.allowFrom.<provider>");
|
||||
lines.push("- agents.list[].tools.elevated.enabled");
|
||||
lines.push("- agents.list[].tools.elevated.allowFrom.<provider>");
|
||||
if (params.sessionKey) {
|
||||
lines.push(`See: clawdbot sandbox explain --session ${params.sessionKey}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
37
src/auto-reply/reply/reply-inline.ts
Normal file
37
src/auto-reply/reply/reply-inline.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
const INLINE_SIMPLE_COMMAND_ALIASES = new Map<string, string>([
|
||||
["/help", "/help"],
|
||||
["/commands", "/commands"],
|
||||
["/whoami", "/whoami"],
|
||||
["/id", "/whoami"],
|
||||
]);
|
||||
const INLINE_SIMPLE_COMMAND_RE =
|
||||
/(?:^|\s)\/(help|commands|whoami|id)(?=$|\s|:)/i;
|
||||
|
||||
const INLINE_STATUS_RE = /(?:^|\s)\/(?:status|usage)(?=$|\s|:)(?:\s*:\s*)?/gi;
|
||||
|
||||
export function extractInlineSimpleCommand(body?: string): {
|
||||
command: string;
|
||||
cleaned: string;
|
||||
} | null {
|
||||
if (!body) return null;
|
||||
const match = body.match(INLINE_SIMPLE_COMMAND_RE);
|
||||
if (!match || match.index === undefined) return null;
|
||||
const alias = `/${match[1].toLowerCase()}`;
|
||||
const command = INLINE_SIMPLE_COMMAND_ALIASES.get(alias);
|
||||
if (!command) return null;
|
||||
const cleaned = body.replace(match[0], " ").replace(/\s+/g, " ").trim();
|
||||
return { command, cleaned };
|
||||
}
|
||||
|
||||
export function stripInlineStatus(body: string): {
|
||||
cleaned: string;
|
||||
didStrip: boolean;
|
||||
} {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return { cleaned: "", didStrip: false };
|
||||
const cleaned = trimmed
|
||||
.replace(INLINE_STATUS_RE, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return { cleaned, didStrip: cleaned !== trimmed };
|
||||
}
|
||||
118
src/auto-reply/reply/stage-sandbox-media.ts
Normal file
118
src/auto-reply/reply/stage-sandbox-media.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
|
||||
export async function stageSandboxMedia(params: {
|
||||
ctx: MsgContext;
|
||||
sessionCtx: TemplateContext;
|
||||
cfg: ClawdbotConfig;
|
||||
sessionKey?: string;
|
||||
workspaceDir: string;
|
||||
}) {
|
||||
const { ctx, sessionCtx, cfg, sessionKey, workspaceDir } = params;
|
||||
const hasPathsArray =
|
||||
Array.isArray(ctx.MediaPaths) && ctx.MediaPaths.length > 0;
|
||||
const pathsFromArray = Array.isArray(ctx.MediaPaths)
|
||||
? ctx.MediaPaths
|
||||
: undefined;
|
||||
const rawPaths =
|
||||
pathsFromArray && pathsFromArray.length > 0
|
||||
? pathsFromArray
|
||||
: ctx.MediaPath?.trim()
|
||||
? [ctx.MediaPath.trim()]
|
||||
: [];
|
||||
if (rawPaths.length === 0 || !sessionKey) return;
|
||||
|
||||
const sandbox = await ensureSandboxWorkspaceForSession({
|
||||
config: cfg,
|
||||
sessionKey,
|
||||
workspaceDir,
|
||||
});
|
||||
if (!sandbox) return;
|
||||
|
||||
const resolveAbsolutePath = (value: string): string | null => {
|
||||
let resolved = value.trim();
|
||||
if (!resolved) return null;
|
||||
if (resolved.startsWith("file://")) {
|
||||
try {
|
||||
resolved = fileURLToPath(resolved);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!path.isAbsolute(resolved)) return null;
|
||||
return resolved;
|
||||
};
|
||||
|
||||
try {
|
||||
const destDir = path.join(sandbox.workspaceDir, "media", "inbound");
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
|
||||
const usedNames = new Set<string>();
|
||||
const staged = new Map<string, string>(); // absolute source -> relative sandbox path
|
||||
|
||||
for (const raw of rawPaths) {
|
||||
const source = resolveAbsolutePath(raw);
|
||||
if (!source) continue;
|
||||
if (staged.has(source)) continue;
|
||||
|
||||
const baseName = path.basename(source);
|
||||
if (!baseName) continue;
|
||||
const parsed = path.parse(baseName);
|
||||
let fileName = baseName;
|
||||
let suffix = 1;
|
||||
while (usedNames.has(fileName)) {
|
||||
fileName = `${parsed.name}-${suffix}${parsed.ext}`;
|
||||
suffix += 1;
|
||||
}
|
||||
usedNames.add(fileName);
|
||||
|
||||
const dest = path.join(destDir, fileName);
|
||||
await fs.copyFile(source, dest);
|
||||
const relative = path.posix.join("media", "inbound", fileName);
|
||||
staged.set(source, relative);
|
||||
}
|
||||
|
||||
const rewriteIfStaged = (value: string | undefined): string | undefined => {
|
||||
const raw = value?.trim();
|
||||
if (!raw) return value;
|
||||
const abs = resolveAbsolutePath(raw);
|
||||
if (!abs) return value;
|
||||
const mapped = staged.get(abs);
|
||||
return mapped ?? value;
|
||||
};
|
||||
|
||||
const nextMediaPaths = hasPathsArray
|
||||
? rawPaths.map((p) => rewriteIfStaged(p) ?? p)
|
||||
: undefined;
|
||||
if (nextMediaPaths) {
|
||||
ctx.MediaPaths = nextMediaPaths;
|
||||
sessionCtx.MediaPaths = nextMediaPaths;
|
||||
ctx.MediaPath = nextMediaPaths[0];
|
||||
sessionCtx.MediaPath = nextMediaPaths[0];
|
||||
} else {
|
||||
const rewritten = rewriteIfStaged(ctx.MediaPath);
|
||||
if (rewritten && rewritten !== ctx.MediaPath) {
|
||||
ctx.MediaPath = rewritten;
|
||||
sessionCtx.MediaPath = rewritten;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(ctx.MediaUrls) && ctx.MediaUrls.length > 0) {
|
||||
const nextUrls = ctx.MediaUrls.map((u) => rewriteIfStaged(u) ?? u);
|
||||
ctx.MediaUrls = nextUrls;
|
||||
sessionCtx.MediaUrls = nextUrls;
|
||||
}
|
||||
const rewrittenUrl = rewriteIfStaged(ctx.MediaUrl);
|
||||
if (rewrittenUrl && rewrittenUrl !== ctx.MediaUrl) {
|
||||
ctx.MediaUrl = rewrittenUrl;
|
||||
sessionCtx.MediaUrl = rewrittenUrl;
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`Failed to stage inbound media for sandbox: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user