Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
5086e17
feat: update showPostUpdateReleaseNotes to prevent showing notes in d…
XingYu-Zhong Jul 4, 2026
a69d25d
feat: add image annotation text operations and handling for IME compo…
XingYu-Zhong Jul 4, 2026
f9af2e1
feat: enhance text annotation functionality with draft creation and r…
XingYu-Zhong Jul 4, 2026
21cec5e
fix(settings): drop legacy top-level instructions on normalize
kxpw Jul 4, 2026
cf192ad
fix(memory): allow disabled records to be re-enabled
musnows Jul 4, 2026
b1a7821
fix(composer): collapse reasoning options
musnows Jul 4, 2026
bf6d5c4
fix(chat): compact timeline jump rail
musnows Jul 4, 2026
9b9bdc9
feat(provider): add configurable model request retry
musnows Jul 4, 2026
07276a0
fix(sidebar): strengthen active pin action background
musnows Jul 4, 2026
166ef02
feat(sidebar): archive workspace threads from context menu
musnows Jul 4, 2026
1a6b766
feat(workflow): move loop entry to top tabs
musnows Jul 4, 2026
8ec00c6
feat(im): add thread management commands
musnows Jul 4, 2026
b60156e
fix(subagent): handle detached interrupt state
musnows Jul 4, 2026
fb98d51
fix(runtime): preserve compaction context outlines
XingYu-Zhong Jul 4, 2026
41cf12e
feat(prompt): add composer prompt optimization
musnows Jul 4, 2026
38b4f3c
fix(design): recover malformed design systems
luoye520ww Jul 4, 2026
fb73873
test(shell): close Windows lifecycle races
luoye520ww Jul 4, 2026
9302ceb
test(tools): gate POSIX fd fixture
luoye520ww Jul 4, 2026
06cd67a
fix(tools): normalize relative path output
luoye520ww Jul 4, 2026
9e75efa
fix(im): normalize attachment relative paths
luoye520ww Jul 4, 2026
132d62b
test(sandbox): use Windows junctions for links
luoye520ww Jul 4, 2026
bafb6c5
fix(write): bound retrieval index cache
luoye520ww Jul 4, 2026
f0e43ec
fix(tools): bound repo map cache
luoye520ww Jul 4, 2026
783ce28
fix(im): guide unavailable user input tools
luoye520ww Jul 4, 2026
ddf849a
fix(runtime): clear deleted thread memory
luoye520ww Jul 4, 2026
d1162e5
fix(feishu): close streaming race windows
luoye520ww Jul 4, 2026
95488a3
test(provider): align retry coverage with defaults
luoye520ww Jul 4, 2026
afc3d74
fix(sidebar): show right rail tooltips
whitelonng Jul 4, 2026
b8d586a
fix(design): require explicit multi-page mode
luoye520ww Jul 4, 2026
3914b04
test(ipc): load handler module before cases
luoye520ww Jul 4, 2026
7bd66cc
test(windows): limit GUI Vitest contention
luoye520ww Jul 4, 2026
0df5ae5
test(checkpoint): use Windows junction fixture
luoye520ww Jul 4, 2026
847ab23
test(windows): limit Vitest worker contention
luoye520ww Jul 4, 2026
39d59a2
fix(shell): flush background output before summary
luoye520ww Jul 4, 2026
c8f1abf
test(tools): await mutation queue start signal
luoye520ww Jul 4, 2026
c4ebbdd
test(shell): avoid wall-clock background assertion
luoye520ww Jul 4, 2026
ecc788f
test(loop): avoid filesystem work in step ceiling case
luoye520ww Jul 4, 2026
50c946a
test(lsp): use native diagnostic file URIs
luoye520ww Jul 4, 2026
16ed262
fix(write): refresh initialized workspace tree
luoye520ww Jul 4, 2026
8667d58
fix(workbench): align sidebar navigation and timeline rails
XingYu-Zhong Jul 6, 2026
2b597cd
feat(composer): improve file mention completion
whitelonng Jul 7, 2026
53486d9
fix(subagent): inherit parent provider for delegated child runs
XingYu-Zhong Jul 7, 2026
7ba7f75
fix(write): allow disabling auto-save
XingYu-Zhong Jul 7, 2026
8e22336
feat(release): 添加 v0.2.24 更新说明,包含 Composer 优化、IM 命令增强及 Write 自动保存等功能
XingYu-Zhong Jul 7, 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
12 changes: 7 additions & 5 deletions docs/KUN_CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ GUI 启动 Kun 时会按下面的顺序合并配置。
"contextCompaction": {
"defaultSoftThreshold": 96000,
"defaultHardThreshold": 108800,
"summaryMode": "heuristic",
"summaryMode": "model",
"summaryTimeoutMs": 15000,
"summaryMaxTokens": 1200,
"summaryMaxTokens": 2048,
"summaryInputMaxBytes": 98304
},
"runtime": {
Expand Down Expand Up @@ -179,9 +179,9 @@ Kun 内置 DeepSeek V4 默认模型画像:
"contextCompaction": {
"defaultSoftThreshold": 96000,
"defaultHardThreshold": 108800,
"summaryMode": "heuristic",
"summaryMode": "model",
"summaryTimeoutMs": 15000,
"summaryMaxTokens": 1200,
"summaryMaxTokens": 2048,
"summaryInputMaxBytes": 98304
}
}
Expand All @@ -191,7 +191,9 @@ Kun 内置 DeepSeek V4 默认模型画像:

- `defaultSoftThreshold`: 未匹配到模型 profile 时,达到多少 input tokens 开始压缩。
- `defaultHardThreshold`: 未匹配到模型 profile 时,达到多少 input tokens 强制压缩。
- `summaryMode`: `heuristic` 使用本地摘要骨架,`model` 会尝试调用模型生成摘要。
- `summaryMode`: GUI 管理的配置默认并归一为 `model`。手工维护 `config.json`
时仍可显式写 `heuristic` 使用本地摘要骨架;`model` 模式下模型摘要失败、
超时或返回空文本时会自动降级为本地摘要骨架。
- `summaryTimeoutMs`: 模型摘要调用超时时间。
- `summaryMaxTokens`: 模型摘要输出 token 上限。
- `summaryInputMaxBytes`: 摘要输入文本最大字节数。
Expand Down
5 changes: 5 additions & 0 deletions kun/src/adapters/file/file-session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,11 @@ export class FileSessionStore implements SessionStore {
this.itemsCacheVersion.clear()
}

clearThreadMemory(threadId: string): void {
this.itemsCache.delete(threadId)
this.itemsCacheVersion.delete(threadId)
}

private itemsVersionOf(threadId: string): number {
return this.itemsCacheVersion.get(threadId) ?? 0
}
Expand Down
4 changes: 4 additions & 0 deletions kun/src/adapters/hybrid/hybrid-session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,8 @@ export class HybridSessionStore implements SessionStore {
async resetMemory(): Promise<void> {
await this.delegate.resetMemory()
}

clearThreadMemory(threadId: string): void {
this.delegate.clearThreadMemory(threadId)
}
}
7 changes: 7 additions & 0 deletions kun/src/adapters/in-memory-event-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,11 @@ export class InMemoryEventBus implements EventBus {
this.nextSeq.clear()
this.highestSeqByThread.clear()
}

clearThread(threadId: string): void {
this.events.delete(threadId)
this.subscribers.delete(threadId)
this.nextSeq.delete(threadId)
this.highestSeqByThread.delete(threadId)
}
}
6 changes: 6 additions & 0 deletions kun/src/adapters/in-memory-session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,10 @@ export class InMemorySessionStore implements SessionStore {
this.items.clear()
this.sessions.clear()
}

clearThreadMemory(threadId: string): void {
this.events.delete(threadId)
this.items.delete(threadId)
this.sessions.delete(threadId)
}
}
24 changes: 16 additions & 8 deletions kun/src/adapters/model/compat-model-client.retry.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import { CompatModelClient } from './compat-model-client.js'
import type { ModelRequestRetryConfig } from '../../config/kun-config.js'
import type { ModelRequest, ModelStreamChunk } from '../../ports/model-client.js'

// Transient upstream gateway failures (502/503/504 from a load balancer) are
// momentary backend hiccups, not request errors. The client retries them a few
// times before failing the turn — see streamInner's transient-retry loop.
// Retry behavior is opt-in and provider-configurable. Keep the status list and
// delays explicit so these tests match the runtime contract.

function request(signal?: AbortSignal): ModelRequest {
return {
Expand Down Expand Up @@ -40,13 +40,14 @@ function gatewayError(status: number): Response {
)
}

function client(fetchImpl: typeof fetch): CompatModelClient {
function client(fetchImpl: typeof fetch, retry?: ModelRequestRetryConfig): CompatModelClient {
return new CompatModelClient({
baseUrl: 'https://provider.example/v1',
apiKey: 'sk-test',
model: 'glm-5.1',
endpointFormat: 'chat_completions',
nonStreaming: true,
retry,
fetchImpl
})
}
Expand All @@ -59,19 +60,22 @@ describe('CompatModelClient transient gateway retry', () => {
return calls === 1 ? gatewayError(502) : okJson()
}) as unknown as typeof fetch

const chunks = await drain(client(fetchImpl).stream(request()))
const chunks = await drain(
client(fetchImpl, { maxAttempts: 1, initialDelayMs: 0, httpStatusCodes: [502] }).stream(request())
)

expect(calls).toBe(2)
expect(chunks).toContainEqual({ kind: 'retrying', status: 502, attempt: 1, maxAttempts: 1, delayMs: 0 })
expect(chunks.some((c) => c.kind === 'assistant_text_delta')).toBe(true)
expect(chunks.at(-1)).toEqual({ kind: 'completed', stopReason: 'stop' })
expect(chunks.some((c) => c.kind === 'error')).toBe(false)
})

it('does not retry a non-transient 500', async () => {
it('does not retry by default', async () => {
let calls = 0
const fetchImpl = (async () => {
calls += 1
return gatewayError(500)
return gatewayError(502)
}) as unknown as typeof fetch

const chunks = await drain(client(fetchImpl).stream(request()))
Expand Down Expand Up @@ -116,7 +120,11 @@ describe('CompatModelClient transient gateway retry', () => {
return gatewayError(503)
}) as unknown as typeof fetch

const chunks = await drain(client(fetchImpl).stream(request(controller.signal)))
const chunks = await drain(
client(fetchImpl, { maxAttempts: 1, initialDelayMs: 1_000, httpStatusCodes: [503] }).stream(
request(controller.signal)
)
)

expect(calls).toBe(1)
expect(chunks.some((c) => c.kind === 'error')).toBe(true)
Expand Down
83 changes: 69 additions & 14 deletions kun/src/adapters/model/compat-model-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { isToolResultBridgeItem, repairModelHistoryItems } from '../../domain/mo
import { extractToolResultImages, toolResultTextWithoutImages } from '../../loop/tool-result-image.js'
import { repairToolArguments } from './tool-argument-repair.js'
import { isDeepSeekHost, probeDeepSeekReachable } from './model-error-probe.js'
import {
DEFAULT_MODEL_REQUEST_RETRY_CONFIG,
type ModelRequestRetryConfig
} from '../../config/kun-config.js'
import {
DEFAULT_MODEL_ENDPOINT_FORMAT,
isCustomModelEndpointFormat,
Expand Down Expand Up @@ -44,6 +48,8 @@ export type CompatModelClientConfig = {
nonStreaming?: boolean
/** Maximum idle time between streaming chunks before the turn fails. */
streamIdleTimeoutMs?: number
/** 流式响应开始前,遇到临时失败或限流响应时使用的 HTTP 重试策略。 */
retry?: ModelRequestRetryConfig
/** Optional model capability resolver used for provider-specific reasoning translation. */
modelCapabilities?: (model: string) => ModelCapabilityMetadata
/** Optional troubleshooting sink that captures each request body + raw output. */
Expand Down Expand Up @@ -264,18 +270,23 @@ export class CompatModelClient implements ModelClient {
round.url = redactUrlForLog(url)
}
const headers = this.buildHeaders(stream, endpointFormat)
const retry = normalizeModelRequestRetryConfig(this.config.retry)
const retryStatuses = new Set(retry.httpStatusCodes)
let result = await this.postChatCompletion(url, headers, body, request.abortSignal)
// Retry transient gateway failures (502/503/504) a few times before giving
// up. These are upstream load-balancer hiccups (e.g. an ALB returning
// "502 Bad Gateway"), not request errors — failing the whole turn on the
// first blip is needlessly fragile, especially for flaky providers. No
// response body has been streamed yet, so re-POSTing the same request is
// safe. Aborts short-circuit the backoff.
for (let attempt = 0; attempt < MAX_TRANSIENT_RETRIES; attempt += 1) {
for (let attempt = 0; attempt < retry.maxAttempts; attempt += 1) {
if (result.kind === 'error') break
if (result.response.ok || !TRANSIENT_RETRY_STATUSES.has(result.response.status)) break
if (result.response.ok || !retryStatuses.has(result.response.status)) break
const delayMs = retryDelayMs(result.response, retry.initialDelayMs, attempt)
const status = result.response.status
await result.response.body?.cancel().catch(() => {})
const aborted = await sleepWithAbort(TRANSIENT_RETRY_BASE_MS * 2 ** attempt, request.abortSignal)
yield {
kind: 'retrying',
status,
attempt: attempt + 1,
maxAttempts: retry.maxAttempts,
delayMs
}
const aborted = await sleepWithAbort(delayMs, request.abortSignal)
if (aborted || request.abortSignal.aborted) {
yield { kind: 'error', message: 'request was aborted during retry backoff' }
return
Expand Down Expand Up @@ -2394,11 +2405,55 @@ function normalizeReasoningEffortValue(effort: string | undefined): NormalizedRe
}
}

// Transient upstream gateway statuses worth retrying — load balancers and
// reverse proxies return these for momentary backend unavailability.
const TRANSIENT_RETRY_STATUSES = new Set([502, 503, 504])
const MAX_TRANSIENT_RETRIES = 2
const TRANSIENT_RETRY_BASE_MS = 500
function normalizeModelRequestRetryConfig(input: ModelRequestRetryConfig | undefined): {
maxAttempts: number
initialDelayMs: number
httpStatusCodes: number[]
} {
const defaults = DEFAULT_MODEL_REQUEST_RETRY_CONFIG
return {
maxAttempts: boundedNonNegativeInteger(input?.maxAttempts, defaults.maxAttempts, 10),
initialDelayMs: boundedNonNegativeInteger(input?.initialDelayMs, defaults.initialDelayMs, 600_000),
httpStatusCodes: normalizeRetryHttpStatusCodes(input?.httpStatusCodes, defaults.httpStatusCodes)
}
}

function normalizeRetryHttpStatusCodes(input: unknown, fallback: readonly number[]): number[] {
const values = Array.isArray(input) ? input : fallback
const codes = new Set<number>()
for (const raw of values) {
const code = typeof raw === 'number' ? raw : Number(raw)
if (!Number.isInteger(code) || code < 400 || code > 599) continue
codes.add(code)
}
return codes.size > 0 ? [...codes].sort((a, b) => a - b) : [...fallback]
}

function boundedNonNegativeInteger(value: unknown, fallback: number, max: number): number {
const num = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(num)) return fallback
return Math.min(max, Math.max(0, Math.round(num)))
}

function retryDelayMs(response: Response, initialDelayMs: number, attempt: number): number {
const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after'))
if (retryAfterMs !== undefined) return retryAfterMs
const exponential = Math.min(600_000, initialDelayMs * 2 ** attempt)
if (exponential <= 0) return 0
return Math.round(exponential * (0.8 + Math.random() * 0.4))
}

function parseRetryAfterMs(value: string | null): number | undefined {
const trimmed = value?.trim()
if (!trimmed) return undefined
const seconds = Number(trimmed)
if (Number.isFinite(seconds) && seconds >= 0) {
return Math.min(600_000, Math.round(seconds * 1000))
}
const dateMs = Date.parse(trimmed)
if (!Number.isFinite(dateMs)) return undefined
return Math.min(600_000, Math.max(0, dateMs - Date.now()))
}

/** Sleep `ms`, resolving early to `true` if the signal aborts first. */
function sleepWithAbort(ms: number, signal: AbortSignal): Promise<boolean> {
Expand Down
25 changes: 25 additions & 0 deletions kun/src/adapters/tool/builtin-repo-map-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ async function createFixture(): Promise<string> {
return root
}

async function createEmptyFixture(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'kun-repo-map-cache-'))
tempRoots.push(root)
return root
}

function context(workspace: string): ToolHostContext {
return {
threadId: 'thread_repo_map',
Expand Down Expand Up @@ -95,4 +101,23 @@ describe('repo_map tool', () => {
expect((cached.output as { cache: { hit: boolean } }).cache.hit).toBe(true)
expect(SUBAGENT_READ_ONLY_TOOL_NAMES).toContain('repo_map')
})

it('evicts the least recently used workspace when the cache reaches its limit', async () => {
const first = await createEmptyFixture()
const tool = createRepoMapLocalTool()
await tool.execute({}, context(first))

const others: string[] = []
for (let index = 0; index < 7; index += 1) {
const root = await createEmptyFixture()
others.push(root)
await tool.execute({}, context(root))
}
expect((await tool.execute({}, context(first))).output).toMatchObject({ cache: { hit: true } })
await tool.execute({}, context(await createEmptyFixture()))

expect((await tool.execute({}, context(first))).output).toMatchObject({ cache: { hit: true } })
const rebuilt = await tool.execute({}, context(others[0]!))
expect((rebuilt.output as { cache: { hit: boolean } }).cache.hit).toBe(false)
}, 10_000)
})
20 changes: 19 additions & 1 deletion kun/src/adapters/tool/builtin-repo-map-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const DEFAULT_REPO_MAP_MAX_FILES = 20
const DEFAULT_REPO_MAP_MAX_SYMBOLS = 12
const DEFAULT_REPO_MAP_SCAN_LIMIT = 2500
const REPO_MAP_CACHE_TTL_MS = 30_000
const REPO_MAP_CACHE_MAX_ENTRIES = 8
const MAX_SYMBOL_BYTES = 512 * 1024
const MAX_GIT_RECENT_FILES = 250
const INDEX_CONCURRENCY = 12
Expand Down Expand Up @@ -99,6 +100,17 @@ type RepoMapCacheEntry = {

const repoMapCache = new Map<string, RepoMapCacheEntry>()

function pruneRepoMapCache(now: number): void {
for (const [key, entry] of repoMapCache) {
if (entry.expiresAt <= now) repoMapCache.delete(key)
}
while (repoMapCache.size > REPO_MAP_CACHE_MAX_ENTRIES) {
const oldest = repoMapCache.keys().next().value
if (oldest === undefined) break
repoMapCache.delete(oldest)
}
}

export function createRepoMapLocalTool(): LocalTool {
return {
name: 'repo_map',
Expand Down Expand Up @@ -148,8 +160,9 @@ export function createRepoMapLocalTool(): LocalTool {
const { workspaceRoot, absolutePath, relativePath } = await resolveWorkspacePath(rawPath, context)
const cacheKey = `${workspaceRoot}\0${absolutePath}`
const gitHead = await gitHeadForWorkspace(workspaceRoot)
const cached = repoMapCache.get(cacheKey)
const now = Date.now()
pruneRepoMapCache(now)
const cached = repoMapCache.get(cacheKey)
const cacheHit = Boolean(
cached &&
!refresh &&
Expand All @@ -167,11 +180,16 @@ export function createRepoMapLocalTool(): LocalTool {
signal: context.abortSignal
})
if (!cacheHit) {
repoMapCache.delete(cacheKey)
repoMapCache.set(cacheKey, {
index,
expiresAt: now + REPO_MAP_CACHE_TTL_MS,
scanLimit: maxScanFiles
})
pruneRepoMapCache(now)
} else {
repoMapCache.delete(cacheKey)
repoMapCache.set(cacheKey, cached!)
}

const ranked = rankRepoMapFiles(index.files, query, index.recentFiles)
Expand Down
4 changes: 3 additions & 1 deletion kun/src/adapters/tool/builtin-tool-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export type BuiltinToolName =
| 'lsp'
| 'repo_map'
| 'verify_changes'
| 'send_im_attachment'
export const allBuiltinToolNames: Set<BuiltinToolName> = new Set([
'read',
'bash',
Expand All @@ -110,7 +111,8 @@ export const allBuiltinToolNames: Set<BuiltinToolName> = new Set([
'ls',
'lsp',
'repo_map',
'verify_changes'
'verify_changes',
'send_im_attachment'
])
export type ToolName = BuiltinToolName
export const allToolNames: Set<ToolName> = allBuiltinToolNames
Expand Down
Loading
Loading