diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index f85446ecec9..951ebbfbfc0 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -43,6 +43,7 @@ import { buildGroupIntro } from "./groups.js"; import { buildInboundMetaSystemPrompt, buildInboundUserContextPrefix } from "./inbound-meta.js"; import { resolveQueueSettings } from "./queue.js"; import { routeReply } from "./route-reply.js"; +import { BARE_SESSION_RESET_PROMPT } from "./session-reset-prompt.js"; import { ensureSkillSnapshot, prependSystemEvents } from "./session-updates.js"; import { resolveTypingMode } from "./typing-mode.js"; import { appendUntrustedContext } from "./untrusted-context.js"; @@ -50,9 +51,6 @@ import { appendUntrustedContext } from "./untrusted-context.js"; type AgentDefaults = NonNullable["defaults"]; type ExecOverrides = Pick; -const BARE_SESSION_RESET_PROMPT = - "A new session was started via /new or /reset. Greet the user in your configured persona, if one is provided. Be yourself - use your defined voice, mannerisms, and mood. Keep it to 1-3 sentences and ask what they want to do. If the runtime model differs from default_model in the system prompt, mention the default model. Do not mention internal steps, files, tools, or reasoning."; - type RunPreparedReplyParams = { ctx: MsgContext; sessionCtx: TemplateContext; diff --git a/src/auto-reply/reply/session-reset-prompt.ts b/src/auto-reply/reply/session-reset-prompt.ts new file mode 100644 index 00000000000..0c16f15c11e --- /dev/null +++ b/src/auto-reply/reply/session-reset-prompt.ts @@ -0,0 +1,2 @@ +export const BARE_SESSION_RESET_PROMPT = + "A new session was started via /new or /reset. Greet the user in your configured persona, if one is provided. Be yourself - use your defined voice, mannerisms, and mood. Keep it to 1-3 sentences and ask what they want to do. If the runtime model differs from default_model in the system prompt, mention the default model. Do not mention internal steps, files, tools, or reasoning."; diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 5ae0df12e44..be7ad137069 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; -import type { GatewayRequestHandlers } from "./types.js"; +import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js"; import { listAgentIds } from "../../agents/agent-scope.js"; +import { BARE_SESSION_RESET_PROMPT } from "../../auto-reply/reply/session-reset-prompt.js"; import { agentCommand } from "../../commands/agent.js"; import { loadConfig } from "../../config/config.js"; import { @@ -47,9 +48,106 @@ import { import { formatForLog } from "../ws-log.js"; import { waitForAgentJob } from "./agent-job.js"; import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js"; +import { sessionsHandlers } from "./sessions.js"; + +const RESET_COMMAND_RE = /^\/(new|reset)(?:\s+([\s\S]*))?$/i; + +function isGatewayErrorShape(value: unknown): value is { code: string; message: string } { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as { code?: unknown; message?: unknown }; + return typeof candidate.code === "string" && typeof candidate.message === "string"; +} + +async function runSessionResetFromAgent(params: { + key: string; + reason: "new" | "reset"; + idempotencyKey: string; + context: GatewayRequestHandlerOptions["context"]; + client: GatewayRequestHandlerOptions["client"]; + isWebchatConnect: GatewayRequestHandlerOptions["isWebchatConnect"]; +}): Promise< + | { ok: true; key: string; sessionId?: string } + | { ok: false; error: ReturnType } +> { + return await new Promise((resolve) => { + let settled = false; + const settle = ( + result: + | { ok: true; key: string; sessionId?: string } + | { ok: false; error: ReturnType }, + ) => { + if (settled) { + return; + } + settled = true; + resolve(result); + }; + + const respond: GatewayRequestHandlerOptions["respond"] = (ok, payload, error) => { + if (!ok) { + settle({ + ok: false, + error: isGatewayErrorShape(error) + ? error + : errorShape(ErrorCodes.UNAVAILABLE, String(error ?? "sessions.reset failed")), + }); + return; + } + const payloadObj = payload as + | { + key?: unknown; + entry?: { + sessionId?: unknown; + }; + } + | undefined; + const key = typeof payloadObj?.key === "string" ? payloadObj.key : params.key; + const sessionId = + payloadObj?.entry && typeof payloadObj.entry.sessionId === "string" + ? payloadObj.entry.sessionId + : undefined; + settle({ ok: true, key, sessionId }); + }; + + void sessionsHandlers["sessions.reset"]({ + req: { + type: "req", + id: `${params.idempotencyKey}:reset`, + method: "sessions.reset", + }, + params: { + key: params.key, + reason: params.reason, + }, + context: params.context, + client: params.client, + isWebchatConnect: params.isWebchatConnect, + respond, + }) + .then(() => { + if (!settled) { + settle({ + ok: false, + error: errorShape( + ErrorCodes.UNAVAILABLE, + "sessions.reset completed without returning a response", + ), + }); + } + }) + .catch((err) => { + settle({ + ok: false, + error: errorShape(ErrorCodes.UNAVAILABLE, String(err)), + }); + }); + }); +} export const agentHandlers: GatewayRequestHandlers = { - agent: async ({ params, respond, context, client }) => { + agent: async ({ params, respond, context, client, isWebchatConnect }) => { const p = params; if (!validateAgentParams(p)) { respond( @@ -147,12 +245,6 @@ export const agentHandlers: GatewayRequestHandlers = { } } - // Inject timestamp into messages that don't already have one. - // Channel messages (Discord, Telegram, etc.) get timestamps via envelope - // formatting in a separate code path — they never reach this handler. - // See: https://github.com/moltbot/moltbot/issues/3658 - message = injectTimestamp(message, timestampOptsFromConfig(cfg)); - const isKnownGatewayChannel = (value: string): boolean => isGatewayMessageChannel(value); const channelHints = [request.channel, request.replyChannel] .filter((value): value is string => typeof value === "string") @@ -194,7 +286,7 @@ export const agentHandlers: GatewayRequestHandlers = { typeof request.sessionKey === "string" && request.sessionKey.trim() ? request.sessionKey.trim() : undefined; - const requestedSessionKey = + let requestedSessionKey = requestedSessionKeyRaw ?? resolveExplicitAgentSessionKey({ cfg, @@ -219,6 +311,43 @@ export const agentHandlers: GatewayRequestHandlers = { let bestEffortDeliver = false; let cfgForAgent: ReturnType | undefined; let resolvedSessionKey = requestedSessionKey; + let skipTimestampInjection = false; + + const resetCommandMatch = message.match(RESET_COMMAND_RE); + if (resetCommandMatch && requestedSessionKey) { + const resetReason = resetCommandMatch[1]?.toLowerCase() === "new" ? "new" : "reset"; + const resetResult = await runSessionResetFromAgent({ + key: requestedSessionKey, + reason: resetReason, + idempotencyKey: idem, + context, + client, + isWebchatConnect, + }); + if (!resetResult.ok) { + respond(false, undefined, resetResult.error); + return; + } + requestedSessionKey = resetResult.key; + resolvedSessionId = resetResult.sessionId ?? resolvedSessionId; + const postResetMessage = resetCommandMatch[2]?.trim() ?? ""; + if (postResetMessage) { + message = postResetMessage; + } else { + // Keep bare /new and /reset behavior aligned with chat.send: + // reset first, then run a fresh-session greeting prompt in-place. + message = BARE_SESSION_RESET_PROMPT; + skipTimestampInjection = true; + } + } + + // Inject timestamp into user-authored messages that don't already have one. + // Channel messages (Discord, Telegram, etc.) get timestamps via envelope + // formatting in a separate code path — they never reach this handler. + // See: https://github.com/moltbot/moltbot/issues/3658 + if (!skipTimestampInjection) { + message = injectTimestamp(message, timestampOptsFromConfig(cfg)); + } if (requestedSessionKey) { const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(requestedSessionKey);