From 07c3632415fa77972c49c39d7171ee5a4790bd01 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Tue, 14 Jul 2026 17:34:35 +0800 Subject: [PATCH 01/76] fix(workspace): sync workspace catalog across v1/v2 and soft-delete (#1701) - register the cwd in workspaces.json when a v1 (TUI/SDK) session is created, via a shared workspaceRegistryFile read/write/touch module - merge session_index.jsonl into the catalog once at v2 startup (awaited during kap-server boot), adding only paths absent from workspaces.json - make v2 workspace delete a soft delete through the v1-compatible deleted_workspace_ids field: the session-index merge never resurrects tombstoned ids, while an explicit createOrTouch clears the tombstone --- .changeset/workspace-catalog-sync.md | 5 + .../fileWorkspacePersistence.ts | 27 ++- .../workspaceRegistry/workspacePersistence.ts | 27 ++- .../workspaceRegistryService.ts | 131 ++++++++++---- .../workspaceRegistryService.test.ts | 112 +++++++++++- packages/agent-core/src/rpc/core-impl.ts | 7 + packages/agent-core/src/services/AGENTS.md | 2 +- .../src/services/workspace/index.ts | 1 + .../workspace/workspaceRegistryFile.ts | 161 ++++++++++++++++++ .../workspace/workspaceRegistryService.ts | 103 ++--------- .../test/services/workspace-registry.test.ts | 97 ++++++++++- packages/kap-server/src/start.ts | 15 ++ 12 files changed, 540 insertions(+), 148 deletions(-) create mode 100644 .changeset/workspace-catalog-sync.md create mode 100644 packages/agent-core/src/services/workspace/workspaceRegistryFile.ts diff --git a/.changeset/workspace-catalog-sync.md b/.changeset/workspace-catalog-sync.md new file mode 100644 index 000000000..eac3819a8 --- /dev/null +++ b/.changeset/workspace-catalog-sync.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep the workspace catalog complete and durable: creating a session registers its directory as a workspace, the server backfills missing workspaces from session history at startup, and a removed workspace no longer reappears after a restart. diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts b/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts index dfa05670b..7f04827d0 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts @@ -4,7 +4,10 @@ * File backend of `IWorkspacePersistence`. Persists the catalog as a single * v1-compatible `workspaces.json` document at the storage root * (`/workspaces.json`, via `scope = ''`) through the - * `IAtomicDocumentStore` access-pattern Store. Bound at App scope. + * `IAtomicDocumentStore` access-pattern Store. The `deleted_workspace_ids` + * tombstone list round-trips with the catalog so soft deletions survive + * regardless of which engine (v1 or v2) last wrote the file. Bound at App + * scope. */ import { InstantiationType } from '#/_base/di/extensions'; @@ -16,6 +19,7 @@ import { IWorkspacePersistence, type PersistedWorkspaceEntry, type PersistedWorkspaceFile, + type WorkspaceCatalog, } from './workspacePersistence'; const WORKSPACE_REGISTRY_VERSION = 1; @@ -27,7 +31,7 @@ export class FileWorkspacePersistence implements IWorkspacePersistence { constructor(@IAtomicDocumentStore private readonly docs: IAtomicDocumentStore) {} - async load(): Promise { + async load(): Promise { const file = await this.docs.get( WORKSPACE_REGISTRY_SCOPE, WORKSPACE_REGISTRY_KEY, @@ -42,11 +46,11 @@ export class FileWorkspacePersistence implements IWorkspacePersistence { return undefined; } const now = Date.now(); - const result: Workspace[] = []; + const workspaces: Workspace[] = []; for (const [id, raw] of Object.entries(file.workspaces)) { - const entry = sanitizeEntry(raw, now); + const entry = sanitizeEntry(raw); if (entry === null) continue; - result.push({ + workspaces.push({ id, root: entry.root, name: entry.name, @@ -54,12 +58,16 @@ export class FileWorkspacePersistence implements IWorkspacePersistence { lastOpenedAt: parseTime(entry.last_opened_at, now), }); } - return result; + const rawDeleted = (file as { deleted_workspace_ids?: unknown }).deleted_workspace_ids; + const deletedIds = Array.isArray(rawDeleted) + ? rawDeleted.filter((id): id is string => typeof id === 'string') + : []; + return { workspaces, deletedIds }; } - async save(workspaces: readonly Workspace[]): Promise { + async save(catalog: WorkspaceCatalog): Promise { const record: Record = {}; - for (const ws of workspaces) { + for (const ws of catalog.workspaces) { record[ws.id] = { root: ws.root, name: ws.name, @@ -70,12 +78,13 @@ export class FileWorkspacePersistence implements IWorkspacePersistence { const file: PersistedWorkspaceFile = { version: WORKSPACE_REGISTRY_VERSION, workspaces: record, + deleted_workspace_ids: [...catalog.deletedIds], }; await this.docs.set(WORKSPACE_REGISTRY_SCOPE, WORKSPACE_REGISTRY_KEY, file); } } -function sanitizeEntry(value: unknown, _now: number): PersistedWorkspaceEntry | null { +function sanitizeEntry(value: unknown): PersistedWorkspaceEntry | null { if (typeof value !== 'object' || value === null) return null; const v = value as Partial; if ( diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts index 8a06729a5..46ae19c20 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts @@ -3,13 +3,20 @@ * * Domain-specific persistence Store for the known-workspaces catalog. It hides * the on-disk document layout (`/workspaces.json`, the v1-compatible - * `{ version, workspaces: { [id]: entry } }` shape) and its serialization - * concerns (ISO ↔ epoch-ms, record ↔ array) from the registry. The generic - * `IAtomicDocumentStore` it builds on stays schema-agnostic. + * `{ version, workspaces: { [id]: entry }, deleted_workspace_ids: string[] }` + * shape — shared with agent-core, which reads and writes the same file) and + * its serialization concerns (ISO ↔ epoch-ms, record ↔ array) from the + * registry. The generic `IAtomicDocumentStore` it builds on stays + * schema-agnostic. + * + * `deleted_workspace_ids` is the soft-delete tombstone list: ids the user + * explicitly removed. Tombstoned entries are absent from `workspaces`, but + * their ids must survive load/save round-trips so the session-index merge + * never resurrects them. * * `load()` returns `undefined` to mean "no usable catalog" so the registry can - * trigger a one-shot rebuild from the legacy session index; an empty array is - * a valid, already-materialized catalog and must NOT trigger a rebuild. + * trigger a one-shot rebuild from the legacy session index; an empty catalog + * is a valid, already-materialized state and must NOT trigger a rebuild. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -26,13 +33,19 @@ export interface PersistedWorkspaceEntry { export interface PersistedWorkspaceFile { readonly version: number; readonly workspaces: Record; + readonly deleted_workspace_ids: string[]; +} + +export interface WorkspaceCatalog { + readonly workspaces: readonly Workspace[]; + readonly deletedIds: readonly string[]; } export interface IWorkspacePersistence { readonly _serviceBrand: undefined; - load(): Promise; - save(workspaces: readonly Workspace[]): Promise; + load(): Promise; + save(catalog: WorkspaceCatalog): Promise; } export const IWorkspacePersistence: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts index 3b45b82e7..9baaebdb9 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts @@ -1,20 +1,32 @@ /** * `workspaceRegistry` domain (L1) — `IWorkspaceRegistry` implementation. * - * Process-wide catalog of known workspaces, now durable: an in-memory cache - * is loaded once from `IWorkspacePersistence` (`/workspaces.json`, v1 - * compatible) and every mutation writes back through it. When the catalog is - * absent or malformed, it is rebuilt once from the legacy - * `/session_index.jsonl` (one workspace per distinct absolute - * `workDir`) and then persisted. All access is serialized through a - * promise-chain mutex so load/rebuild/mutations never race. + * Process-wide catalog of known workspaces, durable: an in-memory cache is + * loaded once from `IWorkspacePersistence` (`/workspaces.json`, the + * v1-compatible file shared with agent-core) and every mutation writes back + * through it. Loading has two paths: + * + * 1. No usable catalog file → one-shot rebuild from the legacy + * `/session_index.jsonl` (one workspace per distinct absolute + * `workDir`), then persisted. + * 2. Catalog loaded → a one-time merge from the same session index adds every + * workDir the file does not know about yet (e.g. sessions created by the + * v1 TUI since the last merge), then persisted if anything changed. + * + * Deletion is soft: `delete` drops the entry but records the id in + * `deleted_workspace_ids`, and the merge never resurrects a tombstoned id. + * An explicit `createOrTouch` clears the tombstone — the user opening the + * folder again is a stronger signal than the historical index. + * + * All access is serialized through a promise-chain mutex so + * load/rebuild/merge/mutations never race. * * `createOrTouch` is the single choke point every workspace/session creation * funnels through, so it owns the root-existence contract: the root must be * an existing directory on the host filesystem, otherwise it throws * `fs.path_not_found` (mirrors v1's `WorkspaceRootNotFoundError`). The rebuild - * path bypasses the check on purpose — it catalogs where sessions *were*, not - * where new ones may open. Bound at App scope. + * and merge paths bypass the check on purpose — they catalog where sessions + * *were*, not where new ones may open. Bound at App scope. */ import { basename, isAbsolute } from 'pathe'; @@ -44,6 +56,7 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { declare readonly _serviceBrand: undefined; private cache: Map | undefined; + private deletedIds: Set | undefined; private opQueue: Promise = Promise.resolve(); constructor( @@ -96,7 +109,9 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { lastOpenedAt: now, }; cache.set(id, ws); - await this.store.save([...cache.values()]); + // An explicit add clears any prior deletion tombstone. + this.deletedIds?.delete(id); + await this.persist(); return ws; }); } @@ -111,7 +126,7 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { ...(patch.name !== undefined ? { name: patch.name } : {}), }; cache.set(id, updated); - await this.store.save([...cache.values()]); + await this.persist(); return updated; }); } @@ -120,45 +135,99 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { return this.runExclusive(async () => { const cache = await this.ensureLoaded(); cache.delete(id); - await this.store.save([...cache.values()]); + // Soft delete: tombstone the id so the session-index merge cannot + // resurrect it, even if sessions still reference the workDir. + this.deletedIds?.add(id); + await this.persist(); }); } private async ensureLoaded(): Promise> { if (this.cache !== undefined) return this.cache; const loaded = await this.store.load(); - if (loaded !== undefined) { - this.cache = new Map(loaded.map((ws) => [ws.id, ws])); - return this.cache; + if (loaded === undefined) { + const rebuilt = await this.rebuildFromSessionIndex(); + this.cache = rebuilt; + this.deletedIds = new Set(); + await this.persist(); + return rebuilt; } - const rebuilt = await this.rebuildFromSessionIndex(); - this.cache = rebuilt; - await this.store.save([...rebuilt.values()]); - return this.cache; + const cache = new Map(loaded.workspaces.map((ws) => [ws.id, ws])); + const deletedIds = new Set(loaded.deletedIds); + this.cache = cache; + this.deletedIds = deletedIds; + if (await this.mergeFromSessionIndex(cache, deletedIds)) { + await this.persist(); + } + return cache; + } + + /** Add every distinct workDir from the legacy session index that the + * catalog does not know about yet. Tombstoned ids are skipped, so a + * soft-deleted workspace stays deleted. Returns whether anything changed. */ + private async mergeFromSessionIndex( + cache: Map, + deletedIds: ReadonlySet, + ): Promise { + let changed = false; + const now = Date.now(); + for (const workDir of await this.readSessionIndexWorkDirs()) { + const id = encodeWorkDirKey(workDir); + if (cache.has(id) || deletedIds.has(id)) continue; + cache.set(id, { + id, + root: workDir, + name: basename(workDir), + createdAt: now, + lastOpenedAt: now, + }); + changed = true; + } + return changed; } private async rebuildFromSessionIndex(): Promise> { const result = new Map(); - const bytes = await this.storage.read(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY); - if (bytes === undefined) return result; const now = Date.now(); + for (const workDir of await this.readSessionIndexWorkDirs()) { + const id = encodeWorkDirKey(workDir); + if (result.has(id)) continue; + result.set(id, { + id, + root: workDir, + name: basename(workDir), + createdAt: now, + lastOpenedAt: now, + }); + } + return result; + } + + private async readSessionIndexWorkDirs(): Promise { + const bytes = await this.storage.read(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY); + if (bytes === undefined) return []; + const workDirs: string[] = []; for (const line of textDecoder.decode(bytes).split(/\r?\n/)) { const trimmed = line.trim(); if (trimmed === '') continue; const entry = parseSessionIndexLine(trimmed); if (entry === undefined) continue; if (!isAbsolute(entry.workDir)) continue; - const id = encodeWorkDirKey(entry.workDir); - if (result.has(id)) continue; - result.set(id, { - id, - root: entry.workDir, - name: basename(entry.workDir), - createdAt: now, - lastOpenedAt: now, - }); + workDirs.push(entry.workDir); } - return result; + return workDirs; + } + + private async persist(): Promise { + const cache = this.cache; + const deletedIds = this.deletedIds; + if (cache === undefined || deletedIds === undefined) { + throw new Error('workspace registry mutated before load completed'); + } + await this.store.save({ + workspaces: [...cache.values()], + deletedIds: [...deletedIds], + }); } private runExclusive(op: () => Promise): Promise { diff --git a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts index 57dbbe0c1..8ca191850 100644 --- a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts +++ b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts @@ -83,14 +83,25 @@ describe('WorkspaceRegistryService (file-backed)', () => { async function writeWorkspacesJson( workspaces: Record, + extra?: { readonly deleted_workspace_ids?: unknown }, ): Promise { await fsp.writeFile( join(homeDir, 'workspaces.json'), - JSON.stringify({ version: 1, workspaces }), + JSON.stringify({ version: 1, workspaces, ...extra }), 'utf8', ); } + async function readWorkspacesJson(): Promise<{ + workspaces: Record; + deleted_workspace_ids?: unknown; + }> { + return JSON.parse(await fsp.readFile(join(homeDir, 'workspaces.json'), 'utf8')) as { + workspaces: Record; + deleted_workspace_ids?: unknown; + }; + } + it('persists the catalog across registry instances', async () => { const created = await build().createOrTouch(homeDir, 'proj'); @@ -137,8 +148,9 @@ describe('WorkspaceRegistryService (file-backed)', () => { expect(await build().list()).toEqual([]); }); - it('prefers an existing workspaces.json over the session index', async () => { + it('merges session-index workDirs into an existing catalog on load', async () => { const work = join(homeDir, 'existing'); + const fromIndex = join(homeDir, 'from-index'); await writeWorkspacesJson({ [encodeWorkDirKey(work)]: { root: work, @@ -150,14 +162,102 @@ describe('WorkspaceRegistryService (file-backed)', () => { await seedSessionIndex([ { sessionId: 's9', - sessionDir: join(homeDir, 'sessions', encodeWorkDirKey(join(homeDir, 'from-index')), 's9'), - workDir: join(homeDir, 'from-index'), + sessionDir: join(homeDir, 'sessions', encodeWorkDirKey(fromIndex), 's9'), + workDir: fromIndex, }, ]); const list = await build().list(); - expect(list.map((w) => w.id)).toEqual([encodeWorkDirKey(work)]); - expect(list[0]?.name).toBe('existing'); + expect(list.map((w) => w.id).toSorted()).toEqual( + [encodeWorkDirKey(work), encodeWorkDirKey(fromIndex)].toSorted(), + ); + const existing = list.find((w) => w.id === encodeWorkDirKey(work)); + // The registered entry keeps its persisted data; the merged entry only + // gets a basename-derived name. + expect(existing?.name).toBe('existing'); + expect(existing?.lastOpenedAt).toBe(Date.parse('2024-01-02T00:00:00.000Z')); + expect(list.find((w) => w.id === encodeWorkDirKey(fromIndex))?.name).toBe('from-index'); + + // The merge is persisted, so a restart sees the same catalog. + expect((await restart().list()).map((w) => w.id).toSorted()).toEqual( + list.map((w) => w.id).toSorted(), + ); + }); + + it('merge skips tombstoned ids and tolerates a dirty deleted_workspace_ids field', async () => { + const work = join(homeDir, 'existing'); + const deleted = join(homeDir, 'deleted'); + const fresh = join(homeDir, 'fresh'); + await writeWorkspacesJson( + { + [encodeWorkDirKey(work)]: { + root: work, + name: 'existing', + created_at: '2024-01-01T00:00:00.000Z', + last_opened_at: '2024-01-02T00:00:00.000Z', + }, + }, + { deleted_workspace_ids: [encodeWorkDirKey(deleted), 42, null] }, + ); + await seedSessionIndex([ + { + sessionId: 's1', + sessionDir: join(homeDir, 'sessions', encodeWorkDirKey(deleted), 's1'), + workDir: deleted, + }, + { + sessionId: 's2', + sessionDir: join(homeDir, 'sessions', encodeWorkDirKey(fresh), 's2'), + workDir: fresh, + }, + ]); + + const list = await build().list(); + expect(list.map((w) => w.id).toSorted()).toEqual( + [encodeWorkDirKey(work), encodeWorkDirKey(fresh)].toSorted(), + ); + }); + + it('delete tombstones the id and the merge never resurrects it', async () => { + const dirA = join(homeDir, 'dir-a'); + const dirB = join(homeDir, 'dir-b'); + await fsp.mkdir(dirA); + await fsp.mkdir(dirB); + const registry = build(); + const a = await registry.createOrTouch(dirA); + await registry.createOrTouch(dirB); + + await registry.delete(a.id); + expect((await registry.list()).map((w) => w.id)).toEqual([encodeWorkDirKey(dirB)]); + + // The tombstone is on disk in the v1-compatible field. + const onDisk = await readWorkspacesJson(); + expect(onDisk.deleted_workspace_ids).toEqual([a.id]); + expect(onDisk.workspaces[a.id]).toBeUndefined(); + + // Sessions referencing the deleted workDir must not resurrect it. + await seedSessionIndex([ + { + sessionId: 's1', + sessionDir: join(homeDir, 'sessions', a.id, 's1'), + workDir: dirA, + }, + ]); + expect((await restart().list()).map((w) => w.id)).toEqual([encodeWorkDirKey(dirB)]); + }); + + it('createOrTouch clears the deletion tombstone', async () => { + const dirA = join(homeDir, 'dir-a'); + await fsp.mkdir(dirA); + const registry = build(); + const a = await registry.createOrTouch(dirA); + await registry.delete(a.id); + + await registry.createOrTouch(dirA); + expect((await registry.list()).map((w) => w.id)).toEqual([a.id]); + expect(await readWorkspacesJson().then((f) => f.deleted_workspace_ids)).toEqual([]); + + expect((await restart().list()).map((w) => w.id)).toEqual([a.id]); }); it('writes through on update and delete', async () => { diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 2b12ad013..36b67239a 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -48,6 +48,7 @@ import { } from '../session/provider-manager'; import { SessionAPIImpl } from '../session/rpc'; import { normalizeWorkDir, SessionStore } from '../session/store/index'; +import { touchWorkspaceRegistry } from '../services/workspace/workspaceRegistryFile'; import { noopTelemetryClient, withTelemetryContext, @@ -268,6 +269,12 @@ export class KimiCore implements PromisableMethods { id, workDir, }); + // Register the cwd in the shared workspaces catalog (`/workspaces.json`, + // also read by the agent-core-v2 server) so TUI-created sessions surface as + // workspaces. Best-effort: the catalog is a hint, never session state. + await touchWorkspaceRegistry(this.homeDir, workDir).catch((error: unknown) => { + log.warn('workspace registry touch failed', { workDir, error: String(error) }); + }); const result: SessionSummary = { ...summary, metadata: options.metadata, diff --git a/packages/agent-core/src/services/AGENTS.md b/packages/agent-core/src/services/AGENTS.md index f6e9730d2..8f6d7c79f 100644 --- a/packages/agent-core/src/services/AGENTS.md +++ b/packages/agent-core/src/services/AGENTS.md @@ -96,7 +96,7 @@ no new suffixes get reintroduced. | `logger/` | `logger.ts` | (adapter lives in server) | `ILogService` | | `fileStore/` | `fileStore.ts` | `fileStoreService.ts` | `IFileStore` | | `fs/` | `fs.ts`, `fsSearch.ts`, `fsGit.ts`, `fsWatcher.ts`, `fsPathSafety.ts` | `fsService.ts`, `fsSearchService.ts`, `fsGitService.ts`, `fsWatcherService.ts` | `IFsService`, `IFsSearchService`, `IFsGitService`, `IFsWatcher` | -| `workspace/` | `workspaceRegistry.ts`, `workspaceFs.ts` | `workspaceRegistryService.ts`, `workspaceFsService.ts` | `IWorkspaceRegistry`, `IWorkspaceFsService` | +| `workspace/` | `workspaceRegistry.ts`, `workspaceFs.ts` | `workspaceRegistryService.ts`, `workspaceRegistryFile.ts`, `workspaceFsService.ts` | `IWorkspaceRegistry`, `IWorkspaceFsService` | | `config/` | `config.ts` | `configService.ts` | `IConfigService` | | `session/` | `session.ts` | `sessionService.ts` | `ISessionService` | | `message/` | `message.ts` | `messageService.ts` | `IMessageService` | diff --git a/packages/agent-core/src/services/workspace/index.ts b/packages/agent-core/src/services/workspace/index.ts index 0da7cfa79..39575e67a 100644 --- a/packages/agent-core/src/services/workspace/index.ts +++ b/packages/agent-core/src/services/workspace/index.ts @@ -5,6 +5,7 @@ export { type WorkspacePatch, } from './workspaceRegistry'; export { WorkspaceRegistryService, detectGit } from './workspaceRegistryService'; +export { touchWorkspaceRegistry } from './workspaceRegistryFile'; export { IWorkspaceFsService, WorkspaceFsNotAbsoluteError, diff --git a/packages/agent-core/src/services/workspace/workspaceRegistryFile.ts b/packages/agent-core/src/services/workspace/workspaceRegistryFile.ts new file mode 100644 index 000000000..7b7164598 --- /dev/null +++ b/packages/agent-core/src/services/workspace/workspaceRegistryFile.ts @@ -0,0 +1,161 @@ +/** + * `workspaces.json` file format and atomic access — the on-disk contract of + * the known-workspaces catalog, shared by `WorkspaceRegistryService` (which + * adds locking and events on top) and by in-process callers that only need a + * best-effort touch (e.g. `KimiCore` registering the cwd on session creation). + * + * The layout is the v1-compatible `{ version, workspaces, deleted_workspace_ids }` + * document at `/workspaces.json`; agent-core-v2 reads and writes the + * same file, so both engines must agree on this shape. + */ + +import { promises as fsp } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { basename as posixBasename } from 'pathe'; + +import { encodeWorkDirKey, normalizeWorkDir } from '../../session/store'; + +const WORKSPACE_REGISTRY_FILE = 'workspaces.json'; +const WORKSPACE_REGISTRY_VERSION = 1; + +export interface WorkspaceRegistryEntry { + root: string; + name: string; + created_at: string; + last_opened_at: string; +} + +export interface WorkspaceRegistryFile { + version: number; + workspaces: Record; + /** Workspace ids the user explicitly removed. Their session buckets stay on + * disk, so derived workspaces (computed from the session index) must skip + * them to keep deletion durable. */ + deleted_workspace_ids: string[]; +} + +/** Diagnostic hook for malformed-content warnings; `(context, message)`. */ +export type WorkspaceRegistryWarn = (context: object, message: string) => void; + +function emptyRegistryFile(): WorkspaceRegistryFile { + return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {}, deleted_workspace_ids: [] }; +} + +/** Read `/workspaces.json`, tolerating a missing or malformed file + * (both yield an empty catalog). Unknown fields are ignored; entries failing + * sanitization are dropped. */ +export async function readWorkspaceRegistryFile( + homeDir: string, + warn?: WorkspaceRegistryWarn, +): Promise { + const registryPath = join(homeDir, WORKSPACE_REGISTRY_FILE); + let raw: string; + try { + raw = await fsp.readFile(registryPath, 'utf8'); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + return emptyRegistryFile(); + } + throw err; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + warn?.({ path: registryPath, err: String(err) }, 'workspaces.json malformed; treating as empty'); + return emptyRegistryFile(); + } + if ( + typeof parsed !== 'object' || + parsed === null || + typeof (parsed as { workspaces?: unknown }).workspaces !== 'object' || + (parsed as { workspaces?: unknown }).workspaces === null + ) { + warn?.({ path: registryPath }, 'workspaces.json missing required keys; treating as empty'); + return emptyRegistryFile(); + } + const rawWorkspaces = (parsed as { workspaces: Record }).workspaces; + const workspaces: Record = {}; + for (const [id, value] of Object.entries(rawWorkspaces)) { + const entry = sanitizeWorkspaceRegistryEntry(value); + if (entry !== null) { + workspaces[id] = entry; + } + } + const version = + typeof (parsed as { version?: unknown }).version === 'number' + ? (parsed as { version: number }).version + : WORKSPACE_REGISTRY_VERSION; + const rawDeleted = (parsed as { deleted_workspace_ids?: unknown }).deleted_workspace_ids; + const deleted_workspace_ids = Array.isArray(rawDeleted) + ? rawDeleted.filter((id): id is string => typeof id === 'string') + : []; + return { version, workspaces, deleted_workspace_ids }; +} + +/** Atomically write `/workspaces.json` (tmp file + rename). */ +export async function writeWorkspaceRegistryFile( + homeDir: string, + file: WorkspaceRegistryFile, +): Promise { + const registryPath = join(homeDir, WORKSPACE_REGISTRY_FILE); + await fsp.mkdir(dirname(registryPath), { recursive: true, mode: 0o700 }); + const tmp = `${registryPath}.tmp`; + await fsp.writeFile(tmp, JSON.stringify(file, null, 2), 'utf8'); + await fsp.rename(tmp, registryPath); +} + +/** + * Best-effort read-modify-write: register `root` in `/workspaces.json` + * (or bump its `last_opened_at` when already present). An explicit touch clears + * any prior deletion tombstone for the workspace id. + * + * Unlike `WorkspaceRegistryService.createOrTouch` this performs no + * root-existence check and publishes no events; callers must treat failures as + * non-fatal (the catalog is a hint, not session state). Concurrent writers in + * other processes cannot corrupt the file (atomic rename), though a lost + * update is possible — the next session-index merge heals missing entries. + */ +export async function touchWorkspaceRegistry( + homeDir: string, + root: string, + name?: string, +): Promise<{ workspaceId: string; created: boolean }> { + const normalizedRoot = normalizeWorkDir(root); + const workspaceId = encodeWorkDirKey(normalizedRoot); + const now = new Date().toISOString(); + const file = await readWorkspaceRegistryFile(homeDir); + const existing = file.workspaces[workspaceId]; + file.workspaces[workspaceId] = + existing !== undefined + ? { ...existing, last_opened_at: now } + : { + root: normalizedRoot, + name: name ?? posixBasename(normalizedRoot), + created_at: now, + last_opened_at: now, + }; + file.deleted_workspace_ids = file.deleted_workspace_ids.filter((id) => id !== workspaceId); + await writeWorkspaceRegistryFile(homeDir, file); + return { workspaceId, created: existing === undefined }; +} + +function sanitizeWorkspaceRegistryEntry(value: unknown): WorkspaceRegistryEntry | null { + if (typeof value !== 'object' || value === null) return null; + const v = value as Partial; + if ( + typeof v.root !== 'string' || + typeof v.name !== 'string' || + typeof v.created_at !== 'string' || + typeof v.last_opened_at !== 'string' + ) { + return null; + } + return { + root: v.root, + name: v.name, + created_at: v.created_at, + last_opened_at: v.last_opened_at, + }; +} diff --git a/packages/agent-core/src/services/workspace/workspaceRegistryService.ts b/packages/agent-core/src/services/workspace/workspaceRegistryService.ts index aa5886c2a..8913eb61d 100644 --- a/packages/agent-core/src/services/workspace/workspaceRegistryService.ts +++ b/packages/agent-core/src/services/workspace/workspaceRegistryService.ts @@ -19,25 +19,12 @@ import { WorkspaceRootNotFoundError, type WorkspacePatch, } from './workspaceRegistry'; - -const WORKSPACE_REGISTRY_FILE = 'workspaces.json'; -const WORKSPACE_REGISTRY_VERSION = 1; - -interface WorkspaceRegistryEntry { - root: string; - name: string; - created_at: string; - last_opened_at: string; -} - -interface WorkspaceRegistryFile { - version: number; - workspaces: Record; - /** Workspace ids the user explicitly removed. Their session buckets stay on - * disk, so derived workspaces (computed from the session index) must skip - * them to keep deletion durable. */ - deleted_workspace_ids: string[]; -} +import { + readWorkspaceRegistryFile, + writeWorkspaceRegistryFile, + type WorkspaceRegistryEntry, + type WorkspaceRegistryFile, +} from './workspaceRegistryFile'; type WorkspaceRegistryEvent = | { type: 'event.workspace.created'; workspace: Workspace } @@ -49,7 +36,6 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe private readonly homeDir: string; private readonly sessionsDir: string; - private readonly registryPath: string; private opQueue: Promise = Promise.resolve(); constructor( @@ -60,7 +46,6 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe super(); this.homeDir = env.homeDir; this.sessionsDir = join(env.homeDir, 'sessions'); - this.registryPath = join(env.homeDir, WORKSPACE_REGISTRY_FILE); } async list(): Promise { @@ -298,81 +283,13 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe } private async readRegistry(): Promise { - let raw: string; - try { - raw = await fsp.readFile(this.registryPath, 'utf8'); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ENOENT' || code === 'ENOTDIR') { - return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {}, deleted_workspace_ids: [] }; - } - throw err; - } - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (err) { - this.logger.warn( - { path: this.registryPath, err: String(err) }, - 'workspaces.json malformed; treating as empty', - ); - return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {}, deleted_workspace_ids: [] }; - } - if ( - typeof parsed !== 'object' || - parsed === null || - typeof (parsed as { workspaces?: unknown }).workspaces !== 'object' || - (parsed as { workspaces?: unknown }).workspaces === null - ) { - this.logger.warn( - { path: this.registryPath }, - 'workspaces.json missing required keys; treating as empty', - ); - return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {}, deleted_workspace_ids: [] }; - } - const rawWorkspaces = (parsed as { workspaces: Record }).workspaces; - const workspaces: Record = {}; - for (const [id, value] of Object.entries(rawWorkspaces)) { - const entry = this.sanitizeEntry(value); - if (entry !== null) { - workspaces[id] = entry; - } - } - const version = - typeof (parsed as { version?: unknown }).version === 'number' - ? (parsed as { version: number }).version - : WORKSPACE_REGISTRY_VERSION; - const rawDeleted = (parsed as { deleted_workspace_ids?: unknown }).deleted_workspace_ids; - const deleted_workspace_ids = Array.isArray(rawDeleted) - ? rawDeleted.filter((id): id is string => typeof id === 'string') - : []; - return { version, workspaces, deleted_workspace_ids }; - } - - private sanitizeEntry(value: unknown): WorkspaceRegistryEntry | null { - if (typeof value !== 'object' || value === null) return null; - const v = value as Partial; - if ( - typeof v.root !== 'string' || - typeof v.name !== 'string' || - typeof v.created_at !== 'string' || - typeof v.last_opened_at !== 'string' - ) { - return null; - } - return { - root: v.root, - name: v.name, - created_at: v.created_at, - last_opened_at: v.last_opened_at, - }; + return readWorkspaceRegistryFile(this.homeDir, (context, message) => + this.logger.warn(context, message), + ); } private async writeRegistry(file: WorkspaceRegistryFile): Promise { - await fsp.mkdir(dirname(this.registryPath), { recursive: true, mode: 0o700 }); - const tmp = `${this.registryPath}.tmp`; - await fsp.writeFile(tmp, JSON.stringify(file, null, 2), 'utf8'); - await fsp.rename(tmp, this.registryPath); + await writeWorkspaceRegistryFile(this.homeDir, file); } private runExclusive(op: () => Promise): Promise { diff --git a/packages/agent-core/test/services/workspace-registry.test.ts b/packages/agent-core/test/services/workspace-registry.test.ts index ca618489b..e2d974b09 100644 --- a/packages/agent-core/test/services/workspace-registry.test.ts +++ b/packages/agent-core/test/services/workspace-registry.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -10,6 +10,7 @@ import type { IEnvironmentService } from '../../src/services/environment/environ import type { IEventService } from '../../src/services/event/event'; import type { ILogService } from '../../src/services/logger/logger'; import { WorkspaceRegistryService } from '../../src/services/workspace/workspaceRegistryService'; +import { touchWorkspaceRegistry } from '../../src/services/workspace/workspaceRegistryFile'; import { appendSessionIndexEntry } from '../../src/session/store/session-index'; import { encodeWorkDirKey, normalizeWorkDir } from '../../src/session/store/workdir-key'; @@ -255,3 +256,97 @@ describe('WorkspaceRegistryService', () => { expect(matches[0]?.session_count).toBe(1); }); }); + +describe('touchWorkspaceRegistry', () => { + let homeDir: string; + let tempRoots: string[] = []; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'kimi-ws-touch-home-')); + tempRoots = []; + }); + + afterEach(async () => { + await rm(homeDir, { recursive: true, force: true }); + for (const root of tempRoots) { + await rm(root, { recursive: true, force: true }); + } + }); + + async function makeProjectRoot(label: string): Promise { + const root = await mkdtemp(join(tmpdir(), `kimi-ws-touch-${label}-`)); + tempRoots.push(root); + return normalizeWorkDir(await realpath(root)); + } + + async function readRegistryFile(): Promise<{ + version: number; + workspaces: Record< + string, + { root: string; name: string; created_at: string; last_opened_at: string } + >; + deleted_workspace_ids: string[]; + }> { + return JSON.parse(await readFile(join(homeDir, 'workspaces.json'), 'utf-8')) as never; + } + + it('creates a new entry in workspaces.json', async () => { + const root = await makeProjectRoot('new'); + + const result = await touchWorkspaceRegistry(homeDir, root); + + expect(result.created).toBe(true); + expect(result.workspaceId).toBe(encodeWorkDirKey(root)); + const file = await readRegistryFile(); + const entry = file.workspaces[result.workspaceId]; + expect(entry).toBeDefined(); + expect(entry?.root).toBe(root); + expect(entry?.name).toBe(root.split('/').pop()); + expect(entry?.created_at).not.toBe(''); + expect(file.deleted_workspace_ids).toEqual([]); + }); + + it('touches an existing entry without resetting its name or created_at', async () => { + const root = await makeProjectRoot('touch'); + const first = await touchWorkspaceRegistry(homeDir, root, 'custom-name'); + const before = (await readRegistryFile()).workspaces[first.workspaceId]; + expect(before?.name).toBe('custom-name'); + + await new Promise((resolve) => setTimeout(resolve, 5)); + const second = await touchWorkspaceRegistry(homeDir, root); + + expect(second.created).toBe(false); + const after = (await readRegistryFile()).workspaces[first.workspaceId]; + expect(after?.name).toBe('custom-name'); + expect(after?.created_at).toBe(before?.created_at); + expect(Date.parse(after?.last_opened_at ?? '')).toBeGreaterThan( + Date.parse(before?.last_opened_at ?? ''), + ); + }); + + it('clears the deletion tombstone for the touched workspace', async () => { + const root = await makeProjectRoot('tombstone'); + const workspaceId = encodeWorkDirKey(root); + await writeFile( + join(homeDir, 'workspaces.json'), + JSON.stringify({ version: 1, workspaces: {}, deleted_workspace_ids: [workspaceId] }), + 'utf-8', + ); + + await touchWorkspaceRegistry(homeDir, root); + + const file = await readRegistryFile(); + expect(file.deleted_workspace_ids).toEqual([]); + expect(file.workspaces[workspaceId]).toBeDefined(); + }); + + it('recovers from a malformed workspaces.json', async () => { + await writeFile(join(homeDir, 'workspaces.json'), '{ not json', 'utf-8'); + const root = await makeProjectRoot('malformed'); + + const result = await touchWorkspaceRegistry(homeDir, root); + + const file = await readRegistryFile(); + expect(file.workspaces[result.workspaceId]?.root).toBe(root); + }); +}); diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 1284d0fa2..3fb940d8e 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -12,6 +12,7 @@ import { hostRequestHeadersSeed, IConfigService, IModelCatalogService, + IWorkspaceRegistry, logSeed, MULTI_SERVER_FLAG_ENV, resolveConfigPath, @@ -255,6 +256,20 @@ export async function startServer(opts: ServerStartOptions = {}): Promise Date: Tue, 14 Jul 2026 17:47:24 +0800 Subject: [PATCH 02/76] docs(changelog): sync 0.24.1 from apps/kimi-code/CHANGELOG.md (#1703) --- docs/en/release-notes/changelog.md | 14 ++++++++++++++ docs/zh/release-notes/changelog.md | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 1f3a76194..74b26545d 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -6,6 +6,20 @@ outline: 2 This page documents the changes in each Kimi Code CLI release. +## 0.24.1 (2026-07-14) + +### Bug Fixes + +- Fix Kimi sessions getting stuck when preserved-thinking history contains an empty reasoning step. +- Fix built-in tools being unavailable when the model provider becomes ready after the session starts. +- Fix Thinking effort routing: non-Kimi providers now preserve configured values, while Kimi models validate runtime selections and fall back safely during model resolution. +- web: Align thinking-level handling with the CLI: submit the selected level verbatim instead of silently downgrading it, fall back to the model's own default when nothing was chosen or the model switches, and persist explicit picks as the default for new sessions. +- Preserve goal completion summaries and show untyped LLM errors without an internal error-code prefix in step interruption events. + +### Polish + +- web: Show just the level name (e.g. Max) in the model pill instead of "thinking: max". + ## 0.24.0 (2026-07-14) ### Features diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 21c2eb1d9..a1924f77e 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -6,6 +6,20 @@ outline: 2 本页记录 Kimi Code CLI 每个版本的变更内容。 +## 0.24.1(2026-07-14) + +### 修复 + +- 修复 preserved-thinking 历史包含空推理步骤时,Kimi 会话卡住的问题。 +- 修复模型供应商在会话启动后才就绪时,内置工具不可用的问题。 +- 修复思考强度(thinking effort)路由问题:非 Kimi 供应商现在保留配置值,Kimi 模型会校验运行时选择,并在模型解析时安全回退。 +- web: 对齐 Web 端与 CLI 的思考级别处理:所选级别原样提交,不再被静默降级;未选择或切换模型时回退到模型自身的默认级别;显式选择会保存为默认值并被新会话继承。 +- 修复目标完成摘要丢失的问题;步骤中断事件中的无类型 LLM 错误不再显示内部错误码前缀。 + +### 优化 + +- web: 模型标签只显示级别名称(如 Max),不再显示 "thinking: max"。 + ## 0.24.0(2026-07-14) ### 新功能 From 9e6d53b0257ca9272cd8d331c663cbb5599456b7 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Tue, 14 Jul 2026 17:52:03 +0800 Subject: [PATCH 03/76] fix(workspace): re-read the catalog on every v2 registry operation (#1706) - drop the in-memory write cache in the v2 registry: every list/get/ mutation is a fresh read-modify-write of workspaces.json under the op mutex, so v1 writers (touchWorkspaceRegistry) are never clobbered by a stale snapshot; the session-index merge stays once per process behind a flag (Codex P1) - move the shared workspaces.json helper from services/workspace (the upper facade) to session/store, so the rpc runtime no longer imports back into services/ (Codex P2) --- .../workspaceRegistryService.ts | 136 ++++++++++-------- .../workspaceRegistryService.test.ts | 95 ++++++++++++ packages/agent-core/src/rpc/core-impl.ts | 2 +- packages/agent-core/src/services/AGENTS.md | 2 +- .../src/services/workspace/index.ts | 1 - .../workspace/workspaceRegistryService.ts | 2 +- .../store/workspace-registry-file.ts} | 11 +- .../test/services/workspace-registry.test.ts | 2 +- 8 files changed, 180 insertions(+), 71 deletions(-) rename packages/agent-core/src/{services/workspace/workspaceRegistryFile.ts => session/store/workspace-registry-file.ts} (93%) diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts index 9baaebdb9..16e154769 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts @@ -1,26 +1,33 @@ /** * `workspaceRegistry` domain (L1) — `IWorkspaceRegistry` implementation. * - * Process-wide catalog of known workspaces, durable: an in-memory cache is - * loaded once from `IWorkspacePersistence` (`/workspaces.json`, the - * v1-compatible file shared with agent-core) and every mutation writes back - * through it. Loading has two paths: + * Process-wide catalog of known workspaces, durable in + * `/workspaces.json` (the v1-compatible file shared with + * agent-core). The service keeps NO in-memory write cache: every operation + * is a fresh read-modify-write against the file, serialized through a + * promise-chain mutex. This is required, not just tidy — the same file is + * written concurrently by other processes (the v1 TUI registers session cwds + * via `touchWorkspaceRegistry`, which also re-reads the file on every call), + * so a write-through cache would clobber external additions and tombstones + * with stale state. Atomic renames at the persistence layer plus fresh + * read-modify-write on both engines shrink the lost-update window to a + * single read-modify-write, and the next session-index merge heals anything + * still lost there. * - * 1. No usable catalog file → one-shot rebuild from the legacy - * `/session_index.jsonl` (one workspace per distinct absolute - * `workDir`), then persisted. - * 2. Catalog loaded → a one-time merge from the same session index adds every - * workDir the file does not know about yet (e.g. sessions created by the - * v1 TUI since the last merge), then persisted if anything changed. + * Once per process, the first operation triggers the startup sync with the + * legacy `/session_index.jsonl`: + * + * 1. No usable catalog file → one-shot rebuild (one workspace per distinct + * absolute `workDir`), persisted. + * 2. Catalog loaded → only workDirs the file does not know about yet are + * added (e.g. sessions created by the v1 TUI since the last sync), + * persisted if anything changed. * * Deletion is soft: `delete` drops the entry but records the id in * `deleted_workspace_ids`, and the merge never resurrects a tombstoned id. * An explicit `createOrTouch` clears the tombstone — the user opening the * folder again is a stronger signal than the historical index. * - * All access is serialized through a promise-chain mutex so - * load/rebuild/merge/mutations never race. - * * `createOrTouch` is the single choke point every workspace/session creation * funnels through, so it owns the root-existence contract: the root must be * an existing directory on the host filesystem, otherwise it throws @@ -39,7 +46,7 @@ import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IWorkspaceRegistry, type Workspace, type WorkspaceUpdate } from './workspaceRegistry'; -import { IWorkspacePersistence } from './workspacePersistence'; +import { IWorkspacePersistence, type WorkspaceCatalog } from './workspacePersistence'; const SESSION_INDEX_SCOPE = ''; const SESSION_INDEX_KEY = 'session_index.jsonl'; @@ -55,8 +62,8 @@ interface SessionIndexLine { export class WorkspaceRegistryService implements IWorkspaceRegistry { declare readonly _serviceBrand: undefined; - private cache: Map | undefined; - private deletedIds: Set | undefined; + /** Whether the once-per-process session-index sync already ran. */ + private merged = false; private opQueue: Promise = Promise.resolve(); constructor( @@ -67,21 +74,23 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { list(): Promise { return this.runExclusive(async () => { - const cache = await this.ensureLoaded(); - return dedupeByRoot(cache); + await this.ensureMerged(); + const catalog = await this.loadCatalog(); + const byId = new Map(catalog.workspaces.map((ws) => [ws.id, ws])); + return dedupeByRoot(byId); }); } get(id: string): Promise { return this.runExclusive(async () => { - const cache = await this.ensureLoaded(); - return cache.get(id); + await this.ensureMerged(); + const catalog = await this.loadCatalog(); + return catalog.workspaces.find((ws) => ws.id === id); }); } createOrTouch(root: string, name?: string): Promise { return this.runExclusive(async () => { - const cache = await this.ensureLoaded(); let stat; try { stat = await this.hostFs.stat(root); @@ -95,8 +104,12 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { if (!stat.isDirectory) { throw new Error2(ErrorCodes.FS_PATH_NOT_FOUND, `workspace root ${root} is not a directory`); } + await this.ensureMerged(); + const catalog = await this.loadCatalog(); + const byId = new Map(catalog.workspaces.map((ws) => [ws.id, ws])); + const deletedIds = new Set(catalog.deletedIds); const id = encodeWorkDirKey(root); - const existing = cache.get(id); + const existing = byId.get(id); const now = Date.now(); const ws: Workspace = existing !== undefined @@ -108,73 +121,84 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { createdAt: now, lastOpenedAt: now, }; - cache.set(id, ws); + byId.set(id, ws); // An explicit add clears any prior deletion tombstone. - this.deletedIds?.delete(id); - await this.persist(); + deletedIds.delete(id); + await this.store.save({ workspaces: [...byId.values()], deletedIds: [...deletedIds] }); return ws; }); } update(id: string, patch: WorkspaceUpdate): Promise { return this.runExclusive(async () => { - const cache = await this.ensureLoaded(); - const existing = cache.get(id); + await this.ensureMerged(); + const catalog = await this.loadCatalog(); + const existing = catalog.workspaces.find((ws) => ws.id === id); if (existing === undefined) return undefined; const updated: Workspace = { ...existing, ...(patch.name !== undefined ? { name: patch.name } : {}), }; - cache.set(id, updated); - await this.persist(); + await this.store.save({ + workspaces: catalog.workspaces.map((ws) => (ws.id === id ? updated : ws)), + deletedIds: catalog.deletedIds, + }); return updated; }); } delete(id: string): Promise { return this.runExclusive(async () => { - const cache = await this.ensureLoaded(); - cache.delete(id); + await this.ensureMerged(); + const catalog = await this.loadCatalog(); // Soft delete: tombstone the id so the session-index merge cannot // resurrect it, even if sessions still reference the workDir. - this.deletedIds?.add(id); - await this.persist(); + await this.store.save({ + workspaces: catalog.workspaces.filter((ws) => ws.id !== id), + deletedIds: [...new Set([...catalog.deletedIds, id])], + }); }); } - private async ensureLoaded(): Promise> { - if (this.cache !== undefined) return this.cache; + /** Once-per-process startup sync with the legacy session index (see the + * file header). Runs inside the op mutex, so it cannot interleave with a + * mutation's read-modify-write. */ + private async ensureMerged(): Promise { + if (this.merged) return; const loaded = await this.store.load(); if (loaded === undefined) { const rebuilt = await this.rebuildFromSessionIndex(); - this.cache = rebuilt; - this.deletedIds = new Set(); - await this.persist(); - return rebuilt; + await this.store.save({ workspaces: [...rebuilt.values()], deletedIds: [] }); + this.merged = true; + return; } - const cache = new Map(loaded.workspaces.map((ws) => [ws.id, ws])); + const byId = new Map(loaded.workspaces.map((ws) => [ws.id, ws])); const deletedIds = new Set(loaded.deletedIds); - this.cache = cache; - this.deletedIds = deletedIds; - if (await this.mergeFromSessionIndex(cache, deletedIds)) { - await this.persist(); + if (await this.mergeFromSessionIndex(byId, deletedIds)) { + await this.store.save({ workspaces: [...byId.values()], deletedIds: [...deletedIds] }); } - return cache; + this.merged = true; + } + + /** Read the current catalog; a missing or malformed file is an empty + * catalog (mirrors v1's tolerant read). */ + private async loadCatalog(): Promise { + return (await this.store.load()) ?? { workspaces: [], deletedIds: [] }; } /** Add every distinct workDir from the legacy session index that the * catalog does not know about yet. Tombstoned ids are skipped, so a * soft-deleted workspace stays deleted. Returns whether anything changed. */ private async mergeFromSessionIndex( - cache: Map, + byId: Map, deletedIds: ReadonlySet, ): Promise { let changed = false; const now = Date.now(); for (const workDir of await this.readSessionIndexWorkDirs()) { const id = encodeWorkDirKey(workDir); - if (cache.has(id) || deletedIds.has(id)) continue; - cache.set(id, { + if (byId.has(id) || deletedIds.has(id)) continue; + byId.set(id, { id, root: workDir, name: basename(workDir), @@ -218,18 +242,6 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { return workDirs; } - private async persist(): Promise { - const cache = this.cache; - const deletedIds = this.deletedIds; - if (cache === undefined || deletedIds === undefined) { - throw new Error('workspace registry mutated before load completed'); - } - await this.store.save({ - workspaces: [...cache.values()], - deletedIds: [...deletedIds], - }); - } - private runExclusive(op: () => Promise): Promise { const next = this.opQueue.then(op, op); this.opQueue = next.then( @@ -262,9 +274,9 @@ function parseSessionIndexLine(line: string): SessionIndexLine | undefined { } } -function dedupeByRoot(cache: ReadonlyMap): Workspace[] { +function dedupeByRoot(byId: ReadonlyMap): Workspace[] { const byRoot = new Map(); - for (const ws of cache.values()) { + for (const ws of byId.values()) { const existing = byRoot.get(ws.root); if (existing === undefined) { byRoot.set(ws.root, ws); diff --git a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts index 8ca191850..1c9f8abbc 100644 --- a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts +++ b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts @@ -260,6 +260,101 @@ describe('WorkspaceRegistryService (file-backed)', () => { expect((await restart().list()).map((w) => w.id)).toEqual([a.id]); }); + it('createOrTouch preserves external additions and tombstones written after load', async () => { + const dirA = join(homeDir, 'dir-a'); + const dirB = join(homeDir, 'dir-b'); + const dirC = join(homeDir, 'dir-c'); + await fsp.mkdir(dirA); + await fsp.mkdir(dirC); + const registry = build(); + await registry.createOrTouch(dirA); + + // Simulate a v1 writer touching the file after the v2 registry already + // ran an operation: a new workspace entry plus an unrelated tombstone. + const onDisk = await readWorkspacesJson(); + onDisk.workspaces[encodeWorkDirKey(dirB)] = { + root: dirB, + name: 'dir-b', + created_at: '2024-01-01T00:00:00.000Z', + last_opened_at: '2024-01-01T00:00:00.000Z', + }; + await fsp.writeFile( + join(homeDir, 'workspaces.json'), + JSON.stringify({ + version: 1, + workspaces: onDisk.workspaces, + deleted_workspace_ids: ['wd_external_tombstone'], + }), + 'utf8', + ); + + await registry.createOrTouch(dirC); + + const after = await readWorkspacesJson(); + expect(Object.keys(after.workspaces).toSorted()).toEqual( + [encodeWorkDirKey(dirA), encodeWorkDirKey(dirB), encodeWorkDirKey(dirC)].toSorted(), + ); + expect(after.deleted_workspace_ids).toEqual(['wd_external_tombstone']); + // Reads also see the external entry without a restart. + expect((await registry.list()).map((w) => w.id)).toContain(encodeWorkDirKey(dirB)); + }); + + it('delete adds its tombstone on top of the current file state', async () => { + const dirA = join(homeDir, 'dir-a'); + await fsp.mkdir(dirA); + const registry = build(); + const a = await registry.createOrTouch(dirA); + + const onDisk = await readWorkspacesJson(); + await fsp.writeFile( + join(homeDir, 'workspaces.json'), + JSON.stringify({ + version: 1, + workspaces: onDisk.workspaces, + deleted_workspace_ids: ['wd_external_tombstone'], + }), + 'utf8', + ); + + await registry.delete(a.id); + + const after = await readWorkspacesJson(); + expect(after.workspaces[a.id]).toBeUndefined(); + expect((after.deleted_workspace_ids as string[]).toSorted()).toEqual( + ['wd_external_tombstone', a.id].toSorted(), + ); + }); + + it('update renames the current file entry and misses externally removed ids', async () => { + const dirA = join(homeDir, 'dir-a'); + await fsp.mkdir(dirA); + const registry = build(); + const a = await registry.createOrTouch(dirA); + + // External rename on disk: the update must start from it, not stale state. + const onDisk = await readWorkspacesJson(); + const entry = onDisk.workspaces[a.id]; + if (entry === undefined) throw new Error('seed entry missing'); + onDisk.workspaces[a.id] = { ...entry, name: 'external-name' }; + await fsp.writeFile( + join(homeDir, 'workspaces.json'), + JSON.stringify({ version: 1, workspaces: onDisk.workspaces, deleted_workspace_ids: [] }), + 'utf8', + ); + + const renamed = await registry.update(a.id, { name: 'local-name' }); + expect(renamed?.name).toBe('local-name'); + expect(renamed?.lastOpenedAt).toBe(Date.parse(entry.last_opened_at)); + + // External removal: update reports the id as gone instead of resurrecting. + await fsp.writeFile( + join(homeDir, 'workspaces.json'), + JSON.stringify({ version: 1, workspaces: {}, deleted_workspace_ids: [] }), + 'utf8', + ); + expect(await registry.update(a.id, { name: 'whatever' })).toBeUndefined(); + }); + it('writes through on update and delete', async () => { const created = await build().createOrTouch(homeDir, 'proj'); await build().update(created.id, { name: 'renamed' }); diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 36b67239a..dc7a102a7 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -48,7 +48,7 @@ import { } from '../session/provider-manager'; import { SessionAPIImpl } from '../session/rpc'; import { normalizeWorkDir, SessionStore } from '../session/store/index'; -import { touchWorkspaceRegistry } from '../services/workspace/workspaceRegistryFile'; +import { touchWorkspaceRegistry } from '../session/store/workspace-registry-file'; import { noopTelemetryClient, withTelemetryContext, diff --git a/packages/agent-core/src/services/AGENTS.md b/packages/agent-core/src/services/AGENTS.md index 8f6d7c79f..f6e9730d2 100644 --- a/packages/agent-core/src/services/AGENTS.md +++ b/packages/agent-core/src/services/AGENTS.md @@ -96,7 +96,7 @@ no new suffixes get reintroduced. | `logger/` | `logger.ts` | (adapter lives in server) | `ILogService` | | `fileStore/` | `fileStore.ts` | `fileStoreService.ts` | `IFileStore` | | `fs/` | `fs.ts`, `fsSearch.ts`, `fsGit.ts`, `fsWatcher.ts`, `fsPathSafety.ts` | `fsService.ts`, `fsSearchService.ts`, `fsGitService.ts`, `fsWatcherService.ts` | `IFsService`, `IFsSearchService`, `IFsGitService`, `IFsWatcher` | -| `workspace/` | `workspaceRegistry.ts`, `workspaceFs.ts` | `workspaceRegistryService.ts`, `workspaceRegistryFile.ts`, `workspaceFsService.ts` | `IWorkspaceRegistry`, `IWorkspaceFsService` | +| `workspace/` | `workspaceRegistry.ts`, `workspaceFs.ts` | `workspaceRegistryService.ts`, `workspaceFsService.ts` | `IWorkspaceRegistry`, `IWorkspaceFsService` | | `config/` | `config.ts` | `configService.ts` | `IConfigService` | | `session/` | `session.ts` | `sessionService.ts` | `ISessionService` | | `message/` | `message.ts` | `messageService.ts` | `IMessageService` | diff --git a/packages/agent-core/src/services/workspace/index.ts b/packages/agent-core/src/services/workspace/index.ts index 39575e67a..0da7cfa79 100644 --- a/packages/agent-core/src/services/workspace/index.ts +++ b/packages/agent-core/src/services/workspace/index.ts @@ -5,7 +5,6 @@ export { type WorkspacePatch, } from './workspaceRegistry'; export { WorkspaceRegistryService, detectGit } from './workspaceRegistryService'; -export { touchWorkspaceRegistry } from './workspaceRegistryFile'; export { IWorkspaceFsService, WorkspaceFsNotAbsoluteError, diff --git a/packages/agent-core/src/services/workspace/workspaceRegistryService.ts b/packages/agent-core/src/services/workspace/workspaceRegistryService.ts index 8913eb61d..ee2fb894a 100644 --- a/packages/agent-core/src/services/workspace/workspaceRegistryService.ts +++ b/packages/agent-core/src/services/workspace/workspaceRegistryService.ts @@ -24,7 +24,7 @@ import { writeWorkspaceRegistryFile, type WorkspaceRegistryEntry, type WorkspaceRegistryFile, -} from './workspaceRegistryFile'; +} from '../../session/store/workspace-registry-file'; type WorkspaceRegistryEvent = | { type: 'event.workspace.created'; workspace: Workspace } diff --git a/packages/agent-core/src/services/workspace/workspaceRegistryFile.ts b/packages/agent-core/src/session/store/workspace-registry-file.ts similarity index 93% rename from packages/agent-core/src/services/workspace/workspaceRegistryFile.ts rename to packages/agent-core/src/session/store/workspace-registry-file.ts index 7b7164598..8671b1c44 100644 --- a/packages/agent-core/src/services/workspace/workspaceRegistryFile.ts +++ b/packages/agent-core/src/session/store/workspace-registry-file.ts @@ -1,8 +1,11 @@ /** * `workspaces.json` file format and atomic access — the on-disk contract of - * the known-workspaces catalog, shared by `WorkspaceRegistryService` (which - * adds locking and events on top) and by in-process callers that only need a - * best-effort touch (e.g. `KimiCore` registering the cwd on session creation). + * the known-workspaces catalog, shared by `WorkspaceRegistryService` (the + * services-layer facade, which adds locking and events on top) and by + * in-process runtime callers that only need a best-effort touch (e.g. + * `KimiCore` registering the cwd on session creation). It lives next to + * `session-index.ts` because the runtime must not import back into + * `services/` (see `src/services/AGENTS.md`). * * The layout is the v1-compatible `{ version, workspaces, deleted_workspace_ids }` * document at `/workspaces.json`; agent-core-v2 reads and writes the @@ -13,7 +16,7 @@ import { promises as fsp } from 'node:fs'; import { dirname, join } from 'node:path'; import { basename as posixBasename } from 'pathe'; -import { encodeWorkDirKey, normalizeWorkDir } from '../../session/store'; +import { encodeWorkDirKey, normalizeWorkDir } from '#/session/store/workdir-key'; const WORKSPACE_REGISTRY_FILE = 'workspaces.json'; const WORKSPACE_REGISTRY_VERSION = 1; diff --git a/packages/agent-core/test/services/workspace-registry.test.ts b/packages/agent-core/test/services/workspace-registry.test.ts index e2d974b09..f670d980f 100644 --- a/packages/agent-core/test/services/workspace-registry.test.ts +++ b/packages/agent-core/test/services/workspace-registry.test.ts @@ -10,7 +10,7 @@ import type { IEnvironmentService } from '../../src/services/environment/environ import type { IEventService } from '../../src/services/event/event'; import type { ILogService } from '../../src/services/logger/logger'; import { WorkspaceRegistryService } from '../../src/services/workspace/workspaceRegistryService'; -import { touchWorkspaceRegistry } from '../../src/services/workspace/workspaceRegistryFile'; +import { touchWorkspaceRegistry } from '../../src/session/store/workspace-registry-file'; import { appendSessionIndexEntry } from '../../src/session/store/session-index'; import { encodeWorkDirKey, normalizeWorkDir } from '../../src/session/store/workdir-key'; From 8490c3e36b6a6cc3ba5c0f15d93b87347ce23878 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Tue, 14 Jul 2026 18:01:11 +0800 Subject: [PATCH 04/76] feat(agent-core-v2): record compaction droppedCount in wire traces and rename select_tools capability (#1707) * feat(agent-core-v2): record droppedCount on full-compaction llm.request wire ops - add per-attempt droppedCount to the llm.request source logFields so replays can see how much history each retry round blinded - cover compaction/loop request events in the full-compaction test * refactor(agent-core-v2): rename select_tools capability to dynamically_loaded_tools - rename the ModelCapability bit and catalog field across capability.ts, catalog.ts, modelResolverService and the toolSelect gate - update toolSelect flag description wording to match - update all affected tests and the test harness capabilityNames helper * chore: add changesets for v2 wire trace and capability rename --- .../compaction-dropped-count-wire-trace.md | 5 +++++ ...amically-loaded-tools-capability-rename.md | 5 +++++ .../fullCompaction/fullCompactionService.ts | 9 ++++++++- .../src/agent/toolSelect/flag.ts | 2 +- .../src/agent/toolSelect/toolSelectService.ts | 2 +- .../src/app/llmProtocol/capability.ts | 6 +++--- .../src/app/llmProtocol/catalog.ts | 4 ++-- .../src/app/model/modelResolverService.ts | 4 +++- .../fullCompaction/fullCompaction.test.ts | 19 ++++++++++++++++++- .../agent/llmRequester/llmRequester.test.ts | 2 +- .../agent/toolSelect/toolSelect.e2e.test.ts | 2 +- .../toolSelect/toolSelectService.test.ts | 16 ++++++++-------- .../test/app/llmProtocol/select-tools.test.ts | 17 +++++++++-------- .../test/app/model/modelResolver.test.ts | 10 +++++----- packages/agent-core-v2/test/harness/agent.ts | 2 +- 15 files changed, 71 insertions(+), 34 deletions(-) create mode 100644 .changeset/compaction-dropped-count-wire-trace.md create mode 100644 .changeset/dynamically-loaded-tools-capability-rename.md diff --git a/.changeset/compaction-dropped-count-wire-trace.md b/.changeset/compaction-dropped-count-wire-trace.md new file mode 100644 index 000000000..6215dbd12 --- /dev/null +++ b/.changeset/compaction-dropped-count-wire-trace.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add the number of messages dropped during compaction retries to the session wire log's LLM request traces. diff --git a/.changeset/dynamically-loaded-tools-capability-rename.md b/.changeset/dynamically-loaded-tools-capability-rename.md new file mode 100644 index 000000000..c2ec3927e --- /dev/null +++ b/.changeset/dynamically-loaded-tools-capability-rename.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Rename the dynamic tool loading model capability from `select_tools` to `dynamically_loaded_tools`, matching the model catalog vocabulary; the `select_tools` tool and the `tool-select` flag are unchanged. diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 3705f20d6..93fd8d054 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -530,7 +530,14 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull { messages, maxOutputSize: compactionMaxOutputSize, - source: { type: 'operation', requestKind: 'full_compaction' }, + source: { + type: 'operation', + requestKind: 'full_compaction', + // Per-attempt count of messages dropped by overflow/empty + // shrinks so far; recorded on the llm.request wire op so a + // replay can see how much history each retry round blinded. + logFields: { droppedCount }, + }, }, undefined, signal, diff --git a/packages/agent-core-v2/src/agent/toolSelect/flag.ts b/packages/agent-core-v2/src/agent/toolSelect/flag.ts index 4015a2b49..c77331403 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/flag.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/flag.ts @@ -20,7 +20,7 @@ export const toolSelectFlag: FlagDefinitionInput = { id: TOOL_SELECT_FLAG_ID, title: 'Tool select (progressive tool disclosure)', description: - 'Keep MCP tool schemas out of the immutable top-level tools[]; the model loads them on demand via the select_tools tool. Only takes effect on models whose capability catalog declares select_tools.', + 'Keep MCP tool schemas out of the immutable top-level tools[]; the model loads them on demand via the select_tools tool. Only takes effect on models whose capability catalog declares dynamically loaded tools.', env: TOOL_SELECT_FLAG_ENV, default: false, surface: 'core', diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts index 02529fc3f..8e605a643 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts @@ -75,7 +75,7 @@ export class AgentToolSelectService extends Disposable implements IAgentToolSele enabled(): boolean { const capabilities = this.profile.getModelCapabilities(); return ( - capabilities.select_tools === true && + capabilities.dynamically_loaded_tools === true && capabilities.tool_use && this.flags.enabled(TOOL_SELECT_FLAG_ID) ); diff --git a/packages/agent-core-v2/src/app/llmProtocol/capability.ts b/packages/agent-core-v2/src/app/llmProtocol/capability.ts index 52a395012..de54d89be 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/capability.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/capability.ts @@ -15,7 +15,7 @@ export interface ModelCapability { readonly thinking: boolean; readonly tool_use: boolean; readonly max_context_tokens: number; - readonly select_tools?: boolean; + readonly dynamically_loaded_tools?: boolean; } const UNKNOWN_CAPABILITY_MARKER = Symbol.for('moonshot-ai.kosong.UNKNOWN_CAPABILITY'); @@ -29,7 +29,7 @@ export const UNKNOWN_CAPABILITY: ModelCapability = Object.freeze( thinking: false, tool_use: false, max_context_tokens: 0, - select_tools: false, + dynamically_loaded_tools: false, }, UNKNOWN_CAPABILITY_MARKER, { value: true }, @@ -47,7 +47,7 @@ export function isUnknownCapability(capability: ModelCapability): boolean { !capability.audio_in && !capability.thinking && !capability.tool_use && - capability.select_tools !== true && + capability.dynamically_loaded_tools !== true && capability.max_context_tokens === 0 ); } diff --git a/packages/agent-core-v2/src/app/llmProtocol/catalog.ts b/packages/agent-core-v2/src/app/llmProtocol/catalog.ts index 744de6229..4fee00397 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/catalog.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/catalog.ts @@ -8,7 +8,7 @@ export interface CatalogModelEntry { readonly limit?: { readonly context?: number; readonly output?: number }; readonly tool_call?: boolean; readonly reasoning?: boolean; - readonly select_tools?: boolean; + readonly dynamically_loaded_tools?: boolean; readonly interleaved?: boolean | { readonly field?: string }; readonly modalities?: { readonly input?: readonly string[]; @@ -109,7 +109,7 @@ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel thinking: Boolean(model.reasoning), tool_use: model.tool_call ?? true, max_context_tokens: context, - select_tools: model.select_tools === true, + dynamically_loaded_tools: model.dynamically_loaded_tools === true, }, }; } diff --git a/packages/agent-core-v2/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index 8eb737e57..175ce2494 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -315,7 +315,9 @@ function resolveModelCapabilities( thinking: declared.has('thinking') || declared.has('always_thinking') || detected.thinking, tool_use: declared.has('tool_use') || detected.tool_use, max_context_tokens: maxContextSize, - select_tools: declared.has('select_tools') || detected.select_tools === true, + dynamically_loaded_tools: + declared.has('dynamically_loaded_tools') || + detected.dynamically_loaded_tools === true, }; } diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index 092bac1d0..b5f4d8f4f 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -1756,7 +1756,7 @@ describe('FullCompaction', () => { modelCapabilities: { ...CATALOGUED_MODEL_CAPABILITIES, max_context_tokens: 2_000, - select_tools: true, + dynamically_loaded_tools: true, }, tools: [LARGE_MCP_TOOL], }); @@ -2500,6 +2500,23 @@ describe('FullCompaction', () => { }), }), ); + type WireRequestEvent = { + type: '[wire]'; + event: 'llm.request'; + args: Record; + }; + const requestEvents = events.filter((event): event is WireRequestEvent => { + if (event === null || typeof event !== 'object') return false; + const candidate = event as { type?: unknown; event?: unknown }; + return candidate.type === '[wire]' && candidate.event === 'llm.request'; + }); + expect( + requestEvents.map((event) => [event.args['kind'], event.args['droppedCount']]), + ).toEqual([ + ['compaction', 0], + ['compaction', 2], + ['loop', undefined], + ]); expect(events).toContainEqual( expect.objectContaining({ event: 'turn.ended', diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts index d54766760..5317c96eb 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts @@ -96,7 +96,7 @@ describe('LLMRequester service migration coverage', () => { thinking: false, tool_use: true, max_context_tokens: 128_000, - select_tools: true, + dynamically_loaded_tools: true, }, }); ctx.mockNextResponse({ type: 'text', text: 'first response' }); diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts index 09f62146b..db6e07250 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts @@ -41,7 +41,7 @@ const DISCLOSURE_CAPABILITIES = { thinking: false, tool_use: true, max_context_tokens: 128_000, - select_tools: true, + dynamically_loaded_tools: true, } as const; type WireEvent = Extract< diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts index 068eed0f3..0e30e87d0 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -71,7 +71,7 @@ let activeToolNames: ReadonlySet | undefined; beforeEach(() => { disposables = new DisposableStore(); - capabilities = makeCapabilities({ tool_use: true, select_tools: true }); + capabilities = makeCapabilities({ tool_use: true, dynamically_loaded_tools: true }); flagEnabled = false; activeToolNames = undefined; }); @@ -80,7 +80,7 @@ afterEach(() => disposables.dispose()); function makeCapabilities(overrides: { readonly tool_use?: boolean; - readonly select_tools?: boolean; + readonly dynamically_loaded_tools?: boolean; } = {}): ModelCapability { return { image_in: false, @@ -89,7 +89,7 @@ function makeCapabilities(overrides: { thinking: false, tool_use: overrides.tool_use ?? false, max_context_tokens: 128_000, - select_tools: overrides.select_tools, + dynamically_loaded_tools: overrides.dynamically_loaded_tools, }; } @@ -404,22 +404,22 @@ async function execute( } describe('AgentToolSelectService gate', () => { - it('opens only when select_tools capability, tool_use capability and flag are all on', () => { + it('opens only when dynamically_loaded_tools capability, tool_use capability and flag are all on', () => { flagEnabled = true; const { sut } = createHarness(); expect(sut.enabled()).toBe(true); }); - it('stays closed without the select_tools capability', () => { + it('stays closed without the dynamically_loaded_tools capability', () => { flagEnabled = true; - capabilities = makeCapabilities({ tool_use: true, select_tools: false }); + capabilities = makeCapabilities({ tool_use: true, dynamically_loaded_tools: false }); const { sut } = createHarness(); expect(sut.enabled()).toBe(false); }); it('stays closed without tool_use capability', () => { flagEnabled = true; - capabilities = makeCapabilities({ tool_use: false, select_tools: true }); + capabilities = makeCapabilities({ tool_use: false, dynamically_loaded_tools: true }); const { sut } = createHarness(); expect(sut.enabled()).toBe(false); }); @@ -432,7 +432,7 @@ describe('AgentToolSelectService gate', () => { }); describe('AgentToolSelectService S0 baseline (gate closed)', () => { - it('shapeTools returns the identical array when select_tools is absent', () => { + it('shapeTools returns the identical array when dynamically_loaded_tools is absent', () => { const h = createHarness(); registerBuiltin(h, new EchoTool()); registerMcp(h, new StubMcpTool(MCP_ALPHA)); diff --git a/packages/agent-core-v2/test/app/llmProtocol/select-tools.test.ts b/packages/agent-core-v2/test/app/llmProtocol/select-tools.test.ts index a0d55ac5f..15fe14b13 100644 --- a/packages/agent-core-v2/test/app/llmProtocol/select-tools.test.ts +++ b/packages/agent-core-v2/test/app/llmProtocol/select-tools.test.ts @@ -7,7 +7,7 @@ * normalization and the `$` builtin branch shared with top-level tools); * - `Tool.deferred` stripping in `generate()` (single strip point for every * provider call — the marker itself must never reach the wire); - * - the `select_tools` capability bit (unknown/default-off semantics). + * - the `dynamically_loaded_tools` capability bit (unknown/default-off semantics). */ import { UNKNOWN_CAPABILITY, isUnknownCapability } from '#/app/llmProtocol/capability'; @@ -318,12 +318,12 @@ describe('providers without message-level tool declarations', () => { }); }); -describe('select_tools capability bit', () => { +describe('dynamically_loaded_tools capability bit', () => { it('defaults to false on UNKNOWN_CAPABILITY', () => { - expect(UNKNOWN_CAPABILITY.select_tools).toBe(false); + expect(UNKNOWN_CAPABILITY.dynamically_loaded_tools).toBe(false); }); - it('a capability that only has select_tools is not "unknown"', () => { + it('a capability that only has dynamically_loaded_tools is not "unknown"', () => { expect( isUnknownCapability({ image_in: false, @@ -332,16 +332,17 @@ describe('select_tools capability bit', () => { thinking: false, tool_use: false, max_context_tokens: 0, - select_tools: true, + dynamically_loaded_tools: true, }), ).toBe(false); }); - it('catalog entries map select_tools and default it to false', () => { + it('catalog entries map dynamically_loaded_tools and default it to false', () => { const base = { id: 'm', limit: { context: 1000 } }; - expect(catalogModelToCapability(base)?.capability.select_tools).toBe(false); + expect(catalogModelToCapability(base)?.capability.dynamically_loaded_tools).toBe(false); expect( - catalogModelToCapability({ ...base, select_tools: true })?.capability.select_tools, + catalogModelToCapability({ ...base, dynamically_loaded_tools: true })?.capability + .dynamically_loaded_tools, ).toBe(true); }); }); diff --git a/packages/agent-core-v2/test/app/model/modelResolver.test.ts b/packages/agent-core-v2/test/app/model/modelResolver.test.ts index f1101da0b..db9f76b10 100644 --- a/packages/agent-core-v2/test/app/model/modelResolver.test.ts +++ b/packages/agent-core-v2/test/app/model/modelResolver.test.ts @@ -148,16 +148,16 @@ describe('ModelResolverService', () => { expect(auth).toEqual({ apiKey: 'sk-model' }); }); - it('forwards declared select_tools capability to the resolved model', () => { + it('forwards declared dynamically_loaded_tools capability to the resolved model', () => { providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk-test' }; models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000, - capabilities: ['select_tools'], + capabilities: ['dynamically_loaded_tools'], }; - expect(ix.get(IModelResolver).resolve('m').capabilities.select_tools).toBe(true); + expect(ix.get(IModelResolver).resolve('m').capabilities.dynamically_loaded_tools).toBe(true); }); it('returns an OAuth access token as ProviderRequestAuth.apiKey', async () => { @@ -727,7 +727,7 @@ describe('ModelResolverService', () => { thinking: true, tool_use: false, max_context_tokens: 1000, - select_tools: false, + dynamically_loaded_tools: false, }); }); @@ -742,7 +742,7 @@ describe('ModelResolverService', () => { thinking: false, tool_use: true, max_context_tokens: 128000, - select_tools: false, + dynamically_loaded_tools: false, }); }); }); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index b50fe2333..691cd956b 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -2217,7 +2217,7 @@ function capabilityNames(capabilities: ModelCapability | undefined): string[] { capabilities.audio_in ? 'audio_in' : undefined, capabilities.thinking ? 'thinking' : undefined, capabilities.tool_use ? 'tool_use' : undefined, - capabilities.select_tools ? 'select_tools' : undefined, + capabilities.dynamically_loaded_tools ? 'dynamically_loaded_tools' : undefined, ].filter((capability): capability is string => capability !== undefined); } From ac216163b98e00bb4f3ad39420bb23256949a5a6 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 14 Jul 2026 18:31:43 +0800 Subject: [PATCH 05/76] fix: emit turn_id on turn_started/ended/interrupted telemetry (#1668) * fix: emit turn_id on turn_started/ended/interrupted telemetry The turn lifecycle telemetry events (turn_started, turn_ended, turn_interrupted) never carried the turn id, while tool_call and tool_call_dedup_detected did. Any analysis correlating a turn's start, end, or interruption back to its tool calls had nothing to join on. Add turn_id (already in scope) to all three track() calls, matching the existing key/value convention used by tool_call_dedup_detected. Update the strict turn_started/turn_interrupted assertion to cover it. * fix(agent-core-v2): emit turn_id on turn_started/ended/interrupted telemetry Port the v1 fix to agent-core-v2: turn lifecycle telemetry events (turn_started, turn_ended, turn_interrupted) carried no turn id while tool_call did, leaving nothing to correlate a turn's start, end, or interruption back to its tool calls. Add turn_id to the three event interfaces, the telemetry registry property docs, and the three track2() calls in AgentLoopService, matching the existing ToolCallEvent key convention. Extend the turn telemetry assertions in loop.test.ts to cover it. * fix: emit turn_id on tool_call telemetry * docs(agent-core-v2): clarify turn_id is a per-agent index in the telemetry registry --- packages/agent-core-v2/src/agent/loop/loopService.ts | 4 +++- packages/agent-core-v2/src/app/telemetry/events.ts | 10 ++++++++-- packages/agent-core-v2/test/agent/loop/loop.test.ts | 6 ++++-- packages/agent-core/src/agent/turn/index.ts | 5 ++++- packages/agent-core/test/agent/turn.test.ts | 8 ++++++-- 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index e77307b5e..956ee7d10 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -369,7 +369,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { const { mode, provider_type, protocol } = telemetryContext; let result: TurnResult | undefined; try { - const started: TurnStartedTelemetryEvent = { mode, provider_type, protocol }; + const started: TurnStartedTelemetryEvent = { turn_id: turn.id, mode, provider_type, protocol }; turnTelemetry.track2('turn_started', started); result = await this.run({ turnId: turn.id, @@ -397,6 +397,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if (error !== undefined) this.eventBus.publish({ type: 'error', ...error }); if (result.type !== 'completed') { const interrupted: TurnInterruptedEvent = { + turn_id: turn.id, at_step: result.steps, mode, interrupt_reason: interruptReasonFor(result), @@ -407,6 +408,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } } const ended: TurnEndedTelemetryEvent = { + turn_id: turn.id, reason: result?.type ?? 'failed', duration_ms: Date.now() - startedAt, mode, diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index bb1a982f9..4952eb51c 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -43,12 +43,14 @@ export type StrictPropertyCheck = string extends keyof T : never; export interface TurnStartedEvent { + turn_id: number; mode: 'agent' | 'plan'; provider_type?: string; protocol?: string; } export interface TurnInterruptedEvent { + turn_id: number; at_step: number; mode: 'agent' | 'plan'; interrupt_reason: 'user_cancelled' | 'aborted' | 'max_steps' | 'error' | 'filtered' | 'blocked'; @@ -57,6 +59,7 @@ export interface TurnInterruptedEvent { } export interface TurnEndedEvent { + turn_id: number; reason: 'completed' | 'cancelled' | 'failed'; duration_ms: number; mode: 'agent' | 'plan'; @@ -388,6 +391,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A turn starts running.', properties: { + turn_id: 'Per-agent turn index (main or subagent); not unique across agents in the same session', mode: 'Agent mode the turn runs in', provider_type: 'Provider protocol type', protocol: 'Request protocol', @@ -397,6 +401,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A running turn is interrupted.', properties: { + turn_id: 'Per-agent turn index (main or subagent); not unique across agents in the same session', at_step: 'Step index the turn reached before interruption', mode: 'Agent mode the turn ran in', interrupt_reason: 'Why the turn was interrupted', @@ -408,6 +413,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A turn ends, unconditionally.', properties: { + turn_id: 'Per-agent turn index (main or subagent); not unique across agents in the same session', reason: 'How the turn ended', duration_ms: 'Turn wall-clock time in milliseconds', mode: 'Agent mode the turn ran in', @@ -419,7 +425,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A tool call finishes execution.', properties: { - turn_id: 'Turn index within the session', + turn_id: 'Per-agent turn index (main or subagent); not unique across agents in the same session', tool_call_id: 'Provider-assigned tool call id', tool_name: 'Registered tool name', outcome: 'Execution outcome', @@ -657,7 +663,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A duplicate tool call is detected.', properties: { - turn_id: 'Turn index within the session', + turn_id: 'Per-agent turn index (main or subagent); not unique across agents in the same session', step_no: 'Step index within the turn', tool_call_id: 'Provider-assigned tool call id', tool_name: 'Registered tool name', diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 61a3b5ecb..759f542e1 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -615,11 +615,12 @@ describe('turn telemetry', () => { expect(records).toContainEqual({ event: 'turn_started', - properties: { mode: 'agent', provider_type: 'kimi', protocol: 'kimi' }, + properties: { turn_id: 0, mode: 'agent', provider_type: 'kimi', protocol: 'kimi' }, }); expect(records).toContainEqual({ event: 'turn_ended', properties: expect.objectContaining({ + turn_id: 0, reason: 'completed', duration_ms: expect.any(Number), mode: 'agent', @@ -647,6 +648,7 @@ describe('turn telemetry', () => { expect(records).toContainEqual({ event: 'turn_interrupted', properties: expect.objectContaining({ + turn_id: 0, at_step: 1, mode: 'agent', interrupt_reason: 'filtered', @@ -694,7 +696,7 @@ describe('turn telemetry', () => { expect(records).toContainEqual({ event: 'turn_interrupted', - properties: expect.objectContaining({ interrupt_reason: expected, mode: 'agent' }), + properties: expect.objectContaining({ turn_id: 0, interrupt_reason: expected, mode: 'agent' }), }); expect(records).toContainEqual({ event: 'turn_ended', diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 957ecc247..587d801ae 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -521,7 +521,7 @@ export class TurnFlow { const telemetryMode = this.telemetryMode(); this.telemetryModeByTurn.set(turnId, telemetryMode); this.currentStepByTurn.set(turnId, 0); - this.agent.telemetry.track('turn_started', { mode: telemetryMode, ...this.requestProtocolProps() }); + this.agent.telemetry.track('turn_started', { turn_id: turnId, mode: telemetryMode, ...this.requestProtocolProps() }); this.agent.fullCompaction.resetForTurn(); this.agent.usage.beginTurn(); this.agent.emitEvent({ type: 'turn.started', turnId, origin }); @@ -619,6 +619,7 @@ export class TurnFlow { }); } this.agent.telemetry.track('turn_ended', { + turn_id: turnId, reason: ended.reason, duration_ms: ended.durationMs, mode: this.telemetryModeByTurn.get(turnId) ?? this.telemetryMode(), @@ -1057,6 +1058,7 @@ export class TurnFlow { this.toolCallDupType.delete(event.toolCallId); const outcome = telemetryToolOutcome(event.result); const properties: Record = { + turn_id: turnId, tool_name: started.name, outcome, duration_ms: Date.now() - started.startedAt, @@ -1116,6 +1118,7 @@ export class TurnFlow { if (this.interruptedTelemetryTurnIds.has(turnId)) return; this.interruptedTelemetryTurnIds.add(turnId); this.agent.telemetry.track('turn_interrupted', { + turn_id: turnId, mode: this.telemetryModeByTurn.get(turnId) ?? this.telemetryMode(), at_step: atStep, interrupt_reason: interruptReason, diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 3d0a0697a..ecb767769 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -519,11 +519,11 @@ describe('Agent turn flow', () => { expect(records).toContainEqual({ event: 'turn_started', - properties: { mode: 'agent' }, + properties: { turn_id: 0, mode: 'agent' }, }); expect(records).toContainEqual({ event: 'turn_interrupted', - properties: { mode: 'agent', at_step: 0, interrupt_reason: 'error' }, + properties: { turn_id: 0, mode: 'agent', at_step: 0, interrupt_reason: 'error' }, }); }); @@ -656,6 +656,7 @@ describe('Agent turn flow', () => { expect(ended).toEqual({ event: 'turn_ended', properties: expect.objectContaining({ + turn_id: 0, mode: 'agent', reason: 'completed', provider_type: 'kimi', @@ -735,6 +736,7 @@ describe('Agent turn flow', () => { expect(records).toContainEqual({ event: 'tool_call', properties: expect.objectContaining({ + turn_id: 0, tool_name: 'Bash', outcome: 'success', dup_type: 'cross_step', @@ -812,6 +814,7 @@ describe('Agent turn flow', () => { expect(records).toContainEqual({ event: 'tool_call', properties: expect.objectContaining({ + turn_id: 0, tool_name: 'MissingTool', outcome: 'error', dup_type: 'normal', @@ -2206,6 +2209,7 @@ describe('Agent turn flow', () => { expect(records).toContainEqual({ event: 'tool_call', properties: expect.objectContaining({ + turn_id: 0, tool_name: 'Bash', outcome: 'cancelled', dup_type: 'normal', From 38a2363a006d8ed32ff6100ccff2dc7d1a70b2b0 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Tue, 14 Jul 2026 18:35:02 +0800 Subject: [PATCH 06/76] fix: cross-version session compatibility and real message times in snapshot history (#1704) * fix(server): report CLI version as server_version - kap-server: accept opts.version in startServer, reported as server_version (/meta, OpenAPI, session exports, lock registry, default User-Agent); defaults to its own package version - kimi-code: pass the CLI product version when starting the server - add boot test covering /api/v1/meta, lock file, and User-Agent * fix(agent-core-v2): make new sessions resumable by older v1 builds - add wireRecord.seal() to write the metadata envelope at agent creation, so fresh logs satisfy v1 replay's first-record-must-be-metadata invariant - seal the wire log before any op dispatch in AgentLifecycleService.create; no-op when the log already has records (resume / forked copies) - seed empty agents/custom maps in session metadata so v1 Session.resume() can index agents['main'] on a v2-created state.json - add seal unit tests and lifecycle sealing tests * fix(kap-server): show real message send times in snapshot history - read per-record times stamped on wire.jsonl via reduceContextTranscript and cache them alongside the transcript messages - prefer each record's real dispatch time for created_at; records without a stamp fall back to session.createdAt + index, clamped so the page stays strictly increasing (mirrors MessageLegacyService.list) - add tests for fallback, clamping, and the page-offset index mapping * fix(agent-core-v2): backfill missing agents/custom maps in existing session metadata - load() now heals pre-fix v2 state.json documents that never gained the agents/custom maps, persisting the backfill so one open on a new build leaves the session resumable by released v1 builds (Session.resume() indexes agents['main'] unconditionally) - updatedAt is deliberately untouched so the format heal does not reorder session listings * feat(agent-core-v2): make subagent timeout configurable, default 2h - add [subagent] config section with timeout_ms and KIMI_SUBAGENT_TIMEOUT_MS env override - resolve Agent and AgentSwarm per-run timeouts through resolveSubagentTimeoutMs - update tool descriptions and tests to reflect the 2-hour default * feat(agent-core-v2): align print-mode background policy with v1 - add printBackgroundMode/printMaxTurns to the task config section with resolvePrintBackgroundMode (keepAliveOnExit fallback) - apply exit/drain/steer policy to kimi -p on the experimental engine, buffering turn.ended events and failing the run when a steered turn fails - register the subagent config section from the package entry * fix(agent-core-v2): strict equality in metadata heal, comments in file headers only - replace == null with === undefined in the session-metadata heal to satisfy eqeqeq (oxlint --type-aware in CI) - move the seal() rationale into the wireRecord contract header and the agents/custom invariant into the sessionMetadata header per the tree's header-only comment convention --- .changeset/align-print-background-policy.md | 5 + .changeset/align-subagent-timeout.md | 5 + .../fix-session-format-backward-compat.md | 5 + .changeset/server-version-from-cli.md | 5 + .changeset/web-message-history-real-times.md | 5 + apps/kimi-code/src/cli/sub/server/run.ts | 3 + apps/kimi-code/src/cli/v2/run-v2-print.ts | 206 +++++++++++++++-- apps/kimi-code/test/cli/run-v2-print.test.ts | 208 ++++++++++++++++++ .../src/agent/swarm/tools/agent-swarm.ts | 7 +- .../src/agent/task/configSection.ts | 25 ++- .../src/agent/wireRecord/wireRecord.ts | 13 ++ .../src/agent/wireRecord/wireRecordService.ts | 27 ++- packages/agent-core-v2/src/index.ts | 7 + .../agentLifecycle/agentLifecycleService.ts | 7 +- .../sessionMetadata/sessionMetadataService.ts | 19 +- .../src/session/subagent/configSection.ts | 79 +++++++ .../src/session/subagent/tools/agent.md | 2 +- .../src/session/subagent/tools/agent.ts | 16 +- .../test/agent/contextMemory/stubs.ts | 1 + .../test/agent/loop/loop.test.ts | 4 +- .../test/agent/swarm/swarm.test.ts | 51 ++++- .../test/agent/wireRecord/persistence.test.ts | 63 ++++++ .../test/app/config/config.test.ts | 122 ++++++++++ .../app/sessionExport/sessionExport.test.ts | 1 + .../agentLifecycle/agentLifecycle.test.ts | 53 ++++- .../sessionMetadata/sessionMetadata.test.ts | 52 ++++- packages/agent-core-v2/test/tool/tool.test.ts | 62 ++++-- .../src/services/snapshot/snapshotReader.ts | 28 ++- packages/kap-server/src/start.ts | 11 +- packages/kap-server/test/boot.test.ts | 29 +++ .../test/snapshotReader.unit.test.ts | 52 +++++ 31 files changed, 1091 insertions(+), 82 deletions(-) create mode 100644 .changeset/align-print-background-policy.md create mode 100644 .changeset/align-subagent-timeout.md create mode 100644 .changeset/fix-session-format-backward-compat.md create mode 100644 .changeset/server-version-from-cli.md create mode 100644 .changeset/web-message-history-real-times.md create mode 100644 apps/kimi-code/test/cli/run-v2-print.test.ts create mode 100644 packages/agent-core-v2/src/session/subagent/configSection.ts diff --git a/.changeset/align-print-background-policy.md b/.changeset/align-print-background-policy.md new file mode 100644 index 000000000..dad316049 --- /dev/null +++ b/.changeset/align-print-background-policy.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Align the print-mode background-task policy across engines: `print_background_mode` and `print_max_turns` now take effect for `kimi -p` on the experimental engine, with the same exit / drain / steer semantics and defaults as the default engine. diff --git a/.changeset/align-subagent-timeout.md b/.changeset/align-subagent-timeout.md new file mode 100644 index 000000000..41920aa5a --- /dev/null +++ b/.changeset/align-subagent-timeout.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Align the subagent timeout across engines: a fixed 2-hour default, overridable with `[subagent] timeout_ms` in config.toml or the KIMI_SUBAGENT_TIMEOUT_MS environment variable. diff --git a/.changeset/fix-session-format-backward-compat.md b/.changeset/fix-session-format-backward-compat.md new file mode 100644 index 000000000..274ebd21f --- /dev/null +++ b/.changeset/fix-session-format-backward-compat.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix sessions created by newer builds failing to open in older CLI builds on the same machine; new sessions are written in a compatible layout, and existing sessions are healed on first open. diff --git a/.changeset/server-version-from-cli.md b/.changeset/server-version-from-cli.md new file mode 100644 index 000000000..17e7f6ff5 --- /dev/null +++ b/.changeset/server-version-from-cli.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix `kimi server` reporting the internal server package version instead of the CLI version in its metadata; the web UI settings now show the CLI version. diff --git a/.changeset/web-message-history-real-times.md b/.changeset/web-message-history-real-times.md new file mode 100644 index 000000000..5c7b78cb6 --- /dev/null +++ b/.changeset/web-message-history-real-times.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Show each message's actual send time in chat history after reloading a session, instead of the session creation time. diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/server/run.ts index 6a903123d..b7a9ffb03 100644 --- a/apps/kimi-code/src/cli/sub/server/run.ts +++ b/apps/kimi-code/src/cli/sub/server/run.ts @@ -401,6 +401,9 @@ async function runServerInProcess( const v2 = await startServer({ host: options.host, port: options.port, + // Report the CLI's product version as `server_version` (/meta, web UI) + // rather than kap-server's private package version. + version, logLevel: options.logLevel, logger, debugEndpoints: options.debugEndpoints, diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index dd8e6c7a9..5cfbeb148 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -10,7 +10,8 @@ * native `DomainEvent` stream (payloads are already v1-protocol-shaped), * - drives a turn through `IAgentPromptService.enqueue()` and awaits * `Turn.result` for authoritative completion, - * - drains background tasks (config-driven) before exiting. + * - applies the print-mode background policy (config-driven, v1-aligned: + * `exit` / `drain` / `steer`) before exiting. * * Selected by `runPrompt` when `KIMI_CODE_EXPERIMENTAL_FLAG` is set. */ @@ -34,13 +35,16 @@ import { ensureMainAgent, hostRequestHeadersSeed, logSeed, + resolveAgentTaskConfig, resolveKimiHome, resolveLoggingConfig, + resolvePrintBackgroundMode, skillCatalogRuntimeOptionsSeed, type DomainEvent, type IAgentScopeHandle, type ISessionScopeHandle, type LoopRunResult, + type PrintBackgroundMode, type Scope, } from '@moonshot-ai/agent-core-v2'; import { createKimiDefaultHeaders, createKimiDeviceId } from '@moonshot-ai/kimi-code-oauth'; @@ -81,12 +85,7 @@ import { const PROMPT_UI_MODE = 'print'; const DEFAULT_PRINT_WAIT_CEILING_S = 3600; -const TASK_CONFIG_SECTION = 'task'; -const LEGACY_BACKGROUND_CONFIG_SECTION = 'background'; - -interface TaskPrintWaitConfig { - readonly printWaitCeilingS?: number; -} +const DEFAULT_PRINT_MAX_TURNS = 50; export async function runV2Print( opts: CLIOptions, @@ -336,8 +335,13 @@ async function runNativeTurn( await agent.accessor.get(IAuthSummaryService).ensureReady(); + const turnEndings = createPrintTurnEndings(); const subscription = agent.accessor.get(IEventBus).subscribe((event: DomainEvent) => { dispatchNativeEvent(writer, event, stderr); + // Arm the turn-endings collector before `turn.result` settles so a + // background-task completion that steers a new turn right after the main + // turn ends cannot have its `turn.ended` slip past the policy loop. + if (event.type === 'turn.ended') turnEndings.push(event); }); try { const handle = await agent.accessor.get(IAgentPromptService).enqueue({ @@ -361,16 +365,39 @@ async function runNativeTurn( } const result = await turn.result; - // Turn settled, but `-p` is not done until any background work the turn - // spawned has drained (config-bounded). Flush the buffered assistant - // message first so a long drain does not withhold the final message. + // Turn settled, but `-p` is not done until the print-mode background + // policy says so (config-driven: exit / drain / steer). Flush the buffered + // assistant message first so a long drain/steer wait does not withhold the + // final message. writer.flushAssistant(); if (result.type === 'completed') { + const configService = app.accessor.get(IConfigService); + const taskConfig = resolveAgentTaskConfig(configService); try { - await drainBackgroundTasks(app, session); - } catch { - // Draining is best-effort; a wedged background task must not fail the - // (already completed) turn. Swallow and proceed to finish. + await applyPrintBackgroundPolicy({ + mode: resolvePrintBackgroundMode(configService), + ceilingS: taskConfig?.printWaitCeilingS ?? DEFAULT_PRINT_WAIT_CEILING_S, + maxTurns: taskConfig?.printMaxTurns ?? DEFAULT_PRINT_MAX_TURNS, + countPending: () => countPendingBackgroundTasks(session), + drain: () => drainBackgroundTasks(session, taskConfig?.printWaitCeilingS), + turnEndings, + skipTurnId: turn.id, + warn: (message) => stderr.write(`Warning: ${message}\n`), + now: () => Date.now(), + }); + } catch (error) { + // A steered turn that fails fails the run (v1 parity). Anything else + // is best-effort: a wedged background task must not fail the (already + // completed) main turn. + if (error instanceof PrintSteeredTurnFailedError) { + writer.finish(); + throw error; + } + stderr.write( + `Warning: print background policy failed: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); } writer.finish(); return; @@ -466,12 +493,151 @@ function dispatchNativeEvent( } } -async function drainBackgroundTasks(app: Scope, session: ISessionScopeHandle): Promise { - const config = app.accessor.get(IConfigService); - const section = - config.get(TASK_CONFIG_SECTION) ?? - config.get(LEGACY_BACKGROUND_CONFIG_SECTION); - const ceilingS = section?.printWaitCeilingS; +export type PrintTurnEnding = Extract; + +/** + * Source of `turn.ended` events for the print steer loop. `next` resolves with + * the next ending (skipping `skipTurnId`, the main turn's own buffered + * ending), or `null` when `remainingMs` elapses first. + */ +export interface PrintTurnEndings { + next(remainingMs: number, skipTurnId: number): Promise; +} + +/** + * Buffered `turn.ended` collector fed from the agent event bus. Events that + * arrive while no one is waiting are queued, so endings that fire between the + * main turn settling and the policy loop starting are not missed. + */ +export function createPrintTurnEndings(): PrintTurnEndings & { + push: (event: PrintTurnEnding) => void; +} { + const buffer: PrintTurnEnding[] = []; + let waiter: ((ending: PrintTurnEnding | null) => void) | undefined; + return { + push: (event) => { + const resolve = waiter; + if (resolve !== undefined) { + waiter = undefined; + resolve(event); + return; + } + buffer.push(event); + }, + next: async (remainingMs, skipTurnId) => { + const deadlineAt = Date.now() + remainingMs; + const waitOnce = (ms: number): Promise => + new Promise((resolve) => { + let settled = false; + const settle = (value: PrintTurnEnding | null): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + waiter = undefined; + // oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the single resolve; the rule cannot see it + resolve(value); + }; + const timer = setTimeout(() => { + settle(null); + }, ms); + waiter = settle; + }); + for (;;) { + while (buffer.length > 0) { + const ending = buffer.shift()!; + if (ending.turnId !== skipTurnId) return ending; + } + const ms = deadlineAt - Date.now(); + if (ms <= 0) return null; + const ending = await waitOnce(ms); + if (ending === null) return null; + if (ending.turnId !== skipTurnId) return ending; + // The skipped turn's own ending: keep waiting within the same budget. + } + }, + }; +} + +/** A background-task completion steered a new main turn that did not complete. */ +export class PrintSteeredTurnFailedError extends Error {} + +export interface PrintBackgroundPolicyInput { + readonly mode: PrintBackgroundMode; + readonly ceilingS: number; + readonly maxTurns: number; + readonly countPending: () => number; + readonly drain: () => Promise; + readonly turnEndings: PrintTurnEndings; + readonly skipTurnId: number; + readonly warn: (message: string) => void; + readonly now: () => number; +} + +/** + * Apply the print-mode (`kimi -p`) background-task policy after the main turn + * completes. Mirrors v1's `Session.handlePrintMainTurnCompleted`: + * - 'exit' : return immediately (default). + * - 'drain' : suppress + drain background tasks, then return. + * - 'steer' : while background tasks are still pending, stay alive so task + * completions steer new main turns; return once quiescent, or + * when the wall-clock ceiling (`ceilingS`) or the turn cap + * (`maxTurns`) is reached. A steered turn that does not complete + * fails the run. + */ +export async function applyPrintBackgroundPolicy( + input: PrintBackgroundPolicyInput, +): Promise { + if (input.mode === 'exit') return; + if (input.mode === 'drain') { + await input.drain(); + return; + } + + // 'steer' + const deadline = input.now() + input.ceilingS * 1000; + let turns = 0; + for (;;) { + turns += 1; + if (input.now() >= deadline) { + input.warn(`print steer ceiling reached (${input.ceilingS}s), finishing`); + return; + } + if (turns > input.maxTurns) { + input.warn(`print steer max turns reached (${input.maxTurns}), finishing`); + return; + } + if (input.countPending() === 0) return; + const ended = await input.turnEndings.next(deadline - input.now(), input.skipTurnId); + if (ended === null) return; + if (ended.reason !== 'completed') { + throw new PrintSteeredTurnFailedError(formatTurnEndingFailure(ended)); + } + } +} + +function formatTurnEndingFailure(ending: PrintTurnEnding): string { + if (ending.error?.code === 'provider.filtered') { + return 'Provider safety policy blocked the response.'; + } + if (ending.error !== undefined) return `${ending.error.code}: ${ending.error.message}`; + if (ending.reason === 'blocked') { + return 'Prompt hook blocked the request.'; + } + return `Prompt turn ended with reason: ${ending.reason}`; +} + +function countPendingBackgroundTasks(session: ISessionScopeHandle): number { + let count = 0; + for (const handle of session.accessor.get(IAgentLifecycleService).list()) { + count += handle.accessor.get(IAgentTaskService).list(true).length; + } + return count; +} + +async function drainBackgroundTasks( + session: ISessionScopeHandle, + ceilingS: number | undefined, +): Promise { const ceilingMs = typeof ceilingS === 'number' && Number.isFinite(ceilingS) && ceilingS > 0 ? ceilingS * 1000 diff --git a/apps/kimi-code/test/cli/run-v2-print.test.ts b/apps/kimi-code/test/cli/run-v2-print.test.ts new file mode 100644 index 000000000..6e7178509 --- /dev/null +++ b/apps/kimi-code/test/cli/run-v2-print.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + applyPrintBackgroundPolicy, + createPrintTurnEndings, + PrintSteeredTurnFailedError, + type PrintTurnEnding, + type PrintTurnEndings, +} from '#/cli/v2/run-v2-print'; + +function ending( + turnId: number, + reason: PrintTurnEnding['reason'] = 'completed', +): PrintTurnEnding { + return { type: 'turn.ended', turnId, reason }; +} + +interface ScriptedEntry { + readonly event: PrintTurnEnding; + /** Side effect applied when this entry is consumed (e.g. mutate pending). */ + readonly apply?: () => void; +} + +/** + * Scripted `PrintTurnEndings`: replays queued endings (honouring `skipTurnId`), + * then resolves `null` once the script is exhausted (the wait "timed out"). + */ +function scriptedTurnEndings(entries: ScriptedEntry[]): PrintTurnEndings { + const queue = [...entries]; + return { + next: async (_remainingMs: number, skipTurnId: number) => { + while (queue.length > 0) { + const entry = queue.shift()!; + if (entry.event.turnId === skipTurnId) continue; + entry.apply?.(); + return entry.event; + } + return null; + }, + }; +} + +describe('applyPrintBackgroundPolicy', () => { + it('exit returns immediately without draining or waiting', async () => { + const drain = vi.fn(async () => {}); + const countPending = vi.fn(() => 1); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 60, + maxTurns: 50, + countPending, + drain, + turnEndings: scriptedTurnEndings([]), + skipTurnId: 1, + warn: () => {}, + now: () => Date.now(), + }); + expect(drain).not.toHaveBeenCalled(); + expect(countPending).not.toHaveBeenCalled(); + }); + + it('drain drains once and returns', async () => { + const drain = vi.fn(async () => {}); + await applyPrintBackgroundPolicy({ + mode: 'drain', + ceilingS: 60, + maxTurns: 50, + countPending: () => 1, + drain, + turnEndings: scriptedTurnEndings([]), + skipTurnId: 1, + warn: () => {}, + now: () => Date.now(), + }); + expect(drain).toHaveBeenCalledTimes(1); + }); + + it('steer returns once background tasks are quiescent', async () => { + let pending = 1; + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 60, + maxTurns: 50, + countPending: () => pending, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + // The main turn's own buffered ending is skipped. + { event: ending(1) }, + // A background task completed and steered a new turn; it finished and + // no tasks remain. + { event: ending(2), apply: () => { pending = 0; } }, + ]), + skipTurnId: 1, + warn, + now: () => Date.now(), + }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('steer finishes with a warning when max turns is reached', async () => { + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 60, + maxTurns: 2, + countPending: () => 1, + drain: async () => {}, + turnEndings: scriptedTurnEndings([{ event: ending(2) }, { event: ending(3) }]), + skipTurnId: 1, + warn, + now: () => Date.now(), + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain('max turns'); + }); + + it('steer finishes with a warning when the ceiling is reached', async () => { + let now = 0; + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 10, + maxTurns: 50, + countPending: () => 1, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + { event: ending(2), apply: () => { now = 10_001; } }, + ]), + skipTurnId: 1, + warn, + now: () => now, + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain('ceiling'); + }); + + it('steer returns when the wait times out with tasks still pending', async () => { + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 60, + maxTurns: 50, + countPending: () => 1, + drain: async () => {}, + // Empty script: no further turn ends before the deadline. + turnEndings: scriptedTurnEndings([]), + skipTurnId: 1, + warn, + now: () => Date.now(), + }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('steer throws when a steered turn does not complete', async () => { + await expect( + applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 60, + maxTurns: 50, + countPending: () => 1, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + { + event: { + type: 'turn.ended', + turnId: 2, + reason: 'failed', + error: { code: 'provider.overloaded', message: 'try later' }, + } as PrintTurnEnding, + }, + ]), + skipTurnId: 1, + warn: () => {}, + now: () => Date.now(), + }), + ).rejects.toThrow(PrintSteeredTurnFailedError); + }); +}); + +describe('createPrintTurnEndings', () => { + it('buffers events pushed before next() and skips the given turn id', async () => { + const endings = createPrintTurnEndings(); + endings.push(ending(1)); + endings.push(ending(2)); + await expect(endings.next(1000, 1)).resolves.toMatchObject({ turnId: 2 }); + }); + + it('delivers a pushed event to a pending next()', async () => { + const endings = createPrintTurnEndings(); + const pending = endings.next(1000, 1); + endings.push(ending(3)); + await expect(pending).resolves.toMatchObject({ turnId: 3 }); + }); + + it('resolves null when the remaining time elapses', async () => { + const endings = createPrintTurnEndings(); + await expect(endings.next(5, 1)).resolves.toBeNull(); + }); + + it('keeps waiting when only the skipped turn ends', async () => { + const endings = createPrintTurnEndings(); + const pending = endings.next(1000, 1); + endings.push(ending(1)); + endings.push(ending(4)); + await expect(pending).resolves.toMatchObject({ turnId: 4 }); + }); +}); diff --git a/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts b/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts index c1475c06d..09db93c30 100644 --- a/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts +++ b/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts @@ -19,13 +19,14 @@ import { } from '#/tool/toolContract'; import { registerTool } from '#/agent/toolRegistry/toolContribution'; import { toInputJsonSchema } from '#/tool/input-schema'; +import { IConfigService } from '#/app/config/config'; import { ISessionSwarmService, type SessionSwarmTask } from '#/session/swarm/sessionSwarm'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; +import { resolveSubagentTimeoutMs } from '#/session/subagent/configSection'; import AGENT_SWARM_DESCRIPTION from './agent-swarm.md?raw'; const DEFAULT_SUBAGENT_TYPE = 'coder'; -const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000; const PROMPT_TEMPLATE_PLACEHOLDER = '{{item}}'; const MAX_AGENT_SWARM_SUBAGENTS = 128; @@ -107,6 +108,7 @@ export class AgentSwarmTool implements BuiltinTool { @ISessionSwarmService private readonly swarmService: ISessionSwarmService, @IAgentScopeContext scopeContext: IAgentScopeContext, @IAgentSwarmService private readonly swarmMode: IAgentSwarmService, + @IConfigService private readonly config: IConfigService, ) { this.callerAgentId = scopeContext.agentId; } @@ -150,6 +152,7 @@ export class AgentSwarmTool implements BuiltinTool { toolCallId: string, ): Promise { const profileName = normalizeOptionalString(args.subagent_type) ?? DEFAULT_SUBAGENT_TYPE; + const timeoutMs = resolveSubagentTimeoutMs(this.config); const specs = await createAgentSwarmSpecs(args, (agentId) => this.swarmService.getSwarmItem({ callerAgentId: this.callerAgentId, agentId }), ); @@ -165,7 +168,7 @@ export class AgentSwarmTool implements BuiltinTool { runInBackground: false, swarmItem: spec.item, signal, - timeout: DEFAULT_SUBAGENT_TIMEOUT_MS, + timeout: timeoutMs, }; if (spec.kind === 'resume') { return { diff --git a/packages/agent-core-v2/src/agent/task/configSection.ts b/packages/agent-core-v2/src/agent/task/configSection.ts index c8f7d5e31..405f9c0f4 100644 --- a/packages/agent-core-v2/src/agent/task/configSection.ts +++ b/packages/agent-core-v2/src/agent/task/configSection.ts @@ -7,7 +7,10 @@ * fields as the base and let `[task]` override matching fields. * `keepAliveOnExit` also * accepts the v1 env override `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` - * (applied live by the config env overlay, never persisted). Self-registered + * (applied live by the config env overlay, never persisted). Also owns the + * `kimi -p` print-mode background policy (`printBackgroundMode` / + * `printWaitCeilingS` / `printMaxTurns`), resolved with v1 semantics by + * `resolvePrintBackgroundMode`. Self-registered * at module load via `registerConfigSection`, so the `config` domain never * imports this domain's types. */ @@ -21,12 +24,18 @@ import { registerConfigSection } from '#/app/config/configSectionContributions'; export const TASK_SECTION = 'task'; export const LEGACY_BACKGROUND_SECTION = 'background'; +export const PrintBackgroundModeSchema = z.enum(['exit', 'drain', 'steer']); + +export type PrintBackgroundMode = z.infer; + export const AgentTaskConfigSchema = z.object({ maxRunningTasks: z.number().int().min(1).optional(), keepAliveOnExit: z.boolean().optional(), bashAutoBackgroundOnTimeout: z.boolean().optional(), killGracePeriodMs: z.number().int().min(0).optional(), printWaitCeilingS: z.number().int().min(1).optional(), + printBackgroundMode: PrintBackgroundModeSchema.optional(), + printMaxTurns: z.number().int().min(1).optional(), }); export type AgentTaskConfig = z.infer; @@ -39,6 +48,20 @@ export function resolveAgentTaskConfig(config: IConfigService): AgentTaskConfig return { ...legacy, ...current }; } +/** + * Resolve the effective print-mode (`kimi -p`) background-task policy, mirroring + * v1's `Session.resolvePrintBackgroundMode`: `printBackgroundMode` is + * authoritative when set; otherwise fall back to the legacy `keepAliveOnExit` + * mapping (`true` ⇒ `'drain'`, otherwise `'exit'`). The + * `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` env override is applied by the + * config env overlay (see `taskEnvBindings`), so it is covered here. + */ +export function resolvePrintBackgroundMode(config: IConfigService): PrintBackgroundMode { + const section = resolveAgentTaskConfig(config); + if (section?.printBackgroundMode !== undefined) return section.printBackgroundMode; + return section?.keepAliveOnExit === true ? 'drain' : 'exit'; +} + export const KEEP_ALIVE_ON_EXIT_ENV = 'KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT'; export const taskEnvBindings: EnvBindings = envBindings(AgentTaskConfigSchema, { diff --git a/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts b/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts index d10522584..e7b9b531f 100644 --- a/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts +++ b/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts @@ -1,3 +1,15 @@ +/** + * `wireRecord` contract (L6) — the persisted wire journal's public surface. + * + * Defines the on-disk record vocabulary (the `metadata` envelope and the + * migration records) and `IAgentWireRecordService`. `seal` starts a fresh log + * with the `metadata` envelope at agent creation (a no-op once any record + * exists) so released v1 builds — whose replay hard-rejects a non-empty log + * lacking the envelope — can read sessions on a shared `KIMI_CODE_HOME`; + * legacy envelope-less logs are healed by `restore`, never by `seal`. Bound + * at Agent scope. + */ + import { createDecorator } from '#/_base/di/instantiation'; import type { WireMigrationRecord } from '#/agent/wireRecord/migration/migration'; @@ -24,6 +36,7 @@ export interface WireRecordRestoreResult { export interface IAgentWireRecordService { readonly _serviceBrand: undefined; + seal(): Promise; getRecords(): readonly PersistedWireRecord[]; restore( records?: readonly PersistedWireRecord[], diff --git a/packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts b/packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts index 7eb92eb52..12d8ba837 100644 --- a/packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts +++ b/packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts @@ -3,9 +3,13 @@ * * Restores and retains the owning agent's wire journal, applies protocol * migrations, rejects non-empty unversioned logs, and awaits durable atomic - * rewrites before restore completes. Tracks live records through `wire`, uses - * `agent/scopeContext` for storage addressing, and persists through the - * `appendLog` access-pattern store. Bound at Agent scope. + * rewrites before restore completes. Seals fresh logs with the `metadata` + * envelope at creation (`seal`) so released v1 builds — whose replay + * hard-rejects envelope-less logs — can read sessions on a shared + * `KIMI_CODE_HOME`; legacy envelope-less logs are healed on `restore`. + * Tracks live records through `wire`, uses `agent/scopeContext` for storage + * addressing, and persists through the `appendLog` access-pattern store. + * Bound at Agent scope. */ import { relative } from 'pathe'; @@ -13,6 +17,7 @@ import { relative } from 'pathe'; import { InstantiationType } from '#/_base/di/extensions'; import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { onUnexpectedError } from '#/_base/errors/unexpectedError'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAgentWireService } from '#/wire/tokens'; @@ -64,6 +69,14 @@ export class AgentWireRecordService extends Disposable implements IAgentWireReco return [...this.records]; } + async seal(): Promise { + if (this.log === undefined) return; + if (await hasAnyRecord(this.log, this.wireScope, WIRE_RECORD_FILENAME)) return; + this.log.append(this.wireScope, WIRE_RECORD_FILENAME, metadataRecord(), { + onError: onUnexpectedError, + }); + } + async restore( records?: readonly PersistedWireRecord[], options: WireRecordRestoreOptions = {}, @@ -161,6 +174,14 @@ function isWireRecordMetadata(record: PersistedWireRecord): record is WireRecord return record.type === 'metadata' && typeof record['protocol_version'] === 'string'; } +async function hasAnyRecord(log: IAppendLogStore, scope: string, key: string): Promise { + for await (const record of log.read(scope, key)) { + void record; + return true; + } + return false; +} + export const WIRE_RECORD_FILENAME = 'wire.jsonl'; export function missingWireMetadataError(): Error { diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index d928d3e93..f7bdfd0c1 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -204,6 +204,12 @@ export * from '#/agent/toolSelect/toolSelectAnnouncements'; export * from '#/agent/toolSelect/toolSelectAnnouncementsService'; import '#/agent/task/configSection'; +export { + resolveAgentTaskConfig, + resolvePrintBackgroundMode, + type AgentTaskConfig, + type PrintBackgroundMode, +} from '#/agent/task/configSection'; import '#/agent/task/tools/task-list'; import '#/agent/task/tools/task-output'; import '#/agent/task/tools/task-stop'; @@ -233,6 +239,7 @@ export * from '#/session/subagent/subagentService'; export * from '#/session/subagent/tools/subagent-task'; export { AGENT_RUN_PROMPT_ORIGIN } from '#/session/subagent/runAgentTurn'; export * from '#/session/subagent/mirrorAgentRun'; +import '#/session/subagent/configSection'; import '#/session/subagent/tools/agent'; export * from '#/app/sessionLifecycle/sessionLifecycle'; export * from '#/app/sessionLifecycle/sessionLifecycleService'; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 65485aa4c..dc4917e6c 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -55,6 +55,7 @@ import { IImageConfigBridge } from '#/agent/media/imageConfigBridge'; import { IAgentMcpService } from '#/agent/mcp/mcp'; import { IAgentExternalHooksService } from '#/agent/externalHooks/externalHooks'; import { IAgentPluginService } from '#/agent/plugin/agentPlugin'; +import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord'; import { ISessionInteractionService } from '#/session/interaction/interaction'; import { type AgentListFilter, @@ -168,6 +169,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle ) as IAgentScopeHandle; this.handles.set(agentId, handle); try { + await handle.accessor.get(IAgentWireRecordService).seal(); await this.sessionMetadata.registerAgent(agentId, { homedir: agentHomedir, type: agentId === 'main' ? 'main' : 'sub', @@ -182,10 +184,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle // Bootstrap (profile binding and the force-instantiated observer // services) is complete: drive the activity kernel `initializing → idle` // so the agent can admit turns. Until this point `begin` rejects with - // `activity.initializing`. The wire log's metadata envelope is NOT - // seeded here — `wireRecord.restore()` heals envelope-less logs on - // resume (prepend + rewrite), so creation stays free of log-format - // concerns. + // `activity.initializing`. handle.accessor.get(IAgentActivityService).markReady(); return handle; } catch (error) { diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index e05901c17..c3bd1a251 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -4,8 +4,13 @@ * Persists the session metadata document (`state.json`) through the `storage` * access-pattern store (`IAtomicDocumentStore`), rooted at the `metaScope` * namespace from `sessionContext`. Loads the existing document on - * construction (creating it on first run), and logs through `log`. Bound at - * Session scope. + * construction (creating it on first run), and logs through `log`. The + * document always carries the `agents` / `custom` maps that v1's + * `Session.resume()` reads unconditionally — seeded at creation, backfilled + * and persisted on load for documents written before the seeding existed + * (without touching `updatedAt`, so a format heal never reorders session + * listings) — keeping sessions on a shared `KIMI_CODE_HOME` resumable by + * released v1 builds. Bound at Session scope. * * Read-model mirroring (flag `persistence_minidb_readmodel`): after a metadata * update is persisted, the fresh summary is mirrored into the `IQueryStore` @@ -131,6 +136,14 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { const existing = await this.store.get(this.scope, META_KEY); if (existing !== undefined) { this.data = normalizeSessionMeta(existing, this.ctx.sessionId); + if (this.data.agents === undefined || this.data.custom === undefined) { + this.data = { + ...this.data, + agents: this.data.agents ?? {}, + custom: this.data.custom ?? {}, + }; + await this.store.set(this.scope, META_KEY, this.data); + } return; } const now = Date.now(); @@ -141,6 +154,8 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { createdAt: now, updatedAt: now, archived: false, + agents: {}, + custom: {}, }; await this.store.set(this.scope, META_KEY, this.data); this.log.debug('session metadata created', { sessionId: this.ctx.sessionId }); diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts new file mode 100644 index 000000000..57aa44d28 --- /dev/null +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -0,0 +1,79 @@ +/** + * `subagent` domain (L6) — subagent config-section schema, env binding, and + * timeout resolution. + * + * Owns the `[subagent]` configuration section (`timeout_ms` on disk) together + * with the `KIMI_SUBAGENT_TIMEOUT_MS` env override, mirroring v1's + * `resolveSubagentTimeoutMs` precedence (env > config.toml > 2h default). Both + * collaboration tools — `Agent` in this domain and `AgentSwarm` in the `swarm` + * domain — resolve their per-run timeout through `resolveSubagentTimeoutMs`, + * and render the timeout message with `formatSubagentTimeoutDescription`. + * Self-registered at module load via `registerConfigSection`, so the `config` + * domain never imports this domain's types. + */ + +import { z } from 'zod'; + +import { type EnvBindings, envBindings, type IConfigService } from '#/app/config/config'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; + +export const SUBAGENT_SECTION = 'subagent'; + +export const SubagentConfigSchema = z.object({ + /** Per-run subagent timeout in milliseconds; set a large value to effectively disable the cap. */ + timeoutMs: z.number().int().min(1).optional(), +}); + +export type SubagentConfig = z.infer; + +/** Default per-run subagent timeout: 2 hours, same as v1. */ +export const DEFAULT_SUBAGENT_TIMEOUT_MS = 2 * 60 * 60 * 1000; + +export const SUBAGENT_TIMEOUT_ENV = 'KIMI_SUBAGENT_TIMEOUT_MS'; + +/** Parse the env override; anything but a positive integer is ignored (v1 semantics). */ +function parseTimeoutMsEnv(raw: string): number | undefined { + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed >= 1 ? parsed : undefined; +} + +export const subagentEnvBindings: EnvBindings = envBindings( + SubagentConfigSchema, + { + timeoutMs: { env: SUBAGENT_TIMEOUT_ENV, parse: parseTimeoutMsEnv }, + }, +); + +registerConfigSection(SUBAGENT_SECTION, SubagentConfigSchema, { + defaultValue: { timeoutMs: DEFAULT_SUBAGENT_TIMEOUT_MS }, + env: subagentEnvBindings, +}); + +/** + * Resolve the effective per-run subagent timeout. Governs foreground and + * background subagents (and AgentSwarm) through the task manager's per-task + * timeout. + */ +export function resolveSubagentTimeoutMs(config: IConfigService): number { + return ( + config.get(SUBAGENT_SECTION)?.timeoutMs ?? + DEFAULT_SUBAGENT_TIMEOUT_MS + ); +} + +/** Human-readable duration for the subagent timeout message. */ +export function formatSubagentTimeoutDescription(ms: number): string { + if (ms % (60 * 60 * 1000) === 0) { + const h = ms / (60 * 60 * 1000); + return `${h} hour${h === 1 ? '' : 's'}`; + } + if (ms % (60 * 1000) === 0) { + const m = ms / (60 * 1000); + return `${m} minute${m === 1 ? '' : 's'}`; + } + if (ms % 1000 === 0) { + const s = ms / 1000; + return `${s} second${s === 1 ? '' : 's'}`; + } + return `${ms} ms`; +} diff --git a/packages/agent-core-v2/src/session/subagent/tools/agent.md b/packages/agent-core-v2/src/session/subagent/tools/agent.md index ec0533e7e..d8b65d7c0 100644 --- a/packages/agent-core-v2/src/session/subagent/tools/agent.md +++ b/packages/agent-core-v2/src/session/subagent/tools/agent.md @@ -9,7 +9,7 @@ Writing the prompt: Usage notes: - When the task continues earlier work a subagent already did, prefer resuming that agent (pass its `resume` id) over spawning a fresh instance — the resumed agent keeps its prior context. - A subagent's result is only visible to you, not to the user. When the user needs to see what a subagent produced, summarize the relevant parts yourself in your own reply. -- Subagents use a fixed 30-minute timeout. If one times out, resume the same agent instead of starting over. +- Subagents use a fixed 2-hour timeout. If one times out, resume the same agent instead of starting over. When NOT to use Agent: skip delegation for trivial work you can do directly — reading a file whose path you already know, searching a small known set of files, or any task that takes only a step or two. Delegation has a context-handoff cost; it pays off only when the task is substantial enough to outweigh it. diff --git a/packages/agent-core-v2/src/session/subagent/tools/agent.ts b/packages/agent-core-v2/src/session/subagent/tools/agent.ts index 6193b0a06..00d94b15a 100644 --- a/packages/agent-core-v2/src/session/subagent/tools/agent.ts +++ b/packages/agent-core-v2/src/session/subagent/tools/agent.ts @@ -41,6 +41,7 @@ import { registerTool } from '#/agent/toolRegistry/toolContribution'; import { IAgentProfileCatalogService, type AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { applyProfilePromptPrefix } from '#/app/agentProfileCatalog/promptPrefix'; import { ILogService } from '#/_base/log/log'; +import { IConfigService } from '#/app/config/config'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { isSubagentMeta, subagentLabels, subagentParentAgentId } from '#/session/agentLifecycle/subagentMetadata'; import { ISessionProcessRunner } from '#/session/process/processRunner'; @@ -49,6 +50,10 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceCo import { emitAgentRunSpawned, mirrorAgentRun } from '../mirrorAgentRun'; import { ISessionSubagentService } from '../subagent'; +import { + formatSubagentTimeoutDescription, + resolveSubagentTimeoutMs, +} from '../configSection'; import { SubagentTask, type SubagentHandle } from './subagent-task'; import AGENT_BACKGROUND_DISABLED_DESCRIPTION from './agent-background-disabled.md?raw'; @@ -57,8 +62,6 @@ import AGENT_DESCRIPTION_BASE from './agent.md?raw'; const DEFAULT_PROFILE_NAME = 'coder'; const RESUMED_LABEL = 'subagent'; -export const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000; -export const DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION = '30 minutes'; export const AgentToolInputSchema = z.preprocess( (input) => { @@ -146,6 +149,7 @@ export class AgentTool implements BuiltinTool { @ISessionMetadata private readonly sessionMetadata: ISessionMetadata, @ILogService private readonly log: ILogService, @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, + @IConfigService private readonly config: IConfigService, ) { this.callerAgentId = scopeContext.agentId; this.canRunInBackground = () => @@ -334,6 +338,7 @@ export class AgentTool implements BuiltinTool { if (runInBackground && !allowBackground) { return { output: BACKGROUND_AGENT_UNAVAILABLE, isError: true }; } + const timeoutMs = resolveSubagentTimeoutMs(this.config); const controller = new AbortController(); const abortBeforeRegister = (): void => { @@ -363,7 +368,7 @@ export class AgentTool implements BuiltinTool { try { const registerOptions: RegisterAgentTaskOptions = { detached: runInBackground, - timeoutMs: DEFAULT_SUBAGENT_TIMEOUT_MS, + timeoutMs, signal: runInBackground ? undefined : signal, }; taskId = this.tasks.registerTask( @@ -403,7 +408,7 @@ export class AgentTool implements BuiltinTool { output: formatBackgroundAgentResult(taskId, handle, args.description, allowBackground), }; } - return await this.formatForegroundResult(taskId, handle); + return await this.formatForegroundResult(taskId, handle, timeoutMs); } catch (error) { return { output: `subagent error: ${launchErrorMessage(error, signal)}`, isError: true }; } @@ -412,6 +417,7 @@ export class AgentTool implements BuiltinTool { private async formatForegroundResult( taskId: string, handle: SubagentHandle, + timeoutMs: number, ): Promise { const info = this.tasks.getTask(taskId); if (info?.status === 'completed') { @@ -421,7 +427,7 @@ export class AgentTool implements BuiltinTool { } const timedOut = info?.status === 'timed_out'; const message = timedOut - ? `Agent timed out after ${DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION}.` + ? `Agent timed out after ${formatSubagentTimeoutDescription(timeoutMs)}.` : info?.stopReason === 'Interrupted by user' ? USER_INTERRUPTED_SUBAGENT_MESSAGE : info?.stopReason !== undefined diff --git a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts index 178b2f24e..fec907db1 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts @@ -24,6 +24,7 @@ import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord'; export function stubWireRecord(): IAgentWireRecordService { return { _serviceBrand: undefined, + seal: () => Promise.resolve(), restore: () => Promise.resolve({}), flush: () => Promise.resolve(), close: () => Promise.resolve(), diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 759f542e1..04e433d5e 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -96,8 +96,8 @@ describe('Agent loop', () => { [emit] context.spliced { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "" } ] } [emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "" } [wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "", "turnId": "0", "step": 1 }, "time": "