Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
27db6ee
fix(runtime): recover missing replies after SSE disconnect
XingYu-Zhong Jun 30, 2026
fa80e2a
Merge pull request #657 from KunAgent/codex/fix-issue-653-missing-reply
XingYu-Zhong Jun 30, 2026
e6aee4b
fix(settings): add visible background to log file path display
musnows Jun 30, 2026
b355bc1
feat(settings): enable unused Git checkpoint cleanup by default
musnows Jun 30, 2026
81d65b1
fix(workspace): run git inspection in selected directory (#678)
luoye520ww Jul 1, 2026
4a63b9e
fix(renderer): preserve Vietnamese grapheme clusters in streamed outp…
huydu-2006 Jul 1, 2026
5b6facc
fix(checkpoint): prevent partial restore data loss (#661)
luoye520ww Jul 1, 2026
0bd65f9
fix(usage): align cache hit rate across session views (#662)
luoye520ww Jul 1, 2026
fb1218b
fix(provider): correct OpenCode Go DeepSeek v4 profiles (#664)
luoye520ww Jul 1, 2026
627dd9e
fix(sdd): make requirement workspace explicit (#666)
luoye520ww Jul 1, 2026
6e45a66
fix(attachments): enforce upload safety boundaries (#667)
luoye520ww Jul 1, 2026
03c5f81
feat(artifacts): offload oversized tool output (#669)
luoye520ww Jul 1, 2026
cfee021
feat(cli): stream headless runs as jsonl (#670)
luoye520ww Jul 1, 2026
93c2c07
fix(marketplace): pin and validate MCP package installs (#673)
luoye520ww Jul 1, 2026
331b77a
fix(skills): preserve project and global root scopes (#679)
luoye520ww Jul 1, 2026
598eafb
feat(git): add configurable branch prefix (#680)
luoye520ww Jul 1, 2026
f6adf4b
feat(cache): detect hit-rate regressions (#681)
luoye520ww Jul 1, 2026
18adf2b
feat(write): expose runtime skills in assistant (#682)
luoye520ww Jul 1, 2026
795656e
fix(chat): improve branch conversation navigation (#683)
luoye520ww Jul 1, 2026
a72900d
feat(tools): prefer specialized MCP inspection (#684)
luoye520ww Jul 1, 2026
d031113
feat(replay): add quality assertions (#685)
luoye520ww Jul 1, 2026
88ea99f
feat(memory): govern provenance and expiry (#686)
luoye520ww Jul 1, 2026
d0f5cb1
feat(delegation): enforce child execution contracts (#687)
luoye520ww Jul 1, 2026
129adca
feat(repo-map): add budgeted local code map (#644)
luoye520ww Jul 1, 2026
c64fbbc
feat(attachments): inject document text attachments (#677)
luoye520ww Jul 1, 2026
8f2b244
feat(tasks): add persistent agent task graph (#668)
luoye520ww Jul 1, 2026
5abe1b1
feat(mcp): add secure remote OAuth runtime (#665)
luoye520ww Jul 1, 2026
662f8de
feat(remote): wire ssh targets into runtime tools (#672)
luoye520ww Jul 1, 2026
3340787
feat(remote): add target picker UI (#675)
luoye520ww Jul 1, 2026
0718c87
feat(remote): add ssh port forwarding tool (#676)
luoye520ww Jul 1, 2026
119c4d4
fix(release): restore develop validation checks
XingYu-Zhong Jul 1, 2026
072d30d
Merge pull request #688 from KunAgent/codex/release-readiness-fixes
XingYu-Zhong Jul 1, 2026
2358a82
Revert "feat(remote): add ssh port forwarding tool (#676)"
XingYu-Zhong Jul 1, 2026
31ca1bc
Revert "feat(remote): add target picker UI (#675)"
XingYu-Zhong Jul 1, 2026
f9687bb
Revert "feat(remote): wire ssh targets into runtime tools (#672)"
XingYu-Zhong Jul 1, 2026
5ea606f
Merge pull request #690 from KunAgent/codex/revert-ssh-remote
XingYu-Zhong Jul 1, 2026
73e20dd
feat(release): 添加 v0.2.21 发布说明,包含新功能、修复和撤回内容
XingYu-Zhong Jul 1, 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
9 changes: 8 additions & 1 deletion kun/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,13 @@ Shape:
"transport": "streamable-http",
"url": "https://mcp.example.com/mcp",
"headers": { "authorization": "Bearer <docs-mcp-token>" },
"oauth": {
"enabled": true,
"clientName": "Kun",
"clientId": "<optional-oauth-client-id>",
"clientSecret": "<optional-oauth-client-secret>",
"scopes": ["docs.readonly"]
},
"trustScope": "user",
"timeoutMs": 30000
}
Expand Down Expand Up @@ -279,7 +286,7 @@ See `../docs/KUN_CONFIG.md` for the detailed file layout and examples.

Feature flags are intentionally explicit:

- `capabilities.mcp` starts configured MCP clients and imports their tools into the dynamic registry. Workspace-scoped servers require `trustedWorkspaceRoots`.
- `capabilities.mcp` starts configured MCP clients and imports their tools into the dynamic registry. Workspace-scoped servers require `trustedWorkspaceRoots`. Remote HTTP/SSE servers can use `oauth`; Kun stores OAuth tokens under the data directory instead of the config file. Use `GET /v1/mcp/oauth` to inspect redacted OAuth state and `DELETE /v1/mcp/oauth/{serverId}` to clear a server's saved authorization.
- `serve.mcpSearch` can collapse a large MCP catalog into four entry points: `mcp_search`, `mcp_describe`, `mcp_call`, and `mcp_refresh_catalog`. When the catalog is too large, the model searches for relevant tools first, then describes and calls the exact tool instead of carrying every MCP schema on every turn.
- `serve.tokenEconomy` / `tokenEconomyMode` compresses tool descriptions, tool results, and history context while preserving code, paths, commands, URLs, errors, and other high-value signals.
- `contextCompaction` controls fallback long-thread compaction thresholds and summary behavior. Per-model thresholds live in `models.profiles`. Compaction preserves goals, constraints, decisions, touched files, tool outcomes, and unresolved next steps.
Expand Down
9 changes: 8 additions & 1 deletion kun/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,13 @@ Kun 使用 JSON 配置文件管理运行时行为,避免重建后重配或硬
"transport": "streamable-http",
"url": "https://mcp.example.com/mcp",
"headers": { "authorization": "Bearer <docs-mcp-token>" },
"oauth": {
"enabled": true,
"clientName": "Kun",
"clientId": "<optional-oauth-client-id>",
"clientSecret": "<optional-oauth-client-secret>",
"scopes": ["docs.readonly"]
},
"trustScope": "user",
"timeoutMs": 30000
}
Expand Down Expand Up @@ -235,7 +242,7 @@ Kun 默认使用混合存储:`threads/{threadId}/messages.jsonl` 与 `events.j

功能开关是显式设计:

- `capabilities.mcp` 启动配置化 MCP 客户端并将工具加入动态注册表;工作区级服务器要求设置 `trustedWorkspaceRoots`。
- `capabilities.mcp` 启动配置化 MCP 客户端并将工具加入动态注册表;工作区级服务器要求设置 `trustedWorkspaceRoots`。远程 HTTP/SSE MCP 可配置 `oauth`,Kun 会把 OAuth token 存在数据目录下,而不是写进 config。使用 `GET /v1/mcp/oauth` 可查看脱敏后的 OAuth 状态,使用 `DELETE /v1/mcp/oauth/{serverId}` 可清除某个服务保存的授权。
- `serve.mcpSearch` 可把大量 MCP 工具收敛为 `mcp_search`、`mcp_describe`、`mcp_call` 和 `mcp_refresh_catalog` 四个入口;当工具目录过大时,模型先检索意图相关工具,再描述和调用具体工具,避免每轮都携带完整 MCP schema。
- `serve.tokenEconomy` / `tokenEconomyMode` 会压缩工具描述、工具结果和历史上下文;保留代码、路径、命令、URL、错误信号等高价值信息,同时省掉重复、超长或二进制 payload。
- `contextCompaction` 控制长会话压缩的兜底阈值和摘要方式;模型级阈值写在 `models.profiles`。压缩时保留目标、约束、决策、已触碰文件、工具结果和未解决事项。
Expand Down
44 changes: 44 additions & 0 deletions kun/src/adapters/model/compat-model-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type ModelEndpointFormat
} from '../../contracts/model-endpoint-format.js'
import { createProxyFetch } from './proxy-fetch.js'
import { wrapUntrustedContent } from '../../security/untrusted-content.js'

/**
* Configuration for the compatible HTTP model client. Chat
Expand Down Expand Up @@ -695,6 +696,9 @@ export class CompatModelClient implements ModelClient {
if (request.attachmentTextFallbacks?.length) {
attachTextFallbacksToLatestUserMessage(out, request.attachmentTextFallbacks)
}
if (request.attachmentDocuments?.length) {
attachDocumentsToLatestUserMessage(out, request.attachmentDocuments)
}
return normalizeThinkingAssistantMessages(healToolMessagePairs(out), thinkingMode)
}

Expand Down Expand Up @@ -2649,6 +2653,46 @@ function attachTextFallbacksToLatestUserMessage(
}
}

function attachDocumentsToLatestUserMessage(
messages: ChatMessage[],
documents: NonNullable<ModelRequest['attachmentDocuments']>
): void {
const text = documents.map(formatAttachmentDocument).join('\n\n')
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index]
if (message.role !== 'user') continue
if (typeof message.content === 'string') {
message.content = message.content ? `${message.content}\n\n${text}` : text
return
}
if (Array.isArray(message.content)) {
message.content.push({ type: 'text', text })
return
}
message.content = text
return
}
}

function formatAttachmentDocument(
document: NonNullable<ModelRequest['attachmentDocuments']>[number]
): string {
return [
'[Attached document]',
`Name: ${document.name}`,
`FilePath: ${document.localFilePath ?? 'unknown'}`,
`MIME: ${document.mimeType}`,
...(document.pageCount ? [`Pages: ${document.pageCount}`] : []),
...(document.truncated ? ['Note: text truncated to fit the context limit'] : []),
'Content:',
wrapUntrustedContent({
content: document.text,
source: { kind: 'document', label: document.name }
}),
'[/Attached document]'
].join('\n')
}

function formatAttachmentTextFallback(
attachment: NonNullable<ModelRequest['attachmentTextFallbacks']>[number]
): string {
Expand Down
54 changes: 54 additions & 0 deletions kun/src/adapters/tool/artifact-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest'
import { createReadArtifactTool } from './artifact-tool.js'
import { InMemoryArtifactStore } from '../../artifacts/artifact-store.js'
import type { ToolHostContext } from '../../ports/tool-host.js'

function context(artifactStore?: ToolHostContext['artifactStore']): ToolHostContext {
return {
threadId: 't', turnId: 'tn', workspace: '/ws', approvalPolicy: 'auto',
abortSignal: new AbortController().signal, awaitApproval: vi.fn(async () => 'allow' as const),
...(artifactStore ? { artifactStore } : {})
}
}

describe('read_artifact tool', () => {
it('reads full content + metadata by id', async () => {
const store = new InMemoryArtifactStore(() => 't0')
const { meta } = await store.put({ content: 'hello world', source: 'mcp', origin: 'docs' })
const tool = createReadArtifactTool()
const result = await tool.execute({ artifactId: meta.id }, context(store))
expect(result.output).toMatchObject({ content: 'hello world', source: 'mcp', origin: 'docs' })
})

it('reads a line range', async () => {
const store = new InMemoryArtifactStore()
const { meta } = await store.put({ content: 'l1\nl2\nl3\nl4' })
const tool = createReadArtifactTool()
const result = await tool.execute({ artifactId: meta.id, startLine: 2, endLine: 3 }, context(store))
expect(result.output).toMatchObject({ content: 'l2\nl3', range: { startLine: 2, endLine: 3 } })
})

it('bounds a no-range read and returns a cursor for a large artifact', async () => {
const { ARTIFACT_MAX_READ_BYTES } = await import('../../artifacts/artifact-store.js')
const store = new InMemoryArtifactStore(() => 't0')
const { meta } = await store.put({ content: 'y'.repeat(ARTIFACT_MAX_READ_BYTES + 1_000) })
const tool = createReadArtifactTool()
const result = await tool.execute({ artifactId: meta.id }, context(store))
const out = result.output as Record<string, unknown>
expect(Buffer.byteLength(String(out.content), 'utf8')).toBe(ARTIFACT_MAX_READ_BYTES)
expect(out.truncated).toBe(true)
expect(out.nextOffset).toBe(ARTIFACT_MAX_READ_BYTES)
})

it('errors for an unknown artifact', async () => {
const tool = createReadArtifactTool()
const result = await tool.execute({ artifactId: 'art_missing' }, context(new InMemoryArtifactStore()))
expect(result.isError).toBe(true)
})

it('errors when the artifact store is unavailable', async () => {
const tool = createReadArtifactTool()
const result = await tool.execute({ artifactId: 'art_x' }, context())
expect(result.isError).toBe(true)
})
})
69 changes: 69 additions & 0 deletions kun/src/adapters/tool/artifact-tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Agent-callable artifact reader (P0 #5).
*
* Large tool results are offloaded to the content-addressed artifact store and
* the model only sees a bounded summary + an artifact id. This tool lets the
* model pull the rest on demand — the full content, a byte range, or a line
* range — instead of forcing every big payload into context. Read-only.
*/

import { LocalToolHost, type LocalTool } from './local-tool-host.js'
import { readArtifactBounded } from '../../artifacts/artifact-store.js'

export function createReadArtifactTool(): LocalTool {
return LocalToolHost.defineTool({
name: 'read_artifact',
description:
'Read a stored artifact (large tool output) by id. Optionally fetch only a slice: ' +
'startLine/endLine for a 1-indexed inclusive line range, or offset/length for a UTF-8 byte range. ' +
'Use the artifact id returned by a previous tool result (e.g. stdoutArtifactId).',
inputSchema: {
type: 'object',
properties: {
artifactId: { type: 'string' },
offset: { type: 'number' },
length: { type: 'number' },
startLine: { type: 'number' },
endLine: { type: 'number' }
},
required: ['artifactId'],
additionalProperties: false
},
policy: 'auto',
execute: async (args, context) => {
if (!context.artifactStore) {
return { output: { error: 'artifact store is not available in this runtime' }, isError: true }
}
const artifactId = typeof args.artifactId === 'string' ? args.artifactId.trim() : ''
if (!artifactId) return { output: { error: 'artifactId is required' }, isError: true }
const meta = await context.artifactStore.stat(artifactId)
if (!meta) return { output: { error: `artifact not found: ${artifactId}` }, isError: true }
const range = {
...(typeof args.offset === 'number' ? { offset: args.offset } : {}),
...(typeof args.length === 'number' ? { length: args.length } : {}),
...(typeof args.startLine === 'number' ? { startLine: args.startLine } : {}),
...(typeof args.endLine === 'number' ? { endLine: args.endLine } : {})
}
// Always read through the bounded reader: a request with no range, or a
// range larger than the cap, is clamped to <=1 MiB / <=2000 lines and a
// cursor is returned so the model pages rather than pulling a huge result
// into context.
const bounded = await readArtifactBounded(context.artifactStore, artifactId, meta, range)
if (bounded === null) return { output: { error: `artifact content not found: ${artifactId}` }, isError: true }
return {
output: {
artifactId,
byteSize: meta.byteSize,
lineCount: meta.lineCount,
...(meta.source ? { source: meta.source } : {}),
...(meta.origin ? { origin: meta.origin } : {}),
range: bounded.range,
truncated: bounded.truncated,
...(bounded.nextOffset !== undefined ? { nextOffset: bounded.nextOffset } : {}),
...(bounded.nextStartLine !== undefined ? { nextStartLine: bounded.nextStartLine } : {}),
content: bounded.content
}
}
}
})
}
98 changes: 98 additions & 0 deletions kun/src/adapters/tool/builtin-repo-map-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SUBAGENT_READ_ONLY_TOOL_NAMES } from '../../contracts/capabilities.js'
import type { ToolHostContext } from '../../ports/tool-host.js'
import { createRepoMapLocalTool } from './builtin-repo-map-tool.js'

const tempRoots: string[] = []

afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})

async function createFixture(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'kun-repo-map-'))
tempRoots.push(root)
await mkdir(join(root, 'src'), { recursive: true })
await mkdir(join(root, 'node_modules', 'ignored'), { recursive: true })
await writeFile(join(root, 'package.json'), JSON.stringify({ name: 'repo-map-fixture' }))
await writeFile(
join(root, 'src', 'user-service.ts'),
[
"import { openDatabase } from './database'",
'export class UserRepository {}',
'export async function findUserByEmail(email: string) {',
' return openDatabase().users.find((user) => user.email === email)',
'}'
].join('\n')
)
await writeFile(
join(root, 'src', 'payment.ts'),
'export function chargeInvoice(total: number) { return total }\n'
)
await writeFile(
join(root, 'node_modules', 'ignored', 'user-copy.ts'),
'export class UserRepositoryCopy {}\n'
)
return root
}

function context(workspace: string): ToolHostContext {
return {
threadId: 'thread_repo_map',
turnId: 'turn_repo_map',
workspace,
approvalPolicy: 'auto',
abortSignal: new AbortController().signal,
awaitApproval: vi.fn()
}
}

describe('repo_map tool', () => {
it('ranks relevant symbols and skips dependency directories', async () => {
const root = await createFixture()
const tool = createRepoMapLocalTool()

const result = await tool.execute(
{ query: 'find user repository by email', maxFiles: 5 },
context(root)
)
const output = result.output as {
cache: { hit: boolean }
totals: { scanBackend: string; scannedFiles: number; indexedFiles: number; truncated: boolean }
files: Array<{
relative_path: string
symbols: Array<{ name: string; kind: string; line: number }>
imports: string[]
}>
skippedDirectories: string[]
}

expect(output.cache.hit).toBe(false)
expect(output.totals).toMatchObject({
scanBackend: 'filesystem',
scannedFiles: 3,
indexedFiles: 3,
truncated: false
})
expect(output.files[0]?.relative_path).toBe('src/user-service.ts')
expect(output.files[0]?.symbols).toEqual(expect.arrayContaining([
{ name: 'UserRepository', kind: 'class', line: 2 },
{ name: 'findUserByEmail', kind: 'function', line: 3 }
]))
expect(output.files[0]?.imports).toContain('./database')
expect(output.skippedDirectories).toContain('node_modules')
})

it('reuses the short-lived index and is enabled for read-only subagents', async () => {
const root = await createFixture()
const tool = createRepoMapLocalTool()
await tool.execute({ query: 'payment' }, context(root))

const cached = await tool.execute({ query: 'user' }, context(root))
expect((cached.output as { cache: { hit: boolean } }).cache.hit).toBe(true)
expect(SUBAGENT_READ_ONLY_TOOL_NAMES).toContain('repo_map')
})
})
Loading
Loading