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:
Glucksberg 2026-01-29 23:51:03 +00:00
parent 4583f88626
commit f38e64b58e
7 changed files with 183 additions and 3 deletions

View File

@ -27,12 +27,58 @@ import {
handleUsageCommand,
} from "./commands-session.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 {
CommandHandler,
CommandHandlerResult,
HandleCommandsParams,
} 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[] = [
// Plugin commands are processed first, before built-in commands
handlePluginCommand,
@ -110,7 +156,22 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
for (const handler of HANDLERS) {
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({

View File

@ -4,7 +4,10 @@ import type { SkillCommandSpec } from "../../agents/skills.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import type { MoltbotConfig } from "../../config/config.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 { logVerbose } from "../../globals.js";
import { listSkillCommandsForWorkspace } from "../skill-commands.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
@ -81,6 +84,51 @@ export type ReplyDirectiveResult =
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
| { 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: {
ctx: MsgContext;
cfg: MoltbotConfig;
@ -439,6 +487,18 @@ export async function resolveReplyDirectives(params: {
typing,
});
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 };
}
directives = applyResult.directives;

View File

@ -14,6 +14,7 @@ import {
type SessionEntry,
updateSessionStore,
} from "../../config/sessions.js";
import type { RecentCommandEntry } from "../../config/sessions/types.js";
import { logVerbose } from "../../globals.js";
import { clearCommandLane, getQueueSize } from "../../process/command-queue.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 =
"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 = {
ctx: MsgContext;
sessionCtx: TemplateContext;
@ -179,7 +197,24 @@ export async function runPreparedReply(
})
: "";
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 ?? "";
// Use CommandBody/RawBody for bare reset detection (clean message without structural context).
const rawBodyTrimmed = (ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "").trim();

View File

@ -277,6 +277,7 @@ const FIELD_LABELS: Record<string, string> = {
"commands.debug": "Allow /debug",
"commands.restart": "Allow Restart",
"commands.useAccessGroups": "Use Access Groups",
"commands.injectToContext": "Inject Commands to Context",
"ui.seamColor": "Accent Color",
"ui.assistant.name": "Assistant Name",
"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.restart": "Allow /restart and gateway restart tool actions (default: false).",
"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":
'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":

View File

@ -23,6 +23,18 @@ export type SessionOrigin = {
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 = {
/**
* Last delivered heartbeat payload (used to suppress duplicate heartbeat notifications).
@ -94,6 +106,11 @@ export type SessionEntry = {
lastThreadId?: string | number;
skillsSnapshot?: SessionSkillSnapshot;
systemPromptReport?: SessionSystemPromptReport;
/**
* Recent native command outputs for agent context injection.
* Cleared after being injected into the agent's context.
*/
recentCommands?: RecentCommandEntry[];
};
export function mergeSessionEntry(

View File

@ -107,6 +107,8 @@ export type CommandsConfig = {
restart?: boolean;
/** Enforce access-group allowlists/policies for commands (default: true). */
useAccessGroups?: boolean;
/** Inject native command outputs into agent context (default: true). */
injectToContext?: boolean;
};
export type ProviderCommandsConfig = {

View File

@ -112,7 +112,9 @@ export const CommandsSchema = z
debug: z.boolean().optional(),
restart: z.boolean().optional(),
useAccessGroups: z.boolean().optional(),
/** Inject native command outputs into agent context. Default: true */
injectToContext: z.boolean().optional().default(true),
})
.strict()
.optional()
.default({ native: "auto", nativeSkills: "auto" });
.default({ native: "auto", nativeSkills: "auto", injectToContext: true });