refactor(auto-reply): split reply pipeline
This commit is contained in:
parent
1089444807
commit
ea018a68cc
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 crypto from "node:crypto";
|
||||||
import fs from "node:fs";
|
import { setCliSessionId } from "../../agents/cli-session.js";
|
||||||
import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js";
|
|
||||||
import { runCliAgent } from "../../agents/cli-runner.js";
|
|
||||||
import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js";
|
|
||||||
import { lookupContextTokens } from "../../agents/context.js";
|
import { lookupContextTokens } from "../../agents/context.js";
|
||||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||||
import { resolveModelAuthMode } from "../../agents/model-auth.js";
|
import { resolveModelAuthMode } from "../../agents/model-auth.js";
|
||||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
|
||||||
import { isCliProvider } from "../../agents/model-selection.js";
|
import { isCliProvider } from "../../agents/model-selection.js";
|
||||||
|
import { queueEmbeddedPiMessage } from "../../agents/pi-embedded.js";
|
||||||
|
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||||
import {
|
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,
|
resolveAgentIdFromSessionKey,
|
||||||
resolveSessionTranscriptPath,
|
resolveSessionTranscriptPath,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
@ -35,49 +15,35 @@ import {
|
|||||||
} from "../../config/sessions.js";
|
} from "../../config/sessions.js";
|
||||||
import type { TypingMode } from "../../config/types.js";
|
import type { TypingMode } from "../../config/types.js";
|
||||||
import { logVerbose } from "../../globals.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 { defaultRuntime } from "../../runtime.js";
|
||||||
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
import { resolveModelCostConfig } from "../../utils/usage-format.js";
|
||||||
import {
|
|
||||||
estimateUsageCost,
|
|
||||||
formatTokenCount,
|
|
||||||
formatUsd,
|
|
||||||
resolveModelCostConfig,
|
|
||||||
} from "../../utils/usage-format.js";
|
|
||||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
|
||||||
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
|
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
|
||||||
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
|
import type { VerboseLevel } from "../thinking.js";
|
||||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
|
||||||
import type { GetReplyOptions, ReplyPayload } from "../types.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 {
|
import {
|
||||||
createAudioAsVoiceBuffer,
|
createAudioAsVoiceBuffer,
|
||||||
createBlockReplyPipeline,
|
createBlockReplyPipeline,
|
||||||
} from "./block-reply-pipeline.js";
|
} from "./block-reply-pipeline.js";
|
||||||
import { resolveBlockStreamingCoalescing } from "./block-streaming.js";
|
import { resolveBlockStreamingCoalescing } from "./block-streaming.js";
|
||||||
import { createFollowupRunner } from "./followup-runner.js";
|
import { createFollowupRunner } from "./followup-runner.js";
|
||||||
import {
|
|
||||||
resolveMemoryFlushContextWindowTokens,
|
|
||||||
resolveMemoryFlushSettings,
|
|
||||||
shouldRunMemoryFlush,
|
|
||||||
} from "./memory-flush.js";
|
|
||||||
import {
|
import {
|
||||||
enqueueFollowupRun,
|
enqueueFollowupRun,
|
||||||
type FollowupRun,
|
type FollowupRun,
|
||||||
type QueueSettings,
|
type QueueSettings,
|
||||||
scheduleFollowupDrain,
|
|
||||||
} from "./queue.js";
|
} from "./queue.js";
|
||||||
import { parseReplyDirectives } from "./reply-directives.js";
|
|
||||||
import {
|
|
||||||
applyReplyTagsToPayload,
|
|
||||||
applyReplyThreading,
|
|
||||||
filterMessagingToolDuplicates,
|
|
||||||
isRenderablePayload,
|
|
||||||
shouldSuppressMessagingToolReplies,
|
|
||||||
} from "./reply-payloads.js";
|
|
||||||
import {
|
import {
|
||||||
createReplyToModeFilterForChannel,
|
createReplyToModeFilterForChannel,
|
||||||
resolveReplyToMode,
|
resolveReplyToMode,
|
||||||
@ -86,113 +52,8 @@ import { incrementCompactionCount } from "./session-updates.js";
|
|||||||
import type { TypingController } from "./typing.js";
|
import type { TypingController } from "./typing.js";
|
||||||
import { createTypingSignaler } from "./typing-mode.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;
|
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: {
|
export async function runReplyAgent(params: {
|
||||||
commandBody: string;
|
commandBody: string;
|
||||||
followupRun: FollowupRun;
|
followupRun: FollowupRun;
|
||||||
@ -261,31 +122,16 @@ export async function runReplyAgent(params: {
|
|||||||
isHeartbeat,
|
isHeartbeat,
|
||||||
});
|
});
|
||||||
|
|
||||||
const shouldEmitToolResult = () => {
|
const shouldEmitToolResult = createShouldEmitToolResult({
|
||||||
if (!sessionKey || !storePath) {
|
sessionKey,
|
||||||
return resolvedVerboseLevel === "on";
|
storePath,
|
||||||
}
|
resolvedVerboseLevel,
|
||||||
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 pendingToolTasks = new Set<Promise<void>>();
|
const pendingToolTasks = new Set<Promise<void>>();
|
||||||
const blockReplyTimeoutMs =
|
const blockReplyTimeoutMs =
|
||||||
opts?.blockReplyTimeoutMs ?? BLOCK_REPLY_SEND_TIMEOUT_MS;
|
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 =
|
const replyToChannel =
|
||||||
sessionCtx.OriginatingChannel ??
|
sessionCtx.OriginatingChannel ??
|
||||||
((sessionCtx.Surface ?? sessionCtx.Provider)?.toLowerCase() as
|
((sessionCtx.Surface ?? sessionCtx.Provider)?.toLowerCase() as
|
||||||
@ -351,133 +197,20 @@ export async function runReplyAgent(params: {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const memoryFlushSettings = resolveMemoryFlushSettings(cfg);
|
activeSessionEntry = await runMemoryFlushIfNeeded({
|
||||||
const memoryFlushWritable = (() => {
|
cfg,
|
||||||
if (!sessionKey) return true;
|
followupRun,
|
||||||
const runtime = resolveSandboxRuntimeStatus({ cfg, sessionKey });
|
sessionCtx,
|
||||||
if (!runtime.sandboxed) return true;
|
opts,
|
||||||
const sandboxCfg = resolveSandboxConfigForAgent(cfg, runtime.agentId);
|
defaultModel,
|
||||||
return sandboxCfg.workspaceAccess === "rw";
|
agentCfgContextTokens,
|
||||||
})();
|
resolvedVerboseLevel,
|
||||||
const shouldFlushMemory =
|
sessionEntry: activeSessionEntry,
|
||||||
memoryFlushSettings &&
|
sessionStore: activeSessionStore,
|
||||||
memoryFlushWritable &&
|
sessionKey,
|
||||||
!isHeartbeat &&
|
storePath,
|
||||||
!isCliProvider(followupRun.run.provider, cfg) &&
|
isHeartbeat,
|
||||||
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)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runFollowupTurn = createFollowupRunner({
|
const runFollowupTurn = createFollowupRunner({
|
||||||
opts,
|
opts,
|
||||||
@ -491,13 +224,6 @@ export async function runReplyAgent(params: {
|
|||||||
agentCfgContextTokens,
|
agentCfgContextTokens,
|
||||||
});
|
});
|
||||||
|
|
||||||
const finalizeWithFollowup = <T>(value: T): T => {
|
|
||||||
scheduleFollowupDrain(queueKey, runFollowupTurn);
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
let didLogHeartbeatStrip = false;
|
|
||||||
let autoCompactionCompleted = false;
|
|
||||||
let responseUsageLine: string | undefined;
|
let responseUsageLine: string | undefined;
|
||||||
const resetSessionAfterCompactionFailure = async (
|
const resetSessionAfterCompactionFailure = async (
|
||||||
reason: string,
|
reason: string,
|
||||||
@ -540,379 +266,38 @@ export async function runReplyAgent(params: {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const runId = crypto.randomUUID();
|
const runOutcome = await runAgentTurnWithFallback({
|
||||||
if (sessionKey) {
|
commandBody,
|
||||||
registerAgentRunContext(runId, {
|
followupRun,
|
||||||
sessionKey,
|
sessionCtx,
|
||||||
verboseLevel: resolvedVerboseLevel,
|
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({
|
const { runResult, fallbackProvider, fallbackModel } = runOutcome;
|
||||||
...taggedPayload,
|
let { didLogHeartbeatStrip, autoCompactionCompleted } = runOutcome;
|
||||||
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.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
shouldInjectGroupIntro &&
|
shouldInjectGroupIntro &&
|
||||||
@ -942,95 +327,31 @@ export async function runReplyAgent(params: {
|
|||||||
// Drain any late tool/block deliveries before deciding there's "nothing to send".
|
// 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
|
// Otherwise, a late typing trigger (e.g. from a tool callback) can outlive the run and
|
||||||
// keep the typing indicator stuck.
|
// keep the typing indicator stuck.
|
||||||
if (payloadArray.length === 0) return finalizeWithFollowup(undefined);
|
if (payloadArray.length === 0)
|
||||||
|
return finalizeWithFollowup(undefined, queueKey, runFollowupTurn);
|
||||||
|
|
||||||
const sanitizedPayloads = isHeartbeat
|
const payloadResult = buildReplyPayloads({
|
||||||
? payloadArray
|
payloads: payloadArray,
|
||||||
: payloadArray.flatMap((payload) => {
|
isHeartbeat,
|
||||||
let text = payload.text;
|
didLogHeartbeatStrip,
|
||||||
|
blockStreamingEnabled,
|
||||||
if (payload.isError && text && isBunFetchSocketError(text)) {
|
blockReplyPipeline,
|
||||||
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,
|
replyToMode,
|
||||||
replyToChannel,
|
replyToChannel,
|
||||||
currentMessageId: sessionCtx.MessageSid,
|
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,
|
messageProvider: followupRun.run.messageProvider,
|
||||||
messagingToolSentTargets,
|
messagingToolSentTexts: runResult.messagingToolSentTexts,
|
||||||
|
messagingToolSentTargets: runResult.messagingToolSentTargets,
|
||||||
originatingTo: sessionCtx.OriginatingTo ?? sessionCtx.To,
|
originatingTo: sessionCtx.OriginatingTo ?? sessionCtx.To,
|
||||||
accountId: sessionCtx.AccountId,
|
accountId: sessionCtx.AccountId,
|
||||||
});
|
});
|
||||||
const dedupedPayloads = filterMessagingToolDuplicates({
|
const { replyPayloads } = payloadResult;
|
||||||
payloads: replyTaggedPayloads,
|
didLogHeartbeatStrip = payloadResult.didLogHeartbeatStrip;
|
||||||
sentTexts: messagingToolSentTexts,
|
|
||||||
});
|
|
||||||
const filteredPayloads = shouldDropFinalPayloads
|
|
||||||
? []
|
|
||||||
: blockStreamingEnabled
|
|
||||||
? dedupedPayloads.filter(
|
|
||||||
(payload) => !blockReplyPipeline?.hasSentPayload(payload),
|
|
||||||
)
|
|
||||||
: dedupedPayloads;
|
|
||||||
const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads;
|
|
||||||
|
|
||||||
if (replyPayloads.length === 0) return finalizeWithFollowup(undefined);
|
if (replyPayloads.length === 0)
|
||||||
|
return finalizeWithFollowup(undefined, queueKey, runFollowupTurn);
|
||||||
|
|
||||||
const shouldSignalTyping = replyPayloads.some((payload) => {
|
await signalTypingIfNeeded(replyPayloads, typingSignals);
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
const usage = runResult.meta.agentMeta?.usage;
|
const usage = runResult.meta.agentMeta?.usage;
|
||||||
const modelUsed =
|
const modelUsed =
|
||||||
@ -1166,6 +487,8 @@ export async function runReplyAgent(params: {
|
|||||||
|
|
||||||
return finalizeWithFollowup(
|
return finalizeWithFollowup(
|
||||||
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
|
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
|
||||||
|
queueKey,
|
||||||
|
runFollowupTurn,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
blockReplyPipeline?.stop();
|
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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user