Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5bb29d0
🐛 fix(sdk): 结构化输出解析兜底提取首个平衡 JSON 块 (#318)
TaTaLiao Aug 22, 2026
a1b5113
🐛 fix(sdk): frontmatter 列表值放行顶格列表项 (#350)
TaTaLiao Aug 22, 2026
a6a9b9d
🐛 fix(sdk): 重试退避尊重 Retry-After 响应头 (#351)
TaTaLiao Aug 22, 2026
57ed168
🐛 fix(sdk): microCompact 覆盖数组形态 tool_result (#364)
TaTaLiao Aug 22, 2026
35a4ab3
🐛 fix(sdk): getContextWindowSize 未命中时回退共享模型注册表 (#366)
TaTaLiao Aug 22, 2026
ede6a9e
🐛 fix(sdk): 工具审批通配符与精确匹配改大小写不敏感 (#379)
TaTaLiao Aug 22, 2026
30ecfa5
🐛 fix(sdk): user settings 路径遵循 LUME_CONFIG_DIR (#291)
TaTaLiao Aug 22, 2026
2967323
🐛 fix(sdk): settings 读取区分 ENOENT,不可读文件输出告警 (#354)
TaTaLiao Aug 22, 2026
7b5f8fa
🐛 fix(sdk): microCompact 仅对超预算媒体块占位 (#364)
TaTaLiao Aug 22, 2026
a8c3ab7
✅ test(sdk): LUME_CONFIG_DIR 测试按平台取绝对路径 (#291)
TaTaLiao Aug 22, 2026
eb38c76
Merge branch 'main' into fix/sdk-utils-prompt
CavinHuang Aug 22, 2026
fa79913
Merge branch 'main' into fix/sdk-utils-prompt
CavinHuang Aug 22, 2026
e14af36
Merge branch 'main' into fix/sdk-utils-prompt
CavinHuang Aug 22, 2026
2cd3d1f
Merge branch 'main' into fix/sdk-utils-prompt
CavinHuang Aug 22, 2026
69c44d1
Merge branch 'main' into fix/sdk-utils-prompt
CavinHuang Aug 22, 2026
1d42485
Merge branch 'main' into fix/sdk-utils-prompt
CavinHuang Aug 22, 2026
626a8db
Merge branch 'main' into fix/sdk-utils-prompt
CavinHuang Aug 22, 2026
a13a762
Merge branch 'main' into fix/sdk-utils-prompt
CavinHuang Aug 22, 2026
91c7403
Merge branch 'main' into fix/sdk-utils-prompt
CavinHuang Aug 22, 2026
6e1b572
Merge branch 'main' into fix/sdk-utils-prompt
CavinHuang Aug 22, 2026
f7bd65a
Merge branch 'main' into fix/sdk-utils-prompt
CavinHuang Aug 22, 2026
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
2 changes: 1 addition & 1 deletion packages/sdk/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1550,7 +1550,7 @@ export class QueryEngine {
this.messages.push({
role: 'user',
content:
'Your previous response did not match the requested JSON schema. Return only valid JSON matching the schema exactly, with no markdown fences or extra commentary.',
'Your previous response did not match the requested JSON schema. Your response must begin with `{` as the very first character — no prose, markdown fences, or commentary before it — and contain only valid JSON matching the schema exactly.',
})
continue
}
Expand Down
77 changes: 77 additions & 0 deletions packages/sdk/src/utils/compact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import {
compactConversation,
createAutoCompactState,
microCompactMessages,
prepareCompaction,
serializeConversation,
shouldAutoCompact,
Expand Down Expand Up @@ -287,3 +288,79 @@ describe("context compaction", () => {
expect(prefixRequest.messages[0].content).not.toContain(MARKER);
});
});

describe("microCompactMessages (#364)", () => {
const budget = 100;

test("still truncates oversized string tool results", () => {
const long = "x".repeat(300);
const [msg] = microCompactMessages(
[{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: long }] }],
budget,
);
const block = msg.content[0];
expect(block.type).toBe("tool_result");
expect(block.content.length).toBeLessThan(long.length);
expect(block.content).toContain("...(truncated)...");
});

test("truncates oversized text blocks inside array tool results", () => {
const long = "y".repeat(300);
const [msg] = microCompactMessages([
{
role: "user",
content: [{
type: "tool_result",
tool_use_id: "t2",
content: [
{ type: "text", text: long },
{ type: "text", text: "keep me" },
],
}],
},
], budget);
const content = msg.content[0].content;
expect(content[0].text.length).toBeLessThan(long.length);
expect(content[0].text).toContain("...(truncated)...");
expect(content[1].text).toBe("keep me");
});

test("replaces only oversized media blocks; small images stay intact (#364)", () => {
const imageData = "z".repeat(500);
const pdfData = "q".repeat(200);
// Well under the 100-char budget once serialized.
const smallImage = { type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } };
const imageBlock = { type: "image", source: { type: "base64", media_type: "image/png", data: imageData } };
const docBlock = { type: "document", source: { type: "base64", media_type: "application/pdf", data: pdfData } };
const [msg] = microCompactMessages([
{
role: "user",
content: [{
type: "tool_result",
tool_use_id: "t3",
content: [smallImage, imageBlock, docBlock, { type: "text", text: "short" }],
}],
},
], budget);

const content = msg.content[0].content;
expect(content[0]).toEqual(smallImage);
expect(content[1].type).toBe("text");
expect(content[1].text).toContain("image");
expect(content[1].text).toContain(String(JSON.stringify(imageBlock).length));
expect(content[2].type).toBe("text");
expect(content[2].text).toContain("document");
// The heavy payloads are gone from the message entirely.
expect(JSON.stringify(content)).not.toContain(imageData.slice(0, 32));
expect(JSON.stringify(content)).not.toContain(pdfData.slice(0, 32));
expect(content[3].text).toBe("short");
});

test("leaves messages without oversized tool results untouched in shape", () => {
const messages = [
{ role: "user", content: "plain string" },
{ role: "assistant", content: [{ type: "text", text: "reply" }] },
];
expect(microCompactMessages(messages, budget)[0]).toBe(messages[0]);
});
});
48 changes: 47 additions & 1 deletion packages/sdk/src/utils/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,8 @@ export function microCompactMessages(
if (!Array.isArray(msg.content)) return msg

const content = (msg.content as any[]).map((block: any) => {
if (block.type === 'tool_result' && typeof block.content === 'string') {
if (block?.type !== 'tool_result') return block
if (typeof block.content === 'string') {
if (block.content.length > maxToolResultChars) {
return {
...block,
Expand All @@ -710,10 +711,55 @@ export function microCompactMessages(
+ block.content.slice(-maxToolResultChars / 2),
}
}
return block
}
// Array-form tool results (images, web-fetch payloads, ...) previously
// passed through unbounded and blew up the provider request (#364).
if (Array.isArray(block.content)) {
return { ...block, content: compactToolResultContent(block.content, maxToolResultChars) }
}
return block
})

return { ...msg, content }
})
}

function compactToolResultContent(blocks: any[], maxToolResultChars: number): any[] {
let changed = false
const next = blocks.map((item: any) => {
if (item?.type === 'text' && typeof item.text === 'string' && item.text.length > maxToolResultChars) {
changed = true
return {
...item,
text:
item.text.slice(0, maxToolResultChars / 2)
+ '\n...(truncated)...\n'
+ item.text.slice(-maxToolResultChars / 2),
}
}
// Only shed media blocks that actually exceed the budget — small images
// must reach the model or visual ability regresses (#364).
if (item?.type === 'image' || item?.type === 'document') {
const originalChars = safeJsonLength(item)
if (originalChars > maxToolResultChars) {
changed = true
return {
type: 'text',
text: `[${item.type} omitted by micro-compaction: original ${item.type} was ${originalChars} chars]`,
}
}
return item
}
return item
})
return changed ? next : blocks
}

function safeJsonLength(value: unknown): number {
try {
return JSON.stringify(value)?.length ?? 0
} catch {
return 0
}
}
57 changes: 57 additions & 0 deletions packages/sdk/src/utils/markdown-frontmatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, spyOn, test } from "bun:test";
import { parseMarkdownFrontmatter } from "./markdown-frontmatter.js";

describe("parseMarkdownFrontmatter list values (#350)", () => {
test("parses indented list items", () => {
const parsed = parseMarkdownFrontmatter(
"---\nname: demo\nallowedTools:\n - Read\n - Write\n---\nbody",
);
expect(parsed.frontmatter.allowedTools).toBe("Read,Write");
expect(parsed.frontmatter.name).toBe("demo");
expect(parsed.content).toBe("body");
});

test("parses top-level (unindented) list items per standard YAML", () => {
const parsed = parseMarkdownFrontmatter(
"---\nallowedTools:\n- Read\n- mcp__x__*\n---\nbody",
);
expect(parsed.frontmatter.allowedTools).toBe("Read,mcp__x__*");
expect(parsed.content).toBe("body");
});

test("accepts mixed indentation", () => {
const parsed = parseMarkdownFrontmatter("---\nlist:\n- one\n - two\n---\nbody");
expect(parsed.frontmatter.list).toBe("one,two");
});

test("stops at the next key-shaped line and keeps parsing it", () => {
const parsed = parseMarkdownFrontmatter(
"---\nallowedTools:\n- Read\nname: demo\n---\nbody",
);
expect(parsed.frontmatter.allowedTools).toBe("Read");
expect(parsed.frontmatter.name).toBe("demo");
expect(parsed.content).toBe("body");
});

test("stops at the closing fence", () => {
const parsed = parseMarkdownFrontmatter(
"---\nallowedTools:\n- Read\n---\n- not a list item",
);
expect(parsed.frontmatter.allowedTools).toBe("Read");
expect(parsed.content).toBe("- not a list item");
});

test("warns when a list-valued key has no items", () => {
const warn = spyOn(console, "warn").mockImplementation(() => {});
try {
const parsed = parseMarkdownFrontmatter(
"---\nallowedTools:\nsomekey: value\n---\nbody",
);
expect(parsed.frontmatter.allowedTools).toBe("");
expect(parsed.frontmatter.somekey).toBe("value");
expect(warn.mock.calls.some((call) => String(call[0]).includes("allowedTools"))).toBe(true);
} finally {
warn.mockRestore();
}
});
});
9 changes: 7 additions & 2 deletions packages/sdk/src/utils/markdown-frontmatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,18 @@ export function parseMarkdownFrontmatter(
if (nextLine === '---') break
const trimmed = nextLine.trim()
if (!trimmed) continue
if (!nextLine.startsWith(' ') && !nextLine.startsWith('\t')) break
// Standard YAML allows top-level list items; scanning stops at the
// next key-shaped line or the closing fence (#350).
if (!trimmed.startsWith('- ')) break
const item = trimmed.slice(2).trim()
if (item) items.push(item)
}
frontmatter[key] = items.join(',')
if (items.length > 0) {
if (items.length === 0) {
console.warn(
`[frontmatter] list value for key "${key}" has no "- " items; key is empty`,
)
} else {
index = nextIndex - 1
}
} else {
Expand Down
70 changes: 69 additions & 1 deletion packages/sdk/src/utils/retry.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, expect, test } from "bun:test";
import { withRetry } from "./retry.js";
import {
DEFAULT_RETRY_CONFIG,
MAX_RETRY_AFTER_DELAY_MS,
computeRetryDelay,
parseRetryAfterHeader,
withRetry,
} from "./retry.js";

function rateLimitError(): Error & { status: number } {
const err = new Error("rate limited") as Error & { status: number };
Expand Down Expand Up @@ -43,4 +49,66 @@ describe("withRetry", () => {
expect(result).toBe("ok");
expect(attempts).toBe(2);
});

test("honors err.retryAfterMs instead of long exponential backoff (#351)", async () => {
let attempts = 0;
const began = Date.now();
const result = await withRetry(
() => {
attempts += 1;
if (attempts < 2) {
const err: any = rateLimitError();
err.retryAfterMs = 5;
throw err;
}
return Promise.resolve("ok");
},
slowBackoff,
);

expect(result).toBe("ok");
expect(attempts).toBe(2);
expect(Date.now() - began).toBeLessThan(5_000);
});
});

describe("parseRetryAfterHeader (#351)", () => {
test("parses delta-seconds values", () => {
expect(parseRetryAfterHeader("30")).toBe(30_000);
expect(parseRetryAfterHeader("0")).toBe(0);
expect(parseRetryAfterHeader("-5")).toBe(0);
});

test("parses HTTP-date values relative to now", () => {
const parsed = parseRetryAfterHeader(new Date(Date.now() + 10_000).toUTCString());
expect(parsed).toBeGreaterThan(0);
expect(parsed).toBeLessThanOrEqual(10_000);
expect(parseRetryAfterHeader(new Date(Date.now() - 60_000).toUTCString())).toBe(0);
});

test("returns undefined for absent or garbage values", () => {
expect(parseRetryAfterHeader(null)).toBeUndefined();
expect(parseRetryAfterHeader(undefined)).toBeUndefined();
expect(parseRetryAfterHeader("n/a")).toBeUndefined();
});
});

describe("computeRetryDelay (#351)", () => {
const config = { ...DEFAULT_RETRY_CONFIG };

test("prefers the server-provided retryAfterMs without jitter", () => {
expect(computeRetryDelay({ retryAfterMs: 7_000 }, 0, config)).toBe(7_000);
});

test("clamps retryAfterMs to the hard cap and floors negatives", () => {
expect(computeRetryDelay({ retryAfterMs: 500_000 }, 0, config)).toBe(MAX_RETRY_AFTER_DELAY_MS);
expect(computeRetryDelay({ retryAfterMs: -5 }, 0, config)).toBe(0);
expect(computeRetryDelay({ retryAfterMs: Number.NaN }, 0, config)).toBeLessThanOrEqual(config.maxDelayMs);
});

test("falls back to exponential backoff when no header was present", () => {
const delay = computeRetryDelay(new Error("plain"), 0, config);
expect(delay).toBeGreaterThanOrEqual(0);
expect(delay).toBeLessThanOrEqual(config.maxDelayMs);
});
});
36 changes: 35 additions & 1 deletion packages/sdk/src/utils/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,40 @@ export function getRetryDelay(attempt: number, config: RetryConfig = DEFAULT_RET
return Math.min(delay + jitter, config.maxDelayMs)
}

/**
* Hard cap for server-provided Retry-After delays (#351).
*/
export const MAX_RETRY_AFTER_DELAY_MS = 120_000

/**
* Parse a Retry-After header value (delta-seconds or HTTP-date) into
* milliseconds. Returns undefined when the value is absent or unparseable (#351).
*/
export function parseRetryAfterHeader(value: string | null | undefined): number | undefined {
if (!value) return undefined
const seconds = Number(value)
if (Number.isFinite(seconds)) return Math.max(seconds, 0) * 1000
const dateMs = Date.parse(value)
if (Number.isNaN(dateMs)) return undefined
return Math.max(dateMs - Date.now(), 0)
}

/**
* Delay before the next retry attempt: the server-provided Retry-After hint
* when present (clamped to a hard cap), exponential backoff otherwise (#351).
*/
export function computeRetryDelay(
err: unknown,
attempt: number,
config: RetryConfig = DEFAULT_RETRY_CONFIG,
): number {
const retryAfterMs = (err as { retryAfterMs?: unknown })?.retryAfterMs
if (typeof retryAfterMs === 'number' && Number.isFinite(retryAfterMs)) {
return Math.min(Math.max(retryAfterMs, 0), MAX_RETRY_AFTER_DELAY_MS)
}
return getRetryDelay(attempt, config)
}

/**
* Execute a function with retries.
*/
Expand Down Expand Up @@ -86,7 +120,7 @@ export async function withRetry<T>(
}

// Wait before retry
const delay = getRetryDelay(attempt, config)
const delay = computeRetryDelay(err, attempt, config)
await onRetry?.({
attempt: attempt + 1,
maxRetries: config.maxRetries,
Expand Down
Loading
Loading