mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 21:41:36 +00:00
chore: migrate to oxlint and oxfmt
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
This commit is contained in:
@@ -9,10 +9,7 @@ import {
|
||||
} from "../../config/sessions.js";
|
||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
import {
|
||||
normalizeCommandBody,
|
||||
shouldHandleTextCommands,
|
||||
} from "../commands-registry.js";
|
||||
import { normalizeCommandBody, shouldHandleTextCommands } from "../commands-registry.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
|
||||
@@ -82,9 +79,7 @@ export async function tryFastAbortFromMessage(params: {
|
||||
config: cfg,
|
||||
});
|
||||
// Use RawBody/CommandBody for abort detection (clean message without structural context).
|
||||
const raw = stripStructuralPrefixes(
|
||||
ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "",
|
||||
);
|
||||
const raw = stripStructuralPrefixes(ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "");
|
||||
const isGroup = ctx.ChatType?.trim().toLowerCase() === "group";
|
||||
const stripped = isGroup ? stripMentions(raw, ctx, cfg, agentId) : raw;
|
||||
const normalized = normalizeCommandBody(stripped);
|
||||
|
||||
@@ -17,27 +17,18 @@ import {
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import {
|
||||
emitAgentEvent,
|
||||
registerAgentRunContext,
|
||||
} from "../../infra/agent-events.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 { 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 { applyReplyTagsToPayload, isRenderablePayload } from "./reply-payloads.js";
|
||||
import type { TypingSignaler } from "./typing-mode.js";
|
||||
|
||||
export type AgentRunLoopResult =
|
||||
@@ -94,12 +85,9 @@ export async function runAgentTurnWithFallback(params: {
|
||||
while (true) {
|
||||
try {
|
||||
const allowPartialStream = !(
|
||||
params.followupRun.run.reasoningLevel === "stream" &&
|
||||
params.opts?.onReasoningStream
|
||||
params.followupRun.run.reasoningLevel === "stream" && params.opts?.onReasoningStream
|
||||
);
|
||||
const normalizeStreamingText = (
|
||||
payload: ReplyPayload,
|
||||
): { text?: string; skip: boolean } => {
|
||||
const normalizeStreamingText = (payload: ReplyPayload): { text?: string; skip: boolean } => {
|
||||
if (!allowPartialStream) return { skip: true };
|
||||
let text = payload.text;
|
||||
if (!params.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||
@@ -120,9 +108,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
}
|
||||
return { text, skip: false };
|
||||
};
|
||||
const handlePartialForTyping = async (
|
||||
payload: ReplyPayload,
|
||||
): Promise<string | undefined> => {
|
||||
const handlePartialForTyping = async (payload: ReplyPayload): Promise<string | undefined> => {
|
||||
const { text, skip } = normalizeStreamingText(payload);
|
||||
if (skip || !text) return undefined;
|
||||
await params.typingSignals.signalTextDelta(text);
|
||||
@@ -149,10 +135,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
startedAt,
|
||||
},
|
||||
});
|
||||
const cliSessionId = getCliSessionId(
|
||||
params.getActiveSessionEntry(),
|
||||
provider,
|
||||
);
|
||||
const cliSessionId = getCliSessionId(params.getActiveSessionEntry(), provider);
|
||||
return runCliAgent({
|
||||
sessionId: params.followupRun.run.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -198,8 +181,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
return runEmbeddedPiAgent({
|
||||
sessionId: params.followupRun.run.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider:
|
||||
params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
agentAccountId: params.sessionCtx.AccountId,
|
||||
// Provider threading context for tool auto-injection
|
||||
...buildThreadingToolContext({
|
||||
@@ -215,10 +197,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
prompt: params.commandBody,
|
||||
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
|
||||
ownerNumbers: params.followupRun.run.ownerNumbers,
|
||||
enforceFinalTag: resolveEnforceFinalTag(
|
||||
params.followupRun.run,
|
||||
provider,
|
||||
),
|
||||
enforceFinalTag: resolveEnforceFinalTag(params.followupRun.run, provider),
|
||||
provider,
|
||||
model,
|
||||
authProfileId: params.followupRun.run.authProfileId,
|
||||
@@ -233,11 +212,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
onPartialReply: allowPartialStream
|
||||
? async (payload) => {
|
||||
const textForTyping = await handlePartialForTyping(payload);
|
||||
if (
|
||||
!params.opts?.onPartialReply ||
|
||||
textForTyping === undefined
|
||||
)
|
||||
return;
|
||||
if (!params.opts?.onPartialReply || textForTyping === undefined) return;
|
||||
await params.opts.onPartialReply({
|
||||
text: textForTyping,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
@@ -248,8 +223,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
await params.typingSignals.signalMessageStart();
|
||||
},
|
||||
onReasoningStream:
|
||||
params.typingSignals.shouldStartOnReasoning ||
|
||||
params.opts?.onReasoningStream
|
||||
params.typingSignals.shouldStartOnReasoning || params.opts?.onReasoningStream
|
||||
? async (payload) => {
|
||||
await params.typingSignals.signalReasoningDelta();
|
||||
await params.opts?.onReasoningStream?.({
|
||||
@@ -261,16 +235,14 @@ export async function runAgentTurnWithFallback(params: {
|
||||
onAgentEvent: (evt) => {
|
||||
// Trigger typing when tools start executing
|
||||
if (evt.stream === "tool") {
|
||||
const phase =
|
||||
typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
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 phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
autoCompactionCompleted = true;
|
||||
@@ -281,8 +253,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
params.blockStreamingEnabled && params.opts?.onBlockReply
|
||||
? async (payload) => {
|
||||
const { text, skip } = normalizeStreamingText(payload);
|
||||
const hasPayloadMedia =
|
||||
(payload.mediaUrls?.length ?? 0) > 0;
|
||||
const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (skip && !hasPayloadMedia) return;
|
||||
const taggedPayload = applyReplyTagsToPayload(
|
||||
{
|
||||
@@ -293,22 +264,14 @@ export async function runAgentTurnWithFallback(params: {
|
||||
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,
|
||||
},
|
||||
);
|
||||
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;
|
||||
Boolean(taggedPayload.mediaUrl) || (taggedPayload.mediaUrls?.length ?? 0) > 0;
|
||||
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
|
||||
if (
|
||||
!cleaned &&
|
||||
@@ -322,21 +285,16 @@ export async function runAgentTurnWithFallback(params: {
|
||||
const blockPayload: ReplyPayload = params.applyReplyToMode({
|
||||
...taggedPayload,
|
||||
text: cleaned,
|
||||
audioAsVoice: Boolean(
|
||||
parsed.audioAsVoice || payload.audioAsVoice,
|
||||
),
|
||||
audioAsVoice: Boolean(parsed.audioAsVoice || payload.audioAsVoice),
|
||||
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
|
||||
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
|
||||
replyToCurrent:
|
||||
taggedPayload.replyToCurrent || parsed.replyToCurrent,
|
||||
replyToCurrent: taggedPayload.replyToCurrent || parsed.replyToCurrent,
|
||||
});
|
||||
|
||||
void params.typingSignals
|
||||
.signalTextDelta(cleaned ?? taggedPayload.text)
|
||||
.catch((err) => {
|
||||
logVerbose(
|
||||
`block reply typing signal failed: ${String(err)}`,
|
||||
);
|
||||
logVerbose(`block reply typing signal failed: ${String(err)}`);
|
||||
});
|
||||
|
||||
params.blockReplyPipeline?.enqueue(blockPayload);
|
||||
@@ -399,8 +357,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
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);
|
||||
const isSessionCorruption = /function call turn comes immediately after/i.test(message);
|
||||
|
||||
if (
|
||||
isCompactionFailure &&
|
||||
@@ -426,8 +383,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
try {
|
||||
// Delete transcript file if it exists
|
||||
if (corruptedSessionId) {
|
||||
const transcriptPath =
|
||||
resolveSessionTranscriptPath(corruptedSessionId);
|
||||
const transcriptPath = resolveSessionTranscriptPath(corruptedSessionId);
|
||||
try {
|
||||
fs.unlinkSync(transcriptPath);
|
||||
} catch {
|
||||
|
||||
@@ -9,9 +9,7 @@ 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),
|
||||
);
|
||||
hasAudioMedia(payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined));
|
||||
|
||||
export const createShouldEmitToolResult = (params: {
|
||||
sessionKey?: string;
|
||||
|
||||
@@ -3,10 +3,7 @@ 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 { resolveSandboxConfigForAgent, resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
@@ -18,10 +15,7 @@ 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 { buildThreadingToolContext, resolveEnforceFinalTag } from "./agent-runner-utils.js";
|
||||
import {
|
||||
resolveMemoryFlushContextWindowTokens,
|
||||
resolveMemoryFlushSettings,
|
||||
@@ -54,10 +48,7 @@ export async function runMemoryFlushIfNeeded(params: {
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
if (!runtime.sandboxed) return true;
|
||||
const sandboxCfg = resolveSandboxConfigForAgent(
|
||||
params.cfg,
|
||||
runtime.agentId,
|
||||
);
|
||||
const sandboxCfg = resolveSandboxConfigForAgent(params.cfg, runtime.agentId);
|
||||
return sandboxCfg.workspaceAccess === "rw";
|
||||
})();
|
||||
|
||||
@@ -69,9 +60,7 @@ export async function runMemoryFlushIfNeeded(params: {
|
||||
shouldRunMemoryFlush({
|
||||
entry:
|
||||
params.sessionEntry ??
|
||||
(params.sessionKey
|
||||
? params.sessionStore?.[params.sessionKey]
|
||||
: undefined),
|
||||
(params.sessionKey ? params.sessionStore?.[params.sessionKey] : undefined),
|
||||
contextWindowTokens: resolveMemoryFlushContextWindowTokens({
|
||||
modelId: params.followupRun.run.model ?? params.defaultModel,
|
||||
agentCfgContextTokens: params.agentCfgContextTokens,
|
||||
@@ -111,8 +100,7 @@ export async function runMemoryFlushIfNeeded(params: {
|
||||
runEmbeddedPiAgent({
|
||||
sessionId: params.followupRun.run.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider:
|
||||
params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
agentAccountId: params.sessionCtx.AccountId,
|
||||
// Provider threading context for tool auto-injection
|
||||
...buildThreadingToolContext({
|
||||
@@ -128,10 +116,7 @@ export async function runMemoryFlushIfNeeded(params: {
|
||||
prompt: memoryFlushSettings.prompt,
|
||||
extraSystemPrompt: flushSystemPrompt,
|
||||
ownerNumbers: params.followupRun.run.ownerNumbers,
|
||||
enforceFinalTag: resolveEnforceFinalTag(
|
||||
params.followupRun.run,
|
||||
provider,
|
||||
),
|
||||
enforceFinalTag: resolveEnforceFinalTag(params.followupRun.run, provider),
|
||||
provider,
|
||||
model,
|
||||
authProfileId: params.followupRun.run.authProfileId,
|
||||
@@ -143,8 +128,7 @@ export async function runMemoryFlushIfNeeded(params: {
|
||||
runId: flushRunId,
|
||||
onAgentEvent: (evt) => {
|
||||
if (evt.stream === "compaction") {
|
||||
const phase =
|
||||
typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
memoryCompactionCompleted = true;
|
||||
@@ -155,9 +139,7 @@ export async function runMemoryFlushIfNeeded(params: {
|
||||
});
|
||||
let memoryFlushCompactionCount =
|
||||
activeSessionEntry?.compactionCount ??
|
||||
(params.sessionKey
|
||||
? activeSessionStore?.[params.sessionKey]?.compactionCount
|
||||
: 0) ??
|
||||
(params.sessionKey ? activeSessionStore?.[params.sessionKey]?.compactionCount : 0) ??
|
||||
0;
|
||||
if (memoryCompactionCompleted) {
|
||||
const nextCount = await incrementCompactionCount({
|
||||
|
||||
@@ -4,10 +4,7 @@ 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 { formatBunFetchSocketError, isBunFetchSocketError } from "./agent-runner-utils.js";
|
||||
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||
import { parseReplyDirectives } from "./reply-directives.js";
|
||||
import {
|
||||
@@ -52,8 +49,7 @@ export function buildReplyPayloads(params: {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||
}
|
||||
const hasMedia =
|
||||
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (stripped.shouldSkip && !hasMedia) return [];
|
||||
return [{ ...payload, text: stripped.text }];
|
||||
});
|
||||
@@ -105,9 +101,7 @@ export function buildReplyPayloads(params: {
|
||||
const filteredPayloads = shouldDropFinalPayloads
|
||||
? []
|
||||
: params.blockStreamingEnabled
|
||||
? dedupedPayloads.filter(
|
||||
(payload) => !params.blockReplyPipeline?.hasSentPayload(payload),
|
||||
)
|
||||
? dedupedPayloads.filter((payload) => !params.blockReplyPipeline?.hasSentPayload(payload))
|
||||
: dedupedPayloads;
|
||||
const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads;
|
||||
|
||||
|
||||
@@ -4,11 +4,7 @@ import type { ChannelThreadingToolContext } from "../../channels/plugins/types.j
|
||||
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 { 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";
|
||||
@@ -73,8 +69,7 @@ export const formatResponseUsageLine = (params: {
|
||||
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 outputLabel = typeof output === "number" ? formatTokenCount(output) : "?";
|
||||
const cost =
|
||||
params.showCost && typeof input === "number" && typeof output === "number"
|
||||
? estimateUsageCost({
|
||||
@@ -92,10 +87,7 @@ export const formatResponseUsageLine = (params: {
|
||||
return `Usage: ${inputLabel} in / ${outputLabel} out${suffix}`;
|
||||
};
|
||||
|
||||
export const appendUsageLine = (
|
||||
payloads: ReplyPayload[],
|
||||
line: string,
|
||||
): ReplyPayload[] => {
|
||||
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) {
|
||||
@@ -116,7 +108,5 @@ export const appendUsageLine = (
|
||||
return updated;
|
||||
};
|
||||
|
||||
export const resolveEnforceFinalTag = (
|
||||
run: FollowupRun["run"],
|
||||
provider: string,
|
||||
) => Boolean(run.enforceFinalTag || isReasoningTagProvider(provider));
|
||||
export const resolveEnforceFinalTag = (run: FollowupRun["run"], provider: string) =>
|
||||
Boolean(run.enforceFinalTag || isReasoningTagProvider(provider));
|
||||
|
||||
@@ -28,8 +28,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
@@ -43,9 +42,7 @@ describe("runReplyAgent block streaming", () => {
|
||||
it("coalesces duplicate text_end block replies", async () => {
|
||||
const onBlockReply = vi.fn();
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params) => {
|
||||
const block = params.onBlockReply as
|
||||
| ((payload: { text?: string }) => void)
|
||||
| undefined;
|
||||
const block = params.onBlockReply as ((payload: { text?: string }) => void) | undefined;
|
||||
block?.({ text: "Hello" });
|
||||
block?.({ text: "Hello" });
|
||||
return {
|
||||
|
||||
@@ -34,8 +34,7 @@ vi.mock("../../agents/cli-runner.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
|
||||
@@ -34,8 +34,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
@@ -124,9 +123,7 @@ function createMinimalRun(params?: {
|
||||
describe("runReplyAgent typing (heartbeat)", () => {
|
||||
it("resets corrupted Gemini sessions and deletes transcripts", async () => {
|
||||
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const stateDir = await fs.mkdtemp(
|
||||
path.join(tmpdir(), "clawdbot-session-reset-"),
|
||||
);
|
||||
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-reset-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||
try {
|
||||
const sessionId = "session-corrupt";
|
||||
@@ -173,9 +170,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
});
|
||||
it("keeps sessions intact on other errors", async () => {
|
||||
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const stateDir = await fs.mkdtemp(
|
||||
path.join(tmpdir(), "clawdbot-session-noreset-"),
|
||||
);
|
||||
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-noreset-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||
try {
|
||||
const sessionId = "session-ok";
|
||||
|
||||
@@ -33,8 +33,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
@@ -123,9 +122,7 @@ function createMinimalRun(params?: {
|
||||
describe("runReplyAgent typing (heartbeat)", () => {
|
||||
it("retries after compaction failure by resetting the session", async () => {
|
||||
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const stateDir = await fs.mkdtemp(
|
||||
path.join(tmpdir(), "clawdbot-session-compaction-reset-"),
|
||||
);
|
||||
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-compaction-reset-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||
try {
|
||||
const sessionId = "session";
|
||||
@@ -173,9 +170,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
});
|
||||
it("retries after context overflow payload by resetting the session", async () => {
|
||||
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const stateDir = await fs.mkdtemp(
|
||||
path.join(tmpdir(), "clawdbot-session-overflow-reset-"),
|
||||
);
|
||||
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-overflow-reset-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||
try {
|
||||
const sessionId = "session";
|
||||
@@ -188,9 +183,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
|
||||
runEmbeddedPiAgentMock
|
||||
.mockImplementationOnce(async () => ({
|
||||
payloads: [
|
||||
{ text: "Context overflow: prompt too large", isError: true },
|
||||
],
|
||||
payloads: [{ text: "Context overflow: prompt too large", isError: true }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
error: {
|
||||
|
||||
@@ -33,8 +33,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
@@ -123,12 +122,10 @@ function createMinimalRun(params?: {
|
||||
describe("runReplyAgent typing (heartbeat)", () => {
|
||||
it("signals typing on block replies", async () => {
|
||||
const onBlockReply = vi.fn();
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onBlockReply?.({ text: "chunk", mediaUrls: [] });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
},
|
||||
);
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onBlockReply?.({ text: "chunk", mediaUrls: [] });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
typingMode: "message",
|
||||
@@ -148,12 +145,10 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
});
|
||||
it("signals typing on tool results", async () => {
|
||||
const onToolResult = vi.fn();
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onToolResult?.({ text: "tooling", mediaUrls: [] });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
},
|
||||
);
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onToolResult?.({ text: "tooling", mediaUrls: [] });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
typingMode: "message",
|
||||
@@ -169,12 +164,10 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
});
|
||||
it("skips typing for silent tool results", async () => {
|
||||
const onToolResult = vi.fn();
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onToolResult?.({ text: "NO_REPLY", mediaUrls: [] });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
},
|
||||
);
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onToolResult?.({ text: "NO_REPLY", mediaUrls: [] });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
typingMode: "message",
|
||||
@@ -195,10 +188,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: {
|
||||
onAgentEvent?: (evt: {
|
||||
stream: string;
|
||||
data: Record<string, unknown>;
|
||||
}) => void;
|
||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
||||
}) => {
|
||||
params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
|
||||
@@ -30,8 +30,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
@@ -120,12 +119,10 @@ function createMinimalRun(params?: {
|
||||
describe("runReplyAgent typing (heartbeat)", () => {
|
||||
it("signals typing for normal runs", async () => {
|
||||
const onPartialReply = vi.fn();
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onPartialReply?.({ text: "hi" });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
},
|
||||
);
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onPartialReply?.({ text: "hi" });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
opts: { isHeartbeat: false, onPartialReply },
|
||||
@@ -137,12 +134,10 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
});
|
||||
it("signals typing even without consumer partial handler", async () => {
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onPartialReply?.({ text: "hi" });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
},
|
||||
);
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onPartialReply?.({ text: "hi" });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
typingMode: "message",
|
||||
@@ -154,12 +149,10 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
});
|
||||
it("never signals typing for heartbeat runs", async () => {
|
||||
const onPartialReply = vi.fn();
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onPartialReply?.({ text: "hi" });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
},
|
||||
);
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onPartialReply?.({ text: "hi" });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
opts: { isHeartbeat: true, onPartialReply },
|
||||
@@ -172,12 +165,10 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
});
|
||||
it("suppresses partial streaming for NO_REPLY", async () => {
|
||||
const onPartialReply = vi.fn();
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onPartialReply?.({ text: "NO_REPLY" });
|
||||
return { payloads: [{ text: "NO_REPLY" }], meta: {} };
|
||||
},
|
||||
);
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onPartialReply?.({ text: "NO_REPLY" });
|
||||
return { payloads: [{ text: "NO_REPLY" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
opts: { isHeartbeat: false, onPartialReply },
|
||||
@@ -189,12 +180,10 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
});
|
||||
it("starts typing on assistant message start in message mode", async () => {
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onAssistantMessageStart?.();
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
},
|
||||
);
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onAssistantMessageStart?.();
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
typingMode: "message",
|
||||
@@ -208,9 +197,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: {
|
||||
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
|
||||
onReasoningStream?: (payload: {
|
||||
text?: string;
|
||||
}) => Promise<void> | void;
|
||||
onReasoningStream?: (payload: { text?: string }) => Promise<void> | void;
|
||||
}) => {
|
||||
await params.onReasoningStream?.({ text: "Reasoning:\n_step_" });
|
||||
await params.onPartialReply?.({ text: "hi" });
|
||||
@@ -228,9 +215,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
});
|
||||
it("suppresses typing in never mode", async () => {
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: {
|
||||
onPartialReply?: (payload: { text?: string }) => void;
|
||||
}) => {
|
||||
async (params: { onPartialReply?: (payload: { text?: string }) => void }) => {
|
||||
params.onPartialReply?.({ text: "hi" });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
},
|
||||
|
||||
@@ -34,8 +34,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
@@ -124,13 +123,9 @@ function createMinimalRun(params?: {
|
||||
describe("runReplyAgent typing (heartbeat)", () => {
|
||||
it("still replies even if session reset fails to persist", async () => {
|
||||
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const stateDir = await fs.mkdtemp(
|
||||
path.join(tmpdir(), "clawdbot-session-reset-fail-"),
|
||||
);
|
||||
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-reset-fail-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||
const saveSpy = vi
|
||||
.spyOn(sessions, "saveSessionStore")
|
||||
.mockRejectedValueOnce(new Error("boom"));
|
||||
const saveSpy = vi.spyOn(sessions, "saveSessionStore").mockRejectedValueOnce(new Error("boom"));
|
||||
try {
|
||||
const sessionId = "session-corrupt";
|
||||
const storePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
@@ -185,9 +180,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
const payloads = Array.isArray(res) ? res : res ? [res] : [];
|
||||
expect(payloads.length).toBe(1);
|
||||
expect(payloads[0]?.text).toContain("LLM connection failed");
|
||||
expect(payloads[0]?.text).toContain(
|
||||
"socket connection was closed unexpectedly",
|
||||
);
|
||||
expect(payloads[0]?.text).toContain("socket connection was closed unexpectedly");
|
||||
expect(payloads[0]?.text).toContain("```");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,10 +13,7 @@ const runCliAgentMock = vi.fn();
|
||||
type EmbeddedRunParams = {
|
||||
prompt?: string;
|
||||
extraSystemPrompt?: string;
|
||||
onAgentEvent?: (evt: {
|
||||
stream?: string;
|
||||
data?: { phase?: string; willRetry?: boolean };
|
||||
}) => void;
|
||||
onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void;
|
||||
};
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
@@ -45,8 +42,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
@@ -140,21 +136,19 @@ describe("runReplyAgent memory flush", () => {
|
||||
|
||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementation(
|
||||
async (params: EmbeddedRunParams) => {
|
||||
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
|
||||
params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "end", willRetry: false },
|
||||
});
|
||||
return { payloads: [], meta: {} };
|
||||
}
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
},
|
||||
);
|
||||
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
|
||||
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
|
||||
params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "end", willRetry: false },
|
||||
});
|
||||
return { payloads: [], meta: {} };
|
||||
}
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
});
|
||||
|
||||
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
|
||||
storePath,
|
||||
|
||||
@@ -13,10 +13,7 @@ const runCliAgentMock = vi.fn();
|
||||
type EmbeddedRunParams = {
|
||||
prompt?: string;
|
||||
extraSystemPrompt?: string;
|
||||
onAgentEvent?: (evt: {
|
||||
stream?: string;
|
||||
data?: { phase?: string; willRetry?: boolean };
|
||||
}) => void;
|
||||
onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void;
|
||||
};
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
@@ -45,8 +42,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
@@ -141,18 +137,16 @@ describe("runReplyAgent memory flush", () => {
|
||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||
|
||||
const calls: Array<{ prompt?: string }> = [];
|
||||
runEmbeddedPiAgentMock.mockImplementation(
|
||||
async (params: EmbeddedRunParams) => {
|
||||
calls.push({ prompt: params.prompt });
|
||||
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
|
||||
return { payloads: [], meta: {} };
|
||||
}
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
},
|
||||
);
|
||||
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
|
||||
calls.push({ prompt: params.prompt });
|
||||
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
|
||||
return { payloads: [], meta: {} };
|
||||
}
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
});
|
||||
|
||||
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
|
||||
storePath,
|
||||
@@ -184,10 +178,7 @@ describe("runReplyAgent memory flush", () => {
|
||||
typingMode: "instant",
|
||||
});
|
||||
|
||||
expect(calls.map((call) => call.prompt)).toEqual([
|
||||
DEFAULT_MEMORY_FLUSH_PROMPT,
|
||||
"hello",
|
||||
]);
|
||||
expect(calls.map((call) => call.prompt)).toEqual([DEFAULT_MEMORY_FLUSH_PROMPT, "hello"]);
|
||||
|
||||
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
expect(stored[sessionKey].memoryFlushAt).toBeTypeOf("number");
|
||||
@@ -207,12 +198,10 @@ describe("runReplyAgent memory flush", () => {
|
||||
|
||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementation(
|
||||
async (_params: EmbeddedRunParams) => ({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
}),
|
||||
);
|
||||
runEmbeddedPiAgentMock.mockImplementation(async (_params: EmbeddedRunParams) => ({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
}));
|
||||
|
||||
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
|
||||
storePath,
|
||||
@@ -250,9 +239,7 @@ describe("runReplyAgent memory flush", () => {
|
||||
});
|
||||
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
|
||||
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as
|
||||
| { prompt?: string }
|
||||
| undefined;
|
||||
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as { prompt?: string } | undefined;
|
||||
expect(call?.prompt).toBe("hello");
|
||||
|
||||
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
|
||||
@@ -12,10 +12,7 @@ const runCliAgentMock = vi.fn();
|
||||
type EmbeddedRunParams = {
|
||||
prompt?: string;
|
||||
extraSystemPrompt?: string;
|
||||
onAgentEvent?: (evt: {
|
||||
stream?: string;
|
||||
data?: { phase?: string; willRetry?: boolean };
|
||||
}) => void;
|
||||
onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void;
|
||||
};
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
@@ -44,8 +41,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
@@ -141,15 +137,13 @@ describe("runReplyAgent memory flush", () => {
|
||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||
|
||||
const calls: Array<{ prompt?: string }> = [];
|
||||
runEmbeddedPiAgentMock.mockImplementation(
|
||||
async (params: EmbeddedRunParams) => {
|
||||
calls.push({ prompt: params.prompt });
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
},
|
||||
);
|
||||
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
|
||||
calls.push({ prompt: params.prompt });
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
});
|
||||
runCliAgentMock.mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
@@ -187,9 +181,7 @@ describe("runReplyAgent memory flush", () => {
|
||||
});
|
||||
|
||||
expect(runCliAgentMock).toHaveBeenCalledTimes(1);
|
||||
const call = runCliAgentMock.mock.calls[0]?.[0] as
|
||||
| { prompt?: string }
|
||||
| undefined;
|
||||
const call = runCliAgentMock.mock.calls[0]?.[0] as { prompt?: string } | undefined;
|
||||
expect(call?.prompt).toBe("hello");
|
||||
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -12,10 +12,7 @@ const runCliAgentMock = vi.fn();
|
||||
type EmbeddedRunParams = {
|
||||
prompt?: string;
|
||||
extraSystemPrompt?: string;
|
||||
onAgentEvent?: (evt: {
|
||||
stream?: string;
|
||||
data?: { phase?: string; willRetry?: boolean };
|
||||
}) => void;
|
||||
onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void;
|
||||
};
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
@@ -44,8 +41,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
@@ -140,15 +136,13 @@ describe("runReplyAgent memory flush", () => {
|
||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||
|
||||
const calls: Array<{ prompt?: string }> = [];
|
||||
runEmbeddedPiAgentMock.mockImplementation(
|
||||
async (params: EmbeddedRunParams) => {
|
||||
calls.push({ prompt: params.prompt });
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
},
|
||||
);
|
||||
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
|
||||
calls.push({ prompt: params.prompt });
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
});
|
||||
|
||||
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
|
||||
storePath,
|
||||
@@ -207,15 +201,13 @@ describe("runReplyAgent memory flush", () => {
|
||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||
|
||||
const calls: Array<{ prompt?: string }> = [];
|
||||
runEmbeddedPiAgentMock.mockImplementation(
|
||||
async (params: EmbeddedRunParams) => {
|
||||
calls.push({ prompt: params.prompt });
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
},
|
||||
);
|
||||
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
|
||||
calls.push({ prompt: params.prompt });
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
});
|
||||
|
||||
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
|
||||
storePath,
|
||||
|
||||
@@ -13,10 +13,7 @@ const runCliAgentMock = vi.fn();
|
||||
type EmbeddedRunParams = {
|
||||
prompt?: string;
|
||||
extraSystemPrompt?: string;
|
||||
onAgentEvent?: (evt: {
|
||||
stream?: string;
|
||||
data?: { phase?: string; willRetry?: boolean };
|
||||
}) => void;
|
||||
onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void;
|
||||
};
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
@@ -45,8 +42,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
@@ -141,18 +137,16 @@ describe("runReplyAgent memory flush", () => {
|
||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||
|
||||
const calls: Array<EmbeddedRunParams> = [];
|
||||
runEmbeddedPiAgentMock.mockImplementation(
|
||||
async (params: EmbeddedRunParams) => {
|
||||
calls.push(params);
|
||||
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
|
||||
return { payloads: [], meta: {} };
|
||||
}
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
},
|
||||
);
|
||||
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
|
||||
calls.push(params);
|
||||
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
|
||||
return { payloads: [], meta: {} };
|
||||
}
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
});
|
||||
|
||||
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
|
||||
storePath,
|
||||
@@ -221,15 +215,13 @@ describe("runReplyAgent memory flush", () => {
|
||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||
|
||||
const calls: Array<{ prompt?: string }> = [];
|
||||
runEmbeddedPiAgentMock.mockImplementation(
|
||||
async (params: EmbeddedRunParams) => {
|
||||
calls.push({ prompt: params.prompt });
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
},
|
||||
);
|
||||
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
|
||||
calls.push({ prompt: params.prompt });
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
});
|
||||
|
||||
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
|
||||
storePath,
|
||||
|
||||
@@ -28,8 +28,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
@@ -101,9 +100,7 @@ describe("runReplyAgent messaging tool suppression", () => {
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messagingToolSentTexts: ["different message"],
|
||||
messagingToolSentTargets: [
|
||||
{ tool: "slack", provider: "slack", to: "channel:C1" },
|
||||
],
|
||||
messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
@@ -116,9 +113,7 @@ describe("runReplyAgent messaging tool suppression", () => {
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messagingToolSentTexts: ["different message"],
|
||||
messagingToolSentTargets: [
|
||||
{ tool: "discord", provider: "discord", to: "channel:C1" },
|
||||
],
|
||||
messagingToolSentTargets: [{ tool: "discord", provider: "discord", to: "channel:C1" }],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
|
||||
@@ -23,8 +23,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
@@ -118,11 +117,7 @@ describe("runReplyAgent fallback reasoning tags", () => {
|
||||
meta: {},
|
||||
});
|
||||
runWithModelFallbackMock.mockImplementationOnce(
|
||||
async ({
|
||||
run,
|
||||
}: {
|
||||
run: (provider: string, model: string) => Promise<unknown>;
|
||||
}) => ({
|
||||
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
||||
result: await run("google-antigravity", "gemini-3"),
|
||||
provider: "google-antigravity",
|
||||
model: "gemini-3",
|
||||
@@ -131,27 +126,19 @@ describe("runReplyAgent fallback reasoning tags", () => {
|
||||
|
||||
await createRun();
|
||||
|
||||
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as
|
||||
| EmbeddedPiAgentParams
|
||||
| undefined;
|
||||
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as EmbeddedPiAgentParams | undefined;
|
||||
expect(call?.enforceFinalTag).toBe(true);
|
||||
});
|
||||
|
||||
it("enforces <final> during memory flush on fallback providers", async () => {
|
||||
runEmbeddedPiAgentMock.mockImplementation(
|
||||
async (params: EmbeddedPiAgentParams) => {
|
||||
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
|
||||
return { payloads: [], meta: {} };
|
||||
}
|
||||
return { payloads: [{ text: "ok" }], meta: {} };
|
||||
},
|
||||
);
|
||||
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedPiAgentParams) => {
|
||||
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
|
||||
return { payloads: [], meta: {} };
|
||||
}
|
||||
return { payloads: [{ text: "ok" }], meta: {} };
|
||||
});
|
||||
runWithModelFallbackMock.mockImplementation(
|
||||
async ({
|
||||
run,
|
||||
}: {
|
||||
run: (provider: string, model: string) => Promise<unknown>;
|
||||
}) => ({
|
||||
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
||||
result: await run("google-antigravity", "gemini-3"),
|
||||
provider: "google-antigravity",
|
||||
model: "gemini-3",
|
||||
@@ -169,8 +156,7 @@ describe("runReplyAgent fallback reasoning tags", () => {
|
||||
|
||||
const flushCall = runEmbeddedPiAgentMock.mock.calls.find(
|
||||
([params]) =>
|
||||
(params as EmbeddedPiAgentParams | undefined)?.prompt ===
|
||||
DEFAULT_MEMORY_FLUSH_PROMPT,
|
||||
(params as EmbeddedPiAgentParams | undefined)?.prompt === DEFAULT_MEMORY_FLUSH_PROMPT,
|
||||
)?.[0] as EmbeddedPiAgentParams | undefined;
|
||||
|
||||
expect(flushCall?.enforceFinalTag).toBe(true);
|
||||
|
||||
@@ -29,25 +29,12 @@ import {
|
||||
} from "./agent-runner-helpers.js";
|
||||
import { runMemoryFlushIfNeeded } from "./agent-runner-memory.js";
|
||||
import { buildReplyPayloads } from "./agent-runner-payloads.js";
|
||||
import {
|
||||
appendUsageLine,
|
||||
formatResponseUsageLine,
|
||||
} from "./agent-runner-utils.js";
|
||||
import {
|
||||
createAudioAsVoiceBuffer,
|
||||
createBlockReplyPipeline,
|
||||
} from "./block-reply-pipeline.js";
|
||||
import { appendUsageLine, formatResponseUsageLine } from "./agent-runner-utils.js";
|
||||
import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||
import { resolveBlockStreamingCoalescing } from "./block-streaming.js";
|
||||
import { createFollowupRunner } from "./followup-runner.js";
|
||||
import {
|
||||
enqueueFollowupRun,
|
||||
type FollowupRun,
|
||||
type QueueSettings,
|
||||
} from "./queue.js";
|
||||
import {
|
||||
createReplyToModeFilterForChannel,
|
||||
resolveReplyToMode,
|
||||
} from "./reply-threading.js";
|
||||
import { enqueueFollowupRun, type FollowupRun, type QueueSettings } from "./queue.js";
|
||||
import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js";
|
||||
import { incrementCompactionCount } from "./session-updates.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
import { createTypingSignaler } from "./typing-mode.js";
|
||||
@@ -129,8 +116,7 @@ export async function runReplyAgent(params: {
|
||||
});
|
||||
|
||||
const pendingToolTasks = new Set<Promise<void>>();
|
||||
const blockReplyTimeoutMs =
|
||||
opts?.blockReplyTimeoutMs ?? BLOCK_REPLY_SEND_TIMEOUT_MS;
|
||||
const blockReplyTimeoutMs = opts?.blockReplyTimeoutMs ?? BLOCK_REPLY_SEND_TIMEOUT_MS;
|
||||
|
||||
const replyToChannel =
|
||||
sessionCtx.OriginatingChannel ??
|
||||
@@ -142,10 +128,7 @@ export async function runReplyAgent(params: {
|
||||
replyToChannel,
|
||||
sessionCtx.AccountId,
|
||||
);
|
||||
const applyReplyToMode = createReplyToModeFilterForChannel(
|
||||
replyToMode,
|
||||
replyToChannel,
|
||||
);
|
||||
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
|
||||
const cfg = followupRun.run.config;
|
||||
const blockReplyCoalescing =
|
||||
blockStreamingEnabled && opts?.onBlockReply
|
||||
@@ -167,10 +150,7 @@ export async function runReplyAgent(params: {
|
||||
: null;
|
||||
|
||||
if (shouldSteer && isStreaming) {
|
||||
const steered = queueEmbeddedPiMessage(
|
||||
followupRun.run.sessionId,
|
||||
followupRun.prompt,
|
||||
);
|
||||
const steered = queueEmbeddedPiMessage(followupRun.run.sessionId, followupRun.prompt);
|
||||
if (steered && !shouldFollowup) {
|
||||
if (activeSessionEntry && activeSessionStore && sessionKey) {
|
||||
activeSessionEntry.updatedAt = Date.now();
|
||||
@@ -225,9 +205,7 @@ export async function runReplyAgent(params: {
|
||||
});
|
||||
|
||||
let responseUsageLine: string | undefined;
|
||||
const resetSessionAfterCompactionFailure = async (
|
||||
reason: string,
|
||||
): Promise<boolean> => {
|
||||
const resetSessionAfterCompactionFailure = async (reason: string): Promise<boolean> => {
|
||||
if (!sessionKey || !activeSessionStore || !storePath) return false;
|
||||
const nextSessionId = crypto.randomUUID();
|
||||
const nextEntry: SessionEntry = {
|
||||
@@ -239,14 +217,8 @@ export async function runReplyAgent(params: {
|
||||
};
|
||||
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||
const topicId =
|
||||
typeof sessionCtx.MessageThreadId === "number"
|
||||
? sessionCtx.MessageThreadId
|
||||
: undefined;
|
||||
const nextSessionFile = resolveSessionTranscriptPath(
|
||||
nextSessionId,
|
||||
agentId,
|
||||
topicId,
|
||||
);
|
||||
typeof sessionCtx.MessageThreadId === "number" ? sessionCtx.MessageThreadId : undefined;
|
||||
const nextSessionFile = resolveSessionTranscriptPath(nextSessionId, agentId, topicId);
|
||||
nextEntry.sessionFile = nextSessionFile;
|
||||
activeSessionStore[sessionKey] = nextEntry;
|
||||
try {
|
||||
@@ -289,11 +261,7 @@ export async function runReplyAgent(params: {
|
||||
});
|
||||
|
||||
if (runOutcome.kind === "final") {
|
||||
return finalizeWithFollowup(
|
||||
runOutcome.payload,
|
||||
queueKey,
|
||||
runFollowupTurn,
|
||||
);
|
||||
return finalizeWithFollowup(runOutcome.payload, queueKey, runFollowupTurn);
|
||||
}
|
||||
|
||||
const { runResult, fallbackProvider, fallbackModel } = runOutcome;
|
||||
@@ -354,12 +322,9 @@ export async function runReplyAgent(params: {
|
||||
await signalTypingIfNeeded(replyPayloads, typingSignals);
|
||||
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed =
|
||||
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
||||
const modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
||||
const providerUsed =
|
||||
runResult.meta.agentMeta?.provider ??
|
||||
fallbackProvider ??
|
||||
followupRun.run.provider;
|
||||
runResult.meta.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider;
|
||||
const cliSessionId = isCliProvider(providerUsed, cfg)
|
||||
? runResult.meta.agentMeta?.sessionId?.trim()
|
||||
: undefined;
|
||||
@@ -378,13 +343,11 @@ export async function runReplyAgent(params: {
|
||||
update: async (entry) => {
|
||||
const input = usage.input ?? 0;
|
||||
const output = usage.output ?? 0;
|
||||
const promptTokens =
|
||||
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
const promptTokens = input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
const patch: Partial<SessionEntry> = {
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
totalTokens:
|
||||
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||
totalTokens: promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||
modelProvider: providerUsed,
|
||||
model: modelUsed,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
@@ -437,9 +400,7 @@ export async function runReplyAgent(params: {
|
||||
|
||||
const responseUsageEnabled =
|
||||
(activeSessionEntry?.responseUsage ??
|
||||
(sessionKey
|
||||
? activeSessionStore?.[sessionKey]?.responseUsage
|
||||
: undefined)) === "on";
|
||||
(sessionKey ? activeSessionStore?.[sessionKey]?.responseUsage : undefined)) === "on";
|
||||
if (responseUsageEnabled && hasNonzeroUsage(usage)) {
|
||||
const authMode = resolveModelAuthMode(providerUsed, cfg);
|
||||
const showCost = authMode === "api-key";
|
||||
@@ -469,17 +430,11 @@ export async function runReplyAgent(params: {
|
||||
});
|
||||
if (resolvedVerboseLevel === "on") {
|
||||
const suffix = typeof count === "number" ? ` (count ${count})` : "";
|
||||
finalPayloads = [
|
||||
{ text: `🧹 Auto-compaction complete${suffix}.` },
|
||||
...finalPayloads,
|
||||
];
|
||||
finalPayloads = [{ text: `🧹 Auto-compaction complete${suffix}.` }, ...finalPayloads];
|
||||
}
|
||||
}
|
||||
if (resolvedVerboseLevel === "on" && activeIsNewSession) {
|
||||
finalPayloads = [
|
||||
{ text: `🧭 New session: ${followupRun.run.sessionId}` },
|
||||
...finalPayloads,
|
||||
];
|
||||
finalPayloads = [{ text: `🧭 New session: ${followupRun.run.sessionId}` }, ...finalPayloads];
|
||||
}
|
||||
if (responseUsageLine) {
|
||||
finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import {
|
||||
getFinishedSession,
|
||||
getSession,
|
||||
markExited,
|
||||
} from "../../agents/bash-process-registry.js";
|
||||
import { getFinishedSession, getSession, markExited } from "../../agents/bash-process-registry.js";
|
||||
import { createExecTool } from "../../agents/bash-tools.js";
|
||||
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
|
||||
import { killProcessTree } from "../../agents/shell-utils.js";
|
||||
@@ -41,8 +37,7 @@ function clampNumber(value: number, min: number, max: number) {
|
||||
|
||||
function resolveForegroundMs(cfg: ClawdbotConfig): number {
|
||||
const raw = cfg.commands?.bashForegroundMs;
|
||||
if (typeof raw !== "number" || Number.isNaN(raw))
|
||||
return DEFAULT_FOREGROUND_MS;
|
||||
if (typeof raw !== "number" || Number.isNaN(raw)) return DEFAULT_FOREGROUND_MS;
|
||||
return clampNumber(Math.floor(raw), 0, MAX_FOREGROUND_MS);
|
||||
}
|
||||
|
||||
@@ -98,8 +93,7 @@ function resolveRawCommandBody(params: {
|
||||
agentId?: string;
|
||||
isGroup: boolean;
|
||||
}) {
|
||||
const source =
|
||||
params.ctx.CommandBody ?? params.ctx.RawBody ?? params.ctx.Body ?? "";
|
||||
const source = params.ctx.CommandBody ?? params.ctx.RawBody ?? params.ctx.Body ?? "";
|
||||
const stripped = stripStructuralPrefixes(source);
|
||||
return params.isGroup
|
||||
? stripMentions(stripped, params.ctx, params.cfg, params.agentId)
|
||||
@@ -110,8 +104,7 @@ function getScopedSession(sessionId: string) {
|
||||
const running = getSession(sessionId);
|
||||
if (running && running.scopeKey === CHAT_BASH_SCOPE_KEY) return { running };
|
||||
const finished = getFinishedSession(sessionId);
|
||||
if (finished && finished.scopeKey === CHAT_BASH_SCOPE_KEY)
|
||||
return { finished };
|
||||
if (finished && finished.scopeKey === CHAT_BASH_SCOPE_KEY) return { finished };
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -165,11 +158,7 @@ function formatElevatedUnavailableMessage(params: {
|
||||
`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(", ")}`,
|
||||
);
|
||||
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>).",
|
||||
@@ -244,18 +233,14 @@ export async function handleBashChatCommand(params: {
|
||||
|
||||
if (request.action === "poll") {
|
||||
const sessionId =
|
||||
request.sessionId?.trim() ||
|
||||
(liveJob?.state === "running" ? liveJob.sessionId : "");
|
||||
request.sessionId?.trim() || (liveJob?.state === "running" ? liveJob.sessionId : "");
|
||||
if (!sessionId) {
|
||||
return { text: "⚙️ No active bash job." };
|
||||
}
|
||||
const { running, finished } = getScopedSession(sessionId);
|
||||
if (running) {
|
||||
attachActiveWatcher(sessionId);
|
||||
const runtimeSec = Math.max(
|
||||
0,
|
||||
Math.floor((Date.now() - running.startedAt) / 1000),
|
||||
);
|
||||
const runtimeSec = Math.max(0, Math.floor((Date.now() - running.startedAt) / 1000));
|
||||
const tail = running.tail || "(no output yet)";
|
||||
return {
|
||||
text: [
|
||||
@@ -291,8 +276,7 @@ export async function handleBashChatCommand(params: {
|
||||
|
||||
if (request.action === "stop") {
|
||||
const sessionId =
|
||||
request.sessionId?.trim() ||
|
||||
(liveJob?.state === "running" ? liveJob.sessionId : "");
|
||||
request.sessionId?.trim() || (liveJob?.state === "running" ? liveJob.sessionId : "");
|
||||
if (!sessionId) {
|
||||
return { text: "⚙️ No active bash job." };
|
||||
}
|
||||
@@ -326,9 +310,7 @@ export async function handleBashChatCommand(params: {
|
||||
// request.action === "run"
|
||||
if (liveJob) {
|
||||
const label =
|
||||
liveJob.state === "running"
|
||||
? formatSessionSnippet(liveJob.sessionId)
|
||||
: "starting";
|
||||
liveJob.state === "running" ? formatSessionSnippet(liveJob.sessionId) : "starting";
|
||||
return {
|
||||
text: `⚠️ A bash job is already running (${label}). Use !poll / !stop (or /bash poll / /bash stop).`,
|
||||
};
|
||||
@@ -346,8 +328,7 @@ export async function handleBashChatCommand(params: {
|
||||
try {
|
||||
const foregroundMs = resolveForegroundMs(params.cfg);
|
||||
const shouldBackgroundImmediately = foregroundMs <= 0;
|
||||
const timeoutSec =
|
||||
params.cfg.tools?.exec?.timeoutSec ?? params.cfg.tools?.bash?.timeoutSec;
|
||||
const timeoutSec = params.cfg.tools?.exec?.timeoutSec ?? params.cfg.tools?.bash?.timeoutSec;
|
||||
const execTool = createExecTool({
|
||||
scopeKey: CHAT_BASH_SCOPE_KEY,
|
||||
allowBackground: true,
|
||||
@@ -385,14 +366,11 @@ export async function handleBashChatCommand(params: {
|
||||
|
||||
// Completed in foreground.
|
||||
activeJob = null;
|
||||
const exitCode =
|
||||
result.details?.status === "completed" ? result.details.exitCode : 0;
|
||||
const exitCode = result.details?.status === "completed" ? result.details.exitCode : 0;
|
||||
const output =
|
||||
result.details?.status === "completed"
|
||||
? result.details.aggregated
|
||||
: result.content
|
||||
.map((chunk) => (chunk.type === "text" ? chunk.text : ""))
|
||||
.join("\n");
|
||||
: result.content.map((chunk) => (chunk.type === "text" ? chunk.text : "")).join("\n");
|
||||
return {
|
||||
text: [
|
||||
`⚙️ bash: ${commandText}`,
|
||||
@@ -404,9 +382,7 @@ export async function handleBashChatCommand(params: {
|
||||
activeJob = null;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
text: [`⚠️ bash failed: ${commandText}`, formatOutputBlock(message)].join(
|
||||
"\n",
|
||||
),
|
||||
text: [`⚠️ bash failed: ${commandText}`, formatOutputBlock(message)].join("\n"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,7 @@ export function createBlockReplyCoalescer(params: {
|
||||
|
||||
const enqueue = (payload: ReplyPayload) => {
|
||||
if (shouldAbort()) return;
|
||||
const hasMedia =
|
||||
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
const text = payload.text ?? "";
|
||||
const hasText = text.trim().length > 0;
|
||||
if (hasMedia) {
|
||||
@@ -79,8 +78,7 @@ export function createBlockReplyCoalescer(params: {
|
||||
|
||||
if (
|
||||
bufferText &&
|
||||
(bufferReplyToId !== payload.replyToId ||
|
||||
bufferAudioAsVoice !== payload.audioAsVoice)
|
||||
(bufferReplyToId !== payload.replyToId || bufferAudioAsVoice !== payload.audioAsVoice)
|
||||
) {
|
||||
void flush({ force: true });
|
||||
}
|
||||
|
||||
@@ -30,8 +30,7 @@ export function createAudioAsVoiceBuffer(params: {
|
||||
}
|
||||
},
|
||||
shouldBuffer: (payload) => params.isAudioPayload(payload),
|
||||
finalize: (payload) =>
|
||||
seenAudioAsVoice ? { ...payload, audioAsVoice: true } : payload,
|
||||
finalize: (payload) => (seenAudioAsVoice ? { ...payload, audioAsVoice: true } : payload),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,9 +96,7 @@ export function createBlockReplyPipeline(params: {
|
||||
if (sentKeys.has(payloadKey) || pendingKeys.has(payloadKey)) return;
|
||||
pendingKeys.add(payloadKey);
|
||||
|
||||
const timeoutError = new Error(
|
||||
`block reply delivery timed out after ${timeoutMs}ms`,
|
||||
);
|
||||
const timeoutError = new Error(`block reply delivery timed out after ${timeoutMs}ms`);
|
||||
const abortController = new AbortController();
|
||||
sendChain = sendChain
|
||||
.then(async () => {
|
||||
@@ -180,8 +177,7 @@ export function createBlockReplyPipeline(params: {
|
||||
const enqueue = (payload: ReplyPayload) => {
|
||||
if (aborted) return;
|
||||
if (bufferPayload(payload)) return;
|
||||
const hasMedia =
|
||||
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (hasMedia) {
|
||||
void coalescer?.flush({ force: true });
|
||||
sendPayload(payload);
|
||||
@@ -189,11 +185,7 @@ export function createBlockReplyPipeline(params: {
|
||||
}
|
||||
if (coalescer) {
|
||||
const payloadKey = createBlockReplyPayloadKey(payload);
|
||||
if (
|
||||
seenKeys.has(payloadKey) ||
|
||||
pendingKeys.has(payloadKey) ||
|
||||
bufferedKeys.has(payloadKey)
|
||||
) {
|
||||
if (seenKeys.has(payloadKey) || pendingKeys.has(payloadKey) || bufferedKeys.has(payloadKey)) {
|
||||
return;
|
||||
}
|
||||
bufferedKeys.add(payloadKey);
|
||||
@@ -217,8 +209,7 @@ export function createBlockReplyPipeline(params: {
|
||||
enqueue,
|
||||
flush,
|
||||
stop,
|
||||
hasBuffered: () =>
|
||||
Boolean(coalescer?.hasBuffered() || bufferedPayloads.length > 0),
|
||||
hasBuffered: () => Boolean(coalescer?.hasBuffered() || bufferedPayloads.length > 0),
|
||||
didStream: () => didStream,
|
||||
isAborted: () => aborted,
|
||||
hasSentPayload: (payload) => {
|
||||
|
||||
@@ -14,9 +14,7 @@ const BLOCK_CHUNK_PROVIDERS = new Set<TextChunkProvider>([
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
]);
|
||||
|
||||
function normalizeChunkProvider(
|
||||
provider?: string,
|
||||
): TextChunkProvider | undefined {
|
||||
function normalizeChunkProvider(provider?: string): TextChunkProvider | undefined {
|
||||
if (!provider) return undefined;
|
||||
const cleaned = provider.trim().toLowerCase();
|
||||
return BLOCK_CHUNK_PROVIDERS.has(cleaned as TextChunkProvider)
|
||||
@@ -26,10 +24,7 @@ function normalizeChunkProvider(
|
||||
|
||||
type ProviderBlockStreamingConfig = {
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
accounts?: Record<
|
||||
string,
|
||||
{ blockStreamingCoalesce?: BlockStreamingCoalesceConfig }
|
||||
>;
|
||||
accounts?: Record<string, { blockStreamingCoalesce?: BlockStreamingCoalesceConfig }>;
|
||||
};
|
||||
|
||||
function resolveProviderBlockStreamingCoalesce(params: {
|
||||
@@ -72,20 +67,13 @@ export function resolveBlockStreamingChunking(
|
||||
fallbackLimit: providerChunkLimit,
|
||||
});
|
||||
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
|
||||
const maxRequested = Math.max(
|
||||
1,
|
||||
Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX),
|
||||
);
|
||||
const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX));
|
||||
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
|
||||
const minFallback = DEFAULT_BLOCK_STREAM_MIN;
|
||||
const minRequested = Math.max(
|
||||
1,
|
||||
Math.floor(chunkCfg?.minChars ?? minFallback),
|
||||
);
|
||||
const minRequested = Math.max(1, Math.floor(chunkCfg?.minChars ?? minFallback));
|
||||
const minChars = Math.min(minRequested, maxChars);
|
||||
const breakPreference =
|
||||
chunkCfg?.breakPreference === "newline" ||
|
||||
chunkCfg?.breakPreference === "sentence"
|
||||
chunkCfg?.breakPreference === "newline" || chunkCfg?.breakPreference === "sentence"
|
||||
? chunkCfg.breakPreference
|
||||
: "paragraph";
|
||||
return { minChars, maxChars, breakPreference };
|
||||
@@ -117,8 +105,7 @@ export function resolveBlockStreamingCoalescing(
|
||||
providerKey,
|
||||
accountId,
|
||||
});
|
||||
const coalesceCfg =
|
||||
providerCfg ?? cfg?.agents?.defaults?.blockStreamingCoalesce;
|
||||
const coalesceCfg = providerCfg ?? cfg?.agents?.defaults?.blockStreamingCoalesce;
|
||||
const minRequested = Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
@@ -128,23 +115,17 @@ export function resolveBlockStreamingCoalescing(
|
||||
DEFAULT_BLOCK_STREAM_MIN,
|
||||
),
|
||||
);
|
||||
const maxRequested = Math.max(
|
||||
1,
|
||||
Math.floor(coalesceCfg?.maxChars ?? textLimit),
|
||||
);
|
||||
const maxRequested = Math.max(1, Math.floor(coalesceCfg?.maxChars ?? textLimit));
|
||||
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
|
||||
const minChars = Math.min(minRequested, maxChars);
|
||||
const idleMs = Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
coalesceCfg?.idleMs ??
|
||||
providerDefaults?.idleMs ??
|
||||
DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS,
|
||||
coalesceCfg?.idleMs ?? providerDefaults?.idleMs ?? DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS,
|
||||
),
|
||||
);
|
||||
const preference = chunking?.breakPreference ?? "paragraph";
|
||||
const joiner =
|
||||
preference === "sentence" ? " " : preference === "newline" ? "\n" : "\n\n";
|
||||
const joiner = preference === "sentence" ? " " : preference === "newline" ? "\n" : "\n\n";
|
||||
return {
|
||||
minChars,
|
||||
maxChars,
|
||||
|
||||
@@ -30,9 +30,7 @@ export async function applySessionHints(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const messageIdHint = params.messageId?.trim()
|
||||
? `[message_id: ${params.messageId.trim()}]`
|
||||
: "";
|
||||
const messageIdHint = params.messageId?.trim() ? `[message_id: ${params.messageId.trim()}]` : "";
|
||||
if (messageIdHint) {
|
||||
prefixedBodyBase = `${prefixedBodyBase}\n${messageIdHint}`;
|
||||
}
|
||||
|
||||
@@ -2,26 +2,17 @@ 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,
|
||||
) => {
|
||||
export const handleBashCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
const { command } = params;
|
||||
const bashSlashRequested =
|
||||
command.commandBodyNormalized === "/bash" ||
|
||||
command.commandBodyNormalized.startsWith("/bash ");
|
||||
command.commandBodyNormalized === "/bash" || command.commandBodyNormalized.startsWith("/bash ");
|
||||
const bashBangRequested = command.commandBodyNormalized.startsWith("!");
|
||||
if (
|
||||
!bashSlashRequested &&
|
||||
!(bashBangRequested && command.isAuthorizedSender)
|
||||
) {
|
||||
if (!bashSlashRequested && !(bashBangRequested && command.isAuthorizedSender)) {
|
||||
return null;
|
||||
}
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /bash from unauthorized sender: ${command.senderId || "<unknown>"}`,
|
||||
);
|
||||
logVerbose(`Ignoring /bash from unauthorized sender: ${command.senderId || "<unknown>"}`);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
const reply = await handleBashChatCommand({
|
||||
|
||||
@@ -73,24 +73,19 @@ export const handleCompactCommand: CommandHandler = async (params) => {
|
||||
skillsSnapshot: params.sessionEntry.skillsSnapshot,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
thinkLevel:
|
||||
params.resolvedThinkLevel ?? (await params.resolveDefaultThinkingLevel()),
|
||||
thinkLevel: params.resolvedThinkLevel ?? (await params.resolveDefaultThinkingLevel()),
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
customInstructions,
|
||||
ownerNumbers:
|
||||
params.command.ownerList.length > 0
|
||||
? params.command.ownerList
|
||||
: undefined,
|
||||
ownerNumbers: params.command.ownerList.length > 0 ? params.command.ownerList : undefined,
|
||||
});
|
||||
|
||||
const totalTokens =
|
||||
params.sessionEntry.totalTokens ??
|
||||
(params.sessionEntry.inputTokens ?? 0) +
|
||||
(params.sessionEntry.outputTokens ?? 0);
|
||||
(params.sessionEntry.inputTokens ?? 0) + (params.sessionEntry.outputTokens ?? 0);
|
||||
const contextSummary = formatContextUsageShort(
|
||||
totalTokens > 0 ? totalTokens : null,
|
||||
params.contextTokens ?? params.sessionEntry.contextTokens ?? null,
|
||||
|
||||
@@ -20,14 +20,9 @@ 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,
|
||||
) => {
|
||||
export const handleConfigCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
const configCommand = parseConfigCommand(
|
||||
params.command.commandBodyNormalized,
|
||||
);
|
||||
const configCommand = parseConfigCommand(params.command.commandBodyNormalized);
|
||||
if (!configCommand) return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
@@ -50,11 +45,7 @@ export const handleConfigCommand: CommandHandler = async (
|
||||
};
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (
|
||||
!snapshot.valid ||
|
||||
!snapshot.parsed ||
|
||||
typeof snapshot.parsed !== "object"
|
||||
) {
|
||||
if (!snapshot.valid || !snapshot.parsed || typeof snapshot.parsed !== "object") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
@@ -62,9 +53,7 @@ export const handleConfigCommand: CommandHandler = async (
|
||||
},
|
||||
};
|
||||
}
|
||||
const parsedBase = structuredClone(
|
||||
snapshot.parsed as Record<string, unknown>,
|
||||
);
|
||||
const parsedBase = structuredClone(snapshot.parsed as Record<string, unknown>);
|
||||
|
||||
if (configCommand.action === "show") {
|
||||
const pathRaw = configCommand.path?.trim();
|
||||
@@ -159,10 +148,7 @@ export const handleConfigCommand: CommandHandler = async (
|
||||
return null;
|
||||
};
|
||||
|
||||
export const handleDebugCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
export const handleDebugCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
const debugCommand = parseDebugCommand(params.command.commandBodyNormalized);
|
||||
if (!debugCommand) return null;
|
||||
|
||||
@@ -14,8 +14,7 @@ export function buildCommandContext(params: {
|
||||
triggerBodyNormalized: string;
|
||||
commandAuthorized: boolean;
|
||||
}): CommandContext {
|
||||
const { ctx, cfg, agentId, sessionKey, isGroup, triggerBodyNormalized } =
|
||||
params;
|
||||
const { ctx, cfg, agentId, sessionKey, isGroup, triggerBodyNormalized } = params;
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
@@ -23,13 +22,10 @@ export function buildCommandContext(params: {
|
||||
});
|
||||
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 abortKey = sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
|
||||
const rawBodyNormalized = triggerBodyNormalized;
|
||||
const commandBodyNormalized = normalizeCommandBody(
|
||||
isGroup
|
||||
? stripMentions(rawBodyNormalized, ctx, cfg, agentId)
|
||||
: rawBodyNormalized,
|
||||
isGroup ? stripMentions(rawBodyNormalized, ctx, cfg, agentId) : rawBodyNormalized,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -39,9 +39,7 @@ const HANDLERS: CommandHandler[] = [
|
||||
handleAbortTrigger,
|
||||
];
|
||||
|
||||
export async function handleCommands(
|
||||
params: HandleCommandsParams,
|
||||
): Promise<CommandHandlerResult> {
|
||||
export async function handleCommands(params: HandleCommandsParams): Promise<CommandHandlerResult> {
|
||||
const resetRequested =
|
||||
params.command.commandBodyNormalized === "/reset" ||
|
||||
params.command.commandBodyNormalized === "/new";
|
||||
@@ -71,9 +69,7 @@ export async function handleCommands(
|
||||
chatType: params.sessionEntry?.chatType,
|
||||
});
|
||||
if (sendPolicy === "deny") {
|
||||
logVerbose(
|
||||
`Send blocked by policy for session ${params.sessionKey ?? "unknown"}`,
|
||||
);
|
||||
logVerbose(`Send blocked by policy for session ${params.sessionKey ?? "unknown"}`);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@ 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,
|
||||
) => {
|
||||
export const handleHelpCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (params.command.commandBodyNormalized !== "/help") return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
@@ -21,10 +18,7 @@ export const handleHelpCommand: CommandHandler = async (
|
||||
};
|
||||
};
|
||||
|
||||
export const handleCommandsListCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
export const handleCommandsListCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (params.command.commandBodyNormalized !== "/commands") return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
@@ -39,14 +33,10 @@ export const handleCommandsListCommand: CommandHandler = async (
|
||||
};
|
||||
};
|
||||
|
||||
export const handleStatusCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
export const handleStatusCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
const statusRequested =
|
||||
params.directives.hasStatusDirective ||
|
||||
params.command.commandBodyNormalized === "/status";
|
||||
params.directives.hasStatusDirective || params.command.commandBodyNormalized === "/status";
|
||||
if (!statusRequested) return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
@@ -74,10 +64,7 @@ export const handleStatusCommand: CommandHandler = async (
|
||||
return { shouldContinue: false, reply };
|
||||
};
|
||||
|
||||
export const handleWhoamiCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
export const handleWhoamiCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (params.command.commandBodyNormalized !== "/whoami") return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
@@ -91,9 +78,7 @@ export const handleWhoamiCommand: CommandHandler = async (
|
||||
const lines = ["🧭 Identity", `Channel: ${params.command.channel}`];
|
||||
if (senderId) lines.push(`User id: ${senderId}`);
|
||||
if (senderUsername) {
|
||||
const handle = senderUsername.startsWith("@")
|
||||
? senderUsername
|
||||
: `@${senderUsername}`;
|
||||
const handle = senderUsername.startsWith("@") ? senderUsername : `@${senderUsername}`;
|
||||
lines.push(`Username: ${handle}`);
|
||||
}
|
||||
if (params.ctx.ChatType === "group" && params.ctx.From) {
|
||||
|
||||
@@ -2,10 +2,7 @@ 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 { 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";
|
||||
@@ -33,12 +30,8 @@ function resolveAbortTarget(params: {
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
}) {
|
||||
const targetSessionKey =
|
||||
params.ctx.CommandTargetSessionKey?.trim() || params.sessionKey;
|
||||
const { entry, key } = resolveSessionEntryForKey(
|
||||
params.sessionStore,
|
||||
targetSessionKey,
|
||||
);
|
||||
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 {
|
||||
@@ -50,14 +43,9 @@ function resolveAbortTarget(params: {
|
||||
return { entry: undefined, key: targetSessionKey, sessionId: undefined };
|
||||
}
|
||||
|
||||
export const handleActivationCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
export const handleActivationCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
const activationCommand = parseActivationCommand(
|
||||
params.command.commandBodyNormalized,
|
||||
);
|
||||
const activationCommand = parseActivationCommand(params.command.commandBodyNormalized);
|
||||
if (!activationCommand.hasCommand) return null;
|
||||
if (!params.isGroup) {
|
||||
return {
|
||||
@@ -94,14 +82,9 @@ export const handleActivationCommand: CommandHandler = async (
|
||||
};
|
||||
};
|
||||
|
||||
export const handleSendPolicyCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
export const handleSendPolicyCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
const sendPolicyCommand = parseSendPolicyCommand(
|
||||
params.command.commandBodyNormalized,
|
||||
);
|
||||
const sendPolicyCommand = parseSendPolicyCommand(params.command.commandBodyNormalized);
|
||||
if (!sendPolicyCommand.hasCommand) return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
@@ -139,10 +122,7 @@ export const handleSendPolicyCommand: CommandHandler = async (
|
||||
};
|
||||
};
|
||||
|
||||
export const handleRestartCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
export const handleRestartCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (params.command.commandBodyNormalized !== "/restart") return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
@@ -171,9 +151,7 @@ export const handleRestartCommand: CommandHandler = async (
|
||||
}
|
||||
const restartMethod = triggerClawdbotRestart();
|
||||
if (!restartMethod.ok) {
|
||||
const detail = restartMethod.detail
|
||||
? ` Details: ${restartMethod.detail}`
|
||||
: "";
|
||||
const detail = restartMethod.detail ? ` Details: ${restartMethod.detail}` : "";
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
@@ -189,10 +167,7 @@ export const handleRestartCommand: CommandHandler = async (
|
||||
};
|
||||
};
|
||||
|
||||
export const handleStopCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
export const handleStopCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (params.command.commandBodyNormalized !== "/stop") return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
@@ -223,10 +198,7 @@ export const handleStopCommand: CommandHandler = async (
|
||||
return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } };
|
||||
};
|
||||
|
||||
export const handleAbortTrigger: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
) => {
|
||||
export const handleAbortTrigger: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (!isAbortTrigger(params.command.rawBodyNormalized)) return null;
|
||||
const abortTarget = resolveAbortTarget({
|
||||
|
||||
@@ -8,10 +8,7 @@ import {
|
||||
resolveAuthProfileDisplayLabel,
|
||||
resolveAuthProfileOrder,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import {
|
||||
getCustomProviderApiKey,
|
||||
resolveEnvApiKey,
|
||||
} from "../../agents/model-auth.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";
|
||||
@@ -23,12 +20,7 @@ import {
|
||||
} 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 { 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";
|
||||
@@ -132,9 +124,7 @@ export async function buildStatusReply(params: {
|
||||
defaultGroupActivation,
|
||||
} = params;
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /status from unauthorized sender: ${command.senderId || "<unknown>"}`,
|
||||
);
|
||||
logVerbose(`Ignoring /status from unauthorized sender: ${command.senderId || "<unknown>"}`);
|
||||
return undefined;
|
||||
}
|
||||
const statusAgentId = sessionKey
|
||||
@@ -151,10 +141,7 @@ export async function buildStatusReply(params: {
|
||||
agentDir: statusAgentDir,
|
||||
});
|
||||
usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() });
|
||||
if (
|
||||
!usageLine &&
|
||||
(resolvedVerboseLevel === "on" || resolvedElevatedLevel === "on")
|
||||
) {
|
||||
if (!usageLine && (resolvedVerboseLevel === "on" || resolvedElevatedLevel === "on")) {
|
||||
const entry = usageSummary.providers[0];
|
||||
if (entry?.error) {
|
||||
usageLine = `📊 Usage: ${entry.displayName} (${entry.error})`;
|
||||
@@ -172,13 +159,10 @@ export async function buildStatusReply(params: {
|
||||
const queueKey = sessionKey ?? sessionEntry?.sessionId;
|
||||
const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0;
|
||||
const queueOverrides = Boolean(
|
||||
sessionEntry?.queueDebounceMs ??
|
||||
sessionEntry?.queueCap ??
|
||||
sessionEntry?.queueDrop,
|
||||
sessionEntry?.queueDebounceMs ?? sessionEntry?.queueCap ?? sessionEntry?.queueDrop,
|
||||
);
|
||||
const groupActivation = isGroup
|
||||
? (normalizeGroupActivation(sessionEntry?.groupActivation) ??
|
||||
defaultGroupActivation())
|
||||
? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? defaultGroupActivation())
|
||||
: undefined;
|
||||
const agentDefaults = cfg.agents?.defaults ?? {};
|
||||
const statusText = buildStatusMessage({
|
||||
@@ -202,12 +186,7 @@ export async function buildStatusReply(params: {
|
||||
resolvedVerbose: resolvedVerboseLevel,
|
||||
resolvedReasoning: resolvedReasoningLevel,
|
||||
resolvedElevated: resolvedElevatedLevel,
|
||||
modelAuth: resolveModelAuthLabel(
|
||||
provider,
|
||||
cfg,
|
||||
sessionEntry,
|
||||
statusAgentDir,
|
||||
),
|
||||
modelAuth: resolveModelAuthLabel(provider, cfg, sessionEntry, statusAgentDir),
|
||||
usageLine: usageLine ?? undefined,
|
||||
queue: {
|
||||
mode: queueSettings.mode,
|
||||
|
||||
@@ -2,12 +2,7 @@ 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 { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import type { InlineDirectives } from "./directive-handling.js";
|
||||
|
||||
|
||||
@@ -6,11 +6,7 @@ import { resetBashChatCommandForTests } from "./bash-command.js";
|
||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.js";
|
||||
|
||||
function buildParams(
|
||||
commandBody: string,
|
||||
cfg: ClawdbotConfig,
|
||||
ctxOverrides?: Partial<MsgContext>,
|
||||
) {
|
||||
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
||||
const ctx = {
|
||||
Body: commandBody,
|
||||
CommandBody: commandBody,
|
||||
@@ -71,9 +67,7 @@ describe("handleCommands gating", () => {
|
||||
params.elevated = {
|
||||
enabled: true,
|
||||
allowed: false,
|
||||
failures: [
|
||||
{ gate: "allowFrom", key: "tools.elevated.allowFrom.whatsapp" },
|
||||
],
|
||||
failures: [{ gate: "allowFrom", key: "tools.elevated.allowFrom.whatsapp" }],
|
||||
};
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
|
||||
@@ -23,8 +23,7 @@ export function parseConfigCommand(raw: string): ConfigCommand | null {
|
||||
case "get":
|
||||
return { action: "show", path: args || undefined };
|
||||
case "unset": {
|
||||
if (!args)
|
||||
return { action: "error", message: "Usage: /config unset path" };
|
||||
if (!args) return { action: "error", message: "Usage: /config unset path" };
|
||||
return { action: "unset", path: args };
|
||||
}
|
||||
case "set": {
|
||||
|
||||
@@ -24,8 +24,7 @@ export function parseDebugCommand(raw: string): DebugCommand | null {
|
||||
case "reset":
|
||||
return { action: "reset" };
|
||||
case "unset": {
|
||||
if (!args)
|
||||
return { action: "error", message: "Usage: /debug unset path" };
|
||||
if (!args) return { action: "error", message: "Usage: /debug unset path" };
|
||||
return { action: "unset", path: args };
|
||||
}
|
||||
case "set": {
|
||||
|
||||
@@ -65,8 +65,7 @@ export const resolveAuthLabel = async (
|
||||
const configProfile = cfg.auth?.profiles?.[profileId];
|
||||
const missing =
|
||||
!profile ||
|
||||
(configProfile?.provider &&
|
||||
configProfile.provider !== profile.provider) ||
|
||||
(configProfile?.provider && configProfile.provider !== profile.provider) ||
|
||||
(configProfile?.mode &&
|
||||
configProfile.mode !== profile.type &&
|
||||
!(configProfile.mode === "oauth" && profile.type === "token"));
|
||||
@@ -115,11 +114,7 @@ export const resolveAuthLabel = async (
|
||||
if (lastGood && profileId === lastGood) flags.push("lastGood");
|
||||
if (isProfileInCooldown(store, profileId)) {
|
||||
const until = store.usageStats?.[profileId]?.cooldownUntil;
|
||||
if (
|
||||
typeof until === "number" &&
|
||||
Number.isFinite(until) &&
|
||||
until > now
|
||||
) {
|
||||
if (typeof until === "number" && Number.isFinite(until) && until > now) {
|
||||
flags.push(`cooldown ${formatUntil(until)}`);
|
||||
} else {
|
||||
flags.push("cooldown");
|
||||
@@ -127,8 +122,7 @@ export const resolveAuthLabel = async (
|
||||
}
|
||||
if (
|
||||
!profile ||
|
||||
(configProfile?.provider &&
|
||||
configProfile.provider !== profile.provider) ||
|
||||
(configProfile?.provider && configProfile.provider !== profile.provider) ||
|
||||
(configProfile?.mode &&
|
||||
configProfile.mode !== profile.type &&
|
||||
!(configProfile.mode === "oauth" && profile.type === "token"))
|
||||
@@ -146,11 +140,7 @@ export const resolveAuthLabel = async (
|
||||
Number.isFinite(profile.expires) &&
|
||||
profile.expires > 0
|
||||
) {
|
||||
flags.push(
|
||||
profile.expires <= now
|
||||
? "expired"
|
||||
: `exp ${formatUntil(profile.expires)}`,
|
||||
);
|
||||
flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`);
|
||||
}
|
||||
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||
return `${profileId}=token:${maskApiKey(profile.token)}${suffix}`;
|
||||
@@ -171,11 +161,7 @@ export const resolveAuthLabel = async (
|
||||
Number.isFinite(profile.expires) &&
|
||||
profile.expires > 0
|
||||
) {
|
||||
flags.push(
|
||||
profile.expires <= now
|
||||
? "expired"
|
||||
: `exp ${formatUntil(profile.expires)}`,
|
||||
);
|
||||
flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`);
|
||||
}
|
||||
const suffixLabel = suffix ? ` ${suffix}` : "";
|
||||
const suffixFlags = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||
@@ -199,8 +185,7 @@ export const resolveAuthLabel = async (
|
||||
if (customKey) {
|
||||
return {
|
||||
label: maskApiKey(customKey),
|
||||
source:
|
||||
mode === "verbose" ? `models.json: ${formatPath(modelsPath)}` : "",
|
||||
source: mode === "verbose" ? `models.json: ${formatPath(modelsPath)}` : "",
|
||||
};
|
||||
}
|
||||
return { label: "missing", source: "missing" };
|
||||
|
||||
@@ -6,12 +6,7 @@ import type { ReplyPayload } from "../types.js";
|
||||
import { handleDirectiveOnly } from "./directive-handling.impl.js";
|
||||
import type { InlineDirectives } from "./directive-handling.parse.js";
|
||||
import { isDirectiveOnly } from "./directive-handling.parse.js";
|
||||
import type {
|
||||
ElevatedLevel,
|
||||
ReasoningLevel,
|
||||
ThinkLevel,
|
||||
VerboseLevel,
|
||||
} from "./directives.js";
|
||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./directives.js";
|
||||
|
||||
export async function applyInlineDirectivesFastLane(params: {
|
||||
directives: InlineDirectives;
|
||||
@@ -45,9 +40,7 @@ export async function applyInlineDirectivesFastLane(params: {
|
||||
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
|
||||
allowedModelKeys: Set<string>;
|
||||
allowedModelCatalog: Awaited<
|
||||
ReturnType<
|
||||
typeof import("../../agents/model-catalog.js").loadModelCatalog
|
||||
>
|
||||
ReturnType<typeof import("../../agents/model-catalog.js").loadModelCatalog>
|
||||
>;
|
||||
resetModelOverride: boolean;
|
||||
};
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import {
|
||||
resolveAgentDir,
|
||||
resolveSessionAgentId,
|
||||
} from "../../agents/agent-scope.js";
|
||||
import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import type { ModelAliasIndex } from "../../agents/model-selection.js";
|
||||
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
|
||||
import {
|
||||
formatThinkingLevels,
|
||||
formatXHighModelHint,
|
||||
supportsXHighThinking,
|
||||
} from "../thinking.js";
|
||||
import { formatThinkingLevels, formatXHighModelHint, supportsXHighThinking } from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import {
|
||||
maybeHandleModelDirectiveInfo,
|
||||
@@ -28,12 +21,7 @@ import {
|
||||
formatReasoningEvent,
|
||||
withOptions,
|
||||
} from "./directive-handling.shared.js";
|
||||
import type {
|
||||
ElevatedLevel,
|
||||
ReasoningLevel,
|
||||
ThinkLevel,
|
||||
VerboseLevel,
|
||||
} from "./directives.js";
|
||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./directives.js";
|
||||
|
||||
export async function handleDirectiveOnly(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
@@ -95,8 +83,7 @@ export async function handleDirectiveOnly(params: {
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
}).sandboxed;
|
||||
const shouldHintDirectRuntime =
|
||||
directives.hasElevatedDirective && !runtimeIsSandboxed;
|
||||
const shouldHintDirectRuntime = directives.hasElevatedDirective && !runtimeIsSandboxed;
|
||||
|
||||
const modelInfo = await maybeHandleModelDirectiveInfo({
|
||||
directives,
|
||||
@@ -161,10 +148,7 @@ export async function handleDirectiveOnly(params: {
|
||||
if (!directives.rawReasoningLevel) {
|
||||
const level = currentReasoningLevel ?? "off";
|
||||
return {
|
||||
text: withOptions(
|
||||
`Current reasoning level: ${level}.`,
|
||||
"on, off, stream",
|
||||
),
|
||||
text: withOptions(`Current reasoning level: ${level}.`, "on, off, stream"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -196,10 +180,7 @@ export async function handleDirectiveOnly(params: {
|
||||
text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
directives.hasElevatedDirective &&
|
||||
(!elevatedEnabled || !elevatedAllowed)
|
||||
) {
|
||||
if (directives.hasElevatedDirective && (!elevatedEnabled || !elevatedAllowed)) {
|
||||
return {
|
||||
text: formatElevatedUnavailableText({
|
||||
runtimeSandboxed: runtimeIsSandboxed,
|
||||
@@ -229,8 +210,7 @@ export async function handleDirectiveOnly(params: {
|
||||
|
||||
const nextThinkLevel = directives.hasThinkDirective
|
||||
? directives.thinkLevel
|
||||
: ((sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
|
||||
currentThinkLevel);
|
||||
: ((sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? currentThinkLevel);
|
||||
const shouldDowngradeXHigh =
|
||||
!directives.hasThinkDirective &&
|
||||
nextThinkLevel === "xhigh" &&
|
||||
@@ -242,17 +222,14 @@ export async function handleDirectiveOnly(params: {
|
||||
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
|
||||
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
|
||||
const prevReasoningLevel =
|
||||
currentReasoningLevel ??
|
||||
(sessionEntry.reasoningLevel as ReasoningLevel | undefined) ??
|
||||
"off";
|
||||
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
|
||||
let elevatedChanged =
|
||||
directives.hasElevatedDirective &&
|
||||
directives.elevatedLevel !== undefined &&
|
||||
elevatedEnabled &&
|
||||
elevatedAllowed;
|
||||
let reasoningChanged =
|
||||
directives.hasReasoningDirective &&
|
||||
directives.reasoningLevel !== undefined;
|
||||
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
|
||||
if (directives.hasThinkDirective && directives.thinkLevel) {
|
||||
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
|
||||
else sessionEntry.thinkingLevel = directives.thinkLevel;
|
||||
@@ -264,12 +241,10 @@ export async function handleDirectiveOnly(params: {
|
||||
applyVerboseOverride(sessionEntry, directives.verboseLevel);
|
||||
}
|
||||
if (directives.hasReasoningDirective && directives.reasoningLevel) {
|
||||
if (directives.reasoningLevel === "off")
|
||||
delete sessionEntry.reasoningLevel;
|
||||
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
|
||||
else sessionEntry.reasoningLevel = directives.reasoningLevel;
|
||||
reasoningChanged =
|
||||
directives.reasoningLevel !== prevReasoningLevel &&
|
||||
directives.reasoningLevel !== undefined;
|
||||
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
|
||||
}
|
||||
if (directives.hasElevatedDirective && directives.elevatedLevel) {
|
||||
// Unlike other toggles, elevated defaults can be "on".
|
||||
@@ -277,8 +252,7 @@ export async function handleDirectiveOnly(params: {
|
||||
sessionEntry.elevatedLevel = directives.elevatedLevel;
|
||||
elevatedChanged =
|
||||
elevatedChanged ||
|
||||
(directives.elevatedLevel !== prevElevatedLevel &&
|
||||
directives.elevatedLevel !== undefined);
|
||||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
|
||||
}
|
||||
if (modelSelection) {
|
||||
if (modelSelection.isDefault) {
|
||||
@@ -319,26 +293,21 @@ export async function handleDirectiveOnly(params: {
|
||||
if (modelSelection) {
|
||||
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
|
||||
if (nextLabel !== initialModelLabel) {
|
||||
enqueueSystemEvent(
|
||||
formatModelSwitchEvent(nextLabel, modelSelection.alias),
|
||||
{
|
||||
sessionKey,
|
||||
contextKey: `model:${nextLabel}`,
|
||||
},
|
||||
);
|
||||
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
|
||||
sessionKey,
|
||||
contextKey: `model:${nextLabel}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (elevatedChanged) {
|
||||
const nextElevated = (sessionEntry.elevatedLevel ??
|
||||
"off") as ElevatedLevel;
|
||||
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
|
||||
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
|
||||
sessionKey,
|
||||
contextKey: "mode:elevated",
|
||||
});
|
||||
}
|
||||
if (reasoningChanged) {
|
||||
const nextReasoning = (sessionEntry.reasoningLevel ??
|
||||
"off") as ReasoningLevel;
|
||||
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
|
||||
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
|
||||
sessionKey,
|
||||
contextKey: "mode:reasoning",
|
||||
@@ -385,9 +354,7 @@ export async function handleDirectiveOnly(params: {
|
||||
}
|
||||
if (modelSelection) {
|
||||
const label = `${modelSelection.provider}/${modelSelection.model}`;
|
||||
const labelWithAlias = modelSelection.alias
|
||||
? `${modelSelection.alias} (${label})`
|
||||
: label;
|
||||
const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label;
|
||||
parts.push(
|
||||
modelSelection.isDefault
|
||||
? `Model reset to default (${labelWithAlias}).`
|
||||
@@ -398,27 +365,18 @@ export async function handleDirectiveOnly(params: {
|
||||
}
|
||||
}
|
||||
if (directives.hasQueueDirective && directives.queueMode) {
|
||||
parts.push(
|
||||
formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`),
|
||||
);
|
||||
parts.push(formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`));
|
||||
} else if (directives.hasQueueDirective && directives.queueReset) {
|
||||
parts.push(formatDirectiveAck("Queue mode reset to default."));
|
||||
}
|
||||
if (
|
||||
directives.hasQueueDirective &&
|
||||
typeof directives.debounceMs === "number"
|
||||
) {
|
||||
parts.push(
|
||||
formatDirectiveAck(`Queue debounce set to ${directives.debounceMs}ms.`),
|
||||
);
|
||||
if (directives.hasQueueDirective && typeof directives.debounceMs === "number") {
|
||||
parts.push(formatDirectiveAck(`Queue debounce set to ${directives.debounceMs}ms.`));
|
||||
}
|
||||
if (directives.hasQueueDirective && typeof directives.cap === "number") {
|
||||
parts.push(formatDirectiveAck(`Queue cap set to ${directives.cap}.`));
|
||||
}
|
||||
if (directives.hasQueueDirective && directives.dropPolicy) {
|
||||
parts.push(
|
||||
formatDirectiveAck(`Queue drop set to ${directives.dropPolicy}.`),
|
||||
);
|
||||
parts.push(formatDirectiveAck(`Queue drop set to ${directives.dropPolicy}.`));
|
||||
}
|
||||
const ack = parts.join(" ").trim();
|
||||
if (!ack && directives.hasStatusDirective) return undefined;
|
||||
|
||||
@@ -52,9 +52,7 @@ function sortProvidersForPicker(providers: string[]): string[] {
|
||||
});
|
||||
}
|
||||
|
||||
export function buildModelPickerItems(
|
||||
catalog: ModelPickerCatalogEntry[],
|
||||
): ModelPickerItem[] {
|
||||
export function buildModelPickerItems(catalog: ModelPickerCatalogEntry[]): ModelPickerItem[] {
|
||||
const byModel = new Map<string, { providerModels: Record<string, string> }>();
|
||||
for (const entry of catalog) {
|
||||
const provider = normalizeProviderId(entry.provider);
|
||||
@@ -72,9 +70,7 @@ export function buildModelPickerItems(
|
||||
const providers = sortProvidersForPicker(Object.keys(data.providerModels));
|
||||
out.push({ model, providers, providerModels: data.providerModels });
|
||||
}
|
||||
out.sort((a, b) =>
|
||||
a.model.toLowerCase().localeCompare(b.model.toLowerCase()),
|
||||
);
|
||||
out.sort((a, b) => a.model.toLowerCase().localeCompare(b.model.toLowerCase()));
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,10 +22,7 @@ import {
|
||||
resolveProviderEndpointLabel,
|
||||
} from "./directive-handling.model-picker.js";
|
||||
import type { InlineDirectives } from "./directive-handling.parse.js";
|
||||
import {
|
||||
type ModelDirectiveSelection,
|
||||
resolveModelDirectiveSelection,
|
||||
} from "./model-selection.js";
|
||||
import { type ModelDirectiveSelection, resolveModelDirectiveSelection } from "./model-selection.js";
|
||||
|
||||
function buildModelPickerCatalog(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
@@ -127,10 +124,7 @@ export async function maybeHandleModelDirectiveInfo(params: {
|
||||
const items = buildModelPickerItems(pickerCatalog);
|
||||
if (items.length === 0) return { text: "No models available." };
|
||||
const current = `${params.provider}/${params.model}`;
|
||||
const lines: string[] = [
|
||||
`Current: ${current}`,
|
||||
"Pick: /model <#> or /model <provider/model>",
|
||||
];
|
||||
const lines: string[] = [`Current: ${current}`, "Pick: /model <#> or /model <provider/model>"];
|
||||
for (const [idx, item] of items.entries()) {
|
||||
lines.push(`${idx + 1}) ${item.model} — ${item.providers.join(", ")}`);
|
||||
}
|
||||
@@ -194,8 +188,7 @@ export async function maybeHandleModelDirectiveInfo(params: {
|
||||
for (const entry of models) {
|
||||
const label = `${provider}/${entry.id}`;
|
||||
const aliases = params.aliasIndex.byKey.get(label);
|
||||
const aliasSuffix =
|
||||
aliases && aliases.length > 0 ? ` (${aliases.join(", ")})` : "";
|
||||
const aliasSuffix = aliases && aliases.length > 0 ? ` (${aliases.join(", ")})` : "";
|
||||
lines.push(` • ${label}${aliasSuffix}`);
|
||||
}
|
||||
}
|
||||
@@ -217,10 +210,7 @@ export function resolveModelSelectionFromDirective(params: {
|
||||
profileOverride?: string;
|
||||
errorText?: string;
|
||||
} {
|
||||
if (
|
||||
!params.directives.hasModelDirective ||
|
||||
!params.directives.rawModelDirective
|
||||
) {
|
||||
if (!params.directives.hasModelDirective || !params.directives.rawModelDirective) {
|
||||
if (params.directives.rawModelProfile) {
|
||||
return { errorText: "Auth profile override requires a model selection." };
|
||||
}
|
||||
@@ -261,9 +251,7 @@ export function resolveModelSelectionFromDirective(params: {
|
||||
modelSelection = {
|
||||
provider: picked.provider,
|
||||
model: picked.model,
|
||||
isDefault:
|
||||
picked.provider === params.defaultProvider &&
|
||||
picked.model === params.defaultModel,
|
||||
isDefault: picked.provider === params.defaultProvider && picked.model === params.defaultModel,
|
||||
...(alias ? { alias } : {}),
|
||||
};
|
||||
} else {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { extractModelDirective } from "../model.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type {
|
||||
ElevatedLevel,
|
||||
ReasoningLevel,
|
||||
ThinkLevel,
|
||||
VerboseLevel,
|
||||
} from "./directives.js";
|
||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./directives.js";
|
||||
import {
|
||||
extractElevatedDirective,
|
||||
extractReasoningDirective,
|
||||
@@ -89,10 +84,9 @@ export function parseInlineDirectives(
|
||||
}
|
||||
: extractElevatedDirective(reasoningCleaned);
|
||||
const allowStatusDirective = options?.allowStatusDirective !== false;
|
||||
const { cleaned: statusCleaned, hasDirective: hasStatusDirective } =
|
||||
allowStatusDirective
|
||||
? extractStatusDirective(elevatedCleaned)
|
||||
: { cleaned: elevatedCleaned, hasDirective: false };
|
||||
const { cleaned: statusCleaned, hasDirective: hasStatusDirective } = allowStatusDirective
|
||||
? extractStatusDirective(elevatedCleaned)
|
||||
: { cleaned: elevatedCleaned, hasDirective: false };
|
||||
const {
|
||||
cleaned: modelCleaned,
|
||||
rawModel,
|
||||
@@ -167,8 +161,6 @@ export function isDirectiveOnly(params: {
|
||||
)
|
||||
return false;
|
||||
const stripped = stripStructuralPrefixes(cleanedBody ?? "");
|
||||
const noMentions = isGroup
|
||||
? stripMentions(stripped, ctx, cfg, agentId)
|
||||
: stripped;
|
||||
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg, agentId) : stripped;
|
||||
return noMentions.length === 0;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,7 @@ import {
|
||||
resolveSessionAgentId,
|
||||
} from "../../agents/agent-scope.js";
|
||||
import { lookupContextTokens } from "../../agents/context.js";
|
||||
import {
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_PROVIDER,
|
||||
} from "../../agents/defaults.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
type ModelAliasIndex,
|
||||
@@ -23,10 +19,7 @@ import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
|
||||
import { resolveProfileOverride } from "./directive-handling.auth.js";
|
||||
import type { InlineDirectives } from "./directive-handling.parse.js";
|
||||
import {
|
||||
formatElevatedEvent,
|
||||
formatReasoningEvent,
|
||||
} from "./directive-handling.shared.js";
|
||||
import { formatElevatedEvent, formatReasoningEvent } from "./directive-handling.shared.js";
|
||||
import type { ElevatedLevel, ReasoningLevel } from "./directives.js";
|
||||
|
||||
export async function persistInlineDirectives(params: {
|
||||
@@ -78,16 +71,14 @@ export async function persistInlineDirectives(params: {
|
||||
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
|
||||
(agentCfg?.elevatedDefault as ElevatedLevel | undefined) ??
|
||||
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
|
||||
const prevReasoningLevel =
|
||||
(sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
|
||||
const prevReasoningLevel = (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
|
||||
let elevatedChanged =
|
||||
directives.hasElevatedDirective &&
|
||||
directives.elevatedLevel !== undefined &&
|
||||
elevatedEnabled &&
|
||||
elevatedAllowed;
|
||||
let reasoningChanged =
|
||||
directives.hasReasoningDirective &&
|
||||
directives.reasoningLevel !== undefined;
|
||||
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
|
||||
let updated = false;
|
||||
|
||||
if (directives.hasThinkDirective && directives.thinkLevel) {
|
||||
@@ -124,8 +115,7 @@ export async function persistInlineDirectives(params: {
|
||||
sessionEntry.elevatedLevel = directives.elevatedLevel;
|
||||
elevatedChanged =
|
||||
elevatedChanged ||
|
||||
(directives.elevatedLevel !== prevElevatedLevel &&
|
||||
directives.elevatedLevel !== undefined);
|
||||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
@@ -156,8 +146,7 @@ export async function persistInlineDirectives(params: {
|
||||
profileOverride = profileResolved.profileId;
|
||||
}
|
||||
const isDefault =
|
||||
resolved.ref.provider === defaultProvider &&
|
||||
resolved.ref.model === defaultModel;
|
||||
resolved.ref.provider === defaultProvider && resolved.ref.model === defaultModel;
|
||||
if (isDefault) {
|
||||
delete sessionEntry.providerOverride;
|
||||
delete sessionEntry.modelOverride;
|
||||
@@ -174,13 +163,10 @@ export async function persistInlineDirectives(params: {
|
||||
model = resolved.ref.model;
|
||||
const nextLabel = `${provider}/${model}`;
|
||||
if (nextLabel !== initialModelLabel) {
|
||||
enqueueSystemEvent(
|
||||
formatModelSwitchEvent(nextLabel, resolved.alias),
|
||||
{
|
||||
sessionKey,
|
||||
contextKey: `model:${nextLabel}`,
|
||||
},
|
||||
);
|
||||
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, resolved.alias), {
|
||||
sessionKey,
|
||||
contextKey: `model:${nextLabel}`,
|
||||
});
|
||||
}
|
||||
updated = true;
|
||||
}
|
||||
@@ -201,16 +187,14 @@ export async function persistInlineDirectives(params: {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
if (elevatedChanged) {
|
||||
const nextElevated = (sessionEntry.elevatedLevel ??
|
||||
"off") as ElevatedLevel;
|
||||
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
|
||||
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
|
||||
sessionKey,
|
||||
contextKey: "mode:elevated",
|
||||
});
|
||||
}
|
||||
if (reasoningChanged) {
|
||||
const nextReasoning = (sessionEntry.reasoningLevel ??
|
||||
"off") as ReasoningLevel;
|
||||
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
|
||||
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
|
||||
sessionKey,
|
||||
contextKey: "mode:reasoning",
|
||||
@@ -222,17 +206,11 @@ export async function persistInlineDirectives(params: {
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
contextTokens:
|
||||
agentCfg?.contextTokens ??
|
||||
lookupContextTokens(model) ??
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
contextTokens: agentCfg?.contextTokens ?? lookupContextTokens(model) ?? DEFAULT_CONTEXT_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDefaultModel(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId?: string;
|
||||
}): {
|
||||
export function resolveDefaultModel(params: { cfg: ClawdbotConfig; agentId?: string }): {
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
aliasIndex: ModelAliasIndex;
|
||||
|
||||
@@ -29,11 +29,8 @@ export function maybeHandleQueueDirective(params: {
|
||||
sessionEntry: params.sessionEntry,
|
||||
});
|
||||
const debounceLabel =
|
||||
typeof settings.debounceMs === "number"
|
||||
? `${settings.debounceMs}ms`
|
||||
: "default";
|
||||
const capLabel =
|
||||
typeof settings.cap === "number" ? String(settings.cap) : "default";
|
||||
typeof settings.debounceMs === "number" ? `${settings.debounceMs}ms` : "default";
|
||||
const capLabel = typeof settings.cap === "number" ? String(settings.cap) : "default";
|
||||
const dropLabel = settings.dropPolicy ?? "default";
|
||||
return {
|
||||
text: withOptions(
|
||||
@@ -44,23 +41,13 @@ export function maybeHandleQueueDirective(params: {
|
||||
}
|
||||
|
||||
const queueModeInvalid =
|
||||
!directives.queueMode &&
|
||||
!directives.queueReset &&
|
||||
Boolean(directives.rawQueueMode);
|
||||
!directives.queueMode && !directives.queueReset && Boolean(directives.rawQueueMode);
|
||||
const queueDebounceInvalid =
|
||||
directives.rawDebounce !== undefined &&
|
||||
typeof directives.debounceMs !== "number";
|
||||
const queueCapInvalid =
|
||||
directives.rawCap !== undefined && typeof directives.cap !== "number";
|
||||
const queueDropInvalid =
|
||||
directives.rawDrop !== undefined && !directives.dropPolicy;
|
||||
directives.rawDebounce !== undefined && typeof directives.debounceMs !== "number";
|
||||
const queueCapInvalid = directives.rawCap !== undefined && typeof directives.cap !== "number";
|
||||
const queueDropInvalid = directives.rawDrop !== undefined && !directives.dropPolicy;
|
||||
|
||||
if (
|
||||
queueModeInvalid ||
|
||||
queueDebounceInvalid ||
|
||||
queueCapInvalid ||
|
||||
queueDropInvalid
|
||||
) {
|
||||
if (queueModeInvalid || queueDebounceInvalid || queueCapInvalid || queueDropInvalid) {
|
||||
const errors: string[] = [];
|
||||
if (queueModeInvalid) {
|
||||
errors.push(
|
||||
|
||||
@@ -37,9 +37,7 @@ export function formatElevatedUnavailableText(params: {
|
||||
);
|
||||
const failures = params.failures ?? [];
|
||||
if (failures.length > 0) {
|
||||
lines.push(
|
||||
`Failing gates: ${failures.map((f) => `${f.gate} (${f.key})`).join(", ")}`,
|
||||
);
|
||||
lines.push(`Failing gates: ${failures.map((f) => `${f.gate} (${f.key})`).join(", ")}`);
|
||||
} else {
|
||||
lines.push(
|
||||
"Fix-it keys: tools.elevated.enabled, tools.elevated.allowFrom.<provider>, agents.list[].tools.elevated.*",
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
export { applyInlineDirectivesFastLane } from "./directive-handling.fast-lane.js";
|
||||
export * from "./directive-handling.impl.js";
|
||||
export type { InlineDirectives } from "./directive-handling.parse.js";
|
||||
export {
|
||||
isDirectiveOnly,
|
||||
parseInlineDirectives,
|
||||
} from "./directive-handling.parse.js";
|
||||
export {
|
||||
persistInlineDirectives,
|
||||
resolveDefaultModel,
|
||||
} from "./directive-handling.persist.js";
|
||||
export { isDirectiveOnly, parseInlineDirectives } from "./directive-handling.parse.js";
|
||||
export { persistInlineDirectives, resolveDefaultModel } from "./directive-handling.persist.js";
|
||||
export { formatDirectiveAck } from "./directive-handling.shared.js";
|
||||
|
||||
@@ -16,17 +16,14 @@ type ExtractedLevel<T> = {
|
||||
hasDirective: boolean;
|
||||
};
|
||||
|
||||
const escapeRegExp = (value: string) =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
const matchLevelDirective = (
|
||||
body: string,
|
||||
names: string[],
|
||||
): { start: number; end: number; rawLevel?: string } | null => {
|
||||
const namePattern = names.map(escapeRegExp).join("|");
|
||||
const match = body.match(
|
||||
new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?=$|\\s|:)`, "i"),
|
||||
);
|
||||
const match = body.match(new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?=$|\\s|:)`, "i"));
|
||||
if (!match || match.index === undefined) return null;
|
||||
const start = match.index;
|
||||
let end = match.index + match[0].length;
|
||||
@@ -76,9 +73,7 @@ const extractSimpleDirective = (
|
||||
const match = body.match(
|
||||
new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?=$|\\s|:)(?:\\s*:\\s*)?`, "i"),
|
||||
);
|
||||
const cleaned = match
|
||||
? body.replace(match[0], " ").replace(/\s+/g, " ").trim()
|
||||
: body.trim();
|
||||
const cleaned = match ? body.replace(match[0], " ").replace(/\s+/g, " ").trim() : body.trim();
|
||||
return {
|
||||
cleaned,
|
||||
hasDirective: Boolean(match),
|
||||
@@ -92,11 +87,7 @@ export function extractThinkDirective(body?: string): {
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
const extracted = extractLevelDirective(
|
||||
body,
|
||||
["thinking", "think", "t"],
|
||||
normalizeThinkLevel,
|
||||
);
|
||||
const extracted = extractLevelDirective(body, ["thinking", "think", "t"], normalizeThinkLevel);
|
||||
return {
|
||||
cleaned: extracted.cleaned,
|
||||
thinkLevel: extracted.level,
|
||||
@@ -112,11 +103,7 @@ export function extractVerboseDirective(body?: string): {
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
const extracted = extractLevelDirective(
|
||||
body,
|
||||
["verbose", "v"],
|
||||
normalizeVerboseLevel,
|
||||
);
|
||||
const extracted = extractLevelDirective(body, ["verbose", "v"], normalizeVerboseLevel);
|
||||
return {
|
||||
cleaned: extracted.cleaned,
|
||||
verboseLevel: extracted.level,
|
||||
@@ -132,11 +119,7 @@ export function extractElevatedDirective(body?: string): {
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
const extracted = extractLevelDirective(
|
||||
body,
|
||||
["elevated", "elev"],
|
||||
normalizeElevatedLevel,
|
||||
);
|
||||
const extracted = extractLevelDirective(body, ["elevated", "elev"], normalizeElevatedLevel);
|
||||
return {
|
||||
cleaned: extracted.cleaned,
|
||||
elevatedLevel: extracted.level,
|
||||
@@ -152,11 +135,7 @@ export function extractReasoningDirective(body?: string): {
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
const extracted = extractLevelDirective(
|
||||
body,
|
||||
["reasoning", "reason"],
|
||||
normalizeReasoningLevel,
|
||||
);
|
||||
const extracted = extractLevelDirective(body, ["reasoning", "reason"], normalizeReasoningLevel);
|
||||
return {
|
||||
cleaned: extracted.cleaned,
|
||||
reasoningLevel: extracted.level,
|
||||
|
||||
@@ -17,14 +17,7 @@ vi.mock("./route-reply.js", () => ({
|
||||
isRoutableChannel: (channel: string | undefined) =>
|
||||
Boolean(
|
||||
channel &&
|
||||
[
|
||||
"telegram",
|
||||
"slack",
|
||||
"discord",
|
||||
"signal",
|
||||
"imessage",
|
||||
"whatsapp",
|
||||
].includes(channel),
|
||||
["telegram", "slack", "discord", "signal", "imessage", "whatsapp"].includes(channel),
|
||||
),
|
||||
routeReply: mocks.routeReply,
|
||||
}));
|
||||
|
||||
@@ -37,9 +37,7 @@ export async function dispatchReplyFromConfig(params: {
|
||||
const originatingTo = ctx.OriginatingTo;
|
||||
const currentSurface = (ctx.Surface ?? ctx.Provider)?.toLowerCase();
|
||||
const shouldRouteToOriginating =
|
||||
isRoutableChannel(originatingChannel) &&
|
||||
originatingTo &&
|
||||
originatingChannel !== currentSurface;
|
||||
isRoutableChannel(originatingChannel) && originatingTo && originatingChannel !== currentSurface;
|
||||
|
||||
/**
|
||||
* Helper to send a payload via route-reply (async).
|
||||
@@ -66,9 +64,7 @@ export async function dispatchReplyFromConfig(params: {
|
||||
abortSignal,
|
||||
});
|
||||
if (!result.ok) {
|
||||
logVerbose(
|
||||
`dispatch-from-config: route-reply failed: ${result.error ?? "unknown error"}`,
|
||||
);
|
||||
logVerbose(`dispatch-from-config: route-reply failed: ${result.error ?? "unknown error"}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -129,11 +125,7 @@ export async function dispatchReplyFromConfig(params: {
|
||||
cfg,
|
||||
);
|
||||
|
||||
const replies = replyResult
|
||||
? Array.isArray(replyResult)
|
||||
? replyResult
|
||||
: [replyResult]
|
||||
: [];
|
||||
const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
|
||||
|
||||
let queuedFinal = false;
|
||||
let routedFinalCount = 0;
|
||||
|
||||
@@ -48,10 +48,7 @@ describe("createFollowupRunner compaction", () => {
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: {
|
||||
onAgentEvent?: (evt: {
|
||||
stream: string;
|
||||
data: Record<string, unknown>;
|
||||
}) => void;
|
||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
||||
}) => {
|
||||
params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
@@ -102,9 +99,7 @@ describe("createFollowupRunner compaction", () => {
|
||||
await runner(queued);
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalled();
|
||||
expect(onBlockReply.mock.calls[0][0].text).toContain(
|
||||
"Auto-compaction complete",
|
||||
);
|
||||
expect(onBlockReply.mock.calls[0][0].text).toContain("Auto-compaction complete");
|
||||
expect(sessionStore.main.compactionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,9 +103,7 @@ describe("createFollowupRunner messaging tool dedupe", () => {
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messagingToolSentTexts: ["different message"],
|
||||
messagingToolSentTargets: [
|
||||
{ tool: "slack", provider: "slack", to: "channel:C1" },
|
||||
],
|
||||
messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
|
||||
@@ -66,14 +66,10 @@ export function createFollowupRunner(params: {
|
||||
* session's current dispatcher. This ensures replies go back to
|
||||
* where the message originated.
|
||||
*/
|
||||
const sendFollowupPayloads = async (
|
||||
payloads: ReplyPayload[],
|
||||
queued: FollowupRun,
|
||||
) => {
|
||||
const sendFollowupPayloads = async (payloads: ReplyPayload[], queued: FollowupRun) => {
|
||||
// Check if we should route to originating channel.
|
||||
const { originatingChannel, originatingTo } = queued;
|
||||
const shouldRouteToOriginating =
|
||||
isRoutableChannel(originatingChannel) && originatingTo;
|
||||
const shouldRouteToOriginating = isRoutableChannel(originatingChannel) && originatingTo;
|
||||
|
||||
if (!shouldRouteToOriginating && !opts?.onBlockReply) {
|
||||
logVerbose("followup queue: no onBlockReply handler; dropping payloads");
|
||||
@@ -168,8 +164,7 @@ export function createFollowupRunner(params: {
|
||||
blockReplyBreak: queued.run.blockReplyBreak,
|
||||
onAgentEvent: (evt) => {
|
||||
if (evt.stream !== "compaction") return;
|
||||
const phase =
|
||||
typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
autoCompactionCompleted = true;
|
||||
@@ -182,9 +177,7 @@ export function createFollowupRunner(params: {
|
||||
fallbackModel = fallbackResult.model;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
defaultRuntime.error?.(
|
||||
`Followup agent failed before reply: ${message}`,
|
||||
);
|
||||
defaultRuntime.error?.(`Followup agent failed before reply: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -194,16 +187,13 @@ export function createFollowupRunner(params: {
|
||||
const text = payload.text;
|
||||
if (!text || !text.includes("HEARTBEAT_OK")) return [payload];
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
const hasMedia =
|
||||
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (stripped.shouldSkip && !hasMedia) return [];
|
||||
return [{ ...payload, text: stripped.text }];
|
||||
});
|
||||
const replyToChannel =
|
||||
queued.originatingChannel ??
|
||||
(queued.run.messageProvider?.toLowerCase() as
|
||||
| OriginatingChannelType
|
||||
| undefined);
|
||||
(queued.run.messageProvider?.toLowerCase() as OriginatingChannelType | undefined);
|
||||
const replyToMode = resolveReplyToMode(
|
||||
queued.run.config,
|
||||
replyToChannel,
|
||||
@@ -247,8 +237,7 @@ export function createFollowupRunner(params: {
|
||||
|
||||
if (storePath && sessionKey) {
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed =
|
||||
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
||||
const modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
||||
const contextTokensUsed =
|
||||
agentCfgContextTokens ??
|
||||
lookupContextTokens(modelUsed) ??
|
||||
@@ -263,13 +252,11 @@ export function createFollowupRunner(params: {
|
||||
update: async (entry) => {
|
||||
const input = usage.input ?? 0;
|
||||
const output = usage.output ?? 0;
|
||||
const promptTokens =
|
||||
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
const promptTokens = input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
return {
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
totalTokens:
|
||||
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||
totalTokens: promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||
modelProvider: fallbackProvider ?? entry.modelProvider,
|
||||
model: modelUsed,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
@@ -278,9 +265,7 @@ export function createFollowupRunner(params: {
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`failed to persist followup usage update: ${String(err)}`,
|
||||
);
|
||||
logVerbose(`failed to persist followup usage update: ${String(err)}`);
|
||||
}
|
||||
} else if (modelUsed || contextTokensUsed) {
|
||||
try {
|
||||
@@ -295,9 +280,7 @@ export function createFollowupRunner(params: {
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`failed to persist followup model/context update: ${String(err)}`,
|
||||
);
|
||||
logVerbose(`failed to persist followup model/context update: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
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 { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { buildStatusReply } from "./commands.js";
|
||||
import {
|
||||
@@ -176,11 +171,7 @@ export async function applyInlineDirectiveOverrides(params: {
|
||||
currentElevatedLevel,
|
||||
});
|
||||
let statusReply: ReplyPayload | undefined;
|
||||
if (
|
||||
directives.hasStatusDirective &&
|
||||
allowTextCommands &&
|
||||
command.isAuthorizedSender
|
||||
) {
|
||||
if (directives.hasStatusDirective && allowTextCommands && command.isAuthorizedSender) {
|
||||
statusReply = await buildStatusReply({
|
||||
cfg,
|
||||
command,
|
||||
@@ -192,8 +183,7 @@ export async function applyInlineDirectiveOverrides(params: {
|
||||
contextTokens,
|
||||
resolvedThinkLevel: resolvedDefaultThinkLevel,
|
||||
resolvedVerboseLevel: (currentVerboseLevel ?? "off") as VerboseLevel,
|
||||
resolvedReasoningLevel: (currentReasoningLevel ??
|
||||
"off") as ReasoningLevel,
|
||||
resolvedReasoningLevel: (currentReasoningLevel ?? "off") as ReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
resolveDefaultThinkingLevel: async () => resolvedDefaultThinkLevel,
|
||||
isGroup,
|
||||
@@ -284,9 +274,7 @@ export async function applyInlineDirectiveOverrides(params: {
|
||||
contextTokens = persisted.contextTokens;
|
||||
|
||||
const perMessageQueueMode =
|
||||
directives.hasQueueDirective && !directives.queueReset
|
||||
? directives.queueMode
|
||||
: undefined;
|
||||
directives.hasQueueDirective && !directives.queueReset ? directives.queueMode : undefined;
|
||||
const perMessageQueueOptions =
|
||||
directives.hasQueueDirective && !directives.queueReset
|
||||
? {
|
||||
|
||||
@@ -2,43 +2,19 @@ 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 { listChatCommands, shouldHandleTextCommands } from "../commands-registry.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import type {
|
||||
ElevatedLevel,
|
||||
ReasoningLevel,
|
||||
ThinkLevel,
|
||||
VerboseLevel,
|
||||
} from "../thinking.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 { 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 { 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";
|
||||
|
||||
@@ -95,12 +71,8 @@ export async function resolveReplyDirectives(params: {
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
sessionScope: Parameters<
|
||||
typeof applyInlineDirectiveOverrides
|
||||
>[0]["sessionScope"];
|
||||
groupResolution: Parameters<
|
||||
typeof resolveGroupRequireMention
|
||||
>[0]["groupResolution"];
|
||||
sessionScope: Parameters<typeof applyInlineDirectiveOverrides>[0]["sessionScope"];
|
||||
groupResolution: Parameters<typeof resolveGroupRequireMention>[0]["groupResolution"];
|
||||
isGroup: boolean;
|
||||
triggerBodyNormalized: string;
|
||||
commandAuthorized: boolean;
|
||||
@@ -175,19 +147,14 @@ export async function resolveReplyDirectives(params: {
|
||||
allowStatusDirective,
|
||||
});
|
||||
const hasInlineStatus =
|
||||
parsedDirectives.hasStatusDirective &&
|
||||
parsedDirectives.cleaned.trim().length > 0;
|
||||
parsedDirectives.hasStatusDirective && parsedDirectives.cleaned.trim().length > 0;
|
||||
if (hasInlineStatus) {
|
||||
parsedDirectives = {
|
||||
...parsedDirectives,
|
||||
hasStatusDirective: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
isGroup &&
|
||||
ctx.WasMentioned !== true &&
|
||||
parsedDirectives.hasElevatedDirective
|
||||
) {
|
||||
if (isGroup && ctx.WasMentioned !== true && parsedDirectives.hasElevatedDirective) {
|
||||
if (parsedDirectives.elevatedLevel !== "off") {
|
||||
parsedDirectives = {
|
||||
...parsedDirectives,
|
||||
@@ -206,18 +173,14 @@ export async function resolveReplyDirectives(params: {
|
||||
parsedDirectives.hasQueueDirective;
|
||||
if (hasInlineDirective) {
|
||||
const stripped = stripStructuralPrefixes(parsedDirectives.cleaned);
|
||||
const noMentions = isGroup
|
||||
? stripMentions(stripped, ctx, cfg, agentId)
|
||||
: stripped;
|
||||
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.hasStatusDirective && allowTextCommands && command.isAuthorizedSender;
|
||||
parsedDirectives = allowInlineStatus
|
||||
? {
|
||||
...clearInlineDirectives(parsedDirectives.cleaned),
|
||||
@@ -257,13 +220,8 @@ export async function resolveReplyDirectives(params: {
|
||||
}).cleaned;
|
||||
}
|
||||
|
||||
const head = existingBody.slice(
|
||||
0,
|
||||
markerIndex + CURRENT_MESSAGE_MARKER.length,
|
||||
);
|
||||
const tail = existingBody.slice(
|
||||
markerIndex + CURRENT_MESSAGE_MARKER.length,
|
||||
);
|
||||
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,
|
||||
@@ -279,9 +237,7 @@ export async function resolveReplyDirectives(params: {
|
||||
sessionCtx.BodyStripped = cleanedBody;
|
||||
|
||||
const messageProviderKey =
|
||||
sessionCtx.Provider?.trim().toLowerCase() ??
|
||||
ctx.Provider?.trim().toLowerCase() ??
|
||||
"";
|
||||
sessionCtx.Provider?.trim().toLowerCase() ?? ctx.Provider?.trim().toLowerCase() ?? "";
|
||||
const elevated = resolveElevatedPermissions({
|
||||
cfg,
|
||||
agentId,
|
||||
@@ -291,10 +247,7 @@ export async function resolveReplyDirectives(params: {
|
||||
const elevatedEnabled = elevated.enabled;
|
||||
const elevatedAllowed = elevated.allowed;
|
||||
const elevatedFailures = elevated.failures;
|
||||
if (
|
||||
directives.hasElevatedDirective &&
|
||||
(!elevatedEnabled || !elevatedAllowed)
|
||||
) {
|
||||
if (directives.hasElevatedDirective && (!elevatedEnabled || !elevatedAllowed)) {
|
||||
typing.cleanup();
|
||||
const runtimeSandboxed = resolveSandboxRuntimeStatus({
|
||||
cfg,
|
||||
@@ -346,17 +299,11 @@ export async function resolveReplyDirectives(params: {
|
||||
? "on"
|
||||
: "off";
|
||||
const resolvedBlockStreamingBreak: "text_end" | "message_end" =
|
||||
agentCfg?.blockStreamingBreak === "message_end"
|
||||
? "message_end"
|
||||
: "text_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,
|
||||
)
|
||||
? resolveBlockStreamingChunking(cfg, sessionCtx.Provider, sessionCtx.AccountId)
|
||||
: undefined;
|
||||
|
||||
const modelState = await createModelSelectionState({
|
||||
@@ -382,20 +329,13 @@ export async function resolveReplyDirectives(params: {
|
||||
|
||||
const initialModelLabel = `${provider}/${model}`;
|
||||
const formatModelSwitchEvent = (label: string, alias?: string) =>
|
||||
alias
|
||||
? `Model switched to ${alias} (${label}).`
|
||||
: `Model switched to ${label}.`;
|
||||
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;
|
||||
["status", "list"].includes(directives.rawModelDirective?.trim().toLowerCase() ?? "");
|
||||
const effectiveModelDirective = isModelListAlias ? undefined : directives.rawModelDirective;
|
||||
|
||||
const inlineStatusRequested =
|
||||
hasInlineStatus && allowTextCommands && command.isAuthorizedSender;
|
||||
const inlineStatusRequested = hasInlineStatus && allowTextCommands && command.isAuthorizedSender;
|
||||
|
||||
const applyResult = await applyInlineDirectiveOverrides({
|
||||
ctx,
|
||||
@@ -437,8 +377,7 @@ export async function resolveReplyDirectives(params: {
|
||||
provider = applyResult.provider;
|
||||
model = applyResult.model;
|
||||
contextTokens = applyResult.contextTokens;
|
||||
const { directiveAck, perMessageQueueMode, perMessageQueueOptions } =
|
||||
applyResult;
|
||||
const { directiveAck, perMessageQueueMode, perMessageQueueOptions } = applyResult;
|
||||
|
||||
return {
|
||||
kind: "continue",
|
||||
|
||||
@@ -2,12 +2,7 @@ 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 { 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";
|
||||
@@ -47,9 +42,7 @@ export async function handleInlineActions(params: {
|
||||
elevatedEnabled: boolean;
|
||||
elevatedAllowed: boolean;
|
||||
elevatedFailures: Array<{ gate: string; key: string }>;
|
||||
defaultActivation: Parameters<
|
||||
typeof buildStatusReply
|
||||
>[0]["defaultGroupActivation"];
|
||||
defaultActivation: Parameters<typeof buildStatusReply>[0]["defaultGroupActivation"];
|
||||
resolvedThinkLevel: ThinkLevel | undefined;
|
||||
resolvedVerboseLevel: VerboseLevel | undefined;
|
||||
resolvedReasoningLevel: ReasoningLevel;
|
||||
|
||||
@@ -163,8 +163,7 @@ export async function runPreparedReply(
|
||||
isHeartbeat,
|
||||
});
|
||||
const shouldInjectGroupIntro = Boolean(
|
||||
isGroupChat &&
|
||||
(isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro),
|
||||
isGroupChat && (isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro),
|
||||
);
|
||||
const groupIntro = shouldInjectGroupIntro
|
||||
? buildGroupIntro({
|
||||
@@ -176,17 +175,10 @@ export async function runPreparedReply(
|
||||
})
|
||||
: "";
|
||||
const groupSystemPrompt = sessionCtx.GroupSystemPrompt?.trim() ?? "";
|
||||
const extraSystemPrompt = [groupIntro, groupSystemPrompt]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
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 rawBodyTrimmed = (ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "").trim();
|
||||
const baseBodyTrimmedRaw = baseBody.trim();
|
||||
if (
|
||||
allowTextCommands &&
|
||||
@@ -198,12 +190,8 @@ export async function runPreparedReply(
|
||||
return undefined;
|
||||
}
|
||||
const isBareSessionReset =
|
||||
isNewSession &&
|
||||
baseBodyTrimmedRaw.length === 0 &&
|
||||
rawBodyTrimmed.length > 0;
|
||||
const baseBodyFinal = isBareSessionReset
|
||||
? BARE_SESSION_RESET_PROMPT
|
||||
: baseBody;
|
||||
isNewSession && baseBodyTrimmedRaw.length === 0 && rawBodyTrimmed.length > 0;
|
||||
const baseBodyFinal = isBareSessionReset ? BARE_SESSION_RESET_PROMPT : baseBody;
|
||||
const baseBodyTrimmed = baseBodyFinal.trim();
|
||||
if (!baseBodyTrimmed) {
|
||||
await typing.onReplyStart();
|
||||
@@ -223,10 +211,8 @@ export async function runPreparedReply(
|
||||
abortKey: command.abortKey,
|
||||
messageId: sessionCtx.MessageSid,
|
||||
});
|
||||
const isGroupSession =
|
||||
sessionEntry?.chatType === "group" || sessionEntry?.chatType === "room";
|
||||
const isMainSession =
|
||||
!isGroupSession && sessionKey === normalizeMainKey(sessionCfg?.mainKey);
|
||||
const isGroupSession = sessionEntry?.chatType === "group" || sessionEntry?.chatType === "room";
|
||||
const isMainSession = !isGroupSession && sessionKey === normalizeMainKey(sessionCfg?.mainKey);
|
||||
prefixedBodyBase = await prependSystemEvents({
|
||||
cfg,
|
||||
sessionKey,
|
||||
@@ -263,18 +249,12 @@ export async function runPreparedReply(
|
||||
? "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()
|
||||
? [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))
|
||||
) {
|
||||
if (maybeLevel && (maybeLevel !== "xhigh" || supportsXHighThinking(provider, model))) {
|
||||
resolvedThinkLevel = maybeLevel;
|
||||
prefixedCommandBody = parts.slice(1).join(" ").trim();
|
||||
}
|
||||
@@ -282,12 +262,8 @@ export async function runPreparedReply(
|
||||
if (!resolvedThinkLevel) {
|
||||
resolvedThinkLevel = await modelState.resolveDefaultThinkingLevel();
|
||||
}
|
||||
if (
|
||||
resolvedThinkLevel === "xhigh" &&
|
||||
!supportsXHighThinking(provider, model)
|
||||
) {
|
||||
const explicitThink =
|
||||
directives.hasThinkDirective && directives.thinkLevel !== undefined;
|
||||
if (resolvedThinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) {
|
||||
const explicitThink = directives.hasThinkDirective && directives.thinkLevel !== undefined;
|
||||
if (explicitThink) {
|
||||
typing.cleanup();
|
||||
return {
|
||||
@@ -295,12 +271,7 @@ export async function runPreparedReply(
|
||||
};
|
||||
}
|
||||
resolvedThinkLevel = "high";
|
||||
if (
|
||||
sessionEntry &&
|
||||
sessionStore &&
|
||||
sessionKey &&
|
||||
sessionEntry.thinkingLevel === "xhigh"
|
||||
) {
|
||||
if (sessionEntry && sessionStore && sessionKey && sessionEntry.thinkingLevel === "xhigh") {
|
||||
sessionEntry.thinkingLevel = "high";
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
@@ -317,10 +288,7 @@ export async function runPreparedReply(
|
||||
.join("\n\n")
|
||||
: [threadStarterNote, baseBodyFinal].filter(Boolean).join("\n\n");
|
||||
const queuedBody = mediaNote
|
||||
? [mediaNote, mediaReplyHint, queueBodyBase]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim()
|
||||
? [mediaNote, mediaReplyHint, queueBodyBase].filter(Boolean).join("\n").trim()
|
||||
: queueBodyBase;
|
||||
const resolvedQueue = resolveQueueSettings({
|
||||
cfg,
|
||||
@@ -329,22 +297,17 @@ export async function runPreparedReply(
|
||||
inlineMode: perMessageQueueMode,
|
||||
inlineOptions: perMessageQueueOptions,
|
||||
});
|
||||
const sessionLaneKey = resolveEmbeddedSessionLane(
|
||||
sessionKey ?? sessionIdFinal,
|
||||
);
|
||||
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})`,
|
||||
);
|
||||
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 shouldSteer = resolvedQueue.mode === "steer" || resolvedQueue.mode === "steer-backlog";
|
||||
const shouldFollowup =
|
||||
resolvedQueue.mode === "followup" ||
|
||||
resolvedQueue.mode === "collect" ||
|
||||
@@ -385,8 +348,7 @@ export async function runPreparedReply(
|
||||
},
|
||||
timeoutMs,
|
||||
blockReplyBreak: resolvedBlockStreamingBreak,
|
||||
ownerNumbers:
|
||||
command.ownerList.length > 0 ? command.ownerList : undefined,
|
||||
ownerNumbers: command.ownerList.length > 0 ? command.ownerList : undefined,
|
||||
extraSystemPrompt: extraSystemPrompt || undefined,
|
||||
...(isReasoningTagProvider(provider) ? { enforceFinalTag: true } : {}),
|
||||
},
|
||||
|
||||
@@ -5,21 +5,14 @@ import {
|
||||
} 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 { 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 { 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";
|
||||
@@ -62,8 +55,7 @@ export async function getReplyFromConfig(
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceDirRaw =
|
||||
resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
|
||||
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
|
||||
const workspace = await ensureAgentWorkspace({
|
||||
dir: workspaceDirRaw,
|
||||
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
|
||||
|
||||
@@ -32,9 +32,7 @@ describe("resolveGroupRequireMention", () => {
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||
});
|
||||
|
||||
it("respects Slack channel requireMention settings", () => {
|
||||
@@ -58,8 +56,6 @@ describe("resolveGroupRequireMention", () => {
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import {
|
||||
getChatChannelMeta,
|
||||
normalizeChannelId,
|
||||
} from "../../channels/registry.js";
|
||||
import { getChatChannelMeta, normalizeChannelId } from "../../channels/registry.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type {
|
||||
GroupKeyResolution,
|
||||
SessionEntry,
|
||||
} from "../../config/sessions.js";
|
||||
import type { GroupKeyResolution, SessionEntry } from "../../config/sessions.js";
|
||||
import { isInternalMessageChannel } from "../../utils/message-channel.js";
|
||||
import { normalizeGroupActivation } from "../group-activation.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
@@ -24,9 +18,7 @@ export function resolveGroupRequireMention(params: {
|
||||
const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, "");
|
||||
const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim();
|
||||
const groupSpace = ctx.GroupSpace?.trim();
|
||||
const requireMention = getChannelDock(
|
||||
channel,
|
||||
)?.groups?.resolveRequireMention?.({
|
||||
const requireMention = getChannelDock(channel)?.groups?.resolveRequireMention?.({
|
||||
cfg,
|
||||
groupId,
|
||||
groupRoom,
|
||||
@@ -37,9 +29,7 @@ export function resolveGroupRequireMention(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function defaultGroupActivation(
|
||||
requireMention: boolean,
|
||||
): "always" | "mention" {
|
||||
export function defaultGroupActivation(requireMention: boolean): "always" | "mention" {
|
||||
return requireMention === false ? "always" : "mention";
|
||||
}
|
||||
|
||||
@@ -51,8 +41,7 @@ export function buildGroupIntro(params: {
|
||||
silentToken: string;
|
||||
}): string {
|
||||
const activation =
|
||||
normalizeGroupActivation(params.sessionEntry?.groupActivation) ??
|
||||
params.defaultActivation;
|
||||
normalizeGroupActivation(params.sessionEntry?.groupActivation) ?? params.defaultActivation;
|
||||
const subject = params.sessionCtx.GroupSubject?.trim();
|
||||
const members = params.sessionCtx.GroupMembers?.trim();
|
||||
const rawProvider = params.sessionCtx.Provider?.trim();
|
||||
|
||||
@@ -56,10 +56,7 @@ describe("history helpers", () => {
|
||||
entry: { sender: "C", body: "three" },
|
||||
});
|
||||
|
||||
expect(historyMap.get("room")?.map((entry) => entry.body)).toEqual([
|
||||
"two",
|
||||
"three",
|
||||
]);
|
||||
expect(historyMap.get("room")?.map((entry) => entry.body)).toEqual(["two", "three"]);
|
||||
});
|
||||
|
||||
it("builds context from map and appends entry", () => {
|
||||
@@ -78,11 +75,7 @@ describe("history helpers", () => {
|
||||
formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
|
||||
});
|
||||
|
||||
expect(historyMap.get("room")?.map((entry) => entry.body)).toEqual([
|
||||
"one",
|
||||
"two",
|
||||
"three",
|
||||
]);
|
||||
expect(historyMap.get("room")?.map((entry) => entry.body)).toEqual(["one", "two", "three"]);
|
||||
expect(result).toContain(HISTORY_CONTEXT_MARKER);
|
||||
expect(result).toContain("A: one");
|
||||
expect(result).toContain("B: two");
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
|
||||
|
||||
export const HISTORY_CONTEXT_MARKER =
|
||||
"[Chat messages since your last reply - for context]";
|
||||
export const HISTORY_CONTEXT_MARKER = "[Chat messages since your last reply - for context]";
|
||||
export const DEFAULT_GROUP_HISTORY_LIMIT = 50;
|
||||
|
||||
export type HistoryEntry = {
|
||||
@@ -19,13 +18,9 @@ export function buildHistoryContext(params: {
|
||||
const { historyText, currentMessage } = params;
|
||||
const lineBreak = params.lineBreak ?? "\n";
|
||||
if (!historyText.trim()) return currentMessage;
|
||||
return [
|
||||
HISTORY_CONTEXT_MARKER,
|
||||
historyText,
|
||||
"",
|
||||
CURRENT_MESSAGE_MARKER,
|
||||
currentMessage,
|
||||
].join(lineBreak);
|
||||
return [HISTORY_CONTEXT_MARKER, historyText, "", CURRENT_MESSAGE_MARKER, currentMessage].join(
|
||||
lineBreak,
|
||||
);
|
||||
}
|
||||
|
||||
export function appendHistoryEntry(params: {
|
||||
@@ -86,8 +81,7 @@ export function buildHistoryContextFromEntries(params: {
|
||||
excludeLast?: boolean;
|
||||
}): string {
|
||||
const lineBreak = params.lineBreak ?? "\n";
|
||||
const entries =
|
||||
params.excludeLast === false ? params.entries : params.entries.slice(0, -1);
|
||||
const entries = params.excludeLast === false ? params.entries : params.entries.slice(0, -1);
|
||||
if (entries.length === 0) return params.currentMessage;
|
||||
const historyText = entries.map(params.formatEntry).join(lineBreak);
|
||||
return buildHistoryContext({
|
||||
|
||||
@@ -38,16 +38,10 @@ describe("inbound dedupe", () => {
|
||||
MessageSid: "msg-1",
|
||||
};
|
||||
expect(
|
||||
shouldSkipDuplicateInbound(
|
||||
{ ...base, OriginatingTo: "whatsapp:+1000" },
|
||||
{ now: 100 },
|
||||
),
|
||||
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+1000" }, { now: 100 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipDuplicateInbound(
|
||||
{ ...base, OriginatingTo: "whatsapp:+2000" },
|
||||
{ now: 200 },
|
||||
),
|
||||
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+2000" }, { now: 200 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -60,22 +54,13 @@ describe("inbound dedupe", () => {
|
||||
MessageSid: "msg-1",
|
||||
};
|
||||
expect(
|
||||
shouldSkipDuplicateInbound(
|
||||
{ ...base, SessionKey: "agent:alpha:main" },
|
||||
{ now: 100 },
|
||||
),
|
||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 100 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipDuplicateInbound(
|
||||
{ ...base, SessionKey: "agent:bravo:main" },
|
||||
{ now: 200 },
|
||||
),
|
||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:bravo:main" }, { now: 200 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipDuplicateInbound(
|
||||
{ ...base, SessionKey: "agent:alpha:main" },
|
||||
{ now: 300 },
|
||||
),
|
||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 300 }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,27 +10,21 @@ const inboundDedupeCache = createDedupeCache({
|
||||
maxSize: DEFAULT_INBOUND_DEDUPE_MAX,
|
||||
});
|
||||
|
||||
const normalizeProvider = (value?: string | null) =>
|
||||
value?.trim().toLowerCase() || "";
|
||||
const normalizeProvider = (value?: string | null) => value?.trim().toLowerCase() || "";
|
||||
|
||||
const resolveInboundPeerId = (ctx: MsgContext) =>
|
||||
ctx.OriginatingTo ?? ctx.To ?? ctx.From ?? ctx.SessionKey;
|
||||
|
||||
export function buildInboundDedupeKey(ctx: MsgContext): string | null {
|
||||
const provider = normalizeProvider(
|
||||
ctx.OriginatingChannel ?? ctx.Provider ?? ctx.Surface,
|
||||
);
|
||||
const provider = normalizeProvider(ctx.OriginatingChannel ?? ctx.Provider ?? ctx.Surface);
|
||||
const messageId = ctx.MessageSid?.trim();
|
||||
if (!provider || !messageId) return null;
|
||||
const peerId = resolveInboundPeerId(ctx);
|
||||
if (!peerId) return null;
|
||||
const sessionKey = ctx.SessionKey?.trim() ?? "";
|
||||
const accountId = ctx.AccountId?.trim() ?? "";
|
||||
const threadId =
|
||||
typeof ctx.MessageThreadId === "number" ? String(ctx.MessageThreadId) : "";
|
||||
return [provider, accountId, sessionKey, peerId, threadId, messageId]
|
||||
.filter(Boolean)
|
||||
.join("|");
|
||||
const threadId = typeof ctx.MessageThreadId === "number" ? String(ctx.MessageThreadId) : "";
|
||||
return [provider, accountId, sessionKey, peerId, threadId, messageId].filter(Boolean).join("|");
|
||||
}
|
||||
|
||||
export function shouldSkipDuplicateInbound(
|
||||
|
||||
@@ -118,8 +118,6 @@ describe("shouldRunMemoryFlush", () => {
|
||||
|
||||
describe("resolveMemoryFlushContextWindowTokens", () => {
|
||||
it("falls back to agent config or default tokens", () => {
|
||||
expect(
|
||||
resolveMemoryFlushContextWindowTokens({ agentCfgContextTokens: 42_000 }),
|
||||
).toBe(42_000);
|
||||
expect(resolveMemoryFlushContextWindowTokens({ agentCfgContextTokens: 42_000 })).toBe(42_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,22 +33,17 @@ const normalizeNonNegativeInt = (value: unknown): number | null => {
|
||||
return int >= 0 ? int : null;
|
||||
};
|
||||
|
||||
export function resolveMemoryFlushSettings(
|
||||
cfg?: ClawdbotConfig,
|
||||
): MemoryFlushSettings | null {
|
||||
export function resolveMemoryFlushSettings(cfg?: ClawdbotConfig): MemoryFlushSettings | null {
|
||||
const defaults = cfg?.agents?.defaults?.compaction?.memoryFlush;
|
||||
const enabled = defaults?.enabled ?? true;
|
||||
if (!enabled) return null;
|
||||
const softThresholdTokens =
|
||||
normalizeNonNegativeInt(defaults?.softThresholdTokens) ??
|
||||
DEFAULT_MEMORY_FLUSH_SOFT_TOKENS;
|
||||
normalizeNonNegativeInt(defaults?.softThresholdTokens) ?? DEFAULT_MEMORY_FLUSH_SOFT_TOKENS;
|
||||
const prompt = defaults?.prompt?.trim() || DEFAULT_MEMORY_FLUSH_PROMPT;
|
||||
const systemPrompt =
|
||||
defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT;
|
||||
const systemPrompt = defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT;
|
||||
const reserveTokensFloor =
|
||||
normalizeNonNegativeInt(
|
||||
cfg?.agents?.defaults?.compaction?.reserveTokensFloor,
|
||||
) ?? DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR;
|
||||
normalizeNonNegativeInt(cfg?.agents?.defaults?.compaction?.reserveTokensFloor) ??
|
||||
DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR;
|
||||
|
||||
return {
|
||||
enabled,
|
||||
@@ -69,17 +64,12 @@ export function resolveMemoryFlushContextWindowTokens(params: {
|
||||
agentCfgContextTokens?: number;
|
||||
}): number {
|
||||
return (
|
||||
lookupContextTokens(params.modelId) ??
|
||||
params.agentCfgContextTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS
|
||||
lookupContextTokens(params.modelId) ?? params.agentCfgContextTokens ?? DEFAULT_CONTEXT_TOKENS
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldRunMemoryFlush(params: {
|
||||
entry?: Pick<
|
||||
SessionEntry,
|
||||
"totalTokens" | "compactionCount" | "memoryFlushCompactionCount"
|
||||
>;
|
||||
entry?: Pick<SessionEntry, "totalTokens" | "compactionCount" | "memoryFlushCompactionCount">;
|
||||
contextWindowTokens: number;
|
||||
reserveTokensFloor: number;
|
||||
softThresholdTokens: number;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildMentionRegexes,
|
||||
matchesMentionPatterns,
|
||||
normalizeMentionText,
|
||||
} from "./mentions.js";
|
||||
import { buildMentionRegexes, matchesMentionPatterns, normalizeMentionText } from "./mentions.js";
|
||||
|
||||
describe("mention helpers", () => {
|
||||
it("builds regexes and skips invalid patterns", () => {
|
||||
|
||||
@@ -36,10 +36,7 @@ function normalizeMentionPatterns(patterns: string[]): string[] {
|
||||
return patterns.map(normalizeMentionPattern);
|
||||
}
|
||||
|
||||
function resolveMentionPatterns(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
agentId?: string,
|
||||
): string[] {
|
||||
function resolveMentionPatterns(cfg: ClawdbotConfig | undefined, agentId?: string): string[] {
|
||||
if (!cfg) return [];
|
||||
const agentConfig = agentId ? resolveAgentConfig(cfg, agentId) : undefined;
|
||||
const agentGroupChat = agentConfig?.groupChat;
|
||||
@@ -54,13 +51,8 @@ function resolveMentionPatterns(
|
||||
return derived.length > 0 ? derived : [];
|
||||
}
|
||||
|
||||
export function buildMentionRegexes(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
agentId?: string,
|
||||
): RegExp[] {
|
||||
const patterns = normalizeMentionPatterns(
|
||||
resolveMentionPatterns(cfg, agentId),
|
||||
);
|
||||
export function buildMentionRegexes(cfg: ClawdbotConfig | undefined, agentId?: string): RegExp[] {
|
||||
const patterns = normalizeMentionPatterns(resolveMentionPatterns(cfg, agentId));
|
||||
return patterns
|
||||
.map((pattern) => {
|
||||
try {
|
||||
@@ -73,15 +65,10 @@ export function buildMentionRegexes(
|
||||
}
|
||||
|
||||
export function normalizeMentionText(text: string): string {
|
||||
return (text ?? "")
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
|
||||
.toLowerCase();
|
||||
return (text ?? "").replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
export function matchesMentionPatterns(
|
||||
text: string,
|
||||
mentionRegexes: RegExp[],
|
||||
): boolean {
|
||||
export function matchesMentionPatterns(text: string, mentionRegexes: RegExp[]): boolean {
|
||||
if (mentionRegexes.length === 0) return false;
|
||||
const cleaned = normalizeMentionText(text ?? "");
|
||||
if (!cleaned) return false;
|
||||
@@ -92,11 +79,7 @@ export function stripStructuralPrefixes(text: string): string {
|
||||
// Ignore wrapper labels, timestamps, and sender prefixes so directive-only
|
||||
// detection still works in group batches that include history/context.
|
||||
const afterMarker = text.includes(CURRENT_MESSAGE_MARKER)
|
||||
? text
|
||||
.slice(
|
||||
text.indexOf(CURRENT_MESSAGE_MARKER) + CURRENT_MESSAGE_MARKER.length,
|
||||
)
|
||||
.trimStart()
|
||||
? text.slice(text.indexOf(CURRENT_MESSAGE_MARKER) + CURRENT_MESSAGE_MARKER.length).trimStart()
|
||||
: text;
|
||||
|
||||
return afterMarker
|
||||
@@ -115,9 +98,7 @@ export function stripMentions(
|
||||
): string {
|
||||
let result = text;
|
||||
const providerId = ctx.Provider ? normalizeChannelId(ctx.Provider) : null;
|
||||
const providerMentions = providerId
|
||||
? getChannelDock(providerId)?.mentions
|
||||
: undefined;
|
||||
const providerMentions = providerId ? getChannelDock(providerId)?.mentions : undefined;
|
||||
const patterns = normalizeMentionPatterns([
|
||||
...resolveMentionPatterns(cfg, agentId),
|
||||
...(providerMentions?.stripPatterns?.({ ctx, cfg, agentId }) ?? []),
|
||||
|
||||
@@ -105,15 +105,9 @@ function scoreFuzzyMatch(params: {
|
||||
score += 30;
|
||||
}
|
||||
|
||||
const fragmentVariants = FUZZY_VARIANT_TOKENS.filter((token) =>
|
||||
fragment.includes(token),
|
||||
);
|
||||
const modelVariants = FUZZY_VARIANT_TOKENS.filter((token) =>
|
||||
modelLower.includes(token),
|
||||
);
|
||||
const variantMatchCount = fragmentVariants.filter((token) =>
|
||||
modelLower.includes(token),
|
||||
).length;
|
||||
const fragmentVariants = FUZZY_VARIANT_TOKENS.filter((token) => fragment.includes(token));
|
||||
const modelVariants = FUZZY_VARIANT_TOKENS.filter((token) => modelLower.includes(token));
|
||||
const variantMatchCount = fragmentVariants.filter((token) => modelLower.includes(token)).length;
|
||||
const variantCount = modelVariants.length;
|
||||
if (fragmentVariants.length === 0 && variantCount > 0) {
|
||||
score -= variantCount * 30;
|
||||
@@ -123,8 +117,7 @@ function scoreFuzzyMatch(params: {
|
||||
}
|
||||
|
||||
const defaultProvider = normalizeProviderId(params.defaultProvider);
|
||||
const isDefault =
|
||||
provider === defaultProvider && model === params.defaultModel;
|
||||
const isDefault = provider === defaultProvider && model === params.defaultModel;
|
||||
if (isDefault) score += 20;
|
||||
|
||||
return {
|
||||
@@ -139,9 +132,7 @@ function scoreFuzzyMatch(params: {
|
||||
|
||||
export async function createModelSelectionState(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentCfg:
|
||||
| NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
|
||||
| undefined;
|
||||
agentCfg: NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]> | undefined;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
@@ -166,13 +157,9 @@ export async function createModelSelectionState(params: {
|
||||
let provider = params.provider;
|
||||
let model = params.model;
|
||||
|
||||
const hasAllowlist =
|
||||
agentCfg?.models && Object.keys(agentCfg.models).length > 0;
|
||||
const hasStoredOverride = Boolean(
|
||||
sessionEntry?.modelOverride || sessionEntry?.providerOverride,
|
||||
);
|
||||
const needsModelCatalog =
|
||||
params.hasModelDirective || hasAllowlist || hasStoredOverride;
|
||||
const hasAllowlist = agentCfg?.models && Object.keys(agentCfg.models).length > 0;
|
||||
const hasStoredOverride = Boolean(sessionEntry?.modelOverride || sessionEntry?.providerOverride);
|
||||
const needsModelCatalog = params.hasModelDirective || hasAllowlist || hasStoredOverride;
|
||||
|
||||
let allowedModelKeys = new Set<string>();
|
||||
let allowedModelCatalog: ModelCatalog = [];
|
||||
@@ -192,8 +179,7 @@ export async function createModelSelectionState(params: {
|
||||
}
|
||||
|
||||
if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) {
|
||||
const overrideProvider =
|
||||
sessionEntry.providerOverride?.trim() || defaultProvider;
|
||||
const overrideProvider = sessionEntry.providerOverride?.trim() || defaultProvider;
|
||||
const overrideModel = sessionEntry.modelOverride?.trim();
|
||||
if (overrideModel) {
|
||||
const key = modelKey(overrideProvider, overrideModel);
|
||||
@@ -221,15 +207,8 @@ export async function createModelSelectionState(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
sessionEntry &&
|
||||
sessionStore &&
|
||||
sessionKey &&
|
||||
sessionEntry.authProfileOverride
|
||||
) {
|
||||
const { ensureAuthProfileStore } = await import(
|
||||
"../../agents/auth-profiles.js"
|
||||
);
|
||||
if (sessionEntry && sessionStore && sessionKey && sessionEntry.authProfileOverride) {
|
||||
const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.js");
|
||||
const store = ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
@@ -259,9 +238,7 @@ export async function createModelSelectionState(params: {
|
||||
catalog: catalogForThinking,
|
||||
});
|
||||
defaultThinkingLevel =
|
||||
resolved ??
|
||||
(agentCfg?.thinkingDefault as ThinkLevel | undefined) ??
|
||||
"off";
|
||||
resolved ?? (agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? "off";
|
||||
return defaultThinkingLevel;
|
||||
};
|
||||
|
||||
@@ -283,21 +260,15 @@ export function resolveModelDirectiveSelection(params: {
|
||||
aliasIndex: ModelAliasIndex;
|
||||
allowedModelKeys: Set<string>;
|
||||
}): { selection?: ModelDirectiveSelection; error?: string } {
|
||||
const { raw, defaultProvider, defaultModel, aliasIndex, allowedModelKeys } =
|
||||
params;
|
||||
const { raw, defaultProvider, defaultModel, aliasIndex, allowedModelKeys } = params;
|
||||
|
||||
const rawTrimmed = raw.trim();
|
||||
const rawLower = rawTrimmed.toLowerCase();
|
||||
|
||||
const pickAliasForKey = (
|
||||
provider: string,
|
||||
model: string,
|
||||
): string | undefined => aliasIndex.byKey.get(modelKey(provider, model))?.[0];
|
||||
const pickAliasForKey = (provider: string, model: string): string | undefined =>
|
||||
aliasIndex.byKey.get(modelKey(provider, model))?.[0];
|
||||
|
||||
const buildSelection = (
|
||||
provider: string,
|
||||
model: string,
|
||||
): ModelDirectiveSelection => {
|
||||
const buildSelection = (provider: string, model: string): ModelDirectiveSelection => {
|
||||
const alias = pickAliasForKey(provider, model);
|
||||
return {
|
||||
provider,
|
||||
@@ -320,13 +291,9 @@ export function resolveModelDirectiveSelection(params: {
|
||||
if (slash <= 0) continue;
|
||||
const provider = normalizeProviderId(key.slice(0, slash));
|
||||
const model = key.slice(slash + 1);
|
||||
if (params.provider && provider !== normalizeProviderId(params.provider))
|
||||
continue;
|
||||
if (params.provider && provider !== normalizeProviderId(params.provider)) continue;
|
||||
const haystack = `${provider}/${model}`.toLowerCase();
|
||||
if (
|
||||
haystack.includes(fragment) ||
|
||||
model.toLowerCase().includes(fragment)
|
||||
) {
|
||||
if (haystack.includes(fragment) || model.toLowerCase().includes(fragment)) {
|
||||
candidates.push({ provider, model });
|
||||
}
|
||||
}
|
||||
@@ -344,11 +311,7 @@ export function resolveModelDirectiveSelection(params: {
|
||||
for (const match of aliasMatches) {
|
||||
const key = modelKey(match.provider, match.model);
|
||||
if (!allowedModelKeys.has(key)) continue;
|
||||
if (
|
||||
!candidates.some(
|
||||
(c) => c.provider === match.provider && c.model === match.model,
|
||||
)
|
||||
) {
|
||||
if (!candidates.some((c) => c.provider === match.provider && c.model === match.model)) {
|
||||
candidates.push(match);
|
||||
}
|
||||
}
|
||||
@@ -378,10 +341,8 @@ export function resolveModelDirectiveSelection(params: {
|
||||
if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;
|
||||
if (a.variantMatchCount !== b.variantMatchCount)
|
||||
return b.variantMatchCount - a.variantMatchCount;
|
||||
if (a.variantCount !== b.variantCount)
|
||||
return a.variantCount - b.variantCount;
|
||||
if (a.modelLength !== b.modelLength)
|
||||
return a.modelLength - b.modelLength;
|
||||
if (a.variantCount !== b.variantCount) return a.variantCount - b.variantCount;
|
||||
if (a.modelLength !== b.modelLength) return a.modelLength - b.modelLength;
|
||||
return a.key.localeCompare(b.key);
|
||||
});
|
||||
|
||||
@@ -410,9 +371,7 @@ export function resolveModelDirectiveSelection(params: {
|
||||
selection: {
|
||||
provider: resolved.ref.provider,
|
||||
model: resolved.ref.model,
|
||||
isDefault:
|
||||
resolved.ref.provider === defaultProvider &&
|
||||
resolved.ref.model === defaultModel,
|
||||
isDefault: resolved.ref.provider === defaultProvider && resolved.ref.model === defaultModel,
|
||||
alias: resolved.alias,
|
||||
},
|
||||
};
|
||||
@@ -438,14 +397,10 @@ export function resolveModelDirectiveSelection(params: {
|
||||
}
|
||||
|
||||
export function resolveContextTokens(params: {
|
||||
agentCfg:
|
||||
| NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
|
||||
| undefined;
|
||||
agentCfg: NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]> | undefined;
|
||||
model: string;
|
||||
}): number {
|
||||
return (
|
||||
params.agentCfg?.contextTokens ??
|
||||
lookupContextTokens(params.model) ??
|
||||
DEFAULT_CONTEXT_TOKENS
|
||||
params.agentCfg?.contextTokens ?? lookupContextTokens(params.model) ?? DEFAULT_CONTEXT_TOKENS
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import {
|
||||
HEARTBEAT_TOKEN,
|
||||
isSilentReplyText,
|
||||
SILENT_REPLY_TOKEN,
|
||||
} from "../tokens.js";
|
||||
import { HEARTBEAT_TOKEN, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
export type NormalizeReplyOptions = {
|
||||
@@ -17,9 +13,7 @@ export function normalizeReplyPayload(
|
||||
payload: ReplyPayload,
|
||||
opts: NormalizeReplyOptions = {},
|
||||
): ReplyPayload | null {
|
||||
const hasMedia = Boolean(
|
||||
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
|
||||
);
|
||||
const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0);
|
||||
const trimmed = payload.text?.trim() ?? "";
|
||||
if (!trimmed && !hasMedia) return null;
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@ export async function dispatchReplyWithBufferedBlockDispatcher(params: {
|
||||
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
|
||||
replyResolver?: typeof import("../reply.js").getReplyFromConfig;
|
||||
}): Promise<DispatchFromConfigResult> {
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
createReplyDispatcherWithTyping(params.dispatcherOptions);
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping(
|
||||
params.dispatcherOptions,
|
||||
);
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: params.ctx,
|
||||
|
||||
@@ -91,9 +91,7 @@ describe("followup queue deduplication", () => {
|
||||
scheduleFollowupDrain(key, runFollowup);
|
||||
await expect.poll(() => calls.length).toBe(1);
|
||||
// Should collect both unique messages
|
||||
expect(calls[0]?.prompt).toContain(
|
||||
"[Queued messages while agent was busy]",
|
||||
);
|
||||
expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]");
|
||||
});
|
||||
|
||||
it("deduplicates exact prompt when routing matches and no message id", async () => {
|
||||
@@ -282,9 +280,7 @@ describe("followup queue collect routing", () => {
|
||||
|
||||
scheduleFollowupDrain(key, runFollowup);
|
||||
await expect.poll(() => calls.length).toBe(1);
|
||||
expect(calls[0]?.prompt).toContain(
|
||||
"[Queued messages while agent was busy]",
|
||||
);
|
||||
expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]");
|
||||
expect(calls[0]?.originatingChannel).toBe("slack");
|
||||
expect(calls[0]?.originatingTo).toBe("channel:A");
|
||||
});
|
||||
|
||||
@@ -3,10 +3,7 @@ import { isRoutableChannel } from "../route-reply.js";
|
||||
import { FOLLOWUP_QUEUES } from "./state.js";
|
||||
import type { FollowupRun } from "./types.js";
|
||||
|
||||
async function waitForQueueDebounce(queue: {
|
||||
debounceMs: number;
|
||||
lastEnqueuedAt: number;
|
||||
}) {
|
||||
async function waitForQueueDebounce(queue: { debounceMs: number; lastEnqueuedAt: number }) {
|
||||
const debounceMs = Math.max(0, queue.debounceMs);
|
||||
if (debounceMs <= 0) return;
|
||||
while (true) {
|
||||
@@ -71,12 +68,9 @@ function hasCrossChannelItems(items: FollowupRun[]): boolean {
|
||||
return true;
|
||||
}
|
||||
keys.add(
|
||||
[
|
||||
channel,
|
||||
to,
|
||||
accountId || "",
|
||||
typeof threadId === "number" ? String(threadId) : "",
|
||||
].join("|"),
|
||||
[channel, to, accountId || "", typeof threadId === "number" ? String(threadId) : ""].join(
|
||||
"|",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,12 +121,8 @@ export function scheduleFollowupDrain(
|
||||
if (!run) break;
|
||||
|
||||
// Preserve originating channel from items when collecting same-channel.
|
||||
const originatingChannel = items.find(
|
||||
(i) => i.originatingChannel,
|
||||
)?.originatingChannel;
|
||||
const originatingTo = items.find(
|
||||
(i) => i.originatingTo,
|
||||
)?.originatingTo;
|
||||
const originatingChannel = items.find((i) => i.originatingChannel)?.originatingChannel;
|
||||
const originatingTo = items.find((i) => i.originatingTo)?.originatingTo;
|
||||
const originatingAccountId = items.find(
|
||||
(i) => i.originatingAccountId,
|
||||
)?.originatingAccountId;
|
||||
@@ -170,9 +160,7 @@ export function scheduleFollowupDrain(
|
||||
await runFollowup(next);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error?.(
|
||||
`followup queue drain failed for ${key}: ${String(err)}`,
|
||||
);
|
||||
defaultRuntime.error?.(`followup queue drain failed for ${key}: ${String(err)}`);
|
||||
} finally {
|
||||
queue.draining = false;
|
||||
if (queue.items.length === 0 && queue.droppedCount === 0) {
|
||||
|
||||
@@ -25,14 +25,10 @@ function isRunAlreadyQueued(
|
||||
|
||||
const messageId = run.messageId?.trim();
|
||||
if (messageId) {
|
||||
return items.some(
|
||||
(item) => item.messageId?.trim() === messageId && hasSameRouting(item),
|
||||
);
|
||||
return items.some((item) => item.messageId?.trim() === messageId && hasSameRouting(item));
|
||||
}
|
||||
if (!allowPromptFallback) return false;
|
||||
return items.some(
|
||||
(item) => item.prompt === run.prompt && hasSameRouting(item),
|
||||
);
|
||||
return items.some((item) => item.prompt === run.prompt && hasSameRouting(item));
|
||||
}
|
||||
|
||||
export function enqueueFollowupRun(
|
||||
|
||||
@@ -4,32 +4,18 @@ export function normalizeQueueMode(raw?: string): QueueMode | undefined {
|
||||
if (!raw) return undefined;
|
||||
const cleaned = raw.trim().toLowerCase();
|
||||
if (cleaned === "queue" || cleaned === "queued") return "steer";
|
||||
if (
|
||||
cleaned === "interrupt" ||
|
||||
cleaned === "interrupts" ||
|
||||
cleaned === "abort"
|
||||
)
|
||||
if (cleaned === "interrupt" || cleaned === "interrupts" || cleaned === "abort")
|
||||
return "interrupt";
|
||||
if (cleaned === "steer" || cleaned === "steering") return "steer";
|
||||
if (
|
||||
cleaned === "followup" ||
|
||||
cleaned === "follow-ups" ||
|
||||
cleaned === "followups"
|
||||
)
|
||||
if (cleaned === "followup" || cleaned === "follow-ups" || cleaned === "followups")
|
||||
return "followup";
|
||||
if (cleaned === "collect" || cleaned === "coalesce") return "collect";
|
||||
if (
|
||||
cleaned === "steer+backlog" ||
|
||||
cleaned === "steer-backlog" ||
|
||||
cleaned === "steer_backlog"
|
||||
)
|
||||
if (cleaned === "steer+backlog" || cleaned === "steer-backlog" || cleaned === "steer_backlog")
|
||||
return "steer-backlog";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeQueueDropPolicy(
|
||||
raw?: string,
|
||||
): QueueDropPolicy | undefined {
|
||||
export function normalizeQueueDropPolicy(raw?: string): QueueDropPolicy | undefined {
|
||||
if (!raw) return undefined;
|
||||
const cleaned = raw.trim().toLowerCase();
|
||||
if (cleaned === "old" || cleaned === "oldest") return "old";
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import { normalizeQueueDropPolicy, normalizeQueueMode } from "./normalize.js";
|
||||
import {
|
||||
DEFAULT_QUEUE_CAP,
|
||||
DEFAULT_QUEUE_DEBOUNCE_MS,
|
||||
DEFAULT_QUEUE_DROP,
|
||||
} from "./state.js";
|
||||
import type {
|
||||
QueueMode,
|
||||
QueueSettings,
|
||||
ResolveQueueSettingsParams,
|
||||
} from "./types.js";
|
||||
import { DEFAULT_QUEUE_CAP, DEFAULT_QUEUE_DEBOUNCE_MS, DEFAULT_QUEUE_DROP } from "./state.js";
|
||||
import type { QueueMode, QueueSettings, ResolveQueueSettingsParams } from "./types.js";
|
||||
|
||||
function defaultQueueModeForChannel(_channel?: string): QueueMode {
|
||||
return "collect";
|
||||
}
|
||||
|
||||
export function resolveQueueSettings(
|
||||
params: ResolveQueueSettingsParams,
|
||||
): QueueSettings {
|
||||
export function resolveQueueSettings(params: ResolveQueueSettingsParams): QueueSettings {
|
||||
const channelKey = params.channel?.trim().toLowerCase();
|
||||
const queueCfg = params.cfg.messages?.queue;
|
||||
const providerModeRaw =
|
||||
@@ -46,10 +36,8 @@ export function resolveQueueSettings(
|
||||
DEFAULT_QUEUE_DROP;
|
||||
return {
|
||||
mode: resolvedMode,
|
||||
debounceMs:
|
||||
typeof debounceRaw === "number" ? Math.max(0, debounceRaw) : undefined,
|
||||
cap:
|
||||
typeof capRaw === "number" ? Math.max(1, Math.floor(capRaw)) : undefined,
|
||||
debounceMs: typeof debounceRaw === "number" ? Math.max(0, debounceRaw) : undefined,
|
||||
cap: typeof capRaw === "number" ? Math.max(1, Math.floor(capRaw)) : undefined,
|
||||
dropPolicy: dropRaw,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type {
|
||||
FollowupRun,
|
||||
QueueDropPolicy,
|
||||
QueueMode,
|
||||
QueueSettings,
|
||||
} from "./types.js";
|
||||
import type { FollowupRun, QueueDropPolicy, QueueMode, QueueSettings } from "./types.js";
|
||||
|
||||
export type FollowupQueueState = {
|
||||
items: FollowupRun[];
|
||||
@@ -24,10 +19,7 @@ export const DEFAULT_QUEUE_DROP: QueueDropPolicy = "summarize";
|
||||
|
||||
export const FOLLOWUP_QUEUES = new Map<string, FollowupQueueState>();
|
||||
|
||||
export function getFollowupQueue(
|
||||
key: string,
|
||||
settings: QueueSettings,
|
||||
): FollowupQueueState {
|
||||
export function getFollowupQueue(key: string, settings: QueueSettings): FollowupQueueState {
|
||||
const existing = FOLLOWUP_QUEUES.get(key);
|
||||
if (existing) {
|
||||
existing.mode = settings.mode;
|
||||
|
||||
@@ -2,20 +2,9 @@ import type { SkillSnapshot } from "../../../agents/skills.js";
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import type { SessionEntry } from "../../../config/sessions.js";
|
||||
import type { OriginatingChannelType } from "../../templating.js";
|
||||
import type {
|
||||
ElevatedLevel,
|
||||
ReasoningLevel,
|
||||
ThinkLevel,
|
||||
VerboseLevel,
|
||||
} from "../directives.js";
|
||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js";
|
||||
|
||||
export type QueueMode =
|
||||
| "steer"
|
||||
| "followup"
|
||||
| "collect"
|
||||
| "steer-backlog"
|
||||
| "interrupt"
|
||||
| "queue";
|
||||
export type QueueMode = "steer" | "followup" | "collect" | "steer-backlog" | "interrupt" | "queue";
|
||||
|
||||
export type QueueDropPolicy = "old" | "new" | "summarize";
|
||||
|
||||
|
||||
@@ -10,9 +10,7 @@ describe("createReplyDispatcher", () => {
|
||||
expect(dispatcher.sendFinalReply({})).toBe(false);
|
||||
expect(dispatcher.sendFinalReply({ text: " " })).toBe(false);
|
||||
expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false);
|
||||
expect(
|
||||
dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` }),
|
||||
).toBe(false);
|
||||
expect(dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` })).toBe(false);
|
||||
|
||||
await dispatcher.waitForIdle();
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
@@ -28,9 +26,7 @@ describe("createReplyDispatcher", () => {
|
||||
});
|
||||
|
||||
expect(dispatcher.sendFinalReply({ text: HEARTBEAT_TOKEN })).toBe(false);
|
||||
expect(
|
||||
dispatcher.sendToolResult({ text: `${HEARTBEAT_TOKEN} hello` }),
|
||||
).toBe(true);
|
||||
expect(dispatcher.sendToolResult({ text: `${HEARTBEAT_TOKEN} hello` })).toBe(true);
|
||||
await dispatcher.waitForIdle();
|
||||
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
@@ -91,9 +87,7 @@ describe("createReplyDispatcher", () => {
|
||||
});
|
||||
|
||||
it("fires onIdle when the queue drains", async () => {
|
||||
const deliver = vi.fn(
|
||||
async () => await new Promise((resolve) => setTimeout(resolve, 5)),
|
||||
);
|
||||
const deliver = vi.fn(async () => await new Promise((resolve) => setTimeout(resolve, 5)));
|
||||
const onIdle = vi.fn();
|
||||
const dispatcher = createReplyDispatcher({ deliver, onIdle });
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@ import type { TypingController } from "./typing.js";
|
||||
|
||||
export type ReplyDispatchKind = "tool" | "block" | "final";
|
||||
|
||||
type ReplyDispatchErrorHandler = (
|
||||
err: unknown,
|
||||
info: { kind: ReplyDispatchKind },
|
||||
) => void;
|
||||
type ReplyDispatchErrorHandler = (err: unknown, info: { kind: ReplyDispatchKind }) => void;
|
||||
|
||||
type ReplyDispatchDeliverer = (
|
||||
payload: ReplyPayload,
|
||||
@@ -23,13 +20,9 @@ function getHumanDelay(config: HumanDelayConfig | undefined): number {
|
||||
const mode = config?.mode ?? "off";
|
||||
if (mode === "off") return 0;
|
||||
const min =
|
||||
mode === "custom"
|
||||
? (config?.minMs ?? DEFAULT_HUMAN_DELAY_MIN_MS)
|
||||
: DEFAULT_HUMAN_DELAY_MIN_MS;
|
||||
mode === "custom" ? (config?.minMs ?? DEFAULT_HUMAN_DELAY_MIN_MS) : DEFAULT_HUMAN_DELAY_MIN_MS;
|
||||
const max =
|
||||
mode === "custom"
|
||||
? (config?.maxMs ?? DEFAULT_HUMAN_DELAY_MAX_MS)
|
||||
: DEFAULT_HUMAN_DELAY_MAX_MS;
|
||||
mode === "custom" ? (config?.maxMs ?? DEFAULT_HUMAN_DELAY_MAX_MS) : DEFAULT_HUMAN_DELAY_MAX_MS;
|
||||
if (max <= min) return min;
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
@@ -47,10 +40,7 @@ export type ReplyDispatcherOptions = {
|
||||
humanDelay?: HumanDelayConfig;
|
||||
};
|
||||
|
||||
export type ReplyDispatcherWithTypingOptions = Omit<
|
||||
ReplyDispatcherOptions,
|
||||
"onIdle"
|
||||
> & {
|
||||
export type ReplyDispatcherWithTypingOptions = Omit<ReplyDispatcherOptions, "onIdle"> & {
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
onIdle?: () => void;
|
||||
};
|
||||
@@ -79,9 +69,7 @@ function normalizeReplyPayloadInternal(
|
||||
});
|
||||
}
|
||||
|
||||
export function createReplyDispatcher(
|
||||
options: ReplyDispatcherOptions,
|
||||
): ReplyDispatcher {
|
||||
export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDispatcher {
|
||||
let sendChain: Promise<void> = Promise.resolve();
|
||||
// Track in-flight deliveries so we can emit a reliable "idle" signal.
|
||||
let pending = 0;
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
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 { 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";
|
||||
|
||||
@@ -64,9 +58,7 @@ function isApprovedElevatedSender(params: {
|
||||
);
|
||||
if (!rawAllow || rawAllow.length === 0) return false;
|
||||
|
||||
const allowTokens = rawAllow
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean);
|
||||
const allowTokens = rawAllow.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
if (allowTokens.length === 0) return false;
|
||||
if (allowTokens.some((entry) => entry === "*")) return true;
|
||||
|
||||
@@ -116,14 +108,12 @@ export function resolveElevatedPermissions(params: {
|
||||
failures: Array<{ gate: string; key: string }>;
|
||||
} {
|
||||
const globalConfig = params.cfg.tools?.elevated;
|
||||
const agentConfig = resolveAgentConfig(params.cfg, params.agentId)?.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 (!globalEnabled) failures.push({ gate: "enabled", key: "tools.elevated.enabled" });
|
||||
if (!agentEnabled)
|
||||
failures.push({
|
||||
gate: "enabled",
|
||||
@@ -184,11 +174,7 @@ export function formatElevatedUnavailableMessage(params: {
|
||||
`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(", ")}`,
|
||||
);
|
||||
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>).",
|
||||
|
||||
@@ -4,8 +4,7 @@ const INLINE_SIMPLE_COMMAND_ALIASES = new Map<string, string>([
|
||||
["/whoami", "/whoami"],
|
||||
["/id", "/whoami"],
|
||||
]);
|
||||
const INLINE_SIMPLE_COMMAND_RE =
|
||||
/(?:^|\s)\/(help|commands|whoami|id)(?=$|\s|:)/i;
|
||||
const INLINE_SIMPLE_COMMAND_RE = /(?:^|\s)\/(help|commands|whoami|id)(?=$|\s|:)/i;
|
||||
|
||||
const INLINE_STATUS_RE = /(?:^|\s)\/(?:status|usage)(?=$|\s|:)(?:\s*:\s*)?/gi;
|
||||
|
||||
@@ -29,9 +28,6 @@ export function stripInlineStatus(body: string): {
|
||||
} {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return { cleaned: "", didStrip: false };
|
||||
const cleaned = trimmed
|
||||
.replace(INLINE_STATUS_RE, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const cleaned = trimmed.replace(INLINE_STATUS_RE, " ").replace(/\s+/g, " ").trim();
|
||||
return { cleaned, didStrip: cleaned !== trimmed };
|
||||
}
|
||||
|
||||
@@ -42,9 +42,7 @@ export function applyReplyTagsToPayload(
|
||||
|
||||
export function isRenderablePayload(payload: ReplyPayload): boolean {
|
||||
return Boolean(
|
||||
payload.text ||
|
||||
payload.mediaUrl ||
|
||||
(payload.mediaUrls && payload.mediaUrls.length > 0),
|
||||
payload.text || payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,10 +53,7 @@ export function applyReplyThreading(params: {
|
||||
currentMessageId?: string;
|
||||
}): ReplyPayload[] {
|
||||
const { payloads, replyToMode, replyToChannel, currentMessageId } = params;
|
||||
const applyReplyToMode = createReplyToModeFilterForChannel(
|
||||
replyToMode,
|
||||
replyToChannel,
|
||||
);
|
||||
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
|
||||
return payloads
|
||||
.map((payload) => applyReplyTagsToPayload(payload, currentMessageId))
|
||||
.filter(isRenderablePayload)
|
||||
@@ -71,9 +66,7 @@ export function filterMessagingToolDuplicates(params: {
|
||||
}): ReplyPayload[] {
|
||||
const { payloads, sentTexts } = params;
|
||||
if (sentTexts.length === 0) return payloads;
|
||||
return payloads.filter(
|
||||
(payload) => !isMessagingToolDuplicate(payload.text ?? "", sentTexts),
|
||||
);
|
||||
return payloads.filter((payload) => !isMessagingToolDuplicate(payload.text ?? "", sentTexts));
|
||||
}
|
||||
|
||||
function normalizeAccountId(value?: string): string | undefined {
|
||||
@@ -89,10 +82,7 @@ export function shouldSuppressMessagingToolReplies(params: {
|
||||
}): boolean {
|
||||
const provider = params.messageProvider?.trim().toLowerCase();
|
||||
if (!provider) return false;
|
||||
const originTarget = normalizeTargetForProvider(
|
||||
provider,
|
||||
params.originatingTo,
|
||||
);
|
||||
const originTarget = normalizeTargetForProvider(provider, params.originatingTo);
|
||||
if (!originTarget) return false;
|
||||
const originAccount = normalizeAccountId(params.accountId);
|
||||
const sentTargets = params.messagingToolSentTargets ?? [];
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
createReplyToModeFilter,
|
||||
resolveReplyToMode,
|
||||
} from "./reply-threading.js";
|
||||
import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js";
|
||||
|
||||
const emptyCfg = {} as ClawdbotConfig;
|
||||
|
||||
@@ -44,9 +41,7 @@ describe("createReplyToModeFilter", () => {
|
||||
|
||||
it("keeps replyToId when mode is off and reply tags are allowed", () => {
|
||||
const filter = createReplyToModeFilter("off", { allowTagsWhenOff: true });
|
||||
expect(
|
||||
filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId,
|
||||
).toBe("1");
|
||||
expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1");
|
||||
});
|
||||
|
||||
it("keeps replyToId when mode is all", () => {
|
||||
|
||||
@@ -131,11 +131,7 @@ describe("routeReply", () => {
|
||||
sessionKey: "agent:rich:main",
|
||||
cfg,
|
||||
});
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"hi",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith("channel:C123", "hi", expect.any(Object));
|
||||
});
|
||||
|
||||
it("passes thread id to Telegram sends", async () => {
|
||||
|
||||
@@ -52,11 +52,8 @@ export type RouteReplyResult = {
|
||||
* back to the originating channel when OriginatingChannel/OriginatingTo
|
||||
* are set.
|
||||
*/
|
||||
export async function routeReply(
|
||||
params: RouteReplyParams,
|
||||
): Promise<RouteReplyResult> {
|
||||
const { payload, channel, to, accountId, threadId, cfg, abortSignal } =
|
||||
params;
|
||||
export async function routeReply(params: RouteReplyParams): Promise<RouteReplyResult> {
|
||||
const { payload, channel, to, accountId, threadId, cfg, abortSignal } = params;
|
||||
|
||||
// Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts`
|
||||
const responsePrefix = params.sessionKey
|
||||
@@ -106,9 +103,7 @@ export async function routeReply(
|
||||
try {
|
||||
// Provider docking: this is an execution boundary (we're about to send).
|
||||
// Keep the module cheap to import by loading outbound plumbing lazily.
|
||||
const { deliverOutboundPayloads } = await import(
|
||||
"../../infra/outbound/deliver.js"
|
||||
);
|
||||
const { deliverOutboundPayloads } = await import("../../infra/outbound/deliver.js");
|
||||
const results = await deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel: channelId,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
enqueueSystemEvent,
|
||||
resetSystemEventsForTest,
|
||||
} from "../../infra/system-events.js";
|
||||
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
|
||||
import { prependSystemEvents } from "./session-updates.js";
|
||||
|
||||
describe("prependSystemEvents", () => {
|
||||
|
||||
@@ -156,13 +156,7 @@ export async function incrementCompactionCount(params: {
|
||||
storePath?: string;
|
||||
now?: number;
|
||||
}): Promise<number | undefined> {
|
||||
const {
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
now = Date.now(),
|
||||
} = params;
|
||||
const { sessionEntry, sessionStore, sessionKey, storePath, now = Date.now() } = params;
|
||||
if (!sessionStore || !sessionKey) return undefined;
|
||||
const entry = sessionStore[sessionKey] ?? sessionEntry;
|
||||
if (!entry) return undefined;
|
||||
|
||||
@@ -10,9 +10,7 @@ import { initSessionState } from "./session.js";
|
||||
|
||||
describe("initSessionState thread forking", () => {
|
||||
it("forks a new session from the parent session file", async () => {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-thread-session-"),
|
||||
);
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-thread-session-"));
|
||||
const sessionsDir = path.join(root, "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
@@ -84,9 +82,7 @@ describe("initSessionState thread forking", () => {
|
||||
});
|
||||
|
||||
it("records topic-specific session files when MessageThreadId is present", async () => {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-topic-session-"),
|
||||
);
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-topic-session-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
|
||||
const cfg = {
|
||||
@@ -134,9 +130,7 @@ describe("initSessionState RawBody", () => {
|
||||
});
|
||||
|
||||
it("Reset triggers (/new, /reset) work with RawBody", async () => {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-rawbody-reset-"),
|
||||
);
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-rawbody-reset-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
@@ -158,9 +152,7 @@ describe("initSessionState RawBody", () => {
|
||||
});
|
||||
|
||||
it("falls back to Body when RawBody is undefined", async () => {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-rawbody-fallback-"),
|
||||
);
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-rawbody-fallback-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
|
||||
@@ -2,10 +2,7 @@ import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
CURRENT_SESSION_VERSION,
|
||||
SessionManager,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import { normalizeChannelId } from "../../channels/registry.js";
|
||||
@@ -59,18 +56,14 @@ function forkSessionFromParent(params: {
|
||||
const manager = SessionManager.open(parentSessionFile);
|
||||
const leafId = manager.getLeafId();
|
||||
if (leafId) {
|
||||
const sessionFile =
|
||||
manager.createBranchedSession(leafId) ?? manager.getSessionFile();
|
||||
const sessionFile = manager.createBranchedSession(leafId) ?? manager.getSessionFile();
|
||||
const sessionId = manager.getSessionId();
|
||||
if (sessionFile && sessionId) return { sessionId, sessionFile };
|
||||
}
|
||||
const sessionId = crypto.randomUUID();
|
||||
const timestamp = new Date().toISOString();
|
||||
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
||||
const sessionFile = path.join(
|
||||
manager.getSessionDir(),
|
||||
`${fileTimestamp}_${sessionId}.jsonl`,
|
||||
);
|
||||
const sessionFile = path.join(manager.getSessionDir(), `${fileTimestamp}_${sessionId}.jsonl`);
|
||||
const header = {
|
||||
type: "session",
|
||||
version: CURRENT_SESSION_VERSION,
|
||||
@@ -95,9 +88,7 @@ export async function initSessionState(params: {
|
||||
// Native slash commands (Telegram/Discord/Slack) are delivered on a separate
|
||||
// "slash session" key, but should mutate the target chat session.
|
||||
const targetSessionKey =
|
||||
ctx.CommandSource === "native"
|
||||
? ctx.CommandTargetSessionKey?.trim()
|
||||
: undefined;
|
||||
ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined;
|
||||
const sessionCtxForState =
|
||||
targetSessionKey && targetSessionKey !== ctx.SessionKey
|
||||
? { ...ctx, SessionKey: targetSessionKey }
|
||||
@@ -111,15 +102,11 @@ export async function initSessionState(params: {
|
||||
const resetTriggers = sessionCfg?.resetTriggers?.length
|
||||
? sessionCfg.resetTriggers
|
||||
: DEFAULT_RESET_TRIGGERS;
|
||||
const idleMinutes = Math.max(
|
||||
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
||||
1,
|
||||
);
|
||||
const idleMinutes = Math.max(sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1);
|
||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
|
||||
|
||||
const sessionStore: Record<string, SessionEntry> =
|
||||
loadSessionStore(storePath);
|
||||
const sessionStore: Record<string, SessionEntry> = loadSessionStore(storePath);
|
||||
let sessionKey: string | undefined;
|
||||
let sessionEntry: SessionEntry;
|
||||
|
||||
@@ -135,16 +122,12 @@ export async function initSessionState(params: {
|
||||
let persistedModelOverride: string | undefined;
|
||||
let persistedProviderOverride: string | undefined;
|
||||
|
||||
const groupResolution =
|
||||
resolveGroupSessionKey(sessionCtxForState) ?? undefined;
|
||||
const isGroup =
|
||||
ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution);
|
||||
const groupResolution = resolveGroupSessionKey(sessionCtxForState) ?? undefined;
|
||||
const isGroup = ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution);
|
||||
// Prefer CommandBody/RawBody (clean message) for command detection; fall back
|
||||
// to Body which may contain structural context (history, sender labels).
|
||||
const commandSource = ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "";
|
||||
const triggerBodyNormalized = stripStructuralPrefixes(commandSource)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const triggerBodyNormalized = stripStructuralPrefixes(commandSource).trim().toLowerCase();
|
||||
|
||||
// Use CommandBody/RawBody for reset trigger matching (clean message without structural context).
|
||||
const rawBody = commandSource;
|
||||
@@ -169,10 +152,7 @@ export async function initSessionState(params: {
|
||||
break;
|
||||
}
|
||||
const triggerPrefix = `${trigger} `;
|
||||
if (
|
||||
trimmedBody.startsWith(triggerPrefix) ||
|
||||
strippedForReset.startsWith(triggerPrefix)
|
||||
) {
|
||||
if (trimmedBody.startsWith(triggerPrefix) || strippedForReset.startsWith(triggerPrefix)) {
|
||||
isNewSession = true;
|
||||
bodyStripped = strippedForReset.slice(trigger.length).trimStart();
|
||||
break;
|
||||
@@ -241,15 +221,10 @@ export async function initSessionState(params: {
|
||||
const normalizedChannel = normalizeChannelId(channel);
|
||||
const isRoomProvider = Boolean(
|
||||
normalizedChannel &&
|
||||
getChannelDock(normalizedChannel)?.capabilities.chatTypes.includes(
|
||||
"channel",
|
||||
),
|
||||
getChannelDock(normalizedChannel)?.capabilities.chatTypes.includes("channel"),
|
||||
);
|
||||
const nextRoom =
|
||||
explicitRoom ??
|
||||
(isRoomProvider && subject && subject.startsWith("#")
|
||||
? subject
|
||||
: undefined);
|
||||
explicitRoom ?? (isRoomProvider && subject && subject.startsWith("#") ? subject : undefined);
|
||||
const nextSubject = nextRoom ? undefined : subject;
|
||||
sessionEntry.chatType = groupResolution.chatType ?? "group";
|
||||
sessionEntry.channel = channel;
|
||||
|
||||
@@ -14,11 +14,8 @@ export async function stageSandboxMedia(params: {
|
||||
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 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
|
||||
@@ -86,9 +83,7 @@ export async function stageSandboxMedia(params: {
|
||||
return mapped ?? value;
|
||||
};
|
||||
|
||||
const nextMediaPaths = hasPathsArray
|
||||
? rawPaths.map((p) => rewriteIfStaged(p) ?? p)
|
||||
: undefined;
|
||||
const nextMediaPaths = hasPathsArray ? rawPaths.map((p) => rewriteIfStaged(p) ?? p) : undefined;
|
||||
if (nextMediaPaths) {
|
||||
ctx.MediaPaths = nextMediaPaths;
|
||||
sessionCtx.MediaPaths = nextMediaPaths;
|
||||
|
||||
@@ -70,9 +70,7 @@ export function createTypingController(params: {
|
||||
}
|
||||
typingTtlTimer = setTimeout(() => {
|
||||
if (!typingTimer) return;
|
||||
log?.(
|
||||
`typing TTL reached (${formatTypingTtl(typingTtlMs)}); stopping typing indicator`,
|
||||
);
|
||||
log?.(`typing TTL reached (${formatTypingTtl(typingTtlMs)}); stopping typing indicator`);
|
||||
cleanup();
|
||||
}, typingTtlMs);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user