ACP: add optional ingress provenance receipts (#40473)

Merged via squash.

Prepared head SHA: b63e46dd94
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano 2026-03-09 04:19:03 +01:00 committed by GitHub
parent 4d501e4ccf
commit e3df94365b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 406 additions and 6 deletions

View File

@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
- macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext `gateway.remote.token` config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek.
- CLI/backup: add `openclaw backup create` and `openclaw backup verify` for local state archives, including `--only-config`, `--no-include-workspace`, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs.
- CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras.
- ACP/Provenance: add optional ACP ingress provenance metadata and visible receipt injection (`openclaw acp --provenance off|meta|meta+receipt`) so OpenClaw agents can retain and report ACP-origin context with session trace IDs. (#40473) thanks @mbelinky.
### Breaking

View File

@ -96,6 +96,52 @@ Each ACP session maps to a single Gateway session key. One agent can have many
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
the key or label.
## Use from `acpx` (Codex, Claude, other ACP clients)
If you want a coding agent such as Codex or Claude Code to talk to your
OpenClaw bot over ACP, use `acpx` with its built-in `openclaw` target.
Typical flow:
1. Run the Gateway and make sure the ACP bridge can reach it.
2. Point `acpx openclaw` at `openclaw acp`.
3. Target the OpenClaw session key you want the coding agent to use.
Examples:
```bash
# One-shot request into your default OpenClaw ACP session
acpx openclaw exec "Summarize the active OpenClaw session state."
# Persistent named session for follow-up turns
acpx openclaw sessions ensure --name codex-bridge
acpx openclaw -s codex-bridge --cwd /path/to/repo \
"Ask my OpenClaw work agent for recent context relevant to this repo."
```
If you want `acpx openclaw` to target a specific Gateway and session key every
time, override the `openclaw` agent command in `~/.acpx/config.json`:
```json
{
"agents": {
"openclaw": {
"command": "env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token --session agent:main:main"
}
}
}
```
For a repo-local OpenClaw checkout, use the direct CLI entrypoint instead of the
dev runner so the ACP stream stays clean. For example:
```bash
env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 node openclaw.mjs acp ...
```
This is the easiest way to let Codex, Claude Code, or another ACP-aware client
pull contextual information from an OpenClaw agent without scraping a terminal.
## Zed editor setup
Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zeds Settings UI):

View File

@ -10,7 +10,7 @@ import { isMainModule } from "../infra/is-main.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { readSecretFromFile } from "./secret-file.js";
import { AcpGatewayAgent } from "./translator.js";
import type { AcpServerOptions } from "./types.js";
import { normalizeAcpProvenanceMode, type AcpServerOptions } from "./types.js";
export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
const cfg = loadConfig();
@ -186,6 +186,15 @@ function parseArgs(args: string[]): AcpServerOptions {
opts.prefixCwd = false;
continue;
}
if (arg === "--provenance") {
const provenanceMode = normalizeAcpProvenanceMode(args[i + 1]);
if (!provenanceMode) {
throw new Error("Invalid --provenance value. Use off, meta, or meta+receipt.");
}
opts.provenanceMode = provenanceMode;
i += 1;
continue;
}
if (arg === "--verbose" || arg === "-v") {
opts.verbose = true;
continue;
@ -226,6 +235,7 @@ Options:
--require-existing Fail if the session key/label does not exist
--reset-session Reset the session key before first use
--no-prefix-cwd Do not prefix prompts with the working directory
--provenance <mode> ACP provenance mode: off, meta, or meta+receipt
--verbose, -v Verbose logging to stderr
--help, -h Show this help message
`);

View File

@ -81,4 +81,117 @@ describe("acp prompt cwd prefix", () => {
{ expectFinal: true },
);
});
it("injects system provenance metadata when enabled", async () => {
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId: "session-1",
sessionKey: "agent:main:main",
cwd: path.join(os.homedir(), "openclaw-test"),
});
const requestSpy = vi.fn(async (method: string) => {
if (method === "chat.send") {
throw new Error("stop-after-send");
}
return {};
});
const agent = new AcpGatewayAgent(
createAcpConnection(),
createAcpGateway(requestSpy as unknown as GatewayClient["request"]),
{
sessionStore,
provenanceMode: "meta",
},
);
await expect(
agent.prompt({
sessionId: "session-1",
prompt: [{ type: "text", text: "hello" }],
_meta: {},
} as unknown as PromptRequest),
).rejects.toThrow("stop-after-send");
expect(requestSpy).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
systemInputProvenance: {
kind: "external_user",
originSessionId: "session-1",
sourceChannel: "acp",
sourceTool: "openclaw_acp",
},
systemProvenanceReceipt: undefined,
}),
{ expectFinal: true },
);
});
it("injects a system provenance receipt when requested", async () => {
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId: "session-1",
sessionKey: "agent:main:main",
cwd: path.join(os.homedir(), "openclaw-test"),
});
const requestSpy = vi.fn(async (method: string) => {
if (method === "chat.send") {
throw new Error("stop-after-send");
}
return {};
});
const agent = new AcpGatewayAgent(
createAcpConnection(),
createAcpGateway(requestSpy as unknown as GatewayClient["request"]),
{
sessionStore,
provenanceMode: "meta+receipt",
},
);
await expect(
agent.prompt({
sessionId: "session-1",
prompt: [{ type: "text", text: "hello" }],
_meta: {},
} as unknown as PromptRequest),
).rejects.toThrow("stop-after-send");
expect(requestSpy).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
systemInputProvenance: {
kind: "external_user",
originSessionId: "session-1",
sourceChannel: "acp",
sourceTool: "openclaw_acp",
},
systemProvenanceReceipt: expect.stringContaining("[Source Receipt]"),
}),
{ expectFinal: true },
);
expect(requestSpy).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
systemProvenanceReceipt: expect.stringContaining("bridge=openclaw-acp"),
}),
{ expectFinal: true },
);
expect(requestSpy).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
systemProvenanceReceipt: expect.stringContaining("originSessionId=session-1"),
}),
{ expectFinal: true },
);
expect(requestSpy).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
systemProvenanceReceipt: expect.stringContaining("targetSession=agent:main:main"),
}),
{ expectFinal: true },
);
});
});

View File

@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import os from "node:os";
import type {
Agent,
AgentSideConnection,
@ -61,6 +62,32 @@ type AcpGatewayAgentOptions = AcpServerOptions & {
const SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS = 120;
const SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS = 10_000;
function buildSystemInputProvenance(originSessionId: string) {
return {
kind: "external_user" as const,
originSessionId,
sourceChannel: "acp",
sourceTool: "openclaw_acp",
};
}
function buildSystemProvenanceReceipt(params: {
cwd: string;
sessionId: string;
sessionKey: string;
}) {
return [
"[Source Receipt]",
"bridge=openclaw-acp",
`originHost=${os.hostname()}`,
`originCwd=${shortenHomePath(params.cwd)}`,
`acpSessionId=${params.sessionId}`,
`originSessionId=${params.sessionId}`,
`targetSession=${params.sessionKey}`,
"[/Source Receipt]",
].join("\n");
}
export class AcpGatewayAgent implements Agent {
private connection: AgentSideConnection;
private gateway: GatewayClient;
@ -251,6 +278,17 @@ export class AcpGatewayAgent implements Agent {
const prefixCwd = meta.prefixCwd ?? this.opts.prefixCwd ?? true;
const displayCwd = shortenHomePath(session.cwd);
const message = prefixCwd ? `[Working directory: ${displayCwd}]\n\n${userText}` : userText;
const provenanceMode = this.opts.provenanceMode ?? "off";
const systemInputProvenance =
provenanceMode === "off" ? undefined : buildSystemInputProvenance(params.sessionId);
const systemProvenanceReceipt =
provenanceMode === "meta+receipt"
? buildSystemProvenanceReceipt({
cwd: session.cwd,
sessionId: params.sessionId,
sessionKey: session.sessionKey,
})
: undefined;
// Defense-in-depth: also check the final assembled message (includes cwd prefix)
if (Buffer.byteLength(message, "utf-8") > MAX_PROMPT_BYTES) {
@ -281,6 +319,8 @@ export class AcpGatewayAgent implements Agent {
thinking: readString(params._meta, ["thinking", "thinkingLevel"]),
deliver: readBool(params._meta, ["deliver"]),
timeoutMs: readNumber(params._meta, ["timeoutMs"]),
systemInputProvenance,
systemProvenanceReceipt,
},
{ expectFinal: true },
)

View File

@ -1,6 +1,22 @@
import type { SessionId } from "@agentclientprotocol/sdk";
import { VERSION } from "../version.js";
export const ACP_PROVENANCE_MODE_VALUES = ["off", "meta", "meta+receipt"] as const;
export type AcpProvenanceMode = (typeof ACP_PROVENANCE_MODE_VALUES)[number];
export function normalizeAcpProvenanceMode(
value: string | undefined,
): AcpProvenanceMode | undefined {
if (!value) {
return undefined;
}
const normalized = value.trim().toLowerCase();
return (ACP_PROVENANCE_MODE_VALUES as readonly string[]).includes(normalized)
? (normalized as AcpProvenanceMode)
: undefined;
}
export type AcpSession = {
sessionId: SessionId;
sessionKey: string;
@ -20,6 +36,7 @@ export type AcpServerOptions = {
requireExistingSession?: boolean;
resetSession?: boolean;
prefixCwd?: boolean;
provenanceMode?: AcpProvenanceMode;
sessionCreateRateLimit?: {
maxRequests?: number;
windowMs?: number;

View File

@ -175,6 +175,7 @@ export function buildEmbeddedRunBaseParams(params: {
config: params.run.config,
skillsSnapshot: params.run.skillsSnapshot,
ownerNumbers: params.run.ownerNumbers,
inputProvenance: params.run.inputProvenance,
senderIsOwner: params.run.senderIsOwner,
enforceFinalTag: resolveEnforceFinalTag(params.run, params.provider),
provider: params.provider,

View File

@ -521,6 +521,7 @@ export async function runPreparedReply(
timeoutMs,
blockReplyBreak: resolvedBlockStreamingBreak,
ownerNumbers: command.ownerList.length > 0 ? command.ownerList : undefined,
inputProvenance: ctx.InputProvenance ?? sessionCtx.InputProvenance,
extraSystemPrompt: extraSystemPromptParts.join("\n\n") || undefined,
...(isReasoningTagProvider(provider) ? { enforceFinalTag: true } : {}),
},

View File

@ -2,6 +2,7 @@ import type { ExecToolDefaults } from "../../../agents/bash-tools.js";
import type { SkillSnapshot } from "../../../agents/skills.js";
import type { OpenClawConfig } from "../../../config/config.js";
import type { SessionEntry } from "../../../config/sessions.js";
import type { InputProvenance } from "../../../sessions/input-provenance.js";
import type { OriginatingChannelType } from "../../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js";
@ -77,6 +78,7 @@ export type FollowupRun = {
timeoutMs: number;
blockReplyBreak: "text_end" | "message_end";
ownerNumbers?: string[];
inputProvenance?: InputProvenance;
extraSystemPrompt?: string;
enforceFinalTag?: boolean;
};

View File

@ -3,6 +3,7 @@ import type {
MediaUnderstandingDecision,
MediaUnderstandingOutput,
} from "../media-understanding/types.js";
import type { InputProvenance } from "../sessions/input-provenance.js";
import type { StickerMetadata } from "../telegram/bot/types.js";
import type { InternalMessageChannel } from "../utils/message-channel.js";
import type { CommandArgs } from "./commands-registry.types.js";
@ -117,6 +118,8 @@ export type MsgContext = {
GroupSystemPrompt?: string;
/** Untrusted metadata that must not be treated as system instructions. */
UntrustedContext?: string[];
/** System-attached provenance for the current inbound message. */
InputProvenance?: InputProvenance;
/** Explicit owner allowlist overrides (trusted, configuration-derived). */
OwnerAllowFrom?: Array<string | number>;
SenderName?: string;

View File

@ -2,6 +2,7 @@ import type { Command } from "commander";
import { runAcpClientInteractive } from "../acp/client.js";
import { readSecretFromFile } from "../acp/secret-file.js";
import { serveAcpGateway } from "../acp/server.js";
import { normalizeAcpProvenanceMode } from "../acp/types.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
@ -45,6 +46,7 @@ export function registerAcpCli(program: Command) {
.option("--require-existing", "Fail if the session key/label does not exist", false)
.option("--reset-session", "Reset the session key before first use", false)
.option("--no-prefix-cwd", "Do not prefix prompts with the working directory", false)
.option("--provenance <mode>", "ACP provenance mode: off, meta, or meta+receipt")
.option("-v, --verbose", "Verbose logging to stderr", false)
.addHelpText(
"after",
@ -72,6 +74,10 @@ export function registerAcpCli(program: Command) {
if (opts.password) {
warnSecretCliFlag("--password");
}
const provenanceMode = normalizeAcpProvenanceMode(opts.provenance as string | undefined);
if (opts.provenance && !provenanceMode) {
throw new Error("Invalid --provenance value. Use off, meta, or meta+receipt.");
}
await serveAcpGateway({
gatewayUrl: opts.url as string | undefined,
gatewayToken,
@ -81,6 +87,7 @@ export function registerAcpCli(program: Command) {
requireExistingSession: Boolean(opts.requireExisting),
resetSession: Boolean(opts.resetSession),
prefixCwd: !opts.noPrefixCwd,
provenanceMode,
verbose: Boolean(opts.verbose),
});
} catch (err) {

View File

@ -100,6 +100,7 @@ export const AgentParamsSchema = Type.Object(
Type.Object(
{
kind: Type.String({ enum: [...INPUT_PROVENANCE_KIND_VALUES] }),
originSessionId: Type.Optional(Type.String()),
sourceSessionKey: Type.Optional(Type.String()),
sourceChannel: Type.Optional(Type.String()),
sourceTool: Type.Optional(Type.String()),

View File

@ -1,4 +1,5 @@
import { Type } from "@sinclair/typebox";
import { INPUT_PROVENANCE_KIND_VALUES } from "../../../sessions/input-provenance.js";
import { ChatSendSessionKeyString, NonEmptyString } from "./primitives.js";
export const LogsTailParamsSchema = Type.Object(
@ -39,6 +40,19 @@ export const ChatSendParamsSchema = Type.Object(
deliver: Type.Optional(Type.Boolean()),
attachments: Type.Optional(Type.Array(Type.Unknown())),
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
systemInputProvenance: Type.Optional(
Type.Object(
{
kind: Type.String({ enum: [...INPUT_PROVENANCE_KIND_VALUES] }),
originSessionId: Type.Optional(Type.String()),
sourceSessionKey: Type.Optional(Type.String()),
sourceChannel: Type.Optional(Type.String()),
sourceTool: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),
),
systemProvenanceReceipt: Type.Optional(Type.String()),
idempotencyKey: NonEmptyString,
},
{ additionalProperties: false },

View File

@ -158,6 +158,8 @@ async function runNonStreamingChatSend(params: {
deliver?: boolean;
client?: unknown;
expectBroadcast?: boolean;
requestParams?: Record<string, unknown>;
waitForCompletion?: boolean;
}) {
const sendParams: {
sessionKey: string;
@ -173,7 +175,10 @@ async function runNonStreamingChatSend(params: {
sendParams.deliver = params.deliver;
}
await chatHandlers["chat.send"]({
params: sendParams,
params: {
...sendParams,
...params.requestParams,
},
respond: params.respond as unknown as Parameters<
(typeof chatHandlers)["chat.send"]
>[0]["respond"],
@ -185,6 +190,9 @@ async function runNonStreamingChatSend(params: {
const shouldExpectBroadcast = params.expectBroadcast ?? true;
if (!shouldExpectBroadcast) {
if (params.waitForCompletion === false) {
return undefined;
}
await vi.waitFor(() => {
expect(params.context.dedupe.has(`chat:${params.idempotencyKey}`)).toBe(true);
}, FAST_WAIT_OPTS);
@ -885,4 +893,77 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
}),
);
});
it("rejects reserved system provenance fields for non-ACP clients", async () => {
createTranscriptFixture("openclaw-chat-send-system-provenance-reject-");
mockState.finalText = "ok";
const respond = vi.fn();
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-system-provenance-reject",
requestParams: {
systemInputProvenance: { kind: "external_user", sourceChannel: "acp" },
systemProvenanceReceipt: "[Source Receipt]\nbridge=openclaw-acp\n[/Source Receipt]",
},
expectBroadcast: false,
waitForCompletion: false,
});
const [ok, _payload, error] = respond.mock.calls.at(-1) ?? [];
expect(ok).toBe(false);
expect(error).toMatchObject({
message: "system provenance fields are reserved for the ACP bridge",
});
expect(mockState.lastDispatchCtx).toBeUndefined();
});
it("injects ACP system provenance into the agent-visible body", async () => {
createTranscriptFixture("openclaw-chat-send-system-provenance-acp-");
mockState.finalText = "ok";
const respond = vi.fn();
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-system-provenance-acp",
message: "bench update",
client: {
connect: {
client: {
id: "cli",
mode: "cli",
displayName: "ACP",
version: "acp",
},
},
},
requestParams: {
systemInputProvenance: {
kind: "external_user",
originSessionId: "acp-session-1",
sourceChannel: "acp",
sourceTool: "openclaw_acp",
},
systemProvenanceReceipt:
"[Source Receipt]\nbridge=openclaw-acp\noriginSessionId=acp-session-1\n[/Source Receipt]",
},
expectBroadcast: false,
});
expect(mockState.lastDispatchCtx?.InputProvenance).toEqual({
kind: "external_user",
originSessionId: "acp-session-1",
sourceChannel: "acp",
sourceTool: "openclaw_acp",
});
expect(mockState.lastDispatchCtx?.Body).toBe(
"[Source Receipt]\nbridge=openclaw-acp\noriginSessionId=acp-session-1\n[/Source Receipt]\n\nbench update",
);
expect(mockState.lastDispatchCtx?.RawBody).toBe("bench update");
expect(mockState.lastDispatchCtx?.CommandBody).toBe("bench update");
});
});

View File

@ -11,6 +11,7 @@ import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.j
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
import { resolveSessionFilePath } from "../../config/sessions.js";
import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js";
import { normalizeInputProvenance, type InputProvenance } from "../../sessions/input-provenance.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
import {
@ -32,7 +33,12 @@ import {
} from "../chat-abort.js";
import { type ChatImageContent, parseMessageWithAttachments } from "../chat-attachments.js";
import { stripEnvelopeFromMessage, stripEnvelopeFromMessages } from "../chat-sanitize.js";
import { GATEWAY_CLIENT_CAPS, hasGatewayClientCap } from "../protocol/client-info.js";
import {
GATEWAY_CLIENT_CAPS,
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
hasGatewayClientCap,
} from "../protocol/client-info.js";
import {
ErrorCodes,
errorShape,
@ -55,7 +61,11 @@ import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
import { setGatewayDedupeEntry } from "./agent-wait-dedupe.js";
import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js";
import { appendInjectedAssistantMessageToTranscript } from "./chat-transcript-inject.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
import type {
GatewayRequestContext,
GatewayRequestHandlerOptions,
GatewayRequestHandlers,
} from "./types.js";
type TranscriptAppendResult = {
ok: boolean;
@ -225,6 +235,33 @@ export function sanitizeChatSendMessageInput(
return { ok: true, message: stripDisallowedChatControlChars(normalized) };
}
function normalizeOptionalChatSystemReceipt(
value: unknown,
): { ok: true; receipt?: string } | { ok: false; error: string } {
if (value == null) {
return { ok: true };
}
if (typeof value !== "string") {
return { ok: false, error: "systemProvenanceReceipt must be a string" };
}
const sanitized = sanitizeChatSendMessageInput(value);
if (!sanitized.ok) {
return sanitized;
}
const receipt = sanitized.message.trim();
return { ok: true, receipt: receipt || undefined };
}
function isAcpBridgeClient(client: GatewayRequestHandlerOptions["client"]): boolean {
const info = client?.connect?.client;
return (
info?.id === GATEWAY_CLIENT_NAMES.CLI &&
info?.mode === GATEWAY_CLIENT_MODES.CLI &&
info?.displayName === "ACP" &&
info?.version === "acp"
);
}
function truncateChatHistoryText(text: string): { text: string; truncated: boolean } {
if (text.length <= CHAT_HISTORY_TEXT_MAX_CHARS) {
return { text, truncated: false };
@ -860,8 +897,21 @@ export const chatHandlers: GatewayRequestHandlers = {
content?: unknown;
}>;
timeoutMs?: number;
systemInputProvenance?: InputProvenance;
systemProvenanceReceipt?: string;
idempotencyKey: string;
};
if ((p.systemInputProvenance || p.systemProvenanceReceipt) && !isAcpBridgeClient(client)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"system provenance fields are reserved for the ACP bridge",
),
);
return;
}
const sanitizedMessageResult = sanitizeChatSendMessageInput(p.message);
if (!sanitizedMessageResult.ok) {
respond(
@ -871,7 +921,14 @@ export const chatHandlers: GatewayRequestHandlers = {
);
return;
}
const systemReceiptResult = normalizeOptionalChatSystemReceipt(p.systemProvenanceReceipt);
if (!systemReceiptResult.ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, systemReceiptResult.error));
return;
}
const inboundMessage = sanitizedMessageResult.message;
const systemInputProvenance = normalizeInputProvenance(p.systemInputProvenance);
const systemProvenanceReceipt = systemReceiptResult.receipt;
const stopCommand = isChatStopCommandText(inboundMessage);
const normalizedAttachments = normalizeRpcAttachmentsToChatAttachments(p.attachments);
const rawMessage = inboundMessage.trim();
@ -972,6 +1029,9 @@ export const chatHandlers: GatewayRequestHandlers = {
p.thinking && trimmedMessage && !trimmedMessage.startsWith("/"),
);
const commandBody = injectThinking ? `/think ${p.thinking} ${parsedMessage}` : parsedMessage;
const messageForAgent = systemProvenanceReceipt
? [systemProvenanceReceipt, parsedMessage].filter(Boolean).join("\n\n")
: parsedMessage;
const clientInfo = client?.connect?.client;
const {
originatingChannel,
@ -990,14 +1050,15 @@ export const chatHandlers: GatewayRequestHandlers = {
// Inject timestamp so agents know the current date/time.
// Only BodyForAgent gets the timestamp — Body stays raw for UI display.
// See: https://github.com/moltbot/moltbot/issues/3658
const stampedMessage = injectTimestamp(parsedMessage, timestampOptsFromConfig(cfg));
const stampedMessage = injectTimestamp(messageForAgent, timestampOptsFromConfig(cfg));
const ctx: MsgContext = {
Body: parsedMessage,
Body: messageForAgent,
BodyForAgent: stampedMessage,
BodyForCommands: commandBody,
RawBody: parsedMessage,
CommandBody: commandBody,
InputProvenance: systemInputProvenance,
SessionKey: sessionKey,
Provider: INTERNAL_MESSAGE_CHANNEL,
Surface: INTERNAL_MESSAGE_CHANNEL,

View File

@ -10,6 +10,7 @@ export type InputProvenanceKind = (typeof INPUT_PROVENANCE_KIND_VALUES)[number];
export type InputProvenance = {
kind: InputProvenanceKind;
originSessionId?: string;
sourceSessionKey?: string;
sourceChannel?: string;
sourceTool?: string;
@ -39,6 +40,7 @@ export function normalizeInputProvenance(value: unknown): InputProvenance | unde
}
return {
kind: record.kind,
originSessionId: normalizeOptionalString(record.originSessionId),
sourceSessionKey: normalizeOptionalString(record.sourceSessionKey),
sourceChannel: normalizeOptionalString(record.sourceChannel),
sourceTool: normalizeOptionalString(record.sourceTool),