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,
|
||||
} 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({
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user