feat: inject native command outputs into agent context
Capture outputs from native slash commands (/models, /status, /model, etc.) and inject them into the agent's context so it can understand what the user saw when following up with questions. Features: - New commands.injectToContext config option (default: true) - Stores last 5 command outputs in session entry - Formats with relative timestamps (e.g. '13s ago') - Auto-clears after injection to prevent repetition - Truncates long outputs to 200 chars Files: - sessions/types.ts: Add RecentCommandEntry type - commands-core.ts: Capture in handleCommands loop - get-reply-directives.ts: Capture for directive responses - get-reply-run.ts: Format and inject into extraSystemPrompt - schema.ts, types.messages.ts, zod-schema.session.ts: Config
This commit is contained in:
parent
4583f88626
commit
f38e64b58e
@ -27,12 +27,58 @@ import {
|
|||||||
handleUsageCommand,
|
handleUsageCommand,
|
||||||
} from "./commands-session.js";
|
} from "./commands-session.js";
|
||||||
import { handlePluginCommand } from "./commands-plugin.js";
|
import { handlePluginCommand } from "./commands-plugin.js";
|
||||||
|
import { updateSessionStoreEntry } from "../../config/sessions/store.js";
|
||||||
|
import type { RecentCommandEntry } from "../../config/sessions/types.js";
|
||||||
|
import type { ReplyPayload } from "../types.js";
|
||||||
import type {
|
import type {
|
||||||
CommandHandler,
|
CommandHandler,
|
||||||
CommandHandlerResult,
|
CommandHandlerResult,
|
||||||
HandleCommandsParams,
|
HandleCommandsParams,
|
||||||
} from "./commands-types.js";
|
} from "./commands-types.js";
|
||||||
|
|
||||||
|
const MAX_RECENT_COMMANDS = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarize a command reply for agent context injection.
|
||||||
|
* Truncates to first 200 chars or first 3 lines.
|
||||||
|
*/
|
||||||
|
function summarizeCommandReply(reply: ReplyPayload): string {
|
||||||
|
const text = reply.text || "";
|
||||||
|
const lines = text.split("\n").slice(0, 3);
|
||||||
|
const summary = lines.join(" | ").slice(0, 200);
|
||||||
|
return summary + (text.length > 200 ? "..." : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a recent command output in the session entry for agent visibility.
|
||||||
|
*/
|
||||||
|
async function storeRecentCommand(params: {
|
||||||
|
storePath?: string;
|
||||||
|
sessionKey: string;
|
||||||
|
cmd: string;
|
||||||
|
summary: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!params.storePath) return;
|
||||||
|
|
||||||
|
const entry: RecentCommandEntry = {
|
||||||
|
cmd: params.cmd,
|
||||||
|
summary: params.summary,
|
||||||
|
ts: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateSessionStoreEntry({
|
||||||
|
storePath: params.storePath,
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
update: async (existing) => {
|
||||||
|
const current = existing.recentCommands ?? [];
|
||||||
|
const updated = [...current, entry].slice(-MAX_RECENT_COMMANDS);
|
||||||
|
return { recentCommands: updated };
|
||||||
|
},
|
||||||
|
}).catch((err) => {
|
||||||
|
logVerbose(`Failed to store recent command: ${err}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const HANDLERS: CommandHandler[] = [
|
const HANDLERS: CommandHandler[] = [
|
||||||
// Plugin commands are processed first, before built-in commands
|
// Plugin commands are processed first, before built-in commands
|
||||||
handlePluginCommand,
|
handlePluginCommand,
|
||||||
@ -110,7 +156,22 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
|
|||||||
|
|
||||||
for (const handler of HANDLERS) {
|
for (const handler of HANDLERS) {
|
||||||
const result = await handler(params, allowTextCommands);
|
const result = await handler(params, allowTextCommands);
|
||||||
if (result) return result;
|
if (result) {
|
||||||
|
// Store command context for agent visibility (if configured)
|
||||||
|
const injectToContext = params.cfg.commands?.injectToContext !== false;
|
||||||
|
if (result.reply && injectToContext && params.storePath) {
|
||||||
|
const cmdMatch = params.command.commandBodyNormalized.match(/^\/(\w+)/);
|
||||||
|
if (cmdMatch) {
|
||||||
|
await storeRecentCommand({
|
||||||
|
storePath: params.storePath,
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
cmd: cmdMatch[0],
|
||||||
|
summary: summarizeCommandReply(result.reply),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendPolicy = resolveSendPolicy({
|
const sendPolicy = resolveSendPolicy({
|
||||||
|
|||||||
@ -4,7 +4,10 @@ import type { SkillCommandSpec } from "../../agents/skills.js";
|
|||||||
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
|
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
|
||||||
import type { MoltbotConfig } from "../../config/config.js";
|
import type { MoltbotConfig } from "../../config/config.js";
|
||||||
import type { SessionEntry } from "../../config/sessions.js";
|
import type { SessionEntry } from "../../config/sessions.js";
|
||||||
|
import { updateSessionStoreEntry } from "../../config/sessions/store.js";
|
||||||
|
import type { RecentCommandEntry } from "../../config/sessions/types.js";
|
||||||
import { listChatCommands, shouldHandleTextCommands } from "../commands-registry.js";
|
import { listChatCommands, shouldHandleTextCommands } from "../commands-registry.js";
|
||||||
|
import { logVerbose } from "../../globals.js";
|
||||||
import { listSkillCommandsForWorkspace } from "../skill-commands.js";
|
import { listSkillCommandsForWorkspace } from "../skill-commands.js";
|
||||||
import type { MsgContext, TemplateContext } from "../templating.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";
|
||||||
@ -81,6 +84,51 @@ export type ReplyDirectiveResult =
|
|||||||
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
|
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
|
||||||
| { kind: "continue"; result: ReplyDirectiveContinuation };
|
| { kind: "continue"; result: ReplyDirectiveContinuation };
|
||||||
|
|
||||||
|
const MAX_RECENT_COMMANDS = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarize a command reply for agent context injection.
|
||||||
|
*/
|
||||||
|
function summarizeReplyForContext(reply: ReplyPayload | ReplyPayload[] | undefined): string {
|
||||||
|
if (!reply) return "(no output)";
|
||||||
|
const text = Array.isArray(reply) ? reply.map((r) => r.text).join(" | ") : reply.text || "";
|
||||||
|
const lines = text.split("\n").slice(0, 3);
|
||||||
|
const summary = lines.join(" | ").slice(0, 200);
|
||||||
|
return summary + (text.length > 200 ? "..." : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a recent command output in the session entry for agent visibility.
|
||||||
|
*/
|
||||||
|
async function captureCommandForContext(params: {
|
||||||
|
cfg: MoltbotConfig;
|
||||||
|
storePath?: string;
|
||||||
|
sessionKey: string;
|
||||||
|
cmd: string;
|
||||||
|
reply: ReplyPayload | ReplyPayload[] | undefined;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!params.storePath) return;
|
||||||
|
if (params.cfg.commands?.injectToContext === false) return;
|
||||||
|
|
||||||
|
const entry: RecentCommandEntry = {
|
||||||
|
cmd: params.cmd,
|
||||||
|
summary: summarizeReplyForContext(params.reply),
|
||||||
|
ts: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateSessionStoreEntry({
|
||||||
|
storePath: params.storePath,
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
update: async (existing) => {
|
||||||
|
const current = existing.recentCommands ?? [];
|
||||||
|
const updated = [...current, entry].slice(-MAX_RECENT_COMMANDS);
|
||||||
|
return { recentCommands: updated };
|
||||||
|
},
|
||||||
|
}).catch((err) => {
|
||||||
|
logVerbose(`Failed to capture command for context: ${err}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveReplyDirectives(params: {
|
export async function resolveReplyDirectives(params: {
|
||||||
ctx: MsgContext;
|
ctx: MsgContext;
|
||||||
cfg: MoltbotConfig;
|
cfg: MoltbotConfig;
|
||||||
@ -439,6 +487,18 @@ export async function resolveReplyDirectives(params: {
|
|||||||
typing,
|
typing,
|
||||||
});
|
});
|
||||||
if (applyResult.kind === "reply") {
|
if (applyResult.kind === "reply") {
|
||||||
|
// Capture the command output for agent context (e.g., /model, /models responses)
|
||||||
|
// Must await to ensure capture completes before the reply is sent
|
||||||
|
const cmdMatch = command.commandBodyNormalized.match(/^\/(\w+)/);
|
||||||
|
if (cmdMatch && applyResult.reply) {
|
||||||
|
await captureCommandForContext({
|
||||||
|
cfg,
|
||||||
|
storePath,
|
||||||
|
sessionKey,
|
||||||
|
cmd: cmdMatch[0],
|
||||||
|
reply: applyResult.reply,
|
||||||
|
});
|
||||||
|
}
|
||||||
return { kind: "reply", reply: applyResult.reply };
|
return { kind: "reply", reply: applyResult.reply };
|
||||||
}
|
}
|
||||||
directives = applyResult.directives;
|
directives = applyResult.directives;
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
updateSessionStore,
|
updateSessionStore,
|
||||||
} from "../../config/sessions.js";
|
} from "../../config/sessions.js";
|
||||||
|
import type { RecentCommandEntry } from "../../config/sessions/types.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
import { clearCommandLane, getQueueSize } from "../../process/command-queue.js";
|
import { clearCommandLane, getQueueSize } from "../../process/command-queue.js";
|
||||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||||
@ -50,6 +51,23 @@ type ExecOverrides = Pick<ExecToolDefaults, "host" | "security" | "ask" | "node"
|
|||||||
const BARE_SESSION_RESET_PROMPT =
|
const BARE_SESSION_RESET_PROMPT =
|
||||||
"A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. If the runtime model differs from default_model in the system prompt, mention the default model in the greeting. Do not mention internal steps, files, tools, or reasoning.";
|
"A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. If the runtime model differs from default_model in the system prompt, mention the default model in the greeting. Do not mention internal steps, files, tools, or reasoning.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format recent commands for agent context injection.
|
||||||
|
* Returns undefined if no recent commands exist.
|
||||||
|
*/
|
||||||
|
function formatRecentCommands(commands?: RecentCommandEntry[]): string | undefined {
|
||||||
|
if (!commands || commands.length === 0) return undefined;
|
||||||
|
|
||||||
|
const lines = ["## Recent Commands (user ran these before this message)"];
|
||||||
|
for (const entry of commands) {
|
||||||
|
const agoMs = Date.now() - entry.ts;
|
||||||
|
const agoStr =
|
||||||
|
agoMs < 60_000 ? `${Math.round(agoMs / 1000)}s ago` : `${Math.round(agoMs / 60_000)}m ago`;
|
||||||
|
lines.push(`- \`${entry.cmd}\` (${agoStr}): ${entry.summary}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
type RunPreparedReplyParams = {
|
type RunPreparedReplyParams = {
|
||||||
ctx: MsgContext;
|
ctx: MsgContext;
|
||||||
sessionCtx: TemplateContext;
|
sessionCtx: TemplateContext;
|
||||||
@ -179,7 +197,24 @@ export async function runPreparedReply(
|
|||||||
})
|
})
|
||||||
: "";
|
: "";
|
||||||
const groupSystemPrompt = sessionCtx.GroupSystemPrompt?.trim() ?? "";
|
const groupSystemPrompt = sessionCtx.GroupSystemPrompt?.trim() ?? "";
|
||||||
const extraSystemPrompt = [groupIntro, groupSystemPrompt].filter(Boolean).join("\n\n");
|
// Inject recent native command outputs into agent context (if configured)
|
||||||
|
const recentCommandsContext =
|
||||||
|
cfg.commands?.injectToContext !== false
|
||||||
|
? formatRecentCommands(sessionEntry?.recentCommands)
|
||||||
|
: undefined;
|
||||||
|
// Clear recent commands after extracting them (so they're not repeated)
|
||||||
|
if (recentCommandsContext && sessionEntry && storePath && sessionKey) {
|
||||||
|
sessionEntry.recentCommands = [];
|
||||||
|
updateSessionStore(storePath, (store) => {
|
||||||
|
if (store[sessionKey]) {
|
||||||
|
store[sessionKey] = { ...store[sessionKey], recentCommands: [] };
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
const extraSystemPrompt = [groupIntro, groupSystemPrompt, recentCommandsContext]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||||
// Use CommandBody/RawBody for bare reset detection (clean message without structural context).
|
// 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();
|
||||||
|
|||||||
@ -277,6 +277,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"commands.debug": "Allow /debug",
|
"commands.debug": "Allow /debug",
|
||||||
"commands.restart": "Allow Restart",
|
"commands.restart": "Allow Restart",
|
||||||
"commands.useAccessGroups": "Use Access Groups",
|
"commands.useAccessGroups": "Use Access Groups",
|
||||||
|
"commands.injectToContext": "Inject Commands to Context",
|
||||||
"ui.seamColor": "Accent Color",
|
"ui.seamColor": "Accent Color",
|
||||||
"ui.assistant.name": "Assistant Name",
|
"ui.assistant.name": "Assistant Name",
|
||||||
"ui.assistant.avatar": "Assistant Avatar",
|
"ui.assistant.avatar": "Assistant Avatar",
|
||||||
@ -593,6 +594,8 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).",
|
"commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).",
|
||||||
"commands.restart": "Allow /restart and gateway restart tool actions (default: false).",
|
"commands.restart": "Allow /restart and gateway restart tool actions (default: false).",
|
||||||
"commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.",
|
"commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.",
|
||||||
|
"commands.injectToContext":
|
||||||
|
"Inject native command outputs (like /models, /model) into agent context so the agent sees what commands the user ran (default: true).",
|
||||||
"session.dmScope":
|
"session.dmScope":
|
||||||
'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).',
|
'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).',
|
||||||
"session.identityLinks":
|
"session.identityLinks":
|
||||||
|
|||||||
@ -23,6 +23,18 @@ export type SessionOrigin = {
|
|||||||
threadId?: string | number;
|
threadId?: string | number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry for recent native command outputs (for agent context injection).
|
||||||
|
*/
|
||||||
|
export type RecentCommandEntry = {
|
||||||
|
/** The command that was executed (e.g., "/models") */
|
||||||
|
cmd: string;
|
||||||
|
/** Summarized output of the command */
|
||||||
|
summary: string;
|
||||||
|
/** Timestamp (ms) when the command was executed */
|
||||||
|
ts: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type SessionEntry = {
|
export type SessionEntry = {
|
||||||
/**
|
/**
|
||||||
* Last delivered heartbeat payload (used to suppress duplicate heartbeat notifications).
|
* Last delivered heartbeat payload (used to suppress duplicate heartbeat notifications).
|
||||||
@ -94,6 +106,11 @@ export type SessionEntry = {
|
|||||||
lastThreadId?: string | number;
|
lastThreadId?: string | number;
|
||||||
skillsSnapshot?: SessionSkillSnapshot;
|
skillsSnapshot?: SessionSkillSnapshot;
|
||||||
systemPromptReport?: SessionSystemPromptReport;
|
systemPromptReport?: SessionSystemPromptReport;
|
||||||
|
/**
|
||||||
|
* Recent native command outputs for agent context injection.
|
||||||
|
* Cleared after being injected into the agent's context.
|
||||||
|
*/
|
||||||
|
recentCommands?: RecentCommandEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function mergeSessionEntry(
|
export function mergeSessionEntry(
|
||||||
|
|||||||
@ -107,6 +107,8 @@ export type CommandsConfig = {
|
|||||||
restart?: boolean;
|
restart?: boolean;
|
||||||
/** Enforce access-group allowlists/policies for commands (default: true). */
|
/** Enforce access-group allowlists/policies for commands (default: true). */
|
||||||
useAccessGroups?: boolean;
|
useAccessGroups?: boolean;
|
||||||
|
/** Inject native command outputs into agent context (default: true). */
|
||||||
|
injectToContext?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProviderCommandsConfig = {
|
export type ProviderCommandsConfig = {
|
||||||
|
|||||||
@ -112,7 +112,9 @@ export const CommandsSchema = z
|
|||||||
debug: z.boolean().optional(),
|
debug: z.boolean().optional(),
|
||||||
restart: z.boolean().optional(),
|
restart: z.boolean().optional(),
|
||||||
useAccessGroups: z.boolean().optional(),
|
useAccessGroups: z.boolean().optional(),
|
||||||
|
/** Inject native command outputs into agent context. Default: true */
|
||||||
|
injectToContext: z.boolean().optional().default(true),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional()
|
.optional()
|
||||||
.default({ native: "auto", nativeSkills: "auto" });
|
.default({ native: "auto", nativeSkills: "auto", injectToContext: true });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user