fix(system): inject transitions only
This commit is contained in:
@@ -676,24 +676,39 @@ export async function getReplyFromConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For new main sessions, prepend a provider snapshot.
|
// Prepend queued system events (transitions only) and (for new main sessions) a provider snapshot.
|
||||||
// Note: We intentionally do NOT prepend queued system events to the user prompt,
|
// Token efficiency: we filter out periodic/heartbeat noise and keep the lines compact.
|
||||||
// since that bloats session logs (token cost) and clutters chat history.
|
|
||||||
const isGroupSession =
|
const isGroupSession =
|
||||||
typeof ctx.From === "string" &&
|
typeof ctx.From === "string" &&
|
||||||
(ctx.From.includes("@g.us") || ctx.From.startsWith("group:"));
|
(ctx.From.includes("@g.us") || ctx.From.startsWith("group:"));
|
||||||
const isMainSession =
|
const isMainSession =
|
||||||
!isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main");
|
!isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main");
|
||||||
if (isMainSession) {
|
if (isMainSession) {
|
||||||
// Drain (discard) queued system events so they remain ephemeral.
|
const compactSystemEvent = (line: string): string | null => {
|
||||||
// They are still available via presence/health in the gateway UI.
|
const trimmed = line.trim();
|
||||||
drainSystemEvents();
|
if (!trimmed) return null;
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
if (lower.includes("reason periodic")) return null;
|
||||||
|
if (lower.includes("heartbeat")) return null;
|
||||||
|
if (trimmed.startsWith("Node:")) {
|
||||||
|
// Drop the chatty "last input … ago" segment; keep connect/disconnect/launch reasons.
|
||||||
|
return trimmed.replace(/ · last input [^·]+/i, "").trim();
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const systemLines: string[] = [];
|
||||||
|
const queued = drainSystemEvents();
|
||||||
|
systemLines.push(
|
||||||
|
...queued.map(compactSystemEvent).filter((v): v is string => Boolean(v)),
|
||||||
|
);
|
||||||
if (isNewSession) {
|
if (isNewSession) {
|
||||||
const summary = await buildProviderSummary(cfg);
|
const summary = await buildProviderSummary(cfg);
|
||||||
if (summary.length > 0) {
|
if (summary.length > 0) systemLines.unshift(...summary);
|
||||||
const block = summary.map((l) => `System: ${l}`).join("\n");
|
}
|
||||||
prefixedBodyBase = `${block}\n\n${prefixedBodyBase}`;
|
if (systemLines.length > 0) {
|
||||||
}
|
const block = systemLines.map((l) => `System: ${l}`).join("\n");
|
||||||
|
prefixedBodyBase = `${block}\n\n${prefixedBodyBase}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1845,17 +1845,7 @@ describe("gateway server", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("chat.history strips injected System blocks and caps payload bytes", async () => {
|
test("chat.history caps payload bytes", async () => {
|
||||||
const firstContentText = (msg: unknown): string | undefined => {
|
|
||||||
if (!msg || typeof msg !== "object") return undefined;
|
|
||||||
const content = (msg as { content?: unknown }).content;
|
|
||||||
if (!Array.isArray(content) || content.length === 0) return undefined;
|
|
||||||
const first = content[0];
|
|
||||||
if (!first || typeof first !== "object") return undefined;
|
|
||||||
const text = (first as { text?: unknown }).text;
|
|
||||||
return typeof text === "string" ? text : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||||
testSessionStorePath = path.join(dir, "sessions.json");
|
testSessionStorePath = path.join(dir, "sessions.json");
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
@@ -1873,35 +1863,9 @@ describe("gateway server", () => {
|
|||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
|
||||||
const injected =
|
|
||||||
"System: Node: Peter’s Mac · app 2.0.0 · last input 0s ago · mode local · reason periodic\n" +
|
|
||||||
"System: WhatsApp gateway connected.\n\n" +
|
|
||||||
"Hello from user";
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(dir, "sess-main.jsonl"),
|
|
||||||
JSON.stringify({
|
|
||||||
message: {
|
|
||||||
role: "user",
|
|
||||||
content: [{ type: "text", text: injected }],
|
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
await connectOk(ws);
|
await connectOk(ws);
|
||||||
|
|
||||||
const scrubbedRes = await rpcReq<{ messages?: unknown[] }>(
|
|
||||||
ws,
|
|
||||||
"chat.history",
|
|
||||||
{ sessionKey: "main", limit: 5 },
|
|
||||||
);
|
|
||||||
expect(scrubbedRes.ok).toBe(true);
|
|
||||||
const scrubbedMsgs = scrubbedRes.payload?.messages ?? [];
|
|
||||||
expect(scrubbedMsgs.length).toBe(1);
|
|
||||||
expect(firstContentText(scrubbedMsgs[0])).toBe("Hello from user");
|
|
||||||
|
|
||||||
const bigText = "x".repeat(300_000);
|
const bigText = "x".repeat(300_000);
|
||||||
const largeLines: string[] = [];
|
const largeLines: string[] = [];
|
||||||
for (let i = 0; i < 60; i += 1) {
|
for (let i = 0; i < 60; i += 1) {
|
||||||
|
|||||||
@@ -351,42 +351,6 @@ function readSessionMessages(
|
|||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripInjectedSystemBlock(text: string): string {
|
|
||||||
if (!text.startsWith("System: ")) return text;
|
|
||||||
const sep = text.indexOf("\n\n");
|
|
||||||
if (sep <= 0) return text;
|
|
||||||
const head = text.slice(0, sep);
|
|
||||||
const lines = head.split("\n");
|
|
||||||
if (lines.length === 0) return text;
|
|
||||||
if (!lines.every((l) => l.startsWith("System: "))) return text;
|
|
||||||
return text.slice(sep + 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrubInjectedSystemBlocks(messages: unknown[]): unknown[] {
|
|
||||||
let changed = false;
|
|
||||||
const out = messages.map((msg) => {
|
|
||||||
if (!msg || typeof msg !== "object") return msg;
|
|
||||||
const obj = msg as Record<string, unknown>;
|
|
||||||
if (obj.role !== "user") return msg;
|
|
||||||
const content = obj.content;
|
|
||||||
if (!Array.isArray(content) || content.length === 0) return msg;
|
|
||||||
const first = content[0];
|
|
||||||
if (!first || typeof first !== "object") return msg;
|
|
||||||
const firstObj = first as Record<string, unknown>;
|
|
||||||
if (firstObj.type !== "text") return msg;
|
|
||||||
const text = firstObj.text;
|
|
||||||
if (typeof text !== "string") return msg;
|
|
||||||
const stripped = stripInjectedSystemBlock(text);
|
|
||||||
if (stripped === text) return msg;
|
|
||||||
changed = true;
|
|
||||||
const nextFirst = { ...firstObj, text: stripped };
|
|
||||||
const nextContent = [...content];
|
|
||||||
nextContent[0] = nextFirst;
|
|
||||||
return { ...obj, content: nextContent };
|
|
||||||
});
|
|
||||||
return changed ? out : messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonUtf8Bytes(value: unknown): number {
|
function jsonUtf8Bytes(value: unknown): number {
|
||||||
try {
|
try {
|
||||||
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
||||||
@@ -916,9 +880,8 @@ export async function startGatewayServer(
|
|||||||
const max = typeof limit === "number" ? limit : 200;
|
const max = typeof limit === "number" ? limit : 200;
|
||||||
const sliced =
|
const sliced =
|
||||||
rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
||||||
const scrubbed = scrubInjectedSystemBlocks(sliced);
|
|
||||||
const capped = capArrayByJsonBytes(
|
const capped = capArrayByJsonBytes(
|
||||||
scrubbed,
|
sliced,
|
||||||
MAX_CHAT_HISTORY_MESSAGES_BYTES,
|
MAX_CHAT_HISTORY_MESSAGES_BYTES,
|
||||||
).items;
|
).items;
|
||||||
const thinkingLevel =
|
const thinkingLevel =
|
||||||
@@ -1895,9 +1858,8 @@ export async function startGatewayServer(
|
|||||||
const max = Math.min(hardMax, requested);
|
const max = Math.min(hardMax, requested);
|
||||||
const sliced =
|
const sliced =
|
||||||
rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
||||||
const scrubbed = scrubInjectedSystemBlocks(sliced);
|
|
||||||
const capped = capArrayByJsonBytes(
|
const capped = capArrayByJsonBytes(
|
||||||
scrubbed,
|
sliced,
|
||||||
MAX_CHAT_HISTORY_MESSAGES_BYTES,
|
MAX_CHAT_HISTORY_MESSAGES_BYTES,
|
||||||
).items;
|
).items;
|
||||||
const thinkingLevel =
|
const thinkingLevel =
|
||||||
@@ -2412,13 +2374,17 @@ export async function startGatewayServer(
|
|||||||
reason,
|
reason,
|
||||||
tags,
|
tags,
|
||||||
});
|
});
|
||||||
|
const isNodePresenceLine = text.startsWith("Node:");
|
||||||
const normalizedReason = (reason ?? "").toLowerCase();
|
const normalizedReason = (reason ?? "").toLowerCase();
|
||||||
const looksPeriodic =
|
const looksPeriodic =
|
||||||
normalizedReason.startsWith("periodic") ||
|
normalizedReason.startsWith("periodic") ||
|
||||||
normalizedReason === "heartbeat";
|
normalizedReason === "heartbeat";
|
||||||
const isNodePresenceLine = text.startsWith("Node:");
|
|
||||||
if (!(isNodePresenceLine && looksPeriodic)) {
|
if (!(isNodePresenceLine && looksPeriodic)) {
|
||||||
enqueueSystemEvent(text);
|
const compactNodeText =
|
||||||
|
isNodePresenceLine && (host || ip || version || mode || reason)
|
||||||
|
? `Node: ${host?.trim() || "Unknown"}${ip ? ` (${ip})` : ""} · app ${version?.trim() || "unknown"} · mode ${mode?.trim() || "unknown"} · reason ${reason?.trim() || "event"}`
|
||||||
|
: text;
|
||||||
|
enqueueSystemEvent(compactNodeText);
|
||||||
}
|
}
|
||||||
presenceVersion += 1;
|
presenceVersion += 1;
|
||||||
broadcast(
|
broadcast(
|
||||||
|
|||||||
Reference in New Issue
Block a user