Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 100 additions & 3 deletions src/context-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1240,8 +1240,105 @@ function escapeMemoryFactText(text: string): string {
// Matches [tool:name] followed by optional whitespace and any trailing JSON object {...}, array [...], or string "..."
const TOOL_CALL_BRACKET_RE = /\[tool:([^\]]+)\](?:\s*(?:\{[\s\S]*?\}|\[[\s\S]*?\]|".*?"))?/gi;

// Matches raw JSON tool-call objects targeting a "name\" field
const TOOL_CALL_JSON_RE = /\{[^\r\n]*"name"\s*:\s*"([^"]+)"[^\r\n]*(?:"arguments"|"args"|"toolCallId"|"tool_call_id"|"type"\s*:\s*"toolCall")[^\r\n]*\}/g;
const TOOL_CALL_JSON_MARKER_KEYS = ["arguments", "args", "toolCallId", "tool_call_id"] as const;

function containsJsonToolCall(value: unknown): boolean {
const pending: unknown[] = [value];

while (pending.length > 0) {
const candidate = pending.pop();
if (Array.isArray(candidate)) {
for (const item of candidate) pending.push(item);
continue;
}
if (!candidate || typeof candidate !== "object") continue;

const record = candidate as Record<string, unknown>;
if (
typeof record.name === "string" &&
record.name.length > 0 &&
(
record.type === "toolCall" ||
TOOL_CALL_JSON_MARKER_KEYS.some((key) => Object.prototype.hasOwnProperty.call(record, key))
)
) {
return true;
}
for (const item of Object.values(record)) pending.push(item);
}

return false;
}

function findBalancedJsonObjectEnd(text: string, start: number): number | null {
let depth = 0;
let inString = false;
let escaped = false;

for (let index = start; index < text.length; index++) {
const char = text[index]!;
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}

if (char === '"') {
inString = true;
} else if (char === "{") {
depth++;
} else if (char === "}") {
depth--;
if (depth === 0) return index + 1;
}
}

return null;
}

/**
* Strips complete JSON objects that contain a tool-call record. Brace-aware
* scanning keeps each match bounded to one object, so multiline support cannot
* start in nearby ordinary JSON and finish at a later tool-call object.
*/
function stripJsonToolCallObjects(text: string): string {
let scanFrom = 0;
let keptFrom = 0;
let result = "";

while (scanFrom < text.length) {
const start = text.indexOf("{", scanFrom);
if (start < 0) break;

const end = findBalancedJsonObjectEnd(text, start);
if (end == null) {
// The opening brace may be prose or malformed JSON. Resume after it so
// a later complete object can still be considered independently.
scanFrom = start + 1;
continue;
}

const parsed = parseJsonRecord(text.slice(start, end));
if (parsed && containsJsonToolCall(parsed)) {
result += text.slice(keptFrom, start);
keptFrom = end;
scanFrom = end;
continue;
}

// A valid ordinary object has already been checked recursively. For an
// invalid outer candidate, advance one character so nested JSON can still
// be discovered without allowing a match to cross object boundaries.
scanFrom = parsed ? end : start + 1;
}

return keptFrom === 0 ? text : result + text.slice(keptFrom);
}

// Strip only the [tool:name] annotation tag; preserve payload on the same line
const TOOL_RESULT_ANNOTATION_RE = /\[tool:[^\]]+\]\s*/g;
Expand Down Expand Up @@ -1269,7 +1366,7 @@ function sanitizeToolCallPatterns(

sanitized = sanitized.replace(TOOL_CALL_BRACKET_RE, "");

sanitized = sanitized.replace(TOOL_CALL_JSON_RE, "");
sanitized = stripJsonToolCallObjects(sanitized);

sanitized = sanitized.replace(TOOL_RESULT_ANNOTATION_RE, "");

Expand Down
37 changes: 37 additions & 0 deletions test/unit/context-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1161,6 +1161,43 @@ test("context engine assemble preserves ordinary JSON with name fields in memory
assert.doesNotMatch(assembled.systemPromptAddition, /"arguments":\{"query":"old"\}/u);
});

test("context engine assemble strips multiline tool-call JSON without consuming nearby ordinary JSON", async () => {
const client = new FakeClient();
client.assembleResponse = {
messages: [makeMessage("user", "current request", "current-user")],
estimatedTokens: 64,
systemPromptAddition: [
"<retrieved_memory>",
'<memory_item>{"name":"ordinary-before","note":"keep before"}</memory_item>',
"<memory_item>{",
' "name": "web_search",',
' "arguments": {',
' "query": "old",',
' "filters": { "language": "en" }',
" },",
' "toolCallId": "call-old"',
"}</memory_item>",
'<memory_item>{"name":"ordinary-after","note":"keep after"}</memory_item>',
"</retrieved_memory>",
].join("\n"),
};
const engine = buildContextEngineFactory(fakeRuntime(client), { userId: "fixed-user" });

const assembled = await engine.assemble({
sessionId: "s1-system-addition-multiline-tool-json",
sessionKey: "sk1",
messages: [makeMessage("user", "current request", "current-user")],
prompt: "current request",
tokenBudget: 4000,
});

assert.match(assembled.systemPromptAddition, /"name":"ordinary-before"/u);
assert.match(assembled.systemPromptAddition, /keep before/u);
assert.match(assembled.systemPromptAddition, /"name":"ordinary-after"/u);
assert.match(assembled.systemPromptAddition, /keep after/u);
assert.doesNotMatch(assembled.systemPromptAddition, /web_search|call-old|"query": "old"/u);
});



test("context engine assemble preserves ordinary assistant planning language", async () => {
Expand Down
Loading