From a66643a429650d7a44224f3f5e1445523dd8683d Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 14 Jul 2026 10:22:47 +0800 Subject: [PATCH] style(agent-core-v2): remove non-header comments Enforce the package comment convention that comments live only in the top-of-file block: strip mid-file doc, prose, and trailing comments while preserving header blocks and eslint/oxlint/@ts-* suppression comments. No code tokens are changed. --- .../scripts/dep-graph/analyzer/analyze.ts | 210 ---------- .../scripts/dep-graph/analyzer/types.ts | 81 ---- .../agent-core-v2/scripts/dep-graph/lint.ts | 6 +- .../dep-graph/plugin/virtual-dep-graph.ts | 25 -- .../scripts/dep-graph/vite.config.ts | 8 - .../scripts/dep-graph/web/src/App.tsx | 4 - .../scripts/dep-graph/web/src/Filters.tsx | 11 - .../scripts/dep-graph/web/src/GraphView.tsx | 137 ------- .../scripts/dep-graph/web/src/layout-dagre.ts | 54 --- .../scripts/dep-graph/web/src/query-params.ts | 10 - .../scripts/dep-graph/web/src/style.ts | 2 - .../scripts/dep-graph/web/src/tags.ts | 5 - packages/agent-core-v2/src/_base/di/scope.ts | 3 - .../src/_base/di/testInstantiationService.ts | 39 -- .../agent-core-v2/src/_base/errors/codes.ts | 11 - .../agent-core-v2/src/_base/errors/errors.ts | 6 - .../src/_base/errors/serialize.ts | 1 - .../src/_base/execEnv/bufferedReadable.ts | 2 - .../src/_base/execEnv/decodeText.ts | 20 - .../src/_base/execEnv/environmentProbe.ts | 19 - .../src/_base/execEnv/globPattern.ts | 7 - .../src/_base/execEnv/loginShellPath.ts | 48 --- packages/agent-core-v2/src/_base/log/log.ts | 4 - .../agent-core-v2/src/_base/log/logService.ts | 5 - .../agent-core-v2/src/_base/utils/abort.ts | 6 - .../agent-core-v2/src/_base/utils/retry.ts | 1 - .../agent-core-v2/src/_base/utils/timer.ts | 8 - .../agent-core-v2/src/activity/activity.ts | 48 --- .../src/activity/agentActivityService.ts | 2 - .../src/activity/sessionActivityKernel.ts | 7 - .../src/agent/blob/byteLruCache.ts | 1 - .../agent/contextInjector/contextInjector.ts | 18 - .../contextInjector/contextInjectorService.ts | 4 - .../src/agent/contextMemory/contextMemory.ts | 9 - .../contextMemory/contextMemoryService.ts | 7 - .../src/agent/contextMemory/contextOps.ts | 23 -- .../agent/contextMemory/contextTranscript.ts | 25 -- .../src/agent/contextMemory/loopEventFold.ts | 16 - .../agent/contextMemory/messageProjection.ts | 19 - .../src/agent/contextMemory/types.ts | 2 - .../contextProjectorService.ts | 91 ----- .../agent/contextSize/contextSizeService.ts | 7 - .../src/agent/externalHooks/configSection.ts | 2 - .../externalHooks/externalHooksService.ts | 10 - .../src/agent/externalHooks/runner.ts | 10 - .../agent/faultInjection/faultInjection.ts | 15 - .../agent/fullCompaction/fullCompaction.ts | 1 - .../fullCompaction/fullCompactionService.ts | 50 --- .../src/agent/fullCompaction/strategy.ts | 30 +- .../src/agent/goal/goalService.ts | 30 -- .../src/agent/goal/tools/create-goal.ts | 6 - .../src/agent/goal/tools/serialize.ts | 5 - .../src/agent/goal/tools/set-goal-budget.ts | 2 - .../src/agent/llmRequester/llmRequestOps.ts | 4 - .../src/agent/llmRequester/llmRequester.ts | 19 - .../agent/llmRequester/llmRequesterService.ts | 73 ---- .../src/agent/loop/configSection.ts | 2 - packages/agent-core-v2/src/agent/loop/loop.ts | 53 --- .../src/agent/loop/loopService.ts | 25 -- .../src/agent/loop/stepRequest.ts | 33 -- .../src/agent/loop/stepRequestQueue.ts | 2 - .../src/agent/mcp/client-http.ts | 62 --- .../src/agent/mcp/client-remote.ts | 3 - .../src/agent/mcp/client-shared.ts | 26 -- .../agent-core-v2/src/agent/mcp/client-sse.ts | 24 -- .../src/agent/mcp/client-stdio.ts | 66 ---- .../src/agent/mcp/config-loader.ts | 11 - .../src/agent/mcp/connection-manager.ts | 68 ---- .../agent-core-v2/src/agent/mcp/mcpService.ts | 2 - .../src/agent/mcp/oauth/callback-server.ts | 6 - .../src/agent/mcp/oauth/service.ts | 28 -- .../agent-core-v2/src/agent/mcp/output.ts | 72 ---- .../src/agent/mcp/tool-naming.ts | 20 - .../agent-core-v2/src/agent/mcp/tools/auth.ts | 15 +- packages/agent-core-v2/src/agent/mcp/types.ts | 43 -- .../src/agent/media/configSection.ts | 13 - .../src/agent/media/file-type.ts | 60 --- .../src/agent/media/image-compress.ts | 368 ------------------ .../src/agent/media/image-format-policy.ts | 100 ----- .../src/agent/media/image-originals.ts | 25 +- .../src/agent/media/imageConfigBridge.ts | 4 - .../src/agent/media/mediaToolsRegistrar.ts | 7 - .../src/agent/media/registerMediaTools.ts | 22 -- .../src/agent/media/tools/read-media.ts | 63 --- .../src/agent/media/webp-decode.ts | 12 +- .../permissionGate/permissionGateService.ts | 5 - .../agent/permissionMode/permissionMode.ts | 1 - .../permissionPolicy/permissionPolicy.ts | 6 - .../permissionPolicy/policies/deny-all.ts | 7 - .../src/agent/permissionPolicy/types.ts | 9 - .../agent/permissionRules/configSection.ts | 2 - .../src/agent/permissionRules/matchesRule.ts | 20 - .../agent/permissionRules/permissionRules.ts | 10 - .../src/agent/plan/planService.ts | 1 - .../src/agent/plan/tools/enter-plan-mode.ts | 2 - .../src/agent/plan/tools/exit-plan-mode.ts | 8 - .../src/agent/profile/context.ts | 20 - .../src/agent/profile/profile.ts | 76 ---- .../src/agent/profile/profileOps.ts | 7 - .../src/agent/profile/profileService.ts | 17 - .../src/agent/prompt/promptStepRequests.ts | 8 - .../src/agent/questionTools/tools/ask-user.ts | 11 - .../src/agent/replayBuilder/types.ts | 5 - .../agent-core-v2/src/agent/rpc/core-api.ts | 29 -- .../src/agent/rpc/prompt-metadata.ts | 3 - .../agent-core-v2/src/agent/rpc/rpcService.ts | 3 - .../src/agent/runtime/runtimeOps.ts | 21 - .../src/agent/runtime/runtimeService.ts | 1 - .../src/agent/scopeContext/scopeContext.ts | 13 - .../agent/shellCommand/shellCommandService.ts | 13 - .../agent-core-v2/src/agent/skill/prompt.ts | 7 - .../agent-core-v2/src/agent/skill/skill.ts | 7 - .../src/agent/skill/tools/skill.ts | 12 - .../src/agent/stepRetry/stepRetryService.ts | 2 - .../agent/systemReminder/systemReminder.ts | 4 - .../src/agent/task/configSection.ts | 4 - .../agent-core-v2/src/agent/task/persist.ts | 1 - packages/agent-core-v2/src/agent/task/task.ts | 27 -- .../src/agent/task/taskService.ts | 63 +-- .../src/agent/task/tools/task-list.ts | 4 - .../src/agent/task/tools/task-output.ts | 21 +- .../src/agent/task/tools/task-stop.ts | 8 - .../agent-core-v2/src/agent/task/types.ts | 8 - .../src/agent/toolDedupe/toolDedupeService.ts | 2 - .../src/agent/toolExecutor/toolExecutor.ts | 16 - .../agent/toolExecutor/toolExecutorService.ts | 7 - .../src/agent/toolExecutor/toolScheduler.ts | 1 - .../agent/toolRegistry/toolContribution.ts | 23 -- .../src/agent/toolSelect/toolSelectService.ts | 6 - .../agent-core-v2/src/agent/usage/usage.ts | 1 - .../agent-core-v2/src/agent/usage/usageOps.ts | 2 - .../src/agent/userTool/userToolService.ts | 5 - .../agent/wireRecord/migration/migration.ts | 5 - .../src/agent/wireRecord/migration/v1.1.ts | 6 - .../src/agent/wireRecord/migration/v1.2.ts | 5 - .../src/agent/wireRecord/migration/v1.3.ts | 9 - .../src/agent/wireRecord/wireRecord.ts | 8 - .../src/agent/wireRecord/wireRecordService.ts | 25 -- .../agentProfileCatalog.ts | 43 -- .../app/agentProfileCatalog/contribution.ts | 4 - .../agent-core-v2/src/app/auth/authService.ts | 34 -- .../src/app/auth/configSection.ts | 4 - .../app/auth/webSearch/tools/web-search.ts | 17 - .../src/app/authLegacy/authLegacy.ts | 5 - .../src/app/authLegacy/authLegacyService.ts | 4 - .../src/app/bootstrap/bootstrap.ts | 34 -- .../src/app/bootstrap/bootstrapService.ts | 3 - .../app/config/configOverlayContributions.ts | 2 - .../app/config/configSectionContributions.ts | 6 - .../src/app/config/configService.ts | 46 --- packages/agent-core-v2/src/app/cron/clock.ts | 47 --- .../agent-core-v2/src/app/cron/cron-expr.ts | 78 +--- .../agent-core-v2/src/app/cron/cronTask.ts | 5 - packages/agent-core-v2/src/app/cron/format.ts | 11 - packages/agent-core-v2/src/app/cron/jitter.ts | 60 --- .../agent-core-v2/src/app/edit/editService.ts | 11 - .../agent-core-v2/src/app/edit/fileEdit.ts | 2 - .../src/app/edit/fileEditService.ts | 5 - .../agent-core-v2/src/app/edit/textModel.ts | 23 -- .../agent-core-v2/src/app/edit/tools/edit.ts | 7 - .../externalHooksRunner.ts | 5 - .../src/app/externalHooksRunner/runner.ts | 3 - .../agent-core-v2/src/app/file/fileService.ts | 11 - packages/agent-core-v2/src/app/git/git.ts | 14 - .../agent-core-v2/src/app/git/gitParsers.ts | 7 - .../agent-core-v2/src/app/git/gitService.ts | 17 - .../hostFolderBrowser/hostFolderBrowser.ts | 10 - .../src/app/llmProtocol/capability.ts | 5 - .../src/app/llmProtocol/catalog.ts | 32 -- .../src/app/llmProtocol/errors.ts | 102 ----- .../src/app/llmProtocol/generate.ts | 99 ----- .../src/app/llmProtocol/message.ts | 86 +--- .../src/app/llmProtocol/provider.ts | 146 ------- .../app/llmProtocol/providers/anthropic.ts | 166 -------- .../providers/capability-registry.ts | 5 - .../providers/chat-completions-stream.ts | 9 - .../app/llmProtocol/providers/google-genai.ts | 86 ---- .../app/llmProtocol/providers/kimi-files.ts | 46 --- .../app/llmProtocol/providers/kimi-schema.ts | 38 -- .../src/app/llmProtocol/providers/kimi.ts | 50 --- .../llmProtocol/providers/openai-common.ts | 69 ---- .../llmProtocol/providers/openai-legacy.ts | 49 --- .../llmProtocol/providers/openai-responses.ts | 56 +-- .../app/llmProtocol/providers/providers.ts | 8 - .../app/llmProtocol/providers/request-auth.ts | 22 -- .../agent-core-v2/src/app/llmProtocol/tool.ts | 3 - .../src/app/llmProtocol/usage.ts | 16 - .../src/app/messageLegacy/messageLegacy.ts | 10 - .../app/messageLegacy/messageLegacyService.ts | 28 -- .../src/app/model/configSection.ts | 2 - .../agent-core-v2/src/app/model/envOverlay.ts | 10 - .../src/app/model/hostRequestHeaders.ts | 1 - packages/agent-core-v2/src/app/model/model.ts | 16 - .../agent-core-v2/src/app/model/modelImpl.ts | 22 -- .../src/app/model/modelInstance.ts | 49 --- .../src/app/model/modelResolver.ts | 2 - .../src/app/model/modelResolverService.ts | 47 --- .../agent-core-v2/src/app/model/thinking.ts | 7 - .../src/app/modelCatalog/configSection.ts | 2 - .../src/app/modelCatalog/modelCatalog.ts | 12 - .../app/modelCatalog/modelCatalogService.ts | 14 - .../src/app/platform/configSection.ts | 2 - .../src/app/platform/platform.ts | 5 - .../agent-core-v2/src/app/plugin/commands.ts | 5 - .../agent-core-v2/src/app/plugin/manifest.ts | 3 - .../agent-core-v2/src/app/plugin/types.ts | 10 +- .../agent-core-v2/src/app/protocol/errors.ts | 9 - .../src/app/protocol/protocol.ts | 8 - .../app/protocol/protocolAdapterRegistry.ts | 15 - .../src/app/provider/configSection.ts | 2 - .../src/app/provider/provider.ts | 6 - .../src/app/provider/providerService.ts | 2 - .../src/app/sessionExport/sessionExport.ts | 10 - .../src/app/sessionExport/zip.ts | 3 - .../src/app/sessionIndex/sessionIndex.ts | 35 -- .../app/sessionIndex/sessionIndexService.ts | 35 -- .../src/app/sessionLegacy/sessionLegacy.ts | 6 - .../app/sessionLegacy/sessionLegacyService.ts | 41 +- .../app/sessionLifecycle/sessionLifecycle.ts | 51 --- .../sessionLifecycleService.ts | 128 ------ .../app/skillCatalog/fileSkillDiscovery.ts | 11 - .../skillCatalogRuntimeOptions.ts | 5 - .../src/app/skillCatalog/skillRoots.ts | 3 - packages/agent-core-v2/src/app/task/task.ts | 13 - .../agent-core-v2/src/app/task/taskService.ts | 1 - .../app/telemetry/agentTelemetryContext.ts | 9 - .../src/app/telemetry/cloudAppender.ts | 10 - .../src/app/telemetry/coreVersion.ts | 1 - .../agent-core-v2/src/app/telemetry/events.ts | 41 -- .../src/app/telemetry/telemetry.ts | 15 - .../src/app/web/providers/local-fetch-url.ts | 18 - .../app/web/providers/moonshot-fetch-url.ts | 6 - .../src/app/web/tools/fetch-url-types.ts | 8 - .../src/app/web/tools/fetch-url.ts | 12 - .../fileWorkspacePersistence.ts | 4 - .../workspaceRegistry/workspacePersistence.ts | 10 - .../app/workspaceRegistry/workspaceQuery.ts | 6 - .../workspaceRegistry/workspaceRegistry.ts | 7 - .../workspaceRegistryService.ts | 15 - packages/agent-core-v2/src/index.ts | 2 - .../node-local/hostEnvironmentService.ts | 6 - .../os/backends/node-local/hostFsService.ts | 6 - .../backends/node-local/hostFsWatchService.ts | 3 - .../backends/node-local/hostProcessService.ts | 1 - .../node-local/hostTerminalService.ts | 1 - .../src/os/backends/node-local/tools/bash.ts | 29 -- .../src/os/backends/node-local/tools/glob.ts | 80 ---- .../src/os/backends/node-local/tools/grep.ts | 46 --- .../backends/node-local/tools/process-task.ts | 18 +- .../src/os/backends/node-local/tools/read.ts | 9 - .../src/os/backends/node-local/tools/runRg.ts | 20 - .../src/os/backends/node-local/tools/write.ts | 25 -- .../src/os/interface/hostEnvironment.ts | 15 - .../src/os/interface/hostFileSystem.ts | 39 -- .../src/os/interface/hostFsErrors.ts | 7 - .../src/os/interface/hostFsWatch.ts | 13 - .../src/os/interface/hostProcess.ts | 21 - .../src/os/interface/terminal.ts | 7 - .../backends/minidb/miniDbQueryStore.ts | 13 - .../backends/node-fs/appendLogStore.ts | 8 - .../backends/node-fs/fileStorageService.ts | 9 - .../node-fs/workspaceLocalConfigService.ts | 1 - .../persistence/interface/appendLogStore.ts | 6 - .../interface/atomicDocumentStore.ts | 1 - .../src/persistence/interface/storage.ts | 5 - .../session/agentLifecycle/agentLifecycle.ts | 79 ---- .../agentLifecycle/agentLifecycleService.ts | 66 +--- .../src/session/agentLifecycle/mainAgent.ts | 13 - .../session/agentLifecycle/mirrorAgentRun.ts | 20 - .../session/agentLifecycle/runAgentTurn.ts | 15 - .../src/session/agentLifecycle/tools/agent.ts | 20 - .../agentLifecycle/tools/subagent-task.ts | 8 - .../src/session/approval/approval.ts | 5 - packages/agent-core-v2/src/session/btw/btw.ts | 9 - .../session/cron/sessionCronServiceImpl.ts | 48 --- .../src/session/cron/tools/cron-create.ts | 96 ----- .../src/session/cron/tools/cron-delete.ts | 20 - .../src/session/cron/tools/cron-list.ts | 34 -- .../src/session/interaction/interaction.ts | 24 -- .../session/interaction/interactionService.ts | 12 - .../session/process/processRunnerService.ts | 5 - .../src/session/question/question.ts | 20 - .../src/session/question/questionService.ts | 5 - .../session/sessionContext/sessionContext.ts | 14 - .../agent-core-v2/src/session/sessionFs/fs.ts | 2 - .../src/session/sessionFs/fsProcess.ts | 1 - .../src/session/sessionFs/fsService.ts | 42 -- .../src/session/sessionFs/fsWatch.ts | 12 - .../src/session/sessionFs/fsWatchService.ts | 5 - .../src/session/sessionFs/gitContext.ts | 3 - .../src/session/sessionFs/rgLocator.ts | 27 -- .../src/session/sessionFs/runRg.ts | 20 - .../src/session/sessionInit/sessionInit.ts | 6 - .../sessionMetadata/sessionMetadata.ts | 30 -- .../sessionMetadata/sessionMetadataService.ts | 15 - .../src/session/swarm/agentRunBatch.ts | 39 -- .../src/session/swarm/sessionSwarmService.ts | 8 - .../src/session/terminal/terminalService.ts | 4 - .../src/session/todo/sessionTodo.ts | 4 - .../src/session/todo/sessionTodoService.ts | 5 - .../agent-core-v2/src/tool/args-validator.ts | 4 - .../agent-core-v2/src/tool/input-schema.ts | 20 - .../agent-core-v2/src/tool/path-access.ts | 38 -- packages/agent-core-v2/src/tool/rule-match.ts | 22 -- .../agent-core-v2/src/tool/toolContract.ts | 12 - packages/agent-core-v2/src/wire/model.ts | 4 - packages/agent-core-v2/src/wire/op.ts | 36 -- packages/agent-core-v2/src/wire/types.ts | 5 - .../agent-core-v2/src/wire/wireService.ts | 13 - .../agent-core-v2/src/wire/wireServiceImpl.ts | 4 - .../agent-core-v2/test/_base/di/child.test.ts | 3 - .../test/_base/di/cyclic.test.ts | 7 - .../test/_base/di/delayed.test.ts | 14 - .../agent-core-v2/test/_base/di/graph.test.ts | 7 - .../test/_base/di/invocation.test.ts | 1 - .../test/_base/errors/serialize.test.ts | 2 - .../_base/execEnv/environmentProbe.test.ts | 1 - .../test/_base/execEnv/loginShellPath.test.ts | 28 -- .../test/_base/log/fileLog.test.ts | 5 - .../agent-core-v2/test/_base/log/stubs.ts | 3 - .../test/activity/activity.test.ts | 2 - .../test/agent/blob/agentBlobService.test.ts | 1 - .../contextInjector/contextInjector.test.ts | 5 - .../test/agent/contextMemory/context.test.ts | 23 -- .../contextMemory/contextTranscript.test.ts | 9 - .../agent/contextMemory/loopEventFold.test.ts | 2 - .../contextMemory/message-history.test.ts | 7 - .../test/agent/contextMemory/stubs.ts | 19 - .../agent/contextMemory/undoPrecheck.test.ts | 2 - .../contextProjector.bench.ts | 17 +- .../projector-tool-exchanges.test.ts | 17 - .../test/agent/externalHooks/runner.test.ts | 6 - .../fullCompaction/compactionOps.test.ts | 3 - .../fullCompaction/fullCompaction.test.ts | 75 ---- .../agent/fullCompaction/strategy.test.ts | 2 - .../test/agent/goal/goal.test.ts | 100 +---- .../test/agent/goal/goalOps.test.ts | 2 - .../goal/injection/goalInjection.test.ts | 18 +- .../agent/llmRequester/llmRequester.test.ts | 8 - .../llmRequester/llmRequesterService.test.ts | 10 - .../test/agent/loop/loop.test.ts | 5 - .../agent-core-v2/test/agent/mcp/mcp.test.ts | 14 +- .../test/agent/mcp/output.test.ts | 4 - .../test/agent/media/file-type.test.ts | 68 +--- .../test/agent/media/image-compress.test.ts | 216 ++-------- .../test/agent/media/tools/read-media.test.ts | 67 +--- .../permissionMode/permissionMode.test.ts | 11 - .../permissionPolicyService.test.ts | 2 - .../policies/goal-start-review-ask.test.ts | 2 - .../permissionRules/permissionRules.test.ts | 4 - .../test/agent/plan/plan.test.ts | 11 - .../test/agent/plan/planOps.test.ts | 2 - .../test/agent/plugin/agentPlugin.test.ts | 1 - .../test/agent/profile/apply-profile.test.ts | 3 - .../test/agent/profile/binding.test.ts | 6 - .../test/agent/profile/config-state.test.ts | 11 - .../test/agent/profile/context.test.ts | 6 - .../test/agent/profile/profileOps.test.ts | 6 - .../test/agent/profile/thinking.test.ts | 8 - .../test/agent/prompt/promptService.test.ts | 4 - .../questionTools/tools/ask-user.test.ts | 1 - .../agent/shellCommand/shellCommand.test.ts | 3 - .../test/agent/skill/prompt.test.ts | 7 - .../test/agent/skill/skill.test.ts | 1 - .../test/agent/stepRetry/stepRetry.test.ts | 8 - .../test/agent/swarm/swarm.test.ts | 4 - .../agent/task/foreground-persistence.test.ts | 16 +- .../task/idle-notification-repro.test.ts | 50 +-- .../test/agent/task/output-access.test.ts | 4 - .../test/agent/task/persist.test.ts | 4 - .../test/agent/task/rpc-events.test.ts | 15 - .../test/agent/task/subagent-timeout.test.ts | 40 -- .../test/agent/task/taskManager.test.ts | 22 +- .../test/agent/task/taskOps.test.ts | 7 - .../test/agent/task/taskService.test.ts | 36 -- .../test/agent/task/tools/task-tools.test.ts | 2 - .../test/agent/toolDedupe/toolDedupe.test.ts | 65 ---- .../agent/toolExecutor/toolExecutor.test.ts | 7 - .../agent/toolSelect/toolSelect.e2e.test.ts | 13 - .../toolSelect/toolSelectService.test.ts | 3 - .../test/agent/usage/usage.test.ts | 2 - .../test/agent/userTool/userTool.test.ts | 10 - .../test/agent/wireRecord/resume.test.ts | 95 ----- .../agent-core-v2/test/app/auth/auth.test.ts | 24 -- .../agent-core-v2/test/app/bootstrap/stubs.ts | 4 - .../test/app/config/config.test.ts | 19 - .../agent-core-v2/test/app/config/stubs.ts | 6 - .../test/app/edit/tools/edit.test.ts | 27 -- .../externalHooksRunner/integration.test.ts | 5 - .../test/app/file/fileService.test.ts | 3 - packages/agent-core-v2/test/app/flag/stubs.ts | 4 - .../test/app/llmProtocol/errors.test.ts | 49 --- .../providers/anthropic-max-tokens.test.ts | 3 - .../test/app/llmProtocol/select-tools.test.ts | 6 - .../app/messageLegacy/messageLegacy.test.ts | 3 - .../test/app/model/model.test.ts | 8 - .../app/model/modelResolver-runtime.test.ts | 2 - .../test/app/model/modelResolver.test.ts | 8 - .../app/modelCatalog/modelCatalog.test.ts | 7 - .../test/app/plugin/github-resolver.test.ts | 2 - .../app/sessionIndex/sessionIndex.test.ts | 10 - .../sessionLifecycle/sessionLifecycle.test.ts | 19 - .../skillCatalog/skill-tool-manager.test.ts | 7 - .../agent-core-v2/test/app/task/task.test.ts | 11 - .../agent-core-v2/test/app/telemetry/stubs.ts | 4 - .../workspaceRegistryService.test.ts | 6 - packages/agent-core-v2/test/harness/agent.ts | 80 ---- packages/agent-core-v2/test/index.test.ts | 287 -------------- .../test/lint/op-uniqueness.test.ts | 1 - .../node-local/hostFsWatchService.test.ts | 1 - .../os/backends/node-local/tools/bash.test.ts | 31 -- .../os/backends/node-local/tools/glob.test.ts | 20 - .../os/backends/node-local/tools/grep.test.ts | 29 +- .../os/backends/node-local/tools/read.test.ts | 21 - .../backends/node-local/tools/write.test.ts | 34 -- .../test/os/interface/hostFsErrors.test.ts | 1 - .../backends/minidb/miniDbQueryStore.test.ts | 7 - .../backends/node-fs/appendLogStore.test.ts | 17 - .../node-fs/fileStorageService.test.ts | 7 - .../persistence/interface/storage.test.ts | 2 - .../test/persistence/interface/stubs.ts | 1 - .../agentLifecycle/agentLifecycle.test.ts | 11 - .../test/session/question/question.test.ts | 5 - .../test/session/sessionFs/fsService.test.ts | 13 - .../session/sessionFs/fsWatchService.test.ts | 2 - .../test/session/sessionFs/gitContext.test.ts | 8 - .../sessionLog/sessionLogService.test.ts | 6 - .../session/terminal/terminalService.test.ts | 1 - .../test/session/todo/sessionTodo.test.ts | 7 - .../stub-workspace-context.ts | 7 - .../test/wire/wire-compat.test.ts | 8 - .../test/wire/wireServiceImpl.test.ts | 10 +- 432 files changed, 79 insertions(+), 8820 deletions(-) diff --git a/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts b/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts index 783350b390..2d35c79edb 100644 --- a/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts +++ b/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts @@ -34,7 +34,6 @@ import type { Edge, EdgeKind, EdgeRef, Graph, ServiceNode, ServiceScope } from ' const __dirname = dirname(fileURLToPath(import.meta.url)); -/** Repo root — three levels above `scripts/dep-graph/analyzer/`. */ export const PKG_ROOT = resolve(__dirname, '..', '..', '..'); export const REPO_ROOT = resolve(PKG_ROOT, '..', '..'); export const SRC_ROOT = join(PKG_ROOT, 'src'); @@ -53,16 +52,6 @@ const EVENT_METHOD_KIND: Record = { const SCOPE_ORDER: ServiceScope[] = ['App', 'Session', 'Agent']; const SCOPE_LEVEL: Record = { App: 0, Session: 1, Agent: 2 }; -/** - * Framework tokens seeded via `ServiceCollection.set(id, value)` at scope - * construction time rather than `registerScopedService`. The analyzer never - * sees a `registerScopedService` for them, so we synthesise virtual bindings - * so edges targeting them resolve rather than showing up as "unresolved". - * - * The scope tags reflect *where the seed lives*: `ISessionContext` is set - * on the Session collection, `IKaos` on App, etc. — this matches the - * bootstrap composition roots in `bootstrap/appContainer.ts` and friends. - */ const FRAMEWORK_BINDINGS: readonly { token: string; scope: ServiceScope; impl: string }[] = [ { token: 'IInstantiationService', scope: 'App', impl: 'InstantiationService' }, { token: 'IKaos', scope: 'App', impl: 'Kaos' }, @@ -72,52 +61,17 @@ const FRAMEWORK_BINDINGS: readonly { token: string; scope: ServiceScope; impl: s { token: 'IAgentScopeContext', scope: 'Agent', impl: 'AgentScopeContext' }, ]; -/** - * Production composition-root bindings seeded by `bootstrap()` via - * `ScopeOptions.extra`. `buildCollection` applies `extra` AFTER the static - * `registerScopedService` registry, so these take precedence at runtime: they - * override a static default where one exists (e.g. `ISkillDiscovery` → - * `FileSkillDiscovery`) and supply the binding where the layer ships no - * in-package default (`IFileSystemStorageService` → `FileStorageService`, the - * byte layer the node-fs Store backends are built on). The analyzer mirrors - * that so the graph reflects the backend that actually runs in production. - * - * Each entry's `file`/`line`/`domain` are derived from the impl class - * declaration at analysis time, so the node points at the real backend rather - * than any registration site it replaces. - */ const PRODUCTION_OVERRIDES: readonly { token: string; scope: ServiceScope; impl: string }[] = [ { token: 'IFileSystemStorageService', scope: 'App', impl: 'FileStorageService' }, { token: 'ISkillDiscovery', scope: 'App', impl: 'FileSkillDiscovery' }, ]; -/** - * Turn a `(scope, token)` pair into the unique node id used across the - * graph. This matches the DI registration identity: one `registerScopedService` - * call = one id. - */ export function nodeId(scope: ServiceScope, token: string): string { return `${scope}::${token}`; } -/** - * Bindings map — `token → scope → ServiceNode`. Used by edge resolution to - * find the impl visible from a given source scope. - */ type Bindings = Map>; -/** - * Return the `ServiceNode` that a source at `sourceScope` would receive when - * it asks for `token`. Walks the source's scope tree from the source scope - * downward toward App (parent), picking the innermost binding visible. - * - * Source scope = Session → check Session, then App - * Source scope = Agent → check Agent, then Session, then App - * Source scope = App → check App only - * - * Returns `undefined` if nothing is registered at any visible scope — the - * container would crash trying to resolve `token` from this source. - */ function resolveFromScope( bindings: Bindings, token: string, @@ -126,7 +80,6 @@ function resolveFromScope( const scopeMap = bindings.get(token); if (!scopeMap) return undefined; const sourceLevel = SCOPE_LEVEL[sourceScope]; - // Walk from source (innermost visible) up to App (root). for (let lvl = sourceLevel; lvl >= 0; lvl--) { const s = SCOPE_ORDER[lvl]; const hit = scopeMap.get(s); @@ -137,7 +90,6 @@ function resolveFromScope( interface EdgeAccumulator { services: ServiceNode[]; - /** `key = fromId|toId|kind` → Edge (refs merged). */ edges: Map; bindings: Bindings; unknownRefs: Set; @@ -158,19 +110,10 @@ function pushEdge( token: string, kind: EdgeKind, ref: EdgeRef, - /** - * When set, resolve `token` from this scope instead of the source service's - * scope. Used for `.accessor.get(IX)` where the handle is statically - * known to belong to an inner scope (e.g. an `IAgentScopeHandle`), so the - * lookup resolves against that inner scope rather than the source's. - */ overrideScope?: ServiceScope, ): void { const target = resolveFromScope(acc.bindings, token, overrideScope ?? source.scope); - // Classify a miss: the token is either unknown everywhere (genuinely - // unresolved) or registered at a scope the resolution scope can't see (a - // scope mismatch). The two render differently in the viewer. let toId: string; let extra: Pick; if (target) { @@ -230,17 +173,6 @@ function sameRef(a: EdgeRef, b: EdgeRef): boolean { ); } -/** - * Collect every top-level `interface` declaration in the tree, keyed by - * name. Used to pull each service's public callable surface out of its - * token interface (e.g. `interface IAgentSystemReminderService { ... }`) - * so the graph view can render every method as a port row even when - * nothing calls into it yet. - * - * Duplicate names win latest — TS itself would merge them via declaration - * merging, but the codebase does not intentionally split a service - * interface across files, so ties here are effectively edge cases. - */ function collectInterfaces(sourceFiles: SourceFile[]): Map { const out = new Map(); for (const file of sourceFiles) { @@ -253,14 +185,6 @@ function collectInterfaces(sourceFiles: SourceFile[]): Map(); for (const member of iface.getMembers()) { @@ -277,10 +201,6 @@ function collectInterfaceMembers(iface: InterfaceDeclaration): string[] { return [...names].sort(); } -/** - * Extract the token identifier from a `registerScopedService(...)` call. - * Returns `undefined` if the call doesn't match the expected shape. - */ function readRegistration( call: CallExpression, ): { token: string; impl: string; scope: ServiceScope; domain: string; line: number } | undefined { @@ -292,7 +212,6 @@ function readRegistration( const implArg = args[2]; const domainArg = args[4]; - // scope: `LifecycleScope.App | .Session | .Agent` if (scopeArg.getKind() !== SyntaxKind.PropertyAccessExpression) return undefined; const scopeText = scopeArg.getText(); const scope = scopeText.split('.').at(-1); @@ -320,12 +239,6 @@ function domainOf(absPath: string): string { return rel.split('/')[0] ?? 'unknown'; } -/** - * Pass 1 — collect every `registerScopedService(...)` call and every impl - * class declaration in the tree. Records the service list, the - * impl-class-name → decl map, and the token → scope → node bindings map - * for pass 2's edge resolution. - */ function collectServices(sourceFiles: SourceFile[]): { services: ServiceNode[]; implClasses: Map; @@ -364,9 +277,6 @@ function collectServices(sourceFiles: SourceFile[]): { scopeMap = new Map(); bindings.set(reg.token, scopeMap); } - // If the same (scope, token) is registered twice we keep the first — - // the DI container would honor the earliest binding too; a duplicate - // is a source-code bug, not an analyzer concern. if (!scopeMap.has(reg.scope)) scopeMap.set(reg.scope, node); } } @@ -374,13 +284,6 @@ function collectServices(sourceFiles: SourceFile[]): { return { services, implClasses, bindings }; } -/** - * From a class ctor, list `{decorator, param}` for every `@IToken`-decorated - * parameter, in declaration order. Also returns the "injected fields": params - * lifted to a class field via a visibility modifier, keyed by field name and - * mapped to the token they're bound to. That map lets pass 2 attribute - * `this..()` call sites back to the correct ctor edge. - */ function readCtor(cls: ClassDeclaration): { ctorDeps: { token: string; line: number }[]; injectedFields: Map; @@ -409,12 +312,6 @@ function readCtor(cls: ClassDeclaration): { return { ctorDeps, injectedFields }; } -/** - * Constructor parameter with `private readonly foo: IX` becomes a field - * named `foo`. When only `@IX foo: IX` (no visibility modifier) is present, - * TypeScript doesn't lift it to a field, but the codebase always uses the - * lifted form for injected deps, so this covers the observed patterns. - */ function fieldNameOf(param: ParameterDeclaration): string | undefined { const modifiers = param.getModifiers().map((m) => m.getText()); if (modifiers.some((m) => m === 'private' || m === 'protected' || m === 'public')) { @@ -423,13 +320,6 @@ function fieldNameOf(param: ParameterDeclaration): string | undefined { return undefined; } -/** - * Walk parents from `node` to the nearest class-body scope so we can label - * a call site by the source method that contains it. Arrow functions and - * `function` expressions are transparent — we want the surrounding method, - * not the closure. Returns `undefined` when the call sits directly in a - * class body but outside any declared member (rare — decorators, etc.). - */ function enclosingMethodName(node: Node): string | undefined { let cur: Node | undefined = node.getParent(); while (cur) { @@ -457,13 +347,6 @@ function enclosingMethodName(node: Node): string | undefined { return undefined; } -/** - * When a `.get(IToken)` call is immediately chained with a method call — - * `.get(IX).(...)` — return that method name. This is the - * only accessor pattern we can attribute without a type checker; results - * stored in a local variable are indistinguishable from other locals and - * would need dataflow tracking to follow. - */ function chainedMethodName(getCall: CallExpression): string | undefined { const parent = getCall.getParent(); if (!parent || parent.getKind() !== SyntaxKind.PropertyAccessExpression) return undefined; @@ -476,11 +359,6 @@ function chainedMethodName(getCall: CallExpression): string | undefined { return pae.getName(); } -/** - * Map scope-typed handle aliases (and the generic `IScopeHandle` - * form) to their scope. Used to resolve `.accessor.get(IX)` against the - * handle's real scope rather than the source service's scope. - */ const HANDLE_ALIAS_SCOPE: Record = { IAppScopeHandle: 'App', ISessionScopeHandle: 'Session', @@ -497,7 +375,6 @@ const FUNCTION_LIKE_KINDS = new Set([ SyntaxKind.SetAccessor, ]); -/** Strip `Promise<...>`, `| undefined` / `| null`, array brackets, `readonly`. */ function stripTypeWrappers(text: string): string { let t = text.trim(); t = t.replace(/\s*\|\s*(undefined|null)\s*/g, '').trim(); @@ -518,7 +395,6 @@ function handleScopeFromTypeText(text: string | undefined): ServiceScope | undef return undefined; } -/** Nearest function-like ancestor (method / function / arrow / ctor / accessor). */ function enclosingFunction(node: Node): Node | undefined { let cur: Node | undefined = node.getParent(); while (cur) { @@ -528,7 +404,6 @@ function enclosingFunction(node: Node): Node | undefined { return undefined; } -/** Parameters of a function-like node (all such nodes carry `getParameters`). */ function getParams(fn: Node): ParameterDeclaration[] { return (fn as unknown as { getParameters(): ParameterDeclaration[] }).getParameters(); } @@ -538,10 +413,6 @@ function isAccessorReceiver(node: Node): boolean { return node.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName() === 'accessor'; } -/** - * Per-interface method → declared return-type text. Lets the analyzer resolve - * what `agents.getHandle(...)` returns once it knows `agents: IAgentLifecycleService`. - */ function collectInterfaceMethodReturns( interfacesByName: Map, ): Map> { @@ -560,17 +431,6 @@ function collectInterfaceMethodReturns( return out; } -/** - * Best-effort, parse-only type-text inference for an expression within a - * function. Handles just enough to follow scope-typed handles: - * - parameter / variable annotations, - * - `.accessor.get(IToken)` → the token (DI accessor returns its type), - * - `this.method(...)` → the class method's declared return type, - * - `.method(...)` → the interface method's declared return type, - * - `await X` and single-step identifier aliases. - * Returns `undefined` when it can't tell — callers fall back to source scope. - * `depth` bounds identifier-chasing so we don't loop on aliased locals. - */ function inferExprTypeText( expr: Node, cls: ClassDeclaration, @@ -599,19 +459,16 @@ function inferExprTypeText( const methodName = pae.getName(); const base = pae.getExpression(); - // `.accessor.get(IToken)` → the DI accessor returns the token's type. if (methodName === 'get' && isAccessorReceiver(base)) { const first = call.getArguments()[0]; if (first && first.getKind() === SyntaxKind.Identifier) return first.getText(); return undefined; } - // `this.method(...)` → class method's declared return type. if (base.getKind() === SyntaxKind.ThisKeyword) { return cls.getMethod(methodName)?.getReturnTypeNode()?.getText(); } - // `.method(...)` → resolve base to an interface, look up the method. const baseType = inferExprTypeText(base, cls, ifaceMethods, fn, depth + 1); if (baseType === undefined) return undefined; return ifaceMethods.get(stripTypeWrappers(baseType))?.get(methodName); @@ -621,8 +478,6 @@ function inferExprTypeText( return resolveIdentifierTypeText(expr, cls, ifaceMethods, fn, depth + 1); } - // `this.` → the field's declared type (ctor parameter property or - // class property annotation). if (kind === SyntaxKind.PropertyAccessExpression) { const pae = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression); if (pae.getExpression().getKind() === SyntaxKind.ThisKeyword) { @@ -631,7 +486,6 @@ function inferExprTypeText( return undefined; } - // `a ?? b` → either branch's type. if (kind === SyntaxKind.BinaryExpression) { const bin = expr.asKindOrThrow(SyntaxKind.BinaryExpression); if (bin.getOperatorToken().getKind() === SyntaxKind.QuestionQuestionToken) { @@ -643,7 +497,6 @@ function inferExprTypeText( return undefined; } - // `cond ? a : b` → either branch's type. if (kind === SyntaxKind.ConditionalExpression) { const cond = expr.asKindOrThrow(SyntaxKind.ConditionalExpression); return ( @@ -656,7 +509,6 @@ function inferExprTypeText( } function thisFieldTypeText(cls: ClassDeclaration, fieldName: string): string | undefined { - // Constructor parameter property: `constructor(@IX private readonly foo: IX)`. const ctor = cls.getConstructors()[0]; if (ctor) { for (const p of ctor.getParameters()) { @@ -665,7 +517,6 @@ function thisFieldTypeText(cls: ClassDeclaration, fieldName: string): string | u if (t) return t; } } - // Class property with an explicit type annotation. return cls.getProperty(fieldName)?.getTypeNode()?.getText(); } @@ -700,12 +551,6 @@ function resolveIdentifierTypeText( return undefined; } -/** - * For a `.accessor.get(IX)` call, return the scope of the handle `` - * when it can be inferred from a scope-typed handle alias. Returns `undefined` - * for the scope-agnostic cases (base `IScopeHandle`, unions, injected - * accessors) so the caller keeps the default source-scope resolution. - */ function inferAccessorScope( getCall: CallExpression, cls: ClassDeclaration, @@ -721,13 +566,6 @@ function inferAccessorScope( return handleScopeFromTypeText(inferExprTypeText(obj, cls, ifaceMethods, fn)); } -/** - * Pass 2 — for a given impl class, walk method bodies and detect: - * - `.get(IToken)[.method(...)]` → accessor edge (with optional `toMethod`) - * - `this..(...)` → attach method info to the ctor - * edge for that field, or emit an event-bus edge when the field's token - * is an event bus (`publish` / `subscribe` / `emit` / `on`). - */ function collectRuntimeEdges( cls: ClassDeclaration, source: ServiceNode, @@ -747,7 +585,6 @@ function collectRuntimeEdges( const baseRef: EdgeRef = { file: filePath, line }; if (fromMethod !== undefined) baseRef.fromMethod = fromMethod; - // Case 1: .get(IX)[.method(...)] if (methodName === 'get') { const args = call.getArguments(); if (args.length === 0) continue; @@ -755,22 +592,15 @@ function collectRuntimeEdges( if (first.getKind() !== SyntaxKind.Identifier) continue; const tokenName = first.getText(); if (!tokenName.startsWith('I')) continue; - // Ignore self-references — a service asking the accessor for itself. if (tokenName === source.token) continue; const toMethod = chainedMethodName(call); const ref: EdgeRef = { ...baseRef }; if (toMethod !== undefined) ref.toMethod = toMethod; - // If this is `.accessor.get(IX)` and the handle's scope is - // statically known (e.g. `agent: IAgentScopeHandle`), resolve IX against - // that scope instead of the source service's scope. const accessorScope = inferAccessorScope(call, cls, ifaceMethods); pushEdge(acc, source.id, source, tokenName, 'accessor', ref, accessorScope); continue; } - // Case 2: .(...) where receiver is a DI-injected field. - // Detect `this.` (the common form) and a bare `` identifier - // (rare — event-bus code historically supported it; kept for parity). const receiver = pae.getExpression(); let fieldName: string | undefined; if (receiver.getKind() === SyntaxKind.PropertyAccessExpression) { @@ -787,9 +617,6 @@ function collectRuntimeEdges( if (fieldToken === undefined) continue; if (fieldToken === source.token) continue; - // Event-bus fields: keep the specialised publish/subscribe/emit/on edge - // kind; the method name is already carried by the kind so we don't - // duplicate it in `toMethod`. if (EVENT_BUS_TOKENS.has(fieldToken)) { const eventKind = EVENT_METHOD_KIND[methodName]; if (eventKind === undefined) continue; @@ -797,18 +624,11 @@ function collectRuntimeEdges( continue; } - // Regular DI field — attach method-call info to the ctor edge. The ctor - // param declaration ref (pushed in the outer loop below) has no - // `toMethod`; this ref does, so both survive the dedup. const ref: EdgeRef = { ...baseRef, toMethod: methodName }; pushEdge(acc, source.id, source, fieldToken, 'ctor', ref); } } -/** - * Run the static analysis. `srcRoot` overrides the default `src/` (used by - * tests). Returns a `Graph` snapshot. - */ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {}): Graph { const srcRoot = options.srcRoot ?? SRC_ROOT; const project = new Project({ @@ -832,10 +652,6 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {} const interfacesByName = collectInterfaces(sourceFiles); const ifaceMethods = collectInterfaceMethodReturns(interfacesByName); - // Seed the framework tokens as synthetic nodes so edges to them resolve - // like any other registered service. They are marked domain=`framework` - // and file/line refer to the `bootstrap` composition root convention; - // the UI can filter them by domain. const frameworkNodes: ServiceNode[] = FRAMEWORK_BINDINGS.map((b) => ({ id: nodeId(b.scope, b.token), token: b.token, @@ -855,11 +671,6 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {} if (!scopeMap.has(node.scope)) scopeMap.set(node.scope, node); } - // Apply production composition-root bindings: bootstrap() seeds these tokens - // via `extra`, which the container applies after the static registry. They - // override any static default (skill catalog) or supply the binding outright - // (storage layer, which ships no in-package default). Mirror that here so - // edges resolve to the backend that actually runs in production. for (const override of PRODUCTION_OVERRIDES) { const id = nodeId(override.scope, override.token); const cls = implClasses.get(override.impl); @@ -896,11 +707,6 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {} unknownRefs: new Set(), }; - // Attach each service's public callable surface. Runs after registration, - // framework seeding, and PRODUCTION_OVERRIDES so every node in the graph - // gets the same treatment. Nodes whose token has no interface declaration - // in `src/` (framework tokens, synthetic overrides) simply get no - // `publicMembers` field — the view falls back to the edge-derived ports. for (const svc of services) { const iface = interfacesByName.get(svc.token); if (!iface) continue; @@ -914,21 +720,12 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {} const { ctorDeps, injectedFields } = readCtor(cls); const filePath = relFromRepo(cls.getSourceFile().getFilePath()); for (const dep of ctorDeps) { - // Self-refs happen when a service also declares a param typed as - // its own interface (rare, never legit) — skip. if (dep.token === svc.token) continue; pushEdge(acc, svc.id, svc, dep.token, 'ctor', { file: filePath, line: dep.line }); } collectRuntimeEdges(cls, svc, injectedFields, acc, ifaceMethods); } - // Synthesise interface-only nodes for tokens referenced by edges but with no - // registered impl at any scope. Each unresolved edge already targets - // `unresolved::${token}`; creating a matching node lets the viewer render it - // (with a distinct border) instead of dropping the edge as dangling. The node - // is placed at the outer-most scope that references it — a hint at where the - // missing binding is first needed — and inherits the interface's declared - // public surface so its ports read like a real service. const nodeById = new Map(services.map((s) => [s.id, s])); const unresolvedReferrers = new Map>(); for (const edge of acc.edges.values()) { @@ -969,10 +766,6 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {} services.push(node); } - // Synthesise scope-mismatch nodes: tokens that ARE registered but referenced - // from a scope that can't see them. Placed at the token's real registered - // scope (from `actualScope`) and flagged so the viewer styles them apart - // from genuinely missing implementations. const mismatchTokens = new Map(); for (const edge of acc.edges.values()) { if (!edge.scopeMismatch || edge.actualScope === undefined) continue; @@ -1014,7 +807,6 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {} }; } -/** Convenience: read the current git HEAD as a stable "generated at" tag. */ export function readHeadSha(): string | undefined { try { const head = readFileSync(join(REPO_ROOT, '.git', 'HEAD'), 'utf8').trim(); @@ -1028,13 +820,11 @@ export function readHeadSha(): string | undefined { } } -/** Persist a graph snapshot to disk (creates parent dir as needed). */ export function writeSnapshot(graph: Graph, path: string = SNAPSHOT_PATH): void { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(graph, null, 2)}\n`); } -/** One-line, sortable summary of a graph — used by both the CLI and the dev-server watcher. */ export function summarize(graph: Graph): string { const byKind = new Map(); for (const e of graph.edges) byKind.set(e.kind, (byKind.get(e.kind) ?? 0) + 1); diff --git a/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts b/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts index 29609a3bd2..c83fe400a3 100644 --- a/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts +++ b/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts @@ -7,128 +7,47 @@ export type ServiceScope = 'App' | 'Session' | 'Agent'; export type EdgeKind = - /** `constructor(@IToken ...)` — declared DI dependency. */ | 'ctor' - /** `.accessor.get(IToken)` — runtime lookup. */ | 'accessor' - /** `.publish(...)` — publishes to `IEventService`. */ | 'publish' - /** `.subscribe(...)` — subscribes to `IEventService`. */ | 'subscribe' - /** `.signal(...)` / `.append(...)` — emits on `IAgentRecordService`. */ | 'emit' - /** `.on(...)` — listens on `IAgentRecordService`. */ | 'on'; export interface ServiceNode { - /** - * Stable unique node id. One `registerScopedService` call = one node. - * Format: `${scope}::${token}` — matches the DI registration identity and - * disambiguates the same impl class bound to multiple tokens (e.g. - * `InMemoryStorageService` registered against 4 different tokens) as well - * as the same token bound at multiple scopes (e.g. `ILogService` - * bound at App and Session). - */ id: string; - /** Token identifier (e.g. `IAgentSystemReminderService`). */ token: string; - /** Impl class name (e.g. `AgentSystemReminderService`). */ impl: string; scope: ServiceScope; - /** First folder under `src/` (e.g. `systemReminder`). */ domain: string; - /** Repo-relative path of the impl file. */ file: string; - /** 1-indexed line of the `registerScopedService(...)` call. */ line: number; - /** - * Public callable surface of this service — the method/property names - * declared on the interface identified by `token`. Sorted, deduped, with - * the `_serviceBrand` DI marker filtered out. Absent when the analyzer - * couldn't locate an interface declaration for the token (e.g. synthetic - * framework bindings whose token has no interface in `src/`). - */ publicMembers?: string[]; - /** - * True for synthesized interface-only nodes: the token is referenced by at - * least one edge but has no implementation registered at any scope. These - * nodes have no real impl (so `impl` mirrors `token`) and the viewer renders - * them with a distinct border so missing bindings stand out from concrete - * services rather than being dropped as dangling edges. - */ unresolved?: true; - /** - * True for synthesized scope-mismatch nodes: the token IS registered, but at - * a scope invisible to the edge's source. Rendered distinctly (and placed at - * the token's real registered scope) so a cross-scope reach reads differently - * from a genuinely missing implementation. - */ scopeMismatch?: true; } export interface EdgeRef { - /** Repo-relative path where the reference occurs. */ file: string; line: number; - /** - * Method on the source impl that contains this reference — the caller. - * `` for the constructor, `get ` / `set ` for accessors, - * `>` for a property initializer, or the plain method name. - * Absent for the ctor-param declaration refs and for refs the analyzer - * couldn't attribute to a named scope. - */ fromMethod?: string; - /** - * Method invoked on the target service at this ref site. - * - `ctor` edge: the method the source calls on the injected field, - * e.g. `this.log.error(...)` → `error`. - * - `accessor` edge: the method chained on `.get(IX).()`. - * Absent for the pure declaration ref (the ctor param), for the pure - * lookup ref (a `get()` whose result is stored rather than called), and - * for event-bus edges where the method name is already the edge kind. - */ toMethod?: string; } export interface Edge { - /** Source `ServiceNode.id` (impl-side, not token). */ from: string; - /** - * Resolved target `ServiceNode.id` — the concrete registration that the - * DI container would actually pick when the source is instantiated. For - * `unresolved: true` edges this is the token that couldn't be resolved, - * prefixed with `unresolved::`; for `scopeMismatch: true` edges it is - * prefixed with `scopeMismatch::`. - */ to: string; - /** The interface/decorator name that appears at the source site. */ token: string; kind: EdgeKind; - /** - * True when there is no impl registered for `token` at ANY scope — the - * token is simply unknown to the container. A `ctor` edge in this state - * would crash the container at instantiation time. - */ unresolved?: true; - /** - * True when the token IS registered, but only at a scope that is not - * visible from the source (e.g. an App-scope service reaching for an - * Agent-scope token through an accessor whose scope the analyzer couldn't - * pin down). Distinct from `unresolved`: an implementation exists, the - * edge just can't be satisfied from where it is requested. - */ scopeMismatch?: true; - /** When `scopeMismatch`, the innermost scope where `token` is registered. */ actualScope?: ServiceScope; - /** One or more locations that produced this edge (deduped). */ refs: EdgeRef[]; } export interface Graph { - /** Wall-clock, but injected from the analyzer caller so the file is deterministic. */ generatedAt: string; services: ServiceNode[]; edges: Edge[]; - /** Tokens referenced by edges but not registered — usually external / test-only. */ unknownTokens: string[]; } diff --git a/packages/agent-core-v2/scripts/dep-graph/lint.ts b/packages/agent-core-v2/scripts/dep-graph/lint.ts index f0d2bddd4b..1304f04335 100644 --- a/packages/agent-core-v2/scripts/dep-graph/lint.ts +++ b/packages/agent-core-v2/scripts/dep-graph/lint.ts @@ -43,9 +43,6 @@ interface Violation { function loadGraph(): Graph { if (existsSync(SNAPSHOT_PATH)) { - // Only trust the snapshot if it's newer than the most recently touched - // source file — otherwise a stale JSON would silently mask violations - // introduced since the last analyze. const snapMtime = statSync(SNAPSHOT_PATH).mtimeMs; const srcMtime = latestMtime(SRC_ROOT); if (snapMtime >= srcMtime) { @@ -79,7 +76,7 @@ function lint(graph: Graph): Violation[] { for (const edge of graph.edges) { if (!edge.unresolved) continue; const from = byId.get(edge.from); - if (!from) continue; // shouldn't happen — edge from unregistered source + if (!from) continue; if (edge.kind === 'ctor') { violations.push({ severity: 'error', edge, from }); } else if (edge.kind === 'accessor') { @@ -101,7 +98,6 @@ function main(): number { console.log( ` [${v.severity.toUpperCase()} ${v.from.scope}→?] ${v.from.impl} (${v.from.token}) --${v.edge.kind}--> ${v.edge.token} (no binding visible from ${v.from.scope})`, ); - // Refs are stored repo-relative in the graph, so print verbatim. for (const ref of v.edge.refs) { console.log(` ${ref.file}:${ref.line}`); } diff --git a/packages/agent-core-v2/scripts/dep-graph/plugin/virtual-dep-graph.ts b/packages/agent-core-v2/scripts/dep-graph/plugin/virtual-dep-graph.ts index 9de280e5cf..a79582b3ae 100644 --- a/packages/agent-core-v2/scripts/dep-graph/plugin/virtual-dep-graph.ts +++ b/packages/agent-core-v2/scripts/dep-graph/plugin/virtual-dep-graph.ts @@ -28,7 +28,6 @@ import type { Graph } from '../analyzer/types'; const VIRTUAL_ID = 'virtual:dep-graph'; const RESOLVED_ID = `\0${VIRTUAL_ID}`; -/** Coalesce watcher bursts (single save often fires add+change+rename). */ const DEBOUNCE_MS = 200; function tag(): string { @@ -41,17 +40,9 @@ function isSrcFile(file: string): boolean { } interface PluginOptions { - /** If false, don't mirror the graph to disk (in-memory only). Default true. */ writeSnapshotFile?: boolean; } -/** - * Structural fingerprint of a graph: services + edges + unknownTokens only, - * with `generatedAt` deliberately excluded. The analyzer already sorts each - * of these arrays deterministically, so a stable `JSON.stringify` is enough - * to detect real content changes and ignore metadata-only churn (e.g. the - * HEAD sha bumping without any DI edit). - */ function fingerprint(g: Graph): string { return JSON.stringify({ services: g.services, @@ -68,11 +59,6 @@ export function depGraphPlugin(options: PluginOptions = {}): Plugin { let debounceTimer: ReturnType | undefined; let watcher: FSWatcher | undefined; - /** - * Re-run the analyzer and swap `cached` only when the structural - * fingerprint changed. Returns whether the graph actually changed so the - * caller can decide whether to invalidate the virtual module. - */ function analyzeNow(reason: string): boolean { const started = Date.now(); const next = analyze({ generatedAt: tag() }); @@ -113,21 +99,10 @@ export function depGraphPlugin(options: PluginOptions = {}): Plugin { return { name: 'agent-core-v2:dep-graph', buildStart() { - // Run once eagerly so the snapshot file exists as soon as the dev - // server prints its "ready" banner — external tools (and the first - // browser load) don't have to wait for the first save. if (!cached) analyzeNow('startup'); }, configureServer(dev) { server = dev; - // Vite's own watcher is scoped to the project `root` (the `web/` - // directory) and doesn't observe files under `src/`, so we spin up a - // dedicated chokidar watcher pointed at the source tree. Debounced - // above so a single save that fires multiple chokidar events only - // triggers one re-analysis. - // - // We watch the directory (not a glob) because chokidar v4 dropped - // built-in glob support — filtering to `.ts` happens in `isSrcFile`. watcher = chokidar.watch(SRC_ROOT, { ignoreInitial: true, ignored: (path, stats) => { diff --git a/packages/agent-core-v2/scripts/dep-graph/vite.config.ts b/packages/agent-core-v2/scripts/dep-graph/vite.config.ts index 208b53cf89..5076f1bf88 100644 --- a/packages/agent-core-v2/scripts/dep-graph/vite.config.ts +++ b/packages/agent-core-v2/scripts/dep-graph/vite.config.ts @@ -8,12 +8,6 @@ import { depGraphPlugin } from './plugin/virtual-dep-graph'; const here = dirname(fileURLToPath(import.meta.url)); -/** - * Dev-only Vite config for the `dep-graph` viewer. Rooted inside - * `scripts/dep-graph/web/` so it never touches `src/` or `dist/`; the - * frontend imports the analyzer output through the `virtual:dep-graph` - * plugin below. - */ export default defineConfig({ root: resolve(here, 'web'), cacheDir: resolve(here, '.vite'), @@ -25,8 +19,6 @@ export default defineConfig({ }, plugins: [react(), depGraphPlugin()], build: { - // Not shipped anywhere — never invoked, but guard against accidental - // `vite build` producing output inside src/. outDir: resolve(here, '.local', 'web-dist'), emptyOutDir: true, }, diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/App.tsx b/packages/agent-core-v2/scripts/dep-graph/web/src/App.tsx index 2913a4e0f6..8ed48fa381 100644 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/App.tsx +++ b/packages/agent-core-v2/scripts/dep-graph/web/src/App.tsx @@ -11,8 +11,6 @@ import { collectTagCounts, loadTags, saveTags, tagsEqual, type TagMap } from './ const ALL_SCOPES: ServiceScope[] = ['App', 'Session', 'Agent']; export function App(): JSX.Element { - // Read once at mount — deep-link params seed the initial filters; later - // interaction is purely client-side and does not write back to the URL. const queryParams = useMemo(() => readQueryParams(window.location.search), []); const domains = useMemo( @@ -45,8 +43,6 @@ export function App(): JSX.Element { : undefined, ); - // User-authored node tags, keyed by `ServiceNode.id`. Loaded once from - // localStorage and re-persisted on every change. const [tags, setTags] = useState(() => loadTags()); useEffect(() => { saveTags(tags); diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/Filters.tsx b/packages/agent-core-v2/scripts/dep-graph/web/src/Filters.tsx index 7f67b16a9a..9abc8a21f1 100644 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/Filters.tsx +++ b/packages/agent-core-v2/scripts/dep-graph/web/src/Filters.tsx @@ -8,13 +8,7 @@ export interface FilterState { hiddenDomains: Set; search: string; hideOrphans: boolean; - /** When true, dagre runs once per scope and the bands are stacked vertically. */ groupByScope: boolean; - /** - * Tags the user is focusing. When non-empty, nodes carrying any of these - * tags (and their neighbours) stay bright and everything else dims — the - * "group by tag" view. Empty set means tag focus is off. - */ activeTags: Set; } @@ -28,11 +22,6 @@ interface FiltersProps { const SCOPES: ServiceScope[] = ['App', 'Session', 'Agent']; -/** - * Left sidebar. All controls mutate `state` via `onChange` — the graph view - * re-derives its nodes/edges from the current filter set. Rendered as a - * fixed-width column so the graph takes the rest of the viewport. - */ export function Filters({ graph, domains, diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx b/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx index bc18f76230..9aaee155a9 100644 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx +++ b/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx @@ -25,90 +25,44 @@ import { } from './style'; import { tagColor, type TagMap } from './tags'; -/** Fixed node width so port rows have a stable horizontal box. */ const NODE_WIDTH = 300; -/** Height of the header block (impl / token / domain lines + padding). */ const HEADER_HEIGHT = 68; -/** Per-port row height. Must stay in sync with the CSS below. */ const PORT_ROW_HEIGHT = 18; -/** Vertical padding between the header divider and the first port row. */ const PORTS_PAD_TOP = 4; -/** Height reserved for the tag chip row when a node carries at least one tag. */ const TAGS_ROW_HEIGHT = 20; -/** - * Per-node method port lists. `outPorts` are methods on this service that - * make calls into a dependency (they anchor the source end of edges leaving - * this node); `inPorts` are methods on this service that other services - * call into (they anchor the target end of edges entering this node). - */ interface ServicePortsInfo { inPorts: string[]; outPorts: string[]; - /** - * Subset of `inPorts` that actually has at least one edge terminating on - * it (as opposed to being seeded from the interface's declared surface - * with no caller). Used to dim the handle / label so unused public - * methods stand out visually. - */ connectedIn: Set; } interface GraphViewProps { graph: Graph; filters: FilterState; - /** Selected `ServiceNode.id`. */ selectedId?: string; onSelect: (id?: string) => void; - /** User-authored tags, keyed by `ServiceNode.id`. */ tags: TagMap; - /** Replace the full tag list for a node (empty list clears the entry). */ onEditTags: (nodeId: string, tags: string[]) => void; } interface ServiceNodeData extends Record { service: ServiceNode; selected: boolean; - /** - * True when the search box has content and this node matches. Rendered - * as a distinct cyan outline so search hits are visually separable from - * the yellow-outlined click-selected node. - */ matched: boolean; dim: boolean; ports: ServicePortsInfo; - /** Tags attached to this node, in entry order. */ tags: string[]; } const EVENT_KINDS: Set = new Set(['publish', 'subscribe', 'emit', 'on']); -/** - * The method name that an edge terminates at on the target node. For plain - * calls this is `ref.toMethod`; for event-bus edges, where the call is - * `bus.publish(...)` etc., the method name is already carried by the edge - * kind so we surface it as the effective toMethod so the target node grows - * a matching port row. - */ function effectiveToMethod(kind: EdgeKind, refTo: string | undefined): string | undefined { if (refTo !== undefined) return refTo; if (EVENT_KINDS.has(kind)) return kind; return undefined; } -/** - * Build the port lists per node from a set of edges. - * - * `inPorts` are seeded from `service.publicMembers` — every method / - * property declared on the service's interface, whether anything actually - * calls it or not, so the node advertises its full public surface. Any - * inbound edge method that isn't already in that seed (unusual — usually - * event-bus edges named after the kind) is folded in too. - * - * `outPorts` remain edge-driven: they are the methods on THIS service - * that make a call outward, so filtering out an edge kind naturally - * collapses the rows it would have populated. - */ function computeServicePorts( services: ServiceNode[], edges: Edge[], @@ -161,11 +115,6 @@ function ServiceNodeView({ data }: NodeProps>): JSX.Elemen const { service, selected, matched, dim, ports, tags } = data; const bg = SCOPE_STYLE[service.scope].color; const rowCount = Math.max(ports.inPorts.length, ports.outPorts.length); - // Interface-only node: the token is referenced but has no registered impl. - // Flagged with a dashed warning border so missing bindings stand out from - // concrete services at a glance. Selection / search-match still win so the - // active node stays unambiguous. Scope-mismatch nodes (token registered, but - // at a scope the caller can't see) get a distinct amber dashed border. const isUnresolved = service.unresolved === true; const isScopeMismatch = service.scopeMismatch === true; const specialBorder = isUnresolved || isScopeMismatch; @@ -201,8 +150,6 @@ function ServiceNodeView({ data }: NodeProps>): JSX.Elemen position: 'relative', }} > - {/* Fallback handles at the header — for refs with no method attribution - (raw ctor param declarations, un-chained `.get(IX)` lookups). */} >): JSX.Elemen style={{ background: '#555', top: HEADER_HEIGHT / 2 }} /> - {/* Header */}
>): JSX.Elemen > {SCOPE_STYLE[service.scope].badge} - {/* Impl is the primary label — that's the actual class the container - constructs; the token is a secondary identity shown below. */} >): JSX.Elemen
- {/* Handles live directly on the row (no `overflow: hidden` - ancestor), so React Flow's default translate(-50%, -50%) - positions the dot straddling the node's border. */} {out !== undefined && ( >): JSX.Elemen id={`in:${inn}`} type="target" position={Position.Right} - // Dim handle when the port is only there because it's - // declared on the interface — nothing calls into it. - // The connected-vs-declared distinction reads at a - // glance without hunting for edges. style={{ background: ports.connectedIn.has(inn) ? '#a8c8f6' : '#3d444d', }} @@ -376,7 +309,6 @@ function BandLabelView({ data }: NodeProps void; } @@ -456,17 +387,10 @@ function TagChip({ tag, onRemove }: TagChipProps): JSX.Element { interface TagEditorProps { tags: string[]; - /** Known tags across the graph, offered as input suggestions. */ allTags: string[]; onChange: (next: string[]) => void; } -/** - * Per-node tag editor rendered in the side panel. Chips remove on click; the - * input adds on Enter or the add button, normalising whitespace and refusing - * duplicates. `allTags` feeds a `` so existing tags are one keystroke - * away — keeps spelling consistent so grouping actually groups. - */ function TagEditor({ tags, allTags, onChange }: TagEditorProps): JSX.Element { const [draft, setDraft] = useState(''); const listId = 'tag-suggestions'; @@ -557,12 +481,6 @@ function TagEditor({ tags, allTags, onChange }: TagEditorProps): JSX.Element { ); } -/** - * Persist the pan/zoom viewport across dev-server reloads so a source-code - * edit (which triggers a `full-reload` from the `virtual:dep-graph` plugin) - * doesn't wipe the position the user carefully panned to. Scoped to - * `sessionStorage` so each fresh browser session starts with `fitView`. - */ const VIEWPORT_STORAGE_KEY = 'agent-core-v2:dep-graph:viewport'; function loadViewport(): Viewport | undefined { @@ -588,8 +506,6 @@ function saveViewport(v: Viewport): void { try { sessionStorage.setItem(VIEWPORT_STORAGE_KEY, JSON.stringify(v)); } catch { - // Storage disabled (private mode / quota) — silently drop; the graph - // still works, it just won't remember the viewport across reloads. } } @@ -600,17 +516,10 @@ function passesFilter( ): boolean { if (!filters.scopes.has(service.scope)) return false; if (filters.hiddenDomains.has(service.domain)) return false; - // NOTE: search intentionally does NOT filter here — it drives the - // highlight/dim treatment below so context around a hit stays visible. if (filters.hideOrphans && !connected.has(service.id)) return false; return true; } -/** - * Case-insensitive substring match across the identity fields and public - * surface. Kept close to `passesFilter` so the two search-related pieces - * (highlight input, matches predicate) stay obviously in sync. - */ function matchesSearch(service: ServiceNode, query: string): boolean { const members = service.publicMembers ? ` ${service.publicMembers.join(' ')}` : ''; const hay = `${service.token} ${service.impl} ${service.domain}${members}`.toLowerCase(); @@ -625,20 +534,11 @@ export function GraphView({ tags, onEditTags, }: GraphViewProps): JSX.Element { - // Compute once at mount so a re-render that adds nodes doesn't yank the - // viewport back to the stored value while the user is panning. const initialViewport = useMemo(() => loadViewport(), []); const { nodes, edges, selectedService, selectedEdges } = useMemo(() => { - // Which edges survive the edge-kind filter? Unresolved edges are kept: the - // analyzer now synthesises an interface-only node for each unresolved token - // (rendered with a distinct border), so their `to` resolves to a real node - // instead of dangling. Edges whose endpoint is filtered out are dropped - // below via the `visibleIds` check. const survivingEdges: Edge[] = graph.edges.filter((e) => filters.kinds.has(e.kind)); - // Node ids that appear on either end of any surviving edge — for the - // orphan filter. const connected = new Set(); for (const e of survivingEdges) { connected.add(e.from); @@ -650,25 +550,12 @@ export function GraphView({ ); const visibleIds = new Set(visibleServices.map((s) => s.id)); - // Also drop edges whose endpoint is not in the visible set. const finalEdges = survivingEdges.filter( (e) => visibleIds.has(e.from) && visibleIds.has(e.to), ); - // Ports depend on the *rendered* edges: a port with no visible edge is - // dead weight on the node, so we compute after filter+visibility. const ports = computeServicePorts(visibleServices, finalEdges); - // Compute the three focus drivers: - // • `selectedId` — the click-selected node (0 or 1 at a time). - // • `matched` — every node whose identity or public surface hits - // the current search string. - // • `tagMatched` — every node carrying at least one active tag - // (the "group by tag" view). - // Their neighbours (nodes touched by any surviving edge) are folded in - // so the graph keeps enough context around a hit to be readable — - // this is the "act like a click" behaviour: nothing disappears, just - // dims. `focused` is the union used to decide dim vs bright. const searchQuery = filters.search.trim().toLowerCase(); const matched = new Set(); if (searchQuery) { @@ -677,8 +564,6 @@ export function GraphView({ } } - // Tag focus: every visible node carrying at least one active tag seeds - // the focus set, so the graph reads as "the group(s) these tags pick out". const tagMatched = new Set(); if (filters.activeTags.size > 0) { for (const s of visibleServices) { @@ -736,8 +621,6 @@ export function GraphView({ }), ); - // If grouped, add one non-interactive label node above each band so the - // three columns are self-labeling. if (layout.bands) { const ys = [...pos.values()].map((p) => p.y); const minY = ys.length > 0 ? Math.min(...ys) : 0; @@ -757,14 +640,7 @@ export function GraphView({ const rfEdges: RFEdge[] = []; for (const e of finalEdges) { const style = EDGE_STYLE[e.kind]; - // With a focus (click or search) active, an edge is bright when both - // ends are in the focus set — i.e. it either sits directly on a hit - // or bridges two things adjacent to a hit. When no focus is active - // every edge stays at its default opacity. const isHighlighted = focusActive && focused.has(e.from) && focused.has(e.to); - // Group refs by (fromMethod, effectiveToMethod) so identical method - // pairs on different lines collapse into a single arrow between the - // same two handles instead of stacking. const pairs = new Map< string, { fromMethod: string | undefined; toMethod: string | undefined } @@ -810,10 +686,6 @@ export function GraphView({ nodes={nodes} edges={edges} nodeTypes={nodeTypes} - // Only `fitView` on the very first mount of a fresh browser session. - // Once a viewport is remembered, hand it to React Flow as - // `defaultViewport` so the pan/zoom the user last landed on is - // preserved across dev-server reloads. {...(initialViewport ? { defaultViewport: initialViewport } : { fitView: true })} @@ -974,9 +846,7 @@ interface EdgeGroup { edge: Edge; peerLabel: string; peerToken?: string; - /** Refs that have at least one attributed method — one table row each. */ methodRefs: EdgeRef[]; - /** Refs with neither `fromMethod` nor `toMethod` (ctor param decls etc.). */ unattributedCount: number; } @@ -998,13 +868,6 @@ function buildEdgeGroups( }); } -/** - * Right-panel table of edges touching the selected service. One row per - * attributed call ref; consecutive rows belonging to the same edge share - * `kind` / `peer` cells via `rowSpan` so the grouping is visible without - * repeating them. The self-side method column is bold so the direction of - * each call reads at a glance (out ⇒ `from` bold, in ⇒ `to` bold). - */ function EdgeList({ title, edges, direction, byId }: EdgeListProps): JSX.Element { const groups = buildEdgeGroups(edges, direction, byId); const selfIsFrom = direction === 'out'; diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/layout-dagre.ts b/packages/agent-core-v2/scripts/dep-graph/web/src/layout-dagre.ts index d73d9b9a6f..b034e06b69 100644 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/layout-dagre.ts +++ b/packages/agent-core-v2/scripts/dep-graph/web/src/layout-dagre.ts @@ -2,45 +2,21 @@ import Dagre from '@dagrejs/dagre'; import type { Edge, ServiceNode, ServiceScope } from '../../analyzer/types'; -/** Fallback node box used when the caller doesn't supply per-node dimensions. */ const NODE_WIDTH = 220; const NODE_HEIGHT = 48; -/** Horizontal gap between scope bands when `groupByScope` is on. */ const BAND_GAP = 120; export interface LayoutOptions { - /** - * Layout direction. Defaults to `RL` so base primitives (nodes with no - * outgoing dependencies) sit on the left and facades sit on the right — - * dependency arrows then flow naturally from right-to-left along the - * "depends on" relation without needing rank hacks. - */ direction?: 'LR' | 'RL' | 'TB' | 'BT'; - /** Space between layers (rank direction). */ ranksep?: number; - /** Space between nodes within a layer. */ nodesep?: number; - /** - * When true, split the graph by `service.scope` and run dagre three times - * (App / Session / Agent), then stack the results vertically with - * `BAND_GAP` between bands. Inter-scope edges are drawn by React Flow as - * cross-band connectors. When false (default), one dagre run over the - * whole set. - */ groupByScope?: boolean; - /** - * Per-node dimensions. Returned dagre positions match the box the caller - * declares here, which lets nodes with per-method port rows request more - * vertical space so their neighbours don't collide with the extra rows. - * Missing entries fall back to `(NODE_WIDTH, NODE_HEIGHT)`. - */ nodeSize?: (id: string) => { width: number; height: number }; } export interface ScopeBand { scope: ServiceScope; - /** Top-left corner of the band's bounding box. */ x: number; y: number; width: number; @@ -51,28 +27,11 @@ export interface LayoutResult { positions: Map; width: number; height: number; - /** Populated only when `groupByScope` is true — one entry per scope. */ bands?: ScopeBand[]; } -/** - * Horizontal ordering of scope bands, outer-most to inner-most: App on the - * left (base / longest-lived), Agent on the right (built on top). This - * matches the "depends on" flow — arrows from Agent go leftward into - * Session and App, which lines up with the intra-scope RL direction. - */ const BAND_ORDER: ServiceScope[] = ['App', 'Session', 'Agent']; -/** - * Run dagre over the filtered node/edge set and return a `ServiceNode.id` → - * position map. When `groupByScope` is on, we run dagre three times (one per - * scope) on the intra-scope edges only, then stack the sub-layouts vertically. - * - * Layout is stable for a given input set — dagre picks node ranks - * deterministically — so filter toggles won't jiggle unrelated nodes. - * dagre handles 100+ nodes / 400+ edges in <50ms so re-running per filter - * change is fine. - */ export function layoutDagre( services: ServiceNode[], edges: Edge[], @@ -102,8 +61,6 @@ function layoutByScope( for (const scope of BAND_ORDER) { const scoped = byScope.get(scope); if (!scoped || scoped.length === 0) continue; - // Only intra-scope edges shape this band's layout; inter-scope edges - // are rendered across bands by React Flow. const scopedIds = new Set(scoped.map((s) => s.id)); const scopedEdges = edges.filter((e) => scopedIds.has(e.from) && scopedIds.has(e.to)); const sub = runDagre(scoped, scopedEdges, options); @@ -139,12 +96,6 @@ function runDagre( }); g.setDefaultEdgeLabel(() => ({})); - // Isolated nodes (no edges at all) have no ranking constraint, so dagre - // parks them at rank 0 — which is the *source* rank (rightmost in RL). - // Semantically they don't depend on anything, so they belong with the - // base primitives on the sink side (leftmost in RL). Pin them to - // `rank: 'max'` — dagre-speak for "put in the sink rank" — regardless of - // rankdir; that keeps the intent stable if the direction is flipped later. const degree = new Map(); for (const s of services) degree.set(s.id, 0); for (const e of edges) { @@ -165,10 +116,6 @@ function runDagre( known.add(s.id); } for (const e of edges) { - // Multigraph: label each parallel edge by kind so dagre keeps them - // distinct instead of collapsing. Unresolved edges point at pseudo - // targets (`unresolved::TOKEN`) that don't have layout nodes, so they - // are skipped here — the frontend renders them separately if needed. if (!known.has(e.from) || !known.has(e.to)) continue; g.setEdge(e.from, e.to, {}, e.kind); } @@ -180,7 +127,6 @@ function runDagre( const n = g.node(s.id); if (!n) continue; const size = options.nodeSize?.(s.id) ?? { width: NODE_WIDTH, height: NODE_HEIGHT }; - // Dagre returns center coordinates; React Flow uses top-left. positions.set(s.id, { x: n.x - size.width / 2, y: n.y - size.height / 2 }); } const { width = 0, height = 0 } = g.graph(); diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/query-params.ts b/packages/agent-core-v2/scripts/dep-graph/web/src/query-params.ts index d0147c53fe..f63052f161 100644 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/query-params.ts +++ b/packages/agent-core-v2/scripts/dep-graph/web/src/query-params.ts @@ -14,19 +14,13 @@ import { EDGE_KINDS } from './style'; const ALL_SCOPES: readonly ServiceScope[] = ['App', 'Session', 'Agent']; -/** Query-string-driven overrides for the initial dep-graph filter state. */ export interface QueryParams { - /** Domains to show; everything else is hidden. Absent ⇒ all domains shown. */ domains?: string[]; - /** Scopes to show. Absent ⇒ all scopes shown. */ scopes?: ServiceScope[]; - /** Edge kinds to show. Absent ⇒ all kinds shown. */ kinds?: EdgeKind[]; - /** Initial search box value. */ search?: string; hideOrphans?: boolean; groupByScope?: boolean; - /** `ServiceNode.id` to pre-select (e.g. `Session::IMyService`). */ focus?: string; } @@ -80,10 +74,6 @@ function isKind(s: string): s is EdgeKind { return (EDGE_KINDS as readonly string[]).includes(s); } -/** - * Presence of the key (`?hideOrphans` or `?hideOrphans=`) means `true`. - * Explicit false-ish spellings (`false`, `0`, `no`, `off`) opt out. - */ function parseBool(raw: string | null): boolean { if (raw === null || raw === '') return true; return !/^(false|0|no|off)$/i.test(raw.trim()); diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/style.ts b/packages/agent-core-v2/scripts/dep-graph/web/src/style.ts index 2f42432204..cba384cfeb 100644 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/style.ts +++ b/packages/agent-core-v2/scripts/dep-graph/web/src/style.ts @@ -22,9 +22,7 @@ export const SCOPE_STYLE: Record Agent: { color: '#2f8a4d', badge: 'Agt' }, }; -/** Border / minimap color for scope-mismatch nodes (token registered elsewhere). */ export const SCOPE_MISMATCH_COLOR = '#f0883e'; -/** Border / minimap color for unresolved nodes (token registered nowhere). */ export const UNRESOLVED_COLOR = '#f85149'; export const EDGE_KINDS: EdgeKind[] = ['ctor', 'accessor', 'publish', 'subscribe', 'emit', 'on']; diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/tags.ts b/packages/agent-core-v2/scripts/dep-graph/web/src/tags.ts index b74b0bcf7a..4a680a40e7 100644 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/tags.ts +++ b/packages/agent-core-v2/scripts/dep-graph/web/src/tags.ts @@ -28,8 +28,6 @@ export function saveTags(tags: TagMap): void { try { localStorage.setItem(TAGS_STORAGE_KEY, JSON.stringify(tags)); } catch { - // Storage disabled (private mode / quota) — silently drop; the graph - // still works, tags just won't survive a reload. } } @@ -46,7 +44,6 @@ export interface TagCount { count: number; } -/** All tags present in the map with their node counts, sorted by name. */ export function collectTagCounts(tags: TagMap): TagCount[] { const counts = new Map(); for (const list of Object.values(tags)) { @@ -57,14 +54,12 @@ export function collectTagCounts(tags: TagMap): TagCount[] { .sort((a, b) => a.tag.localeCompare(b.tag)); } -/** `true` when `next` equals the current tag list for `nodeId`. */ export function tagsEqual(tags: TagMap, nodeId: string, next: string[]): boolean { const cur = tags[nodeId]; if (next.length === 0) return !(nodeId in tags); return cur !== undefined && cur.length === next.length && cur.every((t, i) => t === next[i]); } -/** Deterministic, dark-theme-readable color pair for a tag string. */ export function tagColor(tag: string): { color: string; bg: string } { const hue = ((hashString(tag) % 360) + 360) % 360; return { diff --git a/packages/agent-core-v2/src/_base/di/scope.ts b/packages/agent-core-v2/src/_base/di/scope.ts index 6f66a56dfb..2e8f608dab 100644 --- a/packages/agent-core-v2/src/_base/di/scope.ts +++ b/packages/agent-core-v2/src/_base/di/scope.ts @@ -70,11 +70,8 @@ export interface IScopeHandle { dispose(): void; } -/** Handle to the process-root App scope. */ export type IAppScopeHandle = IScopeHandle; -/** Handle to a Session scope (child of App). */ export type ISessionScopeHandle = IScopeHandle; -/** Handle to an Agent scope (child of Session). */ export type IAgentScopeHandle = IScopeHandle; function buildCollection(kind: LifecycleScope, extra?: ScopeSeed): ServiceCollection { diff --git a/packages/agent-core-v2/src/_base/di/testInstantiationService.ts b/packages/agent-core-v2/src/_base/di/testInstantiationService.ts index 5b6a9d4cdb..372e6aa750 100644 --- a/packages/agent-core-v2/src/_base/di/testInstantiationService.ts +++ b/packages/agent-core-v2/src/_base/di/testInstantiationService.ts @@ -292,65 +292,26 @@ interface SinonOptions { stub?: boolean; } -/** - * Registration surface handed to a {@link ServiceGroup} or to - * `CreateServicesOptions.additionalServices`. Mirrors the three ways a test - * supplies a service: a lazy constructor, a full instance, or a partial mock. - */ export interface ServiceRegistration { - /** - * Register a lazy `SyncDescriptor` for a service constructor. The service is - * instantiated only when first resolved from the container. - */ // eslint-disable-next-line @typescript-eslint/no-explicit-any define(id: ServiceIdentifier, ctor: new (...args: any[]) => T): void; - /** Register a fully-constructed instance. */ defineInstance(id: ServiceIdentifier, instance: T): void; - /** - * Register a partial instance (a mock). Only the supplied members need to be - * provided; the container returns it typed as `T`. - */ definePartialInstance(id: ServiceIdentifier, instance: Partial): void; } -/** A bundle of service registrations, typically one per domain. */ export type ServiceGroup = (reg: ServiceRegistration) => void; export interface CreateServicesOptions { - /** - * Base service groups applied first, in order. Registrations are deduped - * (first writer wins) so groups can supply safe defaults without clobbering - * each other. - */ readonly base?: readonly ServiceGroup[]; - /** - * Applied after `base`. Registrations here overwrite any base default, so a - * test can swap a stub for a spy, register the system under test, or supply a - * one-off collaborator. - */ readonly additionalServices?: (reg: ServiceRegistration) => void; - /** - * When `true`, resolving an unregistered service throws. Defaults to `false` - * to match `new TestInstantiationService()` (missing deps only warn), keeping - * migrated tests behavior-preserving. - */ readonly strict?: boolean; } -/** - * Build a `TestInstantiationService` from domain service groups plus per-test - * overrides. The container is added to `disposables`; directly-registered - * instances are disposed with it. - */ export function createServices( disposables: DisposableStore, options: CreateServicesOptions = {}, ): TestInstantiationService { const serviceCollection = new ServiceCollection(); - // Directly-registered instances are not constructed by the container, so the - // container will not dispose them — track their ids and dispose them below. - // Descriptor-created services are disposed by the container itself and are - // intentionally not tracked here (disposing them again would double-dispose). // eslint-disable-next-line @typescript-eslint/no-explicit-any const instanceIds = new Set>(); diff --git a/packages/agent-core-v2/src/_base/errors/codes.ts b/packages/agent-core-v2/src/_base/errors/codes.ts index bca5b909bb..64c2d0f1fb 100644 --- a/packages/agent-core-v2/src/_base/errors/codes.ts +++ b/packages/agent-core-v2/src/_base/errors/codes.ts @@ -11,7 +11,6 @@ import type { KimiErrorCode } from '@moonshot-ai/protocol'; -/** Wire-stable code carried by every `Error2`. Sourced from the protocol. */ export type ErrorCode = KimiErrorCode; export interface ErrorInfo { @@ -21,11 +20,6 @@ export interface ErrorInfo { readonly action?: string; } -/** - * A domain's error contribution: the `codes` const (name → wire code) plus the - * optional retryable list and per-code human-facing overrides. Every value in - * `codes` must be a protocol-known `ErrorCode`. - */ export interface ErrorDomain { readonly codes: { readonly [name: string]: ErrorCode }; readonly retryable?: ReadonlyArray; @@ -36,10 +30,6 @@ const registeredCodes = new Set(); const retryableCodes = new Set(); const infoOverrides: { [code: string]: ErrorInfo } = {}; -/** - * Merge a domain's error contribution into the runtime registry. Each domain's - * error module calls this at load; re-registering an identical code is a no-op. - */ export function registerErrorDomain(domain: ErrorDomain): void { for (const code of Object.values(domain.codes)) { registeredCodes.add(code); @@ -66,7 +56,6 @@ export function errorInfo(code: ErrorCode): ErrorInfo { }; } -/** Domain-independent codes shared by every consumer. */ export const CoreErrors = { codes: { INTERNAL: 'internal', diff --git a/packages/agent-core-v2/src/_base/errors/errors.ts b/packages/agent-core-v2/src/_base/errors/errors.ts index 922494cf15..373db6344c 100644 --- a/packages/agent-core-v2/src/_base/errors/errors.ts +++ b/packages/agent-core-v2/src/_base/errors/errors.ts @@ -56,12 +56,6 @@ export function isError2(error: unknown): error is Error2 { return error instanceof Error2; } -/** - * Follow `cause` links out of `Error2` wrappers down to the underlying raw - * error. Boundary-translated errors carry the original provider/fs error as - * `cause`, so predicates that classify raw error shapes (retryability, - * status codes) test the unwrapped value. - */ export function unwrapErrorCause(error: unknown): unknown { let current = error; while (current instanceof Error2 && current.cause !== undefined) { diff --git a/packages/agent-core-v2/src/_base/errors/serialize.ts b/packages/agent-core-v2/src/_base/errors/serialize.ts index 4734ba7a11..3d1b2a634b 100644 --- a/packages/agent-core-v2/src/_base/errors/serialize.ts +++ b/packages/agent-core-v2/src/_base/errors/serialize.ts @@ -30,7 +30,6 @@ export interface CodedErrorShape { readonly details?: Readonly>; } -/** Caps `cause` recursion so cyclic / pathological chains stay serializable. */ const MAX_CAUSE_DEPTH = 8; export function isCodedError(error: unknown): error is CodedErrorShape { diff --git a/packages/agent-core-v2/src/_base/execEnv/bufferedReadable.ts b/packages/agent-core-v2/src/_base/execEnv/bufferedReadable.ts index 6e036f08d7..f9f76b9818 100644 --- a/packages/agent-core-v2/src/_base/execEnv/bufferedReadable.ts +++ b/packages/agent-core-v2/src/_base/execEnv/bufferedReadable.ts @@ -15,8 +15,6 @@ export class BufferedReadable extends Readable { private _ended: boolean = false; constructor(source: Readable) { - // Keep a modest prefetch window so wait()-then-read still works for - // common small/medium outputs without draining unboundedly. super({ highWaterMark: 128 * 1024 }); this._source = source; this._source.on('data', this._onData); diff --git a/packages/agent-core-v2/src/_base/execEnv/decodeText.ts b/packages/agent-core-v2/src/_base/execEnv/decodeText.ts index b756557d35..58cc2a3db1 100644 --- a/packages/agent-core-v2/src/_base/execEnv/decodeText.ts +++ b/packages/agent-core-v2/src/_base/execEnv/decodeText.ts @@ -129,26 +129,12 @@ function decodeUtf16LeIgnore(data: Buffer): string { return output; } -/** - * Decode a Buffer into a string with Python-compatible `errors` handling. - * - * - `'strict'` (default): throw on invalid sequences (via TextDecoder `fatal: true`) - * - `'replace'`: substitute each invalid sequence with U+FFFD (TextDecoder default) - * - `'ignore'`: drop invalid input sequences while preserving valid U+FFFD characters - * - * Falls back to `Buffer.toString(encoding)` for encodings TextDecoder does not - * support (e.g. `hex`, `base64`, `binary`, `latin1`) — those are lossless - * byte-to-character mappings so `errors` has no effect. - */ export function decodeTextWithErrors( data: Buffer, encoding: BufferEncoding, errors: TextDecodeErrors = 'strict', ignoreBOM: boolean = false, ): string { - // Map Node's BufferEncoding names to Web TextDecoder labels where the two - // diverge. Only UTF-family encodings participate in the strict/replace/ - // ignore dance; the others are lossless and use Buffer.toString directly. let webLabel: string | undefined; // eslint-disable-next-line typescript-eslint/switch-exhaustiveness-check switch (encoding) { @@ -166,8 +152,6 @@ export function decodeTextWithErrors( } if (webLabel === undefined) { - // Non-UTF encodings (hex/base64/latin1/binary/ascii) are lossless byte↔ - // character mappings; `errors` is meaningless for them. Return raw. return data.toString(encoding); } @@ -175,13 +159,9 @@ export function decodeTextWithErrors( return new TextDecoder(webLabel, { fatal: true, ignoreBOM }).decode(data); } - // 'ignore' must skip invalid input bytes/code units, not delete every - // replacement character in the decoded output. A file can contain a valid - // U+FFFD, and Python preserves it under errors="ignore". if (errors === 'ignore') { return webLabel === 'utf-8' ? decodeUtf8Ignore(data) : decodeUtf16LeIgnore(data); } - // 'replace' → substitute each invalid sequence with U+FFFD (default). return new TextDecoder(webLabel, { fatal: false, ignoreBOM }).decode(data); } diff --git a/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts b/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts index 4756e0358c..8e68cb9cee 100644 --- a/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts +++ b/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts @@ -22,9 +22,6 @@ import { access } from 'node:fs/promises'; import * as nodeOs from 'node:os'; import * as nodePath from 'node:path'; -// `OsKind` carries 'macOS' / 'Linux' / 'Windows' for known platforms and falls -// back to the raw `process.platform` string for unknown ones (e.g. 'freebsd'). -// Typed as `string` so the union is not inhabited-by-string. export type OsKind = string; export type ShellName = 'bash' | 'sh'; export type PathClass = 'posix' | 'win32'; @@ -40,8 +37,6 @@ export interface HostEnvironmentInfo { } export interface HostEnvironmentProbeDeps { - // Accepts the full Node `Platform` enum plus arbitrary strings for - // forward-compatible OS kinds. readonly platform: string; readonly arch: string; readonly release: string; @@ -211,10 +206,6 @@ async function readGitExecPath( return undefined; } -// Most Git for Windows installs put `git.exe` in `\cmd\git.exe`, with -// bash at `\bin\bash.exe`. Portable installs sometimes put both in -// `\bin\`. Only infer from those anchored layouts; package manager -// shims live elsewhere and must resolve through `git --exec-path`. function gitBashCandidatesFromGitExe(gitExe: string): readonly string[] | undefined { const normalizedGitExe = nodePath.win32.normalize(normalizeWindowsPath(gitExe)); const gitDir = nodePath.win32.dirname(normalizedGitExe); @@ -268,16 +259,6 @@ function dedupeWindowsPaths(paths: readonly string[]): readonly string[] { return deduped; } -/** - * Production convenience — derive the deps bag from Node's ambient surface. - * - * The result is memoised: subsequent calls return the original promise. - * `HostEnvironmentInfo` is immutable for the lifetime of the process (it - * derives from `process.platform`, `process.arch`, `os.release()`, `os.homedir()`, - * and one-time shell-path discovery), so caching is sound. Tests that need to - * probe with different inputs should call {@link probeHostEnvironment} directly - * with an injected deps bag. - */ let cachedProbe: Promise | undefined; export function probeHostEnvironmentFromNode(): Promise { diff --git a/packages/agent-core-v2/src/_base/execEnv/globPattern.ts b/packages/agent-core-v2/src/_base/execEnv/globPattern.ts index e72bfc1208..ffca52fb88 100644 --- a/packages/agent-core-v2/src/_base/execEnv/globPattern.ts +++ b/packages/agent-core-v2/src/_base/execEnv/globPattern.ts @@ -28,12 +28,7 @@ export function globPatternToRegex(pattern: string, caseSensitive: boolean): Reg if (end === -1) { regex += '\\['; } else { - // Glob character classes only use `!` for negation. A literal - // leading `^` must remain literal even though JS regex char - // classes treat it as negation in the first position. let charClass = pattern.slice(i + 1, end); - // Escape backslashes inside the class so a trailing backslash - // does not accidentally escape the closing `]`. charClass = charClass.replace(/\\/g, '\\\\'); if (charClass.startsWith('!')) { charClass = '^' + charClass.slice(1); @@ -49,8 +44,6 @@ export function globPatternToRegex(pattern: string, caseSensitive: boolean): Reg if (i + 1 < pattern.length) { const next = pattern.charAt(i + 1); regex += next.replaceAll(/[{}()+.\\[\]^$|]/g, '\\$&'); - // Advance past the escaped character so it is not processed - // again as a regex metacharacter. match literally. i++; } else { regex += '\\\\'; diff --git a/packages/agent-core-v2/src/_base/execEnv/loginShellPath.ts b/packages/agent-core-v2/src/_base/execEnv/loginShellPath.ts index 5930011d16..c2483bb2c6 100644 --- a/packages/agent-core-v2/src/_base/execEnv/loginShellPath.ts +++ b/packages/agent-core-v2/src/_base/execEnv/loginShellPath.ts @@ -29,7 +29,6 @@ import { execFileText } from './environmentProbe'; export interface LoginShellPathDeps { readonly platform: string; readonly env: Record; - /** Login shell from the OS user database; fallback when $SHELL is unset. */ readonly userShell: () => string | undefined; readonly execFileText: ( file: string, @@ -40,31 +39,15 @@ export interface LoginShellPathDeps { const LOGIN_SHELL_ENV_TIMEOUT_MS = 5_000; -/** - * Run the user's login shell and return its PATH, or `undefined` when the probe - * does not apply (Windows, no resolvable shell) or fails (spawn error, timeout, - * no PATH in the output). - */ export async function probeLoginShellPath(deps: LoginShellPathDeps): Promise { if (deps.platform === 'win32') return undefined; - // A set-but-blank $SHELL (some daemon/launchd envs) must also fall back. const envShell = deps.env['SHELL']?.trim(); const shell = envShell === undefined || envShell.length === 0 ? deps.userShell() : envShell; if (shell === undefined || shell.length === 0) return undefined; - // `env` prints the resolved environment in every shell dialect, unlike - // `echo $PATH`, which fish would join with spaces. Invoke it by absolute - // path: a bare `env` resolves through the inherited PATH — which may carry - // cwd-dependent components — from the workspace cwd, so a repo-planted `env` - // binary could run at session startup and feed us an arbitrary PATH. The - // absolute path also bypasses profile function shadowing, and /usr/bin/env is - // guaranteed on every mainstream POSIX system (it is the canonical shebang - // interpreter path). const stdout = await deps.execFileText(shell, ['-l', '-c', '/usr/bin/env'], LOGIN_SHELL_ENV_TIMEOUT_MS); if (stdout === undefined) return undefined; - // Profile output lands on stdout before `env` runs, so keep the last PATH= - // line. let path: string | undefined; for (const line of stdout.split('\n')) { if (line.startsWith('PATH=')) { @@ -75,54 +58,29 @@ export async function probeLoginShellPath(deps: LoginShellPathDeps): Promise entry.length > 0)); const additions: string[] = []; for (const entry of loginShellPath.split(':')) { - // The probe only runs on POSIX (win32 bails before merging), so a leading - // slash is a sufficient absoluteness test. Empty components fail it too. if (!entry.startsWith('/') || seen.has(entry)) continue; seen.add(entry); additions.push(entry); } if (additions.length === 0) return current; - // `undefined` means "no PATH at all", so the additions stand alone; '' is a - // real (cwd-only) PATH whose empty component must survive as a leading colon. if (currentPath === undefined) return additions.join(':'); return `${current}:${additions.join(':')}`; } -/** Probe the login shell and merge its PATH into `deps.env['PATH']`. */ export async function applyLoginShellPath(deps: LoginShellPathDeps): Promise { const loginShellPath = await probeLoginShellPath(deps); if (loginShellPath === undefined) return; const currentPath = deps.env['PATH']; const merged = mergeLoginShellPath(currentPath, loginShellPath); - // Only write when something was appended — an unset PATH must stay unset - // (assigning '' would turn "implementation default search path" into - // "cwd-only lookup"), and a set PATH must not be rewritten. if (merged === (currentPath ?? '')) return; deps.env['PATH'] = merged; } -/** - * Login shell from the OS user database (`/etc/passwd` via getpwuid on Linux, - * Directory Services on macOS). `userInfo()` throws when the uid has no - * database entry (e.g. containers running an arbitrary uid), and service - * accounts may carry `/usr/sbin/nologin` — the latter needs no special casing - * here because probing it simply fails and degrades silently. - */ function userShellFromNode(): string | undefined { try { const shell = userInfo().shell; @@ -134,12 +92,6 @@ function userShellFromNode(): string | undefined { let appliedLoginShellPath: Promise | undefined; -/** - * Production convenience — apply the probe to `process.env` once per process. - * Memoised like `probeHostEnvironmentFromNode`: the login-shell PATH does not - * change for the lifetime of the process, and repeated calls must not re-spawn - * the shell. - */ export function applyLoginShellPathFromNode(): Promise { if (appliedLoginShellPath !== undefined) return appliedLoginShellPath; appliedLoginShellPath = applyLoginShellPath({ diff --git a/packages/agent-core-v2/src/_base/log/log.ts b/packages/agent-core-v2/src/_base/log/log.ts index 266f4d0767..e19b0022c8 100644 --- a/packages/agent-core-v2/src/_base/log/log.ts +++ b/packages/agent-core-v2/src/_base/log/log.ts @@ -31,10 +31,6 @@ export interface LogEntry { readonly error?: LogEntryError; } -/** - * Plain sink interface (not a DI token). `*LogService` implementations own and - * create their sinks; tests construct sinks directly. - */ export interface ILogWriter { write(entry: LogEntry): void; flush?(): Promise; diff --git a/packages/agent-core-v2/src/_base/log/logService.ts b/packages/agent-core-v2/src/_base/log/logService.ts index c931c78a85..fc7c3382fc 100644 --- a/packages/agent-core-v2/src/_base/log/logService.ts +++ b/packages/agent-core-v2/src/_base/log/logService.ts @@ -130,11 +130,6 @@ export class BoundLogger extends Disposable implements ILogger { } } -/** - * App-scope `ILogService`: writes the global rotating file under - * `/logs`, with its level seeded from `ILogOptions`. Flushes - * synchronously when the App scope is disposed (process shutdown). - */ export class AppLogService extends BoundLogger implements ILogService { declare readonly _serviceBrand: undefined; private readonly sink: FileLogWriter; diff --git a/packages/agent-core-v2/src/_base/utils/abort.ts b/packages/agent-core-v2/src/_base/utils/abort.ts index d33ae44213..09b2860fb0 100644 --- a/packages/agent-core-v2/src/_base/utils/abort.ts +++ b/packages/agent-core-v2/src/_base/utils/abort.ts @@ -9,12 +9,6 @@ export function abortError(message = 'Aborted'): Error { return error; } -/** - * Control-flow check for abort-shaped errors (`DOMException('AbortError')`, - * `UserCancellationError`, default `AbortController` reasons). Cancellation is - * not an error — catch sites must branch on this (or `signal.aborted`) before - * any error handling. - */ export function isAbortError(error: unknown): error is Error { return error instanceof Error && error.name === 'AbortError'; } diff --git a/packages/agent-core-v2/src/_base/utils/retry.ts b/packages/agent-core-v2/src/_base/utils/retry.ts index f764b77acb..ee984047e5 100644 --- a/packages/agent-core-v2/src/_base/utils/retry.ts +++ b/packages/agent-core-v2/src/_base/utils/retry.ts @@ -63,7 +63,6 @@ function maybeStatusCode(error: unknown): number | undefined { if (typeof error !== 'object' || error === null) return undefined; const statusCode = (error as { statusCode?: unknown }).statusCode; if (typeof statusCode === 'number') return statusCode; - // Boundary-translated errors carry the HTTP status in `details`. const details = (error as { details?: unknown }).details; if (details !== null && typeof details === 'object') { const detailsStatus = (details as { statusCode?: unknown }).statusCode; diff --git a/packages/agent-core-v2/src/_base/utils/timer.ts b/packages/agent-core-v2/src/_base/utils/timer.ts index 355b74ae75..2d6f00d551 100644 --- a/packages/agent-core-v2/src/_base/utils/timer.ts +++ b/packages/agent-core-v2/src/_base/utils/timer.ts @@ -12,11 +12,6 @@ import type { IDisposable } from '#/_base/di/lifecycle'; export interface IntervalTimerOptions { - /** - * When true, the underlying Node handle is `unref()`-ed so the timer does - * not keep the event loop alive on its own. Use for background polling that - * must not prevent process exit on its own. - */ readonly unref?: boolean; } @@ -25,7 +20,6 @@ export class IntervalTimer implements IDisposable { constructor(private readonly options: IntervalTimerOptions = {}) {} - /** Stop the loop if running. Idempotent. */ cancel(): void { if (this.handle !== undefined) { clearInterval(this.handle); @@ -33,7 +27,6 @@ export class IntervalTimer implements IDisposable { } } - /** Cancel any pending loop and start a new one. */ cancelAndSet(runner: () => void, intervalMs: number): void { this.cancel(); const handle = setInterval(runner, intervalMs); @@ -48,7 +41,6 @@ export class IntervalTimer implements IDisposable { this.handle = handle; } - /** True while a loop is scheduled. */ isSet(): boolean { return this.handle !== undefined; } diff --git a/packages/agent-core-v2/src/activity/activity.ts b/packages/agent-core-v2/src/activity/activity.ts index cc2b0aa584..0026e9a094 100644 --- a/packages/agent-core-v2/src/activity/activity.ts +++ b/packages/agent-core-v2/src/activity/activity.ts @@ -22,9 +22,7 @@ import type { TurnEndReason } from '@moonshot-ai/protocol'; export type AgentLane = 'initializing' | 'idle' | 'turn' | 'disposing' | 'disposed'; export interface BeginOptions { - /** Turn source, forwarded to the lease and the snapshot; admission is origin-agnostic. */ readonly origin?: PromptOrigin; - /** Stable id reserved by the loop when the turn is enqueued. */ readonly turnId?: number; } @@ -32,11 +30,8 @@ export interface ActivityLease { readonly kind: 'turn'; readonly turnId: number; readonly origin: PromptOrigin; - /** Cancellation flows one way from the kernel: `cancel()` aborts this signal. */ readonly signal: AbortSignal; - /** True once `cancel()` has been issued and the turn is draining. */ readonly ending: boolean; - /** Must be called in a `finally`; idempotent. Returns the lane to `idle` and records the outcome. */ end(outcome: 'completed' | 'cancelled' | 'failed', detail?: { error?: unknown }): void; } @@ -52,35 +47,17 @@ export interface IAgentActivityService { lane(): AgentLane; - /** - * Atomic admission: synchronously performs "session admission consult → own - * lane check → enter turn lane → issue lease → register with the session - * kernel". Any failing step throws a coded error with no state residue. The - * synchronous shape (no `await`) is what makes admission atomic under the - * single-threaded event loop. - */ begin(kind: 'turn', opts?: BeginOptions): ActivityLease; - /** Non-throwing variant: returns `undefined` when admission fails. */ tryBegin(kind: 'turn', opts?: BeginOptions): ActivityLease | undefined; - /** - * Drives the `initializing → idle` transition. Called by the agent bootstrap - * (`agentLifecycle.create`) once construction (and the eager tool / hook / MCP - * setup) has finished and the agent is ready to admit turns. Until then - * `begin` rejects with `activity.initializing`. No-op when not `initializing`. - */ markReady(): void; - /** Unified cancel: `turn(active)` → `turn(ending)` and aborts the lease signal. Idempotent. */ cancel(reason?: unknown): boolean; - /** Registers a background activity (compaction etc.): visible, cancellable, aborted on disposal. */ registerBackground(kind: string, controller: AbortController): IDisposable & { readonly id: string }; - /** Enters `disposing`: rejects new `begin`, aborts every lease and background activity. */ beginDisposal(): void; - /** Resolves once every lease and background activity has drained. Awaited by `agentLifecycle`. */ settled(): Promise; } @@ -106,35 +83,17 @@ export interface ISessionActivityKernel { lane(): SessionLane; - /** Leaves the restore/materialize window and admits normal session commands. */ markActive(): void; - /** Admission table for edge (gateway / rpc / legacy) and `agentLifecycle` commands. */ canAccept(command: SessionCommand): boolean; - /** - * Called synchronously by the Agent kernel on `begin` (child-injects-parent): - * throws `activity.session_rejected` while `quiescing` / `closing` / - * `restoring`; otherwise registers the lease for settle tracking and returns - * its unregister handle. - */ admitTurn(agentId: string, lease: ActivityLease): IDisposable; - /** - * Atomically acquires global quiescence: synchronously flips the lane to - * `quiescing` (closing the door so subsequent `admitTurn` calls reject), then - * awaits every in-flight lease to drain. - */ quiesce(reason: string): Promise; beginClosing(): void; settled(): Promise; - /** - * Drives the `restoring → active` transition. Called by the session lifecycle - * once materialization (and, for resume, replay) has finished and the session - * is ready to accept commands. No-op when not `restoring`. - */ markActive(): void; } @@ -185,13 +144,6 @@ export interface ActivityLastTurnState { readonly at: number; } -/** - * Structured read model of "what the agent is doing". The observable state - * space is `lane × turn sub-phase × pending-approval set × active-tool-call set - * × background-activity set`; the authoritative machine itself stays the five - * `AgentLane` positions. Derived by the `runtime` projector from the kernel's - * `LaneModel` plus `IEventBus` facts; emitted as `agent.activity.updated`. - */ export interface AgentActivitySnapshot { readonly lane: AgentLane; readonly turn?: ActivityTurnState; diff --git a/packages/agent-core-v2/src/activity/agentActivityService.ts b/packages/agent-core-v2/src/activity/agentActivityService.ts index b782f30df0..2a548bd042 100644 --- a/packages/agent-core-v2/src/activity/agentActivityService.ts +++ b/packages/agent-core-v2/src/activity/agentActivityService.ts @@ -138,8 +138,6 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe const turnId = opts?.turnId ?? this.wire.getModel(TurnModel).nextTurnId; const origin = opts?.origin ?? USER_PROMPT_ORIGIN; const lease = new LeaseImpl(turnId, origin, this); - // Session admission consult + lease registration. Throws `activity.session_rejected` - // when the session is restoring / quiescing / closing; no lane state is touched yet. lease.registration = this.sessionKernel.admitTurn(this.scopeContext.agentId, lease); this.activeLease = lease; diff --git a/packages/agent-core-v2/src/activity/sessionActivityKernel.ts b/packages/agent-core-v2/src/activity/sessionActivityKernel.ts index df74d80d3f..ecced0378a 100644 --- a/packages/agent-core-v2/src/activity/sessionActivityKernel.ts +++ b/packages/agent-core-v2/src/activity/sessionActivityKernel.ts @@ -48,11 +48,8 @@ export class SessionActivityKernel extends Disposable implements ISessionActivit case 'active': return true; case 'restoring': - // The lifecycle materializes the main agent while restoring; every other - // command (turns, fork, close) must wait for `markActive`. return command === 'agent.create'; default: - // `quiescing` / `closing` / `disposed` reject every new command. return false; } } @@ -135,10 +132,6 @@ export class SessionActivityKernel extends Disposable implements ISessionActivit } private publishLane(): void { - // The Session scope does not yet own a wire service, so the lane is kept as - // kernel-local state in PR3. Publishing to `sessionActivityLane` is deferred - // until a Session wire service is introduced; the derived `ISessionActivity` - // read model keeps its existing polling source meanwhile. } } diff --git a/packages/agent-core-v2/src/agent/blob/byteLruCache.ts b/packages/agent-core-v2/src/agent/blob/byteLruCache.ts index 02ada9c845..8c56674f37 100644 --- a/packages/agent-core-v2/src/agent/blob/byteLruCache.ts +++ b/packages/agent-core-v2/src/agent/blob/byteLruCache.ts @@ -20,7 +20,6 @@ export class ByteLruCache { get(key: string): Buffer | undefined { const value = this.map.get(key); if (value === undefined) return undefined; - // Refresh to most-recently-used. this.map.delete(key); this.map.set(key, value); return value; diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts index 1594bf9ce7..b1d70e4d28 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts @@ -3,23 +3,11 @@ import type { IDisposable } from "#/_base/di/lifecycle"; import type { ContentPart } from "#/app/llmProtocol/message"; export interface ContextInjectionContext { - /** Live positions of this variant's injections in the current history, ascending. */ readonly injectedPositions: readonly number[]; - /** Position of the newest live injection; `null` when none survive. */ readonly lastInjectedAt: number | null; - /** - * `true` on the first inject run after a `turn.started` event (or after the - * service starts), then `false` until the next turn. Injectors that should - * fire once per turn can gate on this flag. - */ readonly isNewTurn: boolean; } -/** - * Content a context injection provider can return. A plain `string` is wrapped - * in `` tags; a {@link ContentPart} array is appended verbatim, - * allowing providers to inject rich content (e.g. multi-part or media content). - */ export type ContextInjectionContent = string | readonly ContentPart[]; export type ContextInjectionProvider = ( @@ -34,12 +22,6 @@ export interface IAgentContextInjectorService { provider: ContextInjectionProvider, ): IDisposable; - /** - * Re-arm the per-turn injectors and run them immediately. Called by full - * compaction after the summary is applied so the first post-compaction - * request already carries the per-turn reminders (goal, plan, ...) that the - * compaction folded away. - */ injectAfterCompaction(): Promise; } diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index 9fd4190e33..24c27d087c 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -25,7 +25,6 @@ import { interface ContextInjectionEntry { readonly provider: ContextInjectionProvider; readonly name: string; - /** Live positions of this variant's injection messages, ascending. */ readonly positions: number[]; } @@ -139,9 +138,6 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon const adopted = insertedInjections?.get(entry.name) ?? []; const positions = entry.positions; if (adopted.length === 0 && positions.length === 0) continue; - // Mirror the context splice onto the ascending positions array: shift - // survivors past the deleted range, then replace the deleted segment - // with the adopted insertions (which land in [start, start + inserted)). let lo = 0; while (lo < positions.length && positions[lo]! < splice.start) lo++; let hi = lo; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts index bf098a9ec1..3334d76ab4 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts @@ -31,23 +31,14 @@ export interface IAgentContextMemoryService { get(): readonly ContextMessage[]; - /** Append one or more already-folded messages (`context.append_message`). */ append(...messages: readonly ContextMessage[]): void; appendLoopEvent(event: LoopRecordedEvent): void; - /** Drop the entire history (`context.clear`). No-op when already empty. */ clear(): void; - /** - * Remove the trailing `count` real-user prompts and the exchange that follows - * them (`context.undo`). Returns the computed cut so the caller can surface a - * `request.invalid` when fewer than `count` prompts were undoable; the model is - * left untouched in that case. - */ undo(count: number): UndoCut; - /** Rewrite the live history into the v1-compatible compaction handoff shape. */ applyCompaction(input: ContextCompactionInput): ContextCompactionResult; } diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index 7f48efa23e..b639b3a0f3 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -143,13 +143,6 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte this.eventBus.publish({ type: 'context.spliced', ...input }); } - /** - * Cascade a `context_size.measured` Op when an undo truncates the measured - * prefix (`ContextSizeModel.length`). If the surviving context still covers - * the measured prefix, the measurement stays valid and nothing is emitted; - * otherwise the prefix is rebased to an estimate of the surviving messages - * (an aggregate measured count can't be truncated without per-message data). - */ private sizeOpsForCut(cutIndex: number, history: readonly ContextMessage[]): Op[] { const model = this.wire.getModel(ContextSizeModel); if (model.length <= cutIndex) return []; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index e8ef66ff80..5f630a250d 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -131,8 +131,6 @@ declare module '#/wire/types' { } } -// `ContextMessage` / `LoopRecordedEvent` are large domain unions owned by -// sibling modules; `z.custom` keeps their exact types without restating them. const contextMessageSchema = z.custom(); const loopRecordedEventSchema = z.custom(); @@ -294,15 +292,6 @@ export interface UndoCut { readonly stoppedAtCompaction: boolean; } -/** - * Locate the trailing cut for an undo of `count` real-user prompts: the oldest - * index of the Nth-from-tail real-user prompt (skipping `injection` messages and - * stopping at a `compaction_summary` boundary). `removedCount` is how many - * real-user prompts were found; `cutIndex` is where the trailing exchange begins - * (everything from there to the end is removed), or `-1` when none was found. - * Shared by the `context.undo` reducer and the live service so dispatch and - * replay produce identical state. - */ export function computeUndoCut(state: readonly ContextMessage[], count: number): UndoCut { let remaining = count; let cutIndex = -1; @@ -324,22 +313,12 @@ export function computeUndoCut(state: readonly ContextMessage[], count: number): return { cutIndex, removedCount, stoppedAtCompaction }; } -/** Whether a {@link computeUndoCut} result satisfied the full requested `count`. */ export function isFullyUndoable(cut: UndoCut, count: number): boolean { return cut.cutIndex >= 0 && cut.removedCount >= count; } -/** Structured reason an undo cannot proceed, derived from a {@link UndoCut}. */ export type UndoUnavailableReason = 'empty' | 'compaction_boundary' | 'insufficient'; -/** - * Result of checking whether `count` real-user prompts can be undone. Returns - * `{ ok: true }` when the cut is fully undoable, otherwise a structured reason - * (`empty` when no real-user prompt exists, `compaction_boundary` when the scan - * hits a compaction summary first, `insufficient` when some exist but fewer - * than `count`) plus the number that *could* be undone. Shared by the live - * `IAgentPromptService.undo` (which throws on `!ok`) and tests. - */ export type UndoPrecheck = | { readonly ok: true } | { @@ -349,7 +328,6 @@ export type UndoPrecheck = readonly undoable: number; }; -/** Classify a history against an undo `count` (wraps {@link computeUndoCut}). */ export function precheckUndo(history: readonly ContextMessage[], count: number): UndoPrecheck { const cut = computeUndoCut(history, count); if (isFullyUndoable(cut, count)) return { ok: true }; @@ -361,7 +339,6 @@ export function precheckUndo(history: readonly ContextMessage[], count: number): return { ok: false, reason, requested: count, undoable: cut.removedCount }; } -/** Wire-facing message for a failed {@link precheckUndo} (`session.undo_unavailable`). */ export function formatUndoUnavailableMessage( precheck: Extract, ): string { diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 6fc661f93c..92c1797634 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -44,17 +44,8 @@ const TOOL_INTERRUPTED_ON_RESUME_OUTPUT = 'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.'; export interface ContextTranscript { - /** Full message history, compacted prefixes included. */ readonly entries: readonly ContextMessage[]; - /** - * Wall-clock time (ms) of the originating wire record per entry, when - * present — v1 `TranscriptEntry.time`. Synthesized entries (e.g. - * interrupted-on-resume tool results) carry the time of the record that - * triggered their synthesis; entries without a source record (live tail) - * have `undefined`. - */ readonly times: readonly (number | undefined)[]; - /** Length the live (folded) `context.history` would have after these records. */ readonly foldedLength: number; } @@ -73,12 +64,9 @@ interface MutableEntry { time?: number; } -/** Reduce `context.*` wire records into the full transcript. Pure (no I/O). */ export function reduceContextTranscript(records: Iterable): ContextTranscript { const transcript: MutableEntry[] = []; - /** What `context.history.length` would be right now (post-folding). */ let foldedLength = 0; - /** Transcript index `context.undo` may not cross (set by `context.clear`). */ let clearFloor = 0; const openSteps = new Map(); const pendingToolResultIds = new Set(); @@ -135,8 +123,6 @@ export function reduceContextTranscript(records: Iterable): Con return; } case 'content.part': { - // Lenient where the live reducer throws: a dangling part in a damaged - // file should not take the whole transcript down. openSteps.get(event.stepUuid)?.message.content.push(event.part); return; } @@ -202,8 +188,6 @@ export function reduceContextTranscript(records: Iterable): Con applyLoopEvent(record['event'] as LoopRecordedEvent, record.time); break; case 'context.apply_compaction': { - // The live context folds into `[...keptUserMessages, summary]`; the - // transcript keeps the full history and appends the summary marker. transcript.push({ message: { role: 'user', @@ -252,7 +236,6 @@ function toMutableEntry(message: ContextMessage, time: number | undefined): Muta }; } -/** Recover the live `context.history.length` after a `context.apply_compaction` record. */ function recoverFoldedLength( record: PersistedRecord, transcript: readonly MutableEntry[], @@ -263,17 +246,11 @@ function recoverFoldedLength( const keptHeadUserMessageCount = readNumber(record, 'keptHeadUserMessageCount'); const compactedCount = readNumber(record, 'compactedCount'); if (keptUserMessageCount !== undefined) { - // +1 for the summary message; +1 more when the selection split into - // head + tail (the live context then also holds an elision marker). return keptUserMessageCount + (keptHeadUserMessageCount === undefined ? 1 : 2); } if (compactedCount !== undefined && compactedCount < foldedLength) { - // Legacy record that kept `history.slice(compactedCount)` verbatim. return 1 + (foldedLength - compactedCount); } - // Legacy record covering the whole live history: re-derive from the - // post-clear transcript only (the live context rebuilds from the - // post-`/clear` messages). const keptUserMessages = selectRecentUserMessages( collectCompactableUserMessages(transcript.slice(clearFloor).map((e) => e.message)), COMPACT_USER_MESSAGE_MAX_TOKENS, @@ -286,7 +263,6 @@ function readCompactionSummaryText(record: PersistedRecord): string { if (typeof summary === 'string') return summary; const contextSummary = record['contextSummary']; if (typeof contextSummary === 'string') return contextSummary; - // Legacy record whose `summary` is a whole ContextMessage — flatten its text. if (isContextMessageLike(summary)) return textOfParts(summary.content); return ''; } @@ -310,7 +286,6 @@ function readNumber(record: PersistedRecord, key: string): number | undefined { return typeof value === 'number' ? value : undefined; } -/** Raw output verbatim — status text is added only at LLM projection, never in the transcript. */ function rawToolResultContent(output: string | readonly ContentPart[]): ContentPart[] { return typeof output === 'string' ? [{ type: 'text', text: output }] : [...output]; } diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index 46eadfd3ec..a9dccda2f0 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -127,7 +127,6 @@ function bind(state: readonly ContextMessage[], ctx: FoldCtx): readonly ContextM return state; } -/** Defer-aware `context.append_message` (matches v1 `ContextMemory.appendMessage`). */ export function foldAppendMessage( state: readonly ContextMessage[], message: ContextMessage, @@ -140,7 +139,6 @@ export function foldAppendMessage( return bind([...state, message], ctx); } -/** Reduce one `context.append_loop_event` record into the history. */ export function foldLoopEvent( state: readonly ContextMessage[], event: LoopRecordedEvent, @@ -148,9 +146,6 @@ export function foldLoopEvent( const ctx = ctxOf(state); switch (event.type) { case 'step.begin': { - // A step that failed before `step.end` (a retried attempt, an aborted - // turn) leaves its partial assistant open; settle it before opening the - // next one so an empty attempt does not strand a ghost assistant. const settled = settleOpenStep(state, ctx); const assistant: ContextMessage = { role: 'assistant', content: [], toolCalls: [], partial: true }; ctx.openStepUuid = event.uuid; @@ -196,11 +191,6 @@ export function foldLoopEvent( } } -/** - * Clear fold bookkeeping after an op that invalidates any open exchange - * (`context.undo` / `context.clear` / `context.apply_compaction`). Returns - * the same state reference with a fresh fold ctx. - */ export function resetFold(state: readonly ContextMessage[]): readonly ContextMessage[] { foldCtxMap.set(state, { openStepUuid: undefined, pending: new Set(), deferred: [] }); return state; @@ -217,12 +207,6 @@ function appendToOpenAssistant( return next; } -/** - * Close the step currently left open: pending tool calls get their interrupted - * result messages, then the partial assistant is dropped when it is empty - * (nothing to keep — an empty assistant only trips provider message - * validation) or sealed in place when it carries content or tool calls. - */ function settleOpenStep( state: readonly ContextMessage[], ctx: FoldCtx, diff --git a/packages/agent-core-v2/src/agent/contextMemory/messageProjection.ts b/packages/agent-core-v2/src/agent/contextMemory/messageProjection.ts index 8f86a5758e..48ac6dddd9 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/messageProjection.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/messageProjection.ts @@ -19,18 +19,15 @@ import type { Message, MessageContent, MessageRole, ToolUseContent } from '@moon import type { ContextMessage } from './types'; -/** Derive a stable opaque message id from (sessionId, index) — fallback for legacy records that predate intrinsic message ids. */ function deriveMessageId(sessionId: string, index: number): string { const padded = String(index).padStart(6, '0'); return `msg_${sessionId}_${padded}`; } -/** kosong's `Role` already matches the wire `MessageRole` — pass through. */ function toProtocolRole(role: ContextMessage['role']): MessageRole { return role as MessageRole; } -/** Translate one kosong content part to a wire content part. */ function mapContentPart(part: ContextMessage['content'][number]): MessageContent { switch (part.type) { case 'text': @@ -53,16 +50,6 @@ function mapContentPart(part: ContextMessage['content'][number]): MessageContent } } -/** - * Build the protocol-shaped `Message.content[]` for one history entry: - * 1. `tool` role → a single `tool_result` part. A result carrying media - * parts (e.g. ReadMediaFile) passes the raw kosong content-part array - * through — the same shape the live `tool.result` event stream carries — - * so REST consumers can still render the media; other results flatten - * to joined text. `is_error` mirrors `ContextMessage.isError`. - * 2. other roles → each mapped content part, then one `tool_use` part per - * `ToolCall` (assistant only). - */ function buildProtocolContent(msg: ContextMessage): MessageContent[] { if (msg.role === 'tool') { if (msg.toolCallId === undefined) { @@ -115,12 +102,6 @@ function buildProtocolContent(msg: ContextMessage): MessageContent[] { return base; } -/** - * Convert one history entry into the protocol's `Message` shape. `created_at` - * defaults to the session's `createdAt` plus the entry index; callers that - * know the real record time pass `createdAtMsOverride` (v1: the wire record - * time, nudged to stay strictly increasing). - */ export function toProtocolMessage( sessionId: string, index: number, diff --git a/packages/agent-core-v2/src/agent/contextMemory/types.ts b/packages/agent-core-v2/src/agent/contextMemory/types.ts index b976e43eb5..10a1792b27 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/types.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/types.ts @@ -78,9 +78,7 @@ export type PromptOrigin = | RetryOrigin; export type ContextMessage = Message & { - /** Stable local message id (`msg_`), assigned when the message enters context. */ readonly id?: string; - /** Provider-assigned response/message id (e.g. Anthropic `msg_…`, `chatcmpl-…`, `resp_…`). */ readonly providerMessageId?: string; readonly origin?: PromptOrigin | undefined; readonly isError?: boolean; diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts index dd1b8f878e..afc9f6b1f2 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts @@ -33,10 +33,6 @@ import { IAgentContextProjectorService } from './contextProjector'; export class AgentContextProjectorService implements IAgentContextProjectorService { declare readonly _serviceBrand: undefined; - // Signature of the last notable repair set that was logged. Lets a defect that - // recurs identically every send (e.g. a persistently lost result re-synthesized - // each turn) log once, not per step; reset to null on a clean projection so a - // later recurrence after a healthy stretch is surfaced again. private lastRepairSignature: string | null = null; constructor( @@ -73,11 +69,6 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi return result; } - // Surface the projector's wire-repairs so a silently-mangled history leaves a - // trace. Deduped by signature so a defect that recurs identically every send - // (e.g. a persistently lost result re-synthesized each turn) surfaces once, - // not per step. Trailing-tail synthesis is excluded — it is the expected - // close of an in-flight call, not a defect. private reportProjectionRepairs(anomalies: readonly ProjectionAnomaly[]): void { const notable = anomalies.filter( (anomaly) => !(anomaly.kind === 'tool_result_synthesized' && anomaly.trailing), @@ -140,39 +131,18 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi } } -/** - * A repair the projector applied to make the history wire-valid. Each one means - * the stored history was not directly sendable to a strict provider. - */ type ProjectionAnomaly = - /** A recorded result was not adjacent to its call and had to be moved up. */ | { readonly kind: 'tool_result_reordered'; readonly toolCallId: string } - /** - * No result existed for a call, so a placeholder was synthesized. `trailing` - * is true when it closed a still-open tail call (expected, not a defect), - * false when it closed a mid-history orphan whose result was lost. - */ | { readonly kind: 'tool_result_synthesized'; readonly toolCallId: string; readonly trailing: boolean } - /** A result with no matching call anywhere was dropped. */ | { readonly kind: 'orphan_tool_result_dropped'; readonly toolCallId: string } - /** A tool call whose id already appeared earlier was dropped (strict only). */ | { readonly kind: 'duplicate_tool_call_dropped'; readonly toolCallId: string } - /** A second result for an already-answered id was dropped (strict only). */ | { readonly kind: 'duplicate_tool_result_dropped'; readonly toolCallId: string } - /** A leading non-user message was dropped so the first turn is user (strict). */ | { readonly kind: 'leading_non_user_dropped'; readonly role: string } - /** Two adjacent assistant turns were merged into one (strict). */ | { readonly kind: 'consecutive_assistants_merged' } - /** A non-empty but all-whitespace text block was dropped. */ | { readonly kind: 'whitespace_text_dropped'; readonly role: string }; type OnAnomaly = (anomaly: ProjectionAnomaly) => void; -/** - * How many of the most recent media parts survive the media-degraded - * projection. The tail images are what the model is actively working from - * (the screenshot it just took); everything older is replaced by a marker. - */ export const MEDIA_DEGRADE_KEEP_RECENT = 2; const MEDIA_DEGRADED_PLACEHOLDERS = { @@ -184,12 +154,6 @@ const MEDIA_DEGRADED_PLACEHOLDERS = { '[video omitted: dropped to fit the provider request size limit; re-read the file to view it]', } as const; -/** - * Markers for the media-stripped resend after the provider rejected an - * image's FORMAT (not its size): the image marker points the model at - * re-reading the file, whose refusal carries per-OS conversion instructions; - * audio/video are collateral of the full strip and say so. - */ export const MEDIA_STRIPPED_PLACEHOLDERS = { image_url: '[image omitted: the provider rejected this image; re-read the file for conversion instructions]', @@ -207,19 +171,6 @@ function isDegradableMediaPart( return part.type in MEDIA_DEGRADED_PLACEHOLDERS; } -/** - * Replace all but the `keepRecent` most recent media parts with deterministic - * text markers. This is the media-degraded projection used to resend a request - * the provider rejected as too large (HTTP 413 on accumulated base64 media) - * and — with `keepRecent = 0` and `MEDIA_STRIPPED_PLACEHOLDERS` — the resend - * after an image-format rejection, where the poisoned image could be anywhere - * and only a full strip guarantees a clean request. A purely read-side - * transform — the underlying history is left untouched — that trades pixels - * for deliverability while the surrounding text (including ReadMediaFile's - * `` wrapper) survives, so the model can re-read any file - * it still needs. Untouched messages are returned by reference, and when - * nothing needs degrading the input array itself is returned. - */ export function degradeOlderMediaParts( messages: readonly Message[], keepRecent: number, @@ -319,42 +270,11 @@ function dropLeadingNonUserMessages(messages: readonly Message[], onAnomaly?: On return start === 0 ? [...messages] : messages.slice(start); } -// Projects the stored context history into the wire messages sent to the -// model, in a single pass over the history. -// -// Strict providers require every tool call to be answered right after the -// assistant message, so each call is closed on the spot with a synthetic -// interrupted result and its slot in the output stays open until the recorded -// result overwrites it in place. A call stays open until its first result; a -// call id reused by a later assistant re-targets the slots that follow. -// Partial messages (stream interrupted) are invisible here, so their calls -// never anchor an exchange. Tool messages are skipped where they originally -// sat — a result either lands in its call's slot or it is an orphan, -// wire-invalid and useless to the model. A history with no assistant at all -// is a bare sizing slice and passes through as-is. Emitting cleans each message (drops empty / -// whitespace-only text blocks, rejected by strict providers), merges runs of -// adjacent user prompts (accumulated and materialized once per run), and -// strips context-only metadata off the wire. -// -// Every repair that changes what the model sees (a displaced result pulled up, -// a lost result synthesized, an orphan dropped, blank text dropped) is reported -// through `onAnomaly`; the projection stays a pure transform and the caller -// decides whether to surface the trace. -// -// The projected messages share their content parts and tool calls with the -// stored context (only the top-level wrapper is rebuilt); consumers must -// treat the projection as read-only, which every provider conversion already -// honors by building fresh structures. function project(history: readonly ContextMessage[], onAnomaly?: OnAnomaly): Message[] { const hasAssistant = history.some( (message) => message.partial !== true && message.role === 'assistant', ); - // Last history index that is a real, non-tool turn. A call still open at the - // end whose owning assistant sits at/after it closed a trailing, possibly - // in-flight call (expected); one whose owner precedes it lost its result - // mid-history (a defect). Mirrors the trailing/mid-history split used to keep - // the trace free of routine in-flight closes. let lastNonToolIndex = history.length - 1; while ( lastNonToolIndex >= 0 && @@ -385,9 +305,6 @@ function project(history: readonly ContextMessage[], onAnomaly?: OnAnomaly): Mes merge = undefined; }; - // A real (non-tool) message — or a result for an unknown call — landing while - // calls are still open means those calls' results were not adjacent in the - // stored history; pulling them up is a real repair worth tracing. const markForeignBetween = (): void => { for (const slot of openSlots.values()) slot.foreignBetween = true; }; @@ -476,8 +393,6 @@ interface MergeGroup { parts: ContentPart[]; } -// Join only the non-empty texts so merging an image-only message never -// produces a whitespace-only text block (rejected by strict providers). function appendMergeContent(group: MergeGroup, content: readonly ContentPart[]): void { let text = ''; for (const part of content) { @@ -510,9 +425,6 @@ function cleanContent( const filtered: ContentPart[] = []; for (const part of rawContent) { if (isBlankText(part)) { - // Report only whitespace-only (non-empty) blocks: a truly empty `''` - // block is routine cleanup, whereas a block that is non-empty yet - // all-whitespace signals upstream fed blank content worth surfacing. if (part.type === 'text' && part.text.length > 0) { onAnomaly?.({ kind: 'whitespace_text_dropped', role: source.role }); } @@ -540,9 +452,6 @@ function outputFromToolContent(content: readonly ContentPart[]): string | readon const TOOL_INTERRUPTED_TEXT = 'Tool result is not available in the current context. Do not assume the tool completed successfully.'; -// Shared inert filler for a call's slot while it awaits its recorded result; -// every slot still open at the end is overwritten with a synthetic result, so -// this object never reaches the returned projection. const TOOL_RESULT_SLOT: Message = createInterruptedToolResult(''); function createInterruptedToolResult(toolCallId: string): Message { diff --git a/packages/agent-core-v2/src/agent/contextSize/contextSizeService.ts b/packages/agent-core-v2/src/agent/contextSize/contextSizeService.ts index ad87806d13..0bda5c1e42 100644 --- a/packages/agent-core-v2/src/agent/contextSize/contextSizeService.ts +++ b/packages/agent-core-v2/src/agent/contextSize/contextSizeService.ts @@ -47,14 +47,10 @@ export class AgentContextSizeService extends Disposable implements IAgentContext get(start?: number, end?: number): ContextSize { const context = this.context.get(); const model = this.wire.getModel(ContextSizeModel); - // Mirrors `Array.prototype.slice`: defaults to the whole context, negative - // indices count back from the end, and an inverted range is empty. const from = normalizeSliceIndex(start ?? 0, context.length); const to = normalizeSliceIndex(end ?? context.length, context.length); const measuredEnd = Math.min(to, model.length); const estimatedStart = Math.max(from, model.length); - // The measured-prefix total is the only deterministic measured value; use it - // when the range covers the whole prefix, otherwise estimate the sub-range. const measured = from === 0 && measuredEnd === model.length ? model.tokens @@ -64,9 +60,6 @@ export class AgentContextSizeService extends Disposable implements IAgentContext } measured(input: readonly Message[], output: readonly Message[], usage: TokenUsage): void { - // Only adopt the measurement when `input` still matches the live context. - // This rejects stale readings (e.g. the context was spliced, or the request - // used overridden messages) so a mismatched measurement cannot poison state. if (!matchesContext(input, this.context.get())) return; const length = input.length + output.length; const tokens = tokenUsageTotal(usage); diff --git a/packages/agent-core-v2/src/agent/externalHooks/configSection.ts b/packages/agent-core-v2/src/agent/externalHooks/configSection.ts index 2765542fbf..594bf9daa6 100644 --- a/packages/agent-core-v2/src/agent/externalHooks/configSection.ts +++ b/packages/agent-core-v2/src/agent/externalHooks/configSection.ts @@ -30,13 +30,11 @@ export type HookDefConfig = z.infer; export const HooksConfigSchema = z.array(HookDefSchema); -/** Read transform: camelCase each hook entry's keys. */ export const hooksFromToml = (rawSnake: unknown): unknown => { if (!Array.isArray(rawSnake)) return rawSnake; return rawSnake.map((hook) => (isPlainObject(hook) ? transformPlainObject(hook) : hook)); }; -/** Write transform: snake_case each hook entry's keys. */ export const hooksToToml = (value: unknown, _rawSnake: unknown): unknown => { if (!Array.isArray(value)) return value; return value.map((hook) => (isPlainObject(hook) ? plainObjectToToml(hook, undefined) : hook)); diff --git a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts index ad39ed2c99..02f843161b 100644 --- a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts +++ b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts @@ -81,10 +81,6 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter matcherValue?: string, signal?: AbortSignal, ): void { - // Genuinely fire-and-forget: never throw on an already-aborted signal. A - // cancelled tool still finalizes its result (e.g. the "manually interrupted" - // output), and throwing here would clobber that with a finalize-abort error. - // The runner mirrors the legacy fire-and-forget behavior. try { void this.runner.fireAndForgetTrigger(event, { matcherValue, @@ -182,9 +178,6 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter if ( ctx.finishReason === 'tool_calls' || ctx.finishReason === 'filtered' || - // The turn already continues on its own (a queued steer or - // orchestrator continuation), so a Stop-hook continuation would - // pile a redundant step onto it. loop.hasPendingRequests() ) { return; @@ -192,9 +185,6 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter const reason = await this.runStop(ctx); if (reason !== undefined) { this.stopHookContinuationUsed = true; - // The message lands immediately so it stays in history even when the - // turn dies before the next step (e.g. max-steps); the queued - // message-less request only drives the continuation step. this.context.append({ role: 'user', content: [{ type: 'text', text: reason }], diff --git a/packages/agent-core-v2/src/agent/externalHooks/runner.ts b/packages/agent-core-v2/src/agent/externalHooks/runner.ts index 3ad3bb4bfb..7f50380e1f 100644 --- a/packages/agent-core-v2/src/agent/externalHooks/runner.ts +++ b/packages/agent-core-v2/src/agent/externalHooks/runner.ts @@ -22,11 +22,6 @@ export function buildHookSpawnOptions(options: { cwd: options.cwd, stdio: 'pipe', detached: process.platform !== 'win32', - // Hide the console Windows would otherwise allocate for the shell child. - // Without `windowsHide:true`, each hook flashes a visible console window — - // the same regression the node-local process host already guards against - // (see `buildSpawnOptions` in os/backends/node-local/hostProcessService.ts) - // and the runner's own taskkill spawn. Unconditional: it is a no-op on POSIX. windowsHide: true, env: options.env === undefined ? undefined : { ...process.env, ...options.env }, }; @@ -104,11 +99,6 @@ export async function runHook( stderr += chunk; }); - // Settle on the exit code AND drained stdio, not on `wait()` alone: - // `wait()` resolves at the child's 'exit', which can precede the - // stdout/stderr 'end', so a fast-exiting hook would otherwise lose its - // trailing output. `proc.dispose()` runs here for every path (clean exit, - // timeout, abort) once the process has exited and the streams have closed. const stdoutDone = new Promise((done) => proc.stdout.once('end', done)); const stderrDone = new Promise((done) => proc.stderr.once('end', done)); void Promise.all([proc.wait(), stdoutDone, stderrDone]).then( diff --git a/packages/agent-core-v2/src/agent/faultInjection/faultInjection.ts b/packages/agent-core-v2/src/agent/faultInjection/faultInjection.ts index 1c64db2efa..286e1a439b 100644 --- a/packages/agent-core-v2/src/agent/faultInjection/faultInjection.ts +++ b/packages/agent-core-v2/src/agent/faultInjection/faultInjection.ts @@ -18,37 +18,22 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -/** The deterministic failures the requester can be armed to raise. */ export type FaultKind = 'request-too-large' | 'image-format'; export interface FaultInjectionStatus { - /** The armed one-shot fault, if any (consumed by the next request attempt). */ readonly armed: FaultKind | undefined; - /** Faults that actually fired, in fire order. */ readonly fired: readonly FaultKind[]; } export interface IFaultInjectionService { readonly _serviceBrand: undefined; - /** - * Arm a one-shot fault: the next LLM request attempt raises it before - * hitting the provider. Refused unless the `fault-injection` experimental - * flag is enabled. - */ arm(kind: FaultKind): void; - /** Current arming and fire history. */ status(): FaultInjectionStatus; - /** Clear the armed fault and the fire history. */ clear(): void; - /** - * Consume the armed one-shot fault — the requester's consumption point, - * called once per request attempt. Returns undefined when nothing is - * armed; a consumed fault is recorded in {@link FaultInjectionStatus.fired}. - */ take(): FaultKind | undefined; } diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts index 59deedd754..5747de3678 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts @@ -28,7 +28,6 @@ export interface IAgentFullCompactionService { onWillCompact: FullCompactionTask; }>; - /** Fires once a compaction finishes (after the summary lands on the wire). */ readonly onDidFinishCompaction: Event; } diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 5614eaea59..aa7073aa46 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -82,7 +82,6 @@ type CompactionTelemetryProperties = Pick< interface ActiveCompaction extends FullCompactionTask { blockedByTurn: boolean; - /** Background-activity registration with the activity kernel (I2 visibility). */ bgRegistration?: IDisposable; } @@ -110,15 +109,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull private compactionCountInTurn = 0; private _compacting: ActiveCompaction | null = null; private readonly observedMaxContextTokensByModel = new Map(); - // Token count right after the last successful compaction. While nothing new - // has been appended, the history is already in its minimal compacted form; - // re-compacting would only summarize the summary again, so - // checkAutoCompaction skips in that case. private lastCompactedTokenCount: number | null = null; - // Counts provider-overflow recoveries in this turn that have not yet been - // followed by a successful step. Trips maxOverflowCompactionAttempts to - // stop an overflow -> compact -> overflow loop when compaction can no - // longer shrink the request below the model window. private consecutiveOverflowCompactions = 0; private contextInjectorService: IAgentContextInjectorService | undefined; @@ -218,8 +209,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull estimatedRequestTokens = this.estimateCurrentRequestTokens(), ): boolean { if (isCodedError(error) && error.code === ErrorCodes.CONTEXT_OVERFLOW) return true; - // The raw provider error rides as `cause` of the translated coded error; - // the 413 heuristic below still needs its status code. const statusError = findAPIStatusError(error); if (statusError instanceof APIContextOverflowError) return true; if (statusError === undefined || statusError.statusCode !== 413) return false; @@ -336,10 +325,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull } private normalizeAfterReplay(): void { - // A compaction in flight when the session was torn down cannot resume — the - // worker and its AbortController are gone — so a `running` phase replayed - // from the log is stranded. Collapse it back to idle silently: no live - // `compaction.cancelled` signal, since restore must stay quiet. if (this.wire.getModel(CompactionModel).phase !== 'running') return; this.wire.dispatch(fullCompactionCancel({})); } @@ -374,10 +359,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull } private retryFailedDriver(context: LoopErrorContext): boolean { - // The failed driver is already materialized, so re-running it does not - // append its messages a second time. The loop only learns that the error - // was caught; the re-run rides the normal step numbering and keeps - // consuming the per-turn maxSteps budget — compacting must not reset it. const driver = context.failedDriver; if (driver === undefined || context.currentStep?.signal.aborted === true) return false; context.retry(driver, { at: 'head' }); @@ -392,9 +373,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull } private async afterStep(): Promise { - // A completed step means a request succeeded, so any prior - // overflow -> compact cycle produced a request that now fits; clear the - // loop guard. this.consecutiveOverflowCompactions = 0; if (this.strategy.checkAfterStep) { this.checkAutoCompaction(false); @@ -474,18 +452,8 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull } catch (error) { this.log.error('failed to refresh system prompt after compaction', { error }); } - // Fallback floor when reinjection throws; raised below once the per-turn - // reminders are back. this.lastCompactedTokenCount = result.tokensAfter; - // Re-arm the per-turn injectors while the compaction still holds the - // context (before markCompleted), so the first post-compaction request — - // including a replayed deferred prompt's — already carries the goal - // reminder the compaction folded away. await this.contextInjector.injectAfterCompaction(); - // The reinjected reminders are part of the post-compaction floor: a - // baseline captured before this point would leave them outside the - // "nothing new since compaction" guard and checkAutoCompaction could - // re-trigger against a shape that cannot shrink. this.lastCompactedTokenCount = this.tokenCountWithPending(); if (!this.markCompleted(active)) { throw compactionCancelledReason(active); @@ -512,10 +480,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull }); throw error; } finally { - // Fires on completion, cancellation, AND failure so input deferred while - // the compaction held the context is never lost. `_compacting` is already - // null on every path, so a replayed launch starts a turn instead of - // re-buffering. this._onDidFinishCompaction.fire(active); } } @@ -555,8 +519,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull let emptyOrTruncatedShrinkCount = 0; while (true) { const messagesToCompact = historyForModel; - // Raw context slice — `llmRequester` projects every request once; - // projecting here too would double-project onto shifted indices. const messages: Message[] = [...messagesToCompact, createUserMessage(instruction)]; const estimatedCompactionRequestTokens = this.estimateRequestTokens(messages); @@ -645,7 +607,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull }); const properties: CompactionFinishedEvent = { - // Never send `data.instruction` (user-authored content) to telemetry. source: data.source, tokens_before: result.tokensBefore, tokens_after: result.tokensAfter, @@ -697,12 +658,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull return this.contextSize.get().size; } - /** - * Resolved lazily (not constructor-injected): materializing the injector - * from this constructor would reorder loop-hook registration across the - * dependency cascade (see AgentPromptService.fullCompaction for the same - * hazard). - */ private get contextInjector(): IAgentContextInjectorService { if (this.contextInjectorService === undefined) { this.contextInjectorService = this.instantiation.invokeFunction((accessor) => @@ -813,11 +768,6 @@ function compactionCancelledReason(active: ActiveCompaction | null): Error { return error; } -// Construct eagerly (not delayed): the service registers turn and loop hooks -// (onLaunched / onWillBeginStep / onDidFinishStep) plus a loop error handler that drive -// auto compaction. With delayed instantiation the eager `accessor.get(IAgentFullCompactionService)` -// only realizes a proxy, so the hooks would not register until the first RPC — -// after turns have already run without the auto-compaction gate. registerScopedService( LifecycleScope.Agent, IAgentFullCompactionService, diff --git a/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts b/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts index 4a8911ea0e..0c55d60500 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts @@ -17,7 +17,7 @@ export interface CompactionConfig { export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = { triggerRatio: 0.85, - blockRatio: 0.85, // Same as triggerRatio to disable async compaction + blockRatio: 0.85, reservedContextSize: 50_000, maxCompactionPerTurn: Infinity, maxOverflowCompactionAttempts: 3, @@ -129,11 +129,7 @@ export class DefaultCompactionStrategy implements CompactionStrategy { } computeCompactCount(messages: readonly Message[], source: CompactionSource): number { - // Return value: N messages to be compacted (0 means no compaction possible) - // LLM Input: messages.slice(0, N) + [user:instruction] - // Preserved recent messages: messages.slice(N) - // Manual compaction if (source === 'manual') { for (let i = messages.length - 1; i > 0; i--) { if (canSplitAfter(messages, i)) { @@ -143,15 +139,6 @@ export class DefaultCompactionStrategy implements CompactionStrategy { return 0; } - // Auto compaction rules (in order of precedence): - // 1. The split after messages[N-1] must be safe per `canSplitAfter`: - // messages[N-1] is not a user or asst-with-tool-calls, and the retained - // suffix messages.slice(N) has no orphan tool result. - // 2. At least one recent message must be preserved - // 3. At most maxRecentMessages recent messages should be preserved - // 4. At most maxRecentUserMessages recent user messages should be preserved - // 5. At most maxRecentSizeRatio * maxSize recent messages should be preserved - // 6. N should be as small as possible let recentMessages = 1; let recentUserMessages = 0; @@ -246,21 +233,6 @@ export class DefaultCompactionStrategy implements CompactionStrategy { } } -/** - * Decide whether a compaction split is safe to place immediately after - * `messages[index]`. A split is safe only when: - * - `messages[index]` itself is not a user message or an assistant message - * with pending tool calls (cutting either of those off from what follows - * would break the conversation), AND - * - the next message is not a tool result. The history is well-formed: - * tool results only appear after their owning `asst_w_tc` and all tool - * results for one exchange land consecutively before the next non-tool - * message. So if the suffix starts with a tool result, its `asst_w_tc` - * must be in the compacted prefix, which would orphan that result - * (e.g. splitting between tool_a and tool_b of a parallel call), AND - * - the compacted prefix itself does not end with an unresolved tool - * exchange, because pending tool results must remain in the retained tail. - */ function canSplitAfter(messages: readonly Message[], index: number): boolean { const m = messages[index]; if (m === undefined) return false; diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index 64004ae31b..0d2e54db88 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -72,8 +72,6 @@ import type { const MAX_GOAL_OBJECTIVE_LENGTH = 4000; -// The criterion is repeated in every goal reminder, so it is truncated instead -// of rejected: an over-long criterion never fails goal creation outright. const MAX_GOAL_COMPLETION_CRITERION_LENGTH = MAX_GOAL_OBJECTIVE_LENGTH; const GOAL_CANCELLED_REMINDER = [ @@ -153,11 +151,6 @@ interface GoalForkNoticeState { readonly reminderPending: boolean; } -// Derived (never persisted) fork bookkeeping, folded over the same records on -// dispatch and replay: a `forked` boundary that clears a copied goal marks the -// fork-cleared reminder as pending. The live reminder append is its own -// acknowledgment — replaying the appended reminder record flips the flag back -// off, so later resumes never duplicate it. const GoalForkNoticeModel = defineDerivedModel( 'goalForkNotice', () => ({ goalPresent: false, reminderPending: false }), @@ -215,9 +208,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { dynamicInjector, ), ); - // The wire forkGoal op clears the goal at a fork boundary; the derived - // notice model tracks whether that clear dropped a copied goal so the - // post-replay pass can tell the model about it exactly once. this._register(this.wire.attach(GoalForkNoticeModel)); this._register(this.wire.onRestored(() => this.normalizeAfterReplay())); this._register( @@ -240,9 +230,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { ); this._register( toolExecutor.hooks.onBeforeExecuteTool.register('goal-budget-reject', async (ctx, next) => { - // During a turn's budget-grace step the model was told to write a - // final message without tools: answer every tool call with a soft - // synthetic result instead of executing it. if (this.budgetGraceTurns.has(ctx.turnId)) { ctx.decision = { syntheticResult: { output: GOAL_BUDGET_TOOLS_REJECTED_MESSAGE }, @@ -466,13 +453,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { private handleTurnLaunched(turnId: number): void { this.liveTurnId = turnId; const state = this.goalState; - // A goal already past its budget must not drive a new turn: block it at - // the launch boundary (blockIfBudgetReached dispatches synchronously, so - // nothing async escapes this event subscriber) and leave the turn off - // goalDrivenTurns. The prompt then runs as a normal non-goal turn — no - // turn counting, no goal_continued telemetry, no continuation — while the - // blocked-goal note still reaches the model, because injection reads the - // goal status in the first onWillBeginStep, after this subscriber ran. if (state?.status === 'active' && this.blockIfBudgetReached(state) === null) { this.goalDrivenTurns.add(turnId); } @@ -480,10 +460,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { this.goalOutcomeContinuationTurns.delete(turnId); } - // The ordinary turn that created or resumed the goal counts as the first - // active goal turn. Its later steps are token-charged like any goal turn, - // but the turn itself is counted at turn end (see handleTurnEnded), not at - // the next step boundary — countedGoalTurns suppresses per-step counting. private adoptStarterTurn(): void { const turnId = this.liveTurnId; if (turnId === undefined || this.goalDrivenTurns.has(turnId)) return; @@ -595,10 +571,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { return false; } - // A rejected turn-ended handler (e.g. a continuation launch losing a race - // to a queued prompt) must never strand an active goal with nothing driving - // it: settle deterministically by pausing. The settle itself is best-effort; - // the turn.ended subscriber must not throw into the event bus. private async settleGoalAfterContinuationFailure(error: unknown): Promise { try { const reason = pauseReasonWithMessage( @@ -607,8 +579,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { ); await this.pauseActiveGoal({ reason }, 'system'); } catch { - // Swallowed on purpose: pausing failed too, and rethrowing would only - // crash the event bus subscriber. } } diff --git a/packages/agent-core-v2/src/agent/goal/tools/create-goal.ts b/packages/agent-core-v2/src/agent/goal/tools/create-goal.ts index 2f06f410ed..1581b7a712 100644 --- a/packages/agent-core-v2/src/agent/goal/tools/create-goal.ts +++ b/packages/agent-core-v2/src/agent/goal/tools/create-goal.ts @@ -64,12 +64,6 @@ export class CreateGoalTool implements BuiltinTool { }; } - /** - * Starting a goal switches the agent into autonomous, multi-turn work, so its - * approval reuses the same choice the `/goal` command offers: pick the - * permission mode to run under, or decline. `auto` mode auto-approves the goal - * upstream and never reaches this prompt, so the menu only covers manual/yolo. - */ private resolveGoalStartDisplay(args: CreateGoalToolInput): ToolInputDisplay | undefined { const mode = this.permissionMode.mode; if (mode === 'auto') return undefined; diff --git a/packages/agent-core-v2/src/agent/goal/tools/serialize.ts b/packages/agent-core-v2/src/agent/goal/tools/serialize.ts index 9d11649556..da40ab9c91 100644 --- a/packages/agent-core-v2/src/agent/goal/tools/serialize.ts +++ b/packages/agent-core-v2/src/agent/goal/tools/serialize.ts @@ -1,10 +1,5 @@ import type { GoalSnapshot, GoalToolResult } from '#/agent/goal/types'; -/** - * The goalId is a random UUID with no user-facing meaning, and no goal tool - * takes one (there is only ever one goal at a time). Keep it out of what the - * model sees so it never echoes the id back to the user as if it mattered. - */ export function goalForModel(goal: GoalSnapshot): Omit { const { goalId: _goalId, ...rest } = goal; return rest; diff --git a/packages/agent-core-v2/src/agent/goal/tools/set-goal-budget.ts b/packages/agent-core-v2/src/agent/goal/tools/set-goal-budget.ts index 875f1e12d6..76d61af804 100644 --- a/packages/agent-core-v2/src/agent/goal/tools/set-goal-budget.ts +++ b/packages/agent-core-v2/src/agent/goal/tools/set-goal-budget.ts @@ -23,8 +23,6 @@ const BUDGET_UNITS = ['turns', 'tokens', 'milliseconds', 'seconds', 'minutes', ' export const SetGoalBudgetToolInputSchema = z .object({ - // Keep the provider-facing schema simple. Fractional turn/token budgets - // are normalized during execution instead of rejected at schema validation. value: z.number().positive().describe('The positive numeric budget value.'), unit: z.enum(BUDGET_UNITS), }) diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts index 2cdadeeac8..0ecfcf1517 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts @@ -62,7 +62,6 @@ export const llmRequest = LlmRequestTraceModel.defineOp('llm.request', { topP: z.number().optional(), maxTokens: z.number().optional(), betaApi: z.boolean().optional(), - /** Progressive tool disclosure in effect (env flag × model capability). */ toolSelect: z.boolean(), systemPromptHash: z.string(), systemPrompt: z.string().optional(), @@ -70,9 +69,6 @@ export const llmRequest = LlmRequestTraceModel.defineOp('llm.request', { messageCount: z.number(), turnStep: z.string().optional(), attempt: z.string().optional(), - /** Set when this request is a recovery resend (strict rebuild after a - * structural rejection, media-degraded rebuild after an HTTP 413 body-size - * rejection, media-stripped rebuild after an image-format rejection). */ projection: z.enum(['strict', 'media-degraded', 'media-stripped']).optional(), droppedCount: z.number().optional(), }), diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts index 75e9fe94bb..259ecf019b 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts @@ -23,24 +23,8 @@ export type LLMRequestSource = export interface LLMStreamTiming { readonly firstTokenLatencyMs: number; readonly streamDurationMs: number; - /** - * Portion of `firstTokenLatencyMs` spent in-process building the request - * (message serialization, param assembly) before the provider dispatched the - * network call. `undefined` when the provider does not report the - * client/server boundary (no `onRequestSent`). - */ readonly requestBuildMs?: number; - /** - * Portion of `firstTokenLatencyMs` spent waiting on the network + API server - * from request dispatch to the first streamed token. `undefined` when the - * provider does not report the client/server boundary. - */ readonly serverFirstTokenMs?: number; - /** - * Split of `streamDurationMs` (the decode window): time spent awaiting parts - * from the provider vs. time spent processing parts in-process. Both are - * `undefined` when the provider stream did not report decode accounting. - */ readonly serverDecodeMs?: number; readonly clientConsumeMs?: number; } @@ -53,14 +37,11 @@ export interface LLMRequestParams { } export interface LLMRequestFinish { - /** Fully assembled assistant message for this provider step. */ message: Message; usage: TokenUsage; - /** Model name/alias used for usage accounting, when known by the requester. */ model?: string | undefined; providerFinishReason?: FinishReason; rawFinishReason?: string; - /** Provider-assigned response/message id, when available. */ providerMessageId?: string; timing?: LLMStreamTiming; } diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 2b5b0aac72..c71f218eac 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -99,14 +99,6 @@ interface ResolvedLLMRequest { readonly logFields: LLMRequestLogFields; } -/** - * Which projection a request attempt is built from: the normal wire - * projection, or one of the three one-shot recovery rebuilds — `strict` - * (guaranteed wire-compliant) after a structural rejection, `media-degraded` - * (all but the most recent media replaced by text markers) after an HTTP 413 - * body-size rejection, `media-stripped` (every media part replaced) after an - * image-format rejection. - */ type RequestProjection = 'normal' | 'strict' | 'media-degraded' | 'media-stripped'; interface LLMRequestLogInput { @@ -121,11 +113,6 @@ interface LLMRequestLogInput { readonly fields?: LLMRequestLogFields; } -/** - * The profile-derived request config one turn runs on: the resolved Model, - * its model context, and the system prompt, captured once on the turn's - * first step request and reused by every later step of the same turn. - */ interface TurnRequestConfig { readonly resolved: ProfileModelContext; readonly model: Model; @@ -137,16 +124,6 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { private lastConfigLogSignature: string | undefined; private readonly turnConfigs = new Map(); - /** - * Turns whose steps must build from a recovery projection: once a step only - * succeeded via the media-degraded (413) or media-stripped (image-format) - * resend, the cause is still in the full history, so later steps of the - * same turn build from the recovery projection directly instead of paying - * a fresh rejection on every step (v1 parity: run-turn's - * `mediaDegradedActive` / `mediaStrippedActive`; stripped wins over - * degraded). Turn ids are monotonic per agent, so a newer turn evicts - * every older entry. - */ private readonly mediaDegradedTurns = new Set(); private readonly mediaStrippedTurns = new Set(); @@ -202,9 +179,6 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { ): void { if (isAbortError(error) || signal?.aborted === true) return; const modelAlias = this.profile.data().modelAlias; - // v1 parity: `model` carries the resolved model id with `alias` alongside, - // and both protocol keys carry the resolved model's protocol (v2 has no - // separate provider type). Resolution must never throw. const model = this.tryGetProvider(); const properties: ApiErrorEvent = { error_type: apiErrorType(error), @@ -217,7 +191,6 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { }; const statusCode = apiStatusCode(error); if (statusCode !== undefined) properties['status_code'] = statusCode; - // v1 parity: the current turn's accumulated total input tokens. const currentTurn = this.usage.status().currentTurn; if (currentTurn !== undefined) properties['input_tokens'] = inputTotal(currentTurn); this.telemetry.track2('api_error', properties); @@ -272,11 +245,6 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { this.logRequest(logInput); this.recordRequest(logInput); - // Fault injection (experimental): an armed one-shot fault replaces this - // attempt with a deterministic provider failure, raised exactly where a - // real rejection would surface — so the recovery-resend chain below - // handles it identically. The resend attempt consumes nothing (the - // latch is one-shot) and reaches the real provider. const fault = this.faultInjection.take(); if (fault !== undefined) { throw faultToError(fault); @@ -326,11 +294,6 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { }; }; - // Once a step of this turn only succeeded via a recovery resend, later - // steps build from the recovery projection directly: the cause is still - // in the full history, so rebuilding it would pay a fresh rejection on - // every step (v1 parity: run-turn's mediaDegradedActive / - // mediaStrippedActive — stripped wins over degraded). const initialProjection: RequestProjection = this.isRecoveryTurn( this.mediaStrippedTurns, request.source, @@ -345,13 +308,6 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { if (signal?.aborted === true) throw error; const raw = unwrapErrorCause(error); if (initialProjection === 'normal' && raw instanceof APIRequestTooLargeError) { - // The provider rejected the request BODY as too large (HTTP 413) — - // accumulated base64 media, not tokens, so compaction's token-driven - // recovery never fires (media is estimated at a small flat cost). The - // same media is re-sent on every request, so without intervention the - // session stays stuck. Resend ONCE with the media-degraded projection - // (old media replaced by text markers, the most recent kept); a - // rejection of that rebuild propagates unchanged. signal?.throwIfAborted(); this.log.warn('provider rejected request as too large; resending with degraded media', { model: request.model.name, @@ -361,16 +317,6 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { return run('media-degraded'); } if (initialProjection !== 'media-stripped' && isImageFormatError(raw)) { - // The provider rejected an IMAGE in the request (unsupported format - // or undecodable data). Unlike a size rejection — too MUCH media — - // the error never says WHICH image is poison, and the same history - // is re-sent every request, so the session would stay stuck. Resend - // ONCE with every media part replaced by a text marker: the only - // projection guaranteed to carry no poison. Read-side only — the - // history keeps its media, and the `` wrappers - // survive so the model can re-read files (getting conversion - // guidance for refused formats). A rejection of that rebuild - // propagates unchanged. signal?.throwIfAborted(); this.log.warn( 'provider rejected an image in the request; resending with all media stripped', @@ -401,7 +347,6 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { private markRecoveryTurn(set: Set, source: LLMRequestSource | undefined): void { if (source?.type !== 'turn') return; - // Turn ids are monotonic per agent: a newer turn evicts every older entry. for (const id of set) { if (id < source.turnId) set.delete(id); } @@ -420,9 +365,6 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { this.config.get('modelOverrides')?.maxCompletionTokens, }), capability: resolved.modelCapabilities, - // The remaining-window clamp only applies to requests built from the - // live context; overridden messages (e.g. compaction) are sized - // independently and would be squeezed to nothing at high water marks. usedContextTokens: overrides.messages === undefined ? this.contextSize.get().measured @@ -442,15 +384,6 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { }; } - /** - * Per-turn request-config snapshot (v1 parity): model + system prompt - * captured on the turn's first step request and reused by every later step - * of that turn, so a mid-turn `config.update` only takes effect on the NEXT - * turn. Tools are deliberately NOT snapshotted — they are re-read per step - * so a `select_tools` load or `setActiveTools` lands on the very next step - * of the same turn. Turn ids are monotonic per agent, so a newer turn - * evicts every older entry; no `turn.ended` subscription is needed. - */ private resolveTurnConfig(source: LLMRequestSource | undefined): TurnRequestConfig | undefined { if (source?.type !== 'turn') return undefined; const turnId = source.turnId; @@ -630,9 +563,6 @@ function projectionField( : undefined; } -/** The deterministic provider failure an armed fault raises. Mirrors the - * real rejections the recovery projections key off: an HTTP 413 body-size - * rejection, or a 400 image-format rejection. */ function faultToError(kind: FaultKind): Error { return kind === 'request-too-large' ? new APIRequestTooLargeError(413, 'Request Entity Too Large (fault injection)') @@ -644,8 +574,6 @@ function fingerprint(content: string): string { } function apiErrorType(error: unknown): string { - // Errors crossing the model boundary are coded `Error2`s with the raw - // provider error as `cause`; classify on the raw shape when available. const raw = unwrapErrorCause(error); if (raw instanceof APIContextOverflowError) return 'context_overflow'; if (raw instanceof APIProviderOverloadedError) return 'overloaded'; @@ -672,7 +600,6 @@ function apiStatusCode(error: unknown): number | undefined { const status = (raw as Record)['status']; if (typeof status === 'number') return status; } - // Boundary-translated errors carry the HTTP status in `details`. if (typeof error === 'object' && error !== null) { const details = (error as Record)['details']; if (typeof details === 'object' && details !== null) { diff --git a/packages/agent-core-v2/src/agent/loop/configSection.ts b/packages/agent-core-v2/src/agent/loop/configSection.ts index 8a9a0cce00..026faea047 100644 --- a/packages/agent-core-v2/src/agent/loop/configSection.ts +++ b/packages/agent-core-v2/src/agent/loop/configSection.ts @@ -25,7 +25,6 @@ export const LoopControlSchema = z.object({ export type LoopControl = z.infer; -/** Read transform: camelCase keys and fold legacy `max_steps_per_run` into `maxStepsPerTurn`. */ export const loopControlFromToml = (rawSnake: unknown): unknown => { if (rawSnake === null || typeof rawSnake !== 'object' || Array.isArray(rawSnake)) return rawSnake; const out = transformPlainObject(rawSnake as Record); @@ -36,7 +35,6 @@ export const loopControlFromToml = (rawSnake: unknown): unknown => { return out; }; -/** Write transform: plain camelCase → snake_case key mapping. */ export const loopControlToToml = (value: unknown, rawSnake: unknown): unknown => { if (value === null || typeof value !== 'object' || Array.isArray(value)) return value; return plainObjectToToml(value as Record, rawSnake); diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts index 4bf8fa710b..b1895c6512 100644 --- a/packages/agent-core-v2/src/agent/loop/loop.ts +++ b/packages/agent-core-v2/src/agent/loop/loop.ts @@ -38,48 +38,23 @@ export interface BeforeStepContext { export interface AfterStepContext extends BeforeStepContext { readonly usage: TokenUsage; readonly finishReason: FinishReason; - /** - * Set to true to end the turn at this step boundary. Takes precedence in - * the run loop over both requested tool calls and any queued step - * requests, so a hard stop (e.g. a reached goal budget) cannot be - * overridden by another hook's continuation. - */ stopTurn: boolean; } export interface LoopErrorContext { readonly currentStep?: Step; readonly turnId: number; - /** The currently executing step, or undefined for turn-level failures. */ readonly step?: number; - /** The failed step's wire uuid, when the failure happened inside a step. */ readonly stepId?: string; readonly signal: AbortSignal; readonly error: unknown; - /** - * The driver whose step failed; already popped from the queue. A handler - * that recovers by re-running the step enqueues it back (at the head of - * the queue) itself before reporting the error as caught. - */ readonly failedDriver?: StepRequest; - /** Reinsert recovery work into the failed driver's original Turn. */ retry(request: StepRequest, options?: StepEnqueueOptions): Step; } export interface LoopErrorHandler { readonly id: string; - /** Claim the error: the first matching handler in registration order handles it. */ match(context: LoopErrorContext): boolean; - /** - * Recover from a claimed error. Awaiting inside the handler (backoff sleeps, - * compaction) suspends the loop in its catch path — aborting `context.signal` - * still cancels the turn. Resolve `true` when the error is caught: the - * handler has already arranged how the turn continues (typically by - * enqueueing the requests it wants run next) and the loop simply drains on, - * learning nothing but caught-or-not. Resolve `false`/`undefined` to fail - * the turn with the original error; throwing fails it with the handler's - * error. - */ handle(context: LoopErrorContext): Promise; } @@ -91,7 +66,6 @@ export interface LoopErrorHandlerRegistrationOptions { export interface LoopRunOptions { readonly turnId: number; readonly signal?: AbortSignal; - /** Fires on the first model response event for a step, or at step completion. */ readonly onStarted?: (step: number) => void; } @@ -133,16 +107,7 @@ export interface Step { export interface Turn { readonly id: number; readonly state?: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; - /** - * Cancellation signal owned by the `activity` kernel's turn lease. Abort it - * through `IAgentLoopService.cancel(...)` rather than holding a controller; - * the kernel is the single authority for turn cancellation. - */ readonly signal: AbortSignal; - /** - * Resolves on the first model response event for the first loop step, or at - * step completion; rejects if the turn ends earlier. - */ readonly ready: Promise; readonly result: Promise; cancel(reason?: unknown): boolean; @@ -166,40 +131,22 @@ export interface AgentLoopStatus { } export interface StepEnqueueOptions { - /** `tail` (default) preserves order for normal work; `head` jumps the queue (used to retry a failed step). */ readonly at?: 'head' | 'tail'; } export interface IAgentLoopService { readonly _serviceBrand: undefined; - /** Atomically admits a request according to its admission semantics. */ enqueue(request: StepRequest, options?: StepEnqueueOptions): EnqueueReceipt; - /** Low-level loop runner used by focused loop tests and recovery integrations. */ run(options: LoopRunOptions): Promise; - /** Read-only scheduling state. */ status(): AgentLoopStatus; - /** - * Cancel the active turn (optionally only when its id matches `turnId`), - * recording `turn.cancel` on the wire. The `activity` kernel owns the actual - * abort; returns false when no (matching) turn is active. - */ cancel(turnId?: number, reason?: unknown): boolean; - /** True while any non-aborted step request is queued. */ hasPendingRequests(): boolean; - /** - * Register a recovery handler for step failures. Handlers dispatch in - * registration order, first match wins — the loop itself knows nothing - * about concrete error types: retry policies (`stepRetry`) and overflow - * recovery (`fullCompaction`) plug in here. A handler that catches an - * error arranges the turn's continuation itself; the loop only learns - * whether the error was caught. - */ registerLoopErrorHandler( handler: LoopErrorHandler, options?: LoopErrorHandlerRegistrationOptions, diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index d6aca1dc40..d1c1fbab2c 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -104,8 +104,6 @@ declare module '#/app/event/eventBus' { 'assistant.delta': AssistantDeltaEvent; 'thinking.delta': ThinkingDeltaEvent; 'tool.call.delta': ToolCallDeltaEvent; - // `error` is declared by the `mcp` domain (interface-merge); reused here, - // not re-declared. } } @@ -408,8 +406,6 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { turnTelemetry.track2('turn_interrupted', interrupted); } } - // v1 parity: `turn_ended` fires unconditionally at every turn end, even - // when the turn never produced a result (it died before the first step). const ended: TurnEndedTelemetryEvent = { reason: result?.type ?? 'failed', duration_ms: Date.now() - startedAt, @@ -482,11 +478,6 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { return true; } - /** - * Drain the step queue for one turn: each queued `StepRequest` drives (or - * merges into) one step, and the turn completes once the queue empties. - * Only `runTurn` calls this — turns start exclusively through `enqueue`. - */ async run(options: LoopRunOptions): Promise { const runtime = this.createLoopRuntime(options); try { @@ -659,11 +650,6 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { return { type: 'return', result: { type: 'failed', error, steps: runtime.steps } }; } - /** - * Append the batch's context messages (driver first, then merged requests) - * before `onWillBeginStep` hooks run, so compaction / injection hooks observe the - * full step input. A materialized driver (a retried step) is skipped. - */ private materializeBatch(batch: StepRequestBatch): void { this.materializeRequest(batch.driver); for (const request of batch.merged) { @@ -908,14 +894,6 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { turnId: number, onResponseEvent: () => void, ): (part: StreamedMessagePart) => void { - // Maps a tool call's streaming index to its identity so that interleaved - // argument deltas from parallel tool calls can be routed to the right call. - // Each provider emits a `function` header before any of its `tool_call_part` - // deltas, and a delta's `index` always matches a previously-seen header's - // `_streamIndex`. The `undefined` key doubles as the single-call fallback - // for providers that stream without indices: those streams never mix indexed - // and unindexed parts, so the most recent unindexed header is always the - // target. const callsByIndex = new Map(); return (part) => { @@ -1016,9 +994,6 @@ interface StepRuntime { type BeginStepResult = { readonly step: StepRuntime } | { readonly result: LoopRunResult }; -// Map a non-completed turn result to v1's `interrupt_reason` taxonomy. -// `blocked` exists in the union for parity but is never emitted — the v2 loop -// has no blocked end. function interruptReasonFor( result: Extract, ): TurnInterruptedEvent['interrupt_reason'] { diff --git a/packages/agent-core-v2/src/agent/loop/stepRequest.ts b/packages/agent-core-v2/src/agent/loop/stepRequest.ts index 3f61d7a0eb..012323dce1 100644 --- a/packages/agent-core-v2/src/agent/loop/stepRequest.ts +++ b/packages/agent-core-v2/src/agent/loop/stepRequest.ts @@ -26,18 +26,14 @@ export type StepRequestAdmission = | 'activeOrNextTurn' | 'activeTurnOnly'; -/** Input/origin recorded through `turn.prompt` when a request starts a turn. */ export interface TurnSeed { readonly input: readonly ContentPart[]; readonly origin: PromptOrigin; } export interface StepRequestOptions { - /** Mergeable requests fold into the next step's driver instead of forcing their own. */ readonly mergeable?: boolean; - /** Turn-scoped requests are aborted when the owning run ends; agent-scoped ones (steers) carry into the next turn. */ readonly turnScoped?: boolean; - /** Turn admission semantics. Defaults to `activeOrNextTurn`. */ readonly admission?: StepRequestAdmission; } @@ -56,7 +52,6 @@ export abstract class StepRequest { this.admission = options.admission ?? 'activeOrNextTurn'; } - /** Seed for the `turn.prompt` record when this request starts a turn. */ get turnSeed(): TurnSeed | undefined { return undefined; } @@ -69,11 +64,6 @@ export abstract class StepRequest { return this._state === 'aborted'; } - /** - * Abort a still-pending request; the loop discards it when popped. Returns - * false once the request has materialized — its message already landed in - * context and can no longer be withdrawn. - */ abort(): boolean { if (this._state !== 'pending') return false; this._state = 'aborted'; @@ -81,28 +71,16 @@ export abstract class StepRequest { return true; } - /** - * One-time side effects run by the loop right before the request's messages - * are appended (wire record-keeping, reminder rerouting). Called at most - * once, at pop time. - */ onWillMaterialize(): void {} - /** - * Compute this request's context contribution at pop time. Called at most - * once; the loop appends the returned messages to the context. Requests - * that only drive a step (continuations, retries) return an empty list. - */ abstract resolveContextMessages(): readonly ContextMessage[]; - /** Loop-only transition invoked at pop time; idempotent. */ markMaterialized(): void { if (this._state !== 'pending') return; this._state = 'materialized'; this.onSettled(); } - /** Fired exactly once when the request leaves the pending state (materialized or aborted). */ protected onSettled(): void {} } @@ -110,11 +88,6 @@ export interface MessageStepRequestOptions extends StepRequestOptions { readonly kind?: string; } -/** - * A request carrying a single pre-built context message. Domains with - * materialization side effects (caption rerouting, steer record-keeping) - * subclass it and override `onWillMaterialize`. - */ export class MessageStepRequest extends StepRequest { readonly kind: string; @@ -135,12 +108,6 @@ export class MessageStepRequest extends StepRequest { } } -/** - * A message-less driver request: contributes no context of its own and simply - * runs one more step over the current context. Enqueued by the loop after a - * tool-executing step, and by orchestrators (`goal`, `externalHooks`) that - * need one extra step after delivering their own input. - */ export class ContinuationStepRequest extends StepRequest { readonly kind: string; diff --git a/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts b/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts index 381e01db37..ab8d107458 100644 --- a/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts +++ b/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts @@ -31,7 +31,6 @@ export class StepRequestQueue { } } - /** True while any non-aborted request is queued. */ hasPendingRequests(): boolean { return this.items.some((item) => !item.aborted); } @@ -59,7 +58,6 @@ export class StepRequestQueue { return this.items.splice(0); } - /** Abort every queued turn-scoped request (run-end cleanup); agent-scoped requests survive. */ abortTurnScoped(): void { for (const item of this.items) { if (item.turnScoped) item.abort(); diff --git a/packages/agent-core-v2/src/agent/mcp/client-http.ts b/packages/agent-core-v2/src/agent/mcp/client-http.ts index b30bace003..fbeab86e79 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-http.ts +++ b/packages/agent-core-v2/src/agent/mcp/client-http.ts @@ -19,48 +19,22 @@ export interface HttpMcpClientOptions { readonly clientName?: string; readonly clientVersion?: string; readonly toolCallTimeoutMs?: number; - /** - * Reads `process.env[name]` by default. Tests can inject a deterministic - * lookup function so they do not have to mutate global env. - */ readonly envLookup?: (name: string) => string | undefined; - /** - * Lets tests inject a fake `fetch` for the underlying transport. - */ readonly fetch?: typeof fetch; - /** - * OAuth client provider attached to the transport. Set only when the server - * has no static token configuration; the SDK uses this to handle 401s with - * RFC 9728 / RFC 8414 / DCR discovery and PKCE. The connection manager wires - * this in and surfaces `UnauthorizedError` as a `needs-auth` status. - */ readonly oauthProvider?: OAuthClientProvider; } -/** - * Wraps the SDK streamable-HTTP transport as a kosong {@link MCPClient}. - * Static bearer tokens are looked up from `process.env[bearerTokenEnvVar]`. - * OAuth providers are attached separately by the connection manager. - */ export class HttpMcpClient implements MCPClient { private readonly client: Client; private readonly transport: StreamableHTTPClientTransport; private readonly toolCallTimeoutMs?: number; private started = false; private closed = false; - // See StdioMcpClient.ready — distinguishes handshake-phase failures (caller - // sees them via `connect()` throwing, no unexpectedClose) from post-ready - // disconnects (the case `onUnexpectedClose` is designed to surface). private ready = false; private hooksInstalled = false; private unexpectedCloseListener: UnexpectedCloseListener | undefined; private lastTransportError: Error | undefined; - // See StdioMcpClient — buffered when the listener has not been installed - // yet so an early close is replayed instead of dropped. private pendingUnexpectedClose: UnexpectedCloseReason | undefined; - // Latch so `onerror` and a (theoretical) `onclose` for the same transport - // failure do not double-fire. Once we have decided the connection is dead, - // additional SDK notifications are noise. private unexpectedCloseFired = false; constructor(config: McpServerHttpConfig, options: HttpMcpClientOptions = {}) { @@ -85,7 +59,6 @@ export class HttpMcpClient implements MCPClient { } if (this.started) return; this.started = true; - // Install hooks BEFORE the SDK handshake; see StdioMcpClient.connect. this.installTransportHooks(); try { await this.client.connect(this.transport); @@ -106,12 +79,6 @@ export class HttpMcpClient implements MCPClient { await this.closeStartedClient(); } - /** - * Register a listener for unsolicited transport drops. See - * `StdioMcpClient.onUnexpectedClose` for semantics. If the transport - * already signalled a terminal failure, the buffered reason is replayed - * synchronously. - */ onUnexpectedClose(listener: UnexpectedCloseListener): void { this.unexpectedCloseListener = listener; const pending = this.pendingUnexpectedClose; @@ -143,28 +110,16 @@ export class HttpMcpClient implements MCPClient { } private installTransportHooks(): void { - // Idempotent — see StdioMcpClient.installTransportHooks. if (this.hooksInstalled) return; this.hooksInstalled = true; this.client.onclose = () => { if (this.closed) return; - // Handshake-phase close surfaces via `client.connect()` throwing. if (!this.ready) return; this.fireUnexpectedClose({ error: this.lastTransportError }); }; - // streamable-http's transport only calls `onclose` on its own `close()` - // path, so 99% of remote disconnects (SSE flap → reconnect exhaustion, - // POST send failure on a dead session) arrive as `onerror` instead. Mirror - // the way the SDK exposes a "the transport is gone" signal there by - // mapping the known-terminal error messages back to an unexpected close; - // everything else is treated as transient and only cached for diagnostics. this.client.onerror = (error) => { this.lastTransportError = error; if (this.closed) return; - // During the handshake, terminal errors (Unauthorized, reconnect - // exhaustion) propagate through `client.connect()` and the manager's - // `shouldMarkNeedsAuth` / `formatStartupError`. Firing here would - // double-report. if (!this.ready) return; if (isTerminalTransportError(error)) { this.fireUnexpectedClose({ error }); @@ -184,23 +139,6 @@ export class HttpMcpClient implements MCPClient { } } -/** - * Returns true when an error reported via `Client.onerror` indicates the - * underlying HTTP transport is dead. The streamable-http SDK does not call - * `onclose` for remote disconnects; instead it surfaces them through - * `onerror`, but only a few specific messages mean "give up" rather than - * "we will retry": - * - * - `UnauthorizedError` — RFC 9728/8414 auth flow gave up; the SDK won't - * retry without a fresh provider call. - * - "Maximum reconnection attempts ... exceeded." — emitted from - * `_scheduleReconnection` after the SSE reconnect budget is gone - * (`streamableHttp.js`, `_scheduleReconnection`). - * - * Transient signals (per-request fetch failures, single SSE flaps that the - * SDK is about to reconnect from) MUST NOT match; otherwise a brief network - * blip would tear down every HTTP MCP entry. - */ export function isTerminalTransportError(error: Error): boolean { if (error.name === 'UnauthorizedError') return true; if (/Maximum reconnection attempts/i.test(error.message)) return true; diff --git a/packages/agent-core-v2/src/agent/mcp/client-remote.ts b/packages/agent-core-v2/src/agent/mcp/client-remote.ts index 73b5bb5de2..8ca292abd6 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-remote.ts +++ b/packages/agent-core-v2/src/agent/mcp/client-remote.ts @@ -14,9 +14,6 @@ export function buildMcpRemoteHeaders( `MCP ${config.transport.toUpperCase()} bearer token env var "${config.bearerTokenEnvVar}" is not set or is empty`, ); } - // Strip any case-variant 'authorization' static header before injecting the - // bearer; Fetch Headers folds duplicate keys into a comma-joined value, - // which produces an invalid auth header rather than letting the bearer win. for (const key of Object.keys(headers)) { if (key.toLowerCase() === 'authorization') { delete headers[key]; diff --git a/packages/agent-core-v2/src/agent/mcp/client-shared.ts b/packages/agent-core-v2/src/agent/mcp/client-shared.ts index ace44e868b..f878aae9b6 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-shared.ts +++ b/packages/agent-core-v2/src/agent/mcp/client-shared.ts @@ -3,22 +3,8 @@ import { getCoreVersion } from '#/_base/version'; import type { MCPToolDefinition, MCPToolResult } from './types'; export const KIMI_MCP_CLIENT_NAME = 'kimi-code'; -// Resolved from agent-core's package.json so MCP servers see the real version -// in `initialize` (used for compatibility checks, telemetry, debugging). -// `getCoreVersion()` falls back to '0.0.0' if the package.json read fails. export const KIMI_MCP_CLIENT_VERSION = getCoreVersion(); -/** - * Why-context attached when a runtime client notices its underlying transport - * has gone away on its own — i.e. {@link RuntimeMcpClient.close} was NOT - * called. The connection manager turns this into a `failed` status so the - * UI/SDK do not keep advertising tools backed by a dead transport. - * - * - `error` is the last error reported via the SDK's `onerror` channel, if - * any. Useful for HTTP where there is no stderr. - * - `stderr` is the tail of bytes captured from the child process's stderr; - * populated only for the stdio transport. - */ export interface UnexpectedCloseReason { readonly error?: Error; readonly stderr?: string; @@ -31,12 +17,6 @@ export interface McpRequestOptions { readonly signal?: AbortSignal; } -/** - * Build the `RequestOptions` object accepted by the MCP SDK's `callTool`, - * including either the configured tool-call timeout, an in-flight abort - * signal, both, or neither. Returns `undefined` when nothing needs to be - * passed so the SDK falls back to its defaults. - */ export function buildRequestOptions( toolCallTimeoutMs: number | undefined, signal: AbortSignal | undefined, @@ -59,12 +39,6 @@ export function toMcpToolDefinition(tool: SdkListedTool): MCPToolDefinition { }; } -/** - * Normalise the SDK's `callTool` return into kosong's {@link MCPToolResult}. - * The SDK can return either the modern `{ content, isError }` shape or a - * legacy `{ toolResult }` shape; we collapse the legacy shape to a single - * text content block. - */ export function toMcpToolResult(result: unknown): MCPToolResult { if (typeof result === 'object' && result !== null && 'content' in result) { const typed = result as { content: unknown; isError?: unknown }; diff --git a/packages/agent-core-v2/src/agent/mcp/client-sse.ts b/packages/agent-core-v2/src/agent/mcp/client-sse.ts index 813146fd32..ee758e91fb 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-sse.ts +++ b/packages/agent-core-v2/src/agent/mcp/client-sse.ts @@ -19,36 +19,17 @@ export interface SseMcpClientOptions { readonly clientName?: string; readonly clientVersion?: string; readonly toolCallTimeoutMs?: number; - /** - * Reads `process.env[name]` by default. Tests can inject a deterministic - * lookup function so they do not have to mutate global env. - */ readonly envLookup?: (name: string) => string | undefined; - /** - * Lets tests inject a fake `fetch` for the underlying transport. - */ readonly fetch?: typeof fetch; - /** - * OAuth client provider attached to the transport. Set only when the server - * has no static token configuration; the connection manager wires this in - * and surfaces `UnauthorizedError` as a `needs-auth` status. - */ readonly oauthProvider?: OAuthClientProvider; } -/** - * Wraps the SDK's deprecated HTTP+SSE transport as a kosong - * {@link MCPClient}. This exists for compatibility with older MCP servers; - * new remote servers should prefer streamable HTTP. - */ export class SseMcpClient implements MCPClient { private readonly client: Client; private readonly transport: SSEClientTransport; private readonly toolCallTimeoutMs?: number; private started = false; private closed = false; - // Mirrors HttpMcpClient: handshake failures surface through connect(), while - // post-ready terminal transport errors become unexpected closes. private ready = false; private hooksInstalled = false; private unexpectedCloseListener: UnexpectedCloseListener | undefined; @@ -98,11 +79,6 @@ export class SseMcpClient implements MCPClient { await this.closeStartedClient(); } - /** - * Register a listener for unsolicited terminal transport drops. Brief SSE - * stream flaps are left to EventSource's retry loop; terminal HTTP status - * errors after startup remove the tools from the agent. - */ onUnexpectedClose(listener: UnexpectedCloseListener): void { this.unexpectedCloseListener = listener; const pending = this.pendingUnexpectedClose; diff --git a/packages/agent-core-v2/src/agent/mcp/client-stdio.ts b/packages/agent-core-v2/src/agent/mcp/client-stdio.ts index c555881540..e5acf2c524 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-stdio.ts +++ b/packages/agent-core-v2/src/agent/mcp/client-stdio.ts @@ -25,12 +25,6 @@ export interface StdioMcpClientOptions { const STDERR_BUFFER_CAPACITY = 4 * 1024; -/** - * Wraps the `@modelcontextprotocol/sdk` stdio client and exposes the small - * surface required by kosong's {@link MCPClient}. Lifecycle is explicit: - * the caller must `connect()` before use and `close()` to terminate the - * child process. - */ export class StdioMcpClient implements MCPClient { private readonly client: Client; private readonly transport: StdioClientTransport; @@ -38,21 +32,12 @@ export class StdioMcpClient implements MCPClient { private readonly stderrBuffer = new BoundedTail(STDERR_BUFFER_CAPACITY); private started = false; private closed = false; - // Flips to true only after `client.connect()` resolves AND the caller has - // not torn things down mid-startup. The `onclose` hook uses this to - // distinguish "transport died after the handshake" (→ unexpected close) - // from "transport died during the handshake" (→ `connect()` throws; the - // manager surfaces the failure via `formatStartupError`). private ready = false; private hooksInstalled = false; private unexpectedCloseListener: UnexpectedCloseListener | undefined; private lastTransportError: Error | undefined; - // Buffered when the transport closes before a listener is installed (e.g. - // a server that exits seconds after answering `tools/list`). Replayed when - // `onUnexpectedClose` registers so the close is never silently dropped. private pendingUnexpectedClose: UnexpectedCloseReason | undefined; - /** Capacity (in characters) of the stderr tail captured for diagnostics. */ static readonly stderrBufferCapacity = STDERR_BUFFER_CAPACITY; constructor(config: McpServerStdioConfig, options: StdioMcpClientOptions = {}) { @@ -66,10 +51,6 @@ export class StdioMcpClient implements MCPClient { cwd: resolveStdioCwd(config.cwd, options.defaultCwd), stderr: 'pipe', }); - // `stderr: 'pipe'` means we MUST drain the stream — otherwise the child - // can block on a full pipe. We also keep the last few KB around so the - // connection manager can attach it to user-facing failure messages - // (`Timed out after 30000ms` on its own tells the user nothing). this.transport.stderr?.on('data', (chunk: Buffer | string) => { this.stderrBuffer.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8')); }); @@ -86,10 +67,6 @@ export class StdioMcpClient implements MCPClient { } if (this.started) return; this.started = true; - // Install transport hooks BEFORE the SDK handshake so we never lose an - // onclose that fires between handshake completion and our wiring. The - // hooks themselves gate on `this.ready`, so a close that happens DURING - // the handshake still flows through `client.connect()` rejecting. this.installTransportHooks(); try { await this.client.connect(this.transport); @@ -110,15 +87,6 @@ export class StdioMcpClient implements MCPClient { await this.closeStartedClient(); } - /** - * Register a listener that fires when the underlying transport closes on - * its own — i.e. the caller has not yet invoked {@link close}. At most one - * listener can be installed; later registrations replace earlier ones. - * Intentional closes never invoke the listener. - * - * If the transport already closed before this method was called, the - * buffered reason is replayed synchronously so the close is never dropped. - */ onUnexpectedClose(listener: UnexpectedCloseListener): void { this.unexpectedCloseListener = listener; const pending = this.pendingUnexpectedClose; @@ -128,11 +96,6 @@ export class StdioMcpClient implements MCPClient { } } - /** - * Returns the tail of bytes captured from the child's stderr since spawn. - * Bounded by {@link StdioMcpClient.stderrBufferCapacity} so a noisy server - * cannot exhaust memory. - */ stderrSnapshot(): string { return this.stderrBuffer.snapshot(); } @@ -159,17 +122,8 @@ export class StdioMcpClient implements MCPClient { } private installTransportHooks(): void { - // Idempotent: `connect()` is the only caller and is itself guarded by - // `started`, but defending here lets future refactors call this freely. if (this.hooksInstalled) return; this.hooksInstalled = true; - // `Client.onclose` fires for THREE situations: - // 1. The intentional `close()` path → gated by `this.closed`. - // 2. Transport dying during the SDK handshake → gated by `!this.ready`; - // the failure already surfaces via `client.connect()` rejecting, and - // `formatStartupError` attaches stderr at the manager layer. - // 3. Transport dying after the handshake succeeded → the case we care - // about: fire or buffer for the manager's watch listener. this.client.onclose = () => { if (this.closed) return; if (!this.ready) return; @@ -182,26 +136,15 @@ export class StdioMcpClient implements MCPClient { if (listener !== undefined) { listener(reason); } else { - // Buffer so a listener registered moments later still sees the close. this.pendingUnexpectedClose = reason; } }; this.client.onerror = (error) => { - // Errors are informational on their own — `_onclose` is what tells us - // the transport is gone — so just remember the latest one and let the - // close handler decide whether to surface it. During startup the thrown - // error from `client.connect()` already carries the message, so this - // capture is only load-bearing post-`ready`. this.lastTransportError = error; }; } } -/** - * A bounded "tail" buffer: appends characters and drops the oldest when the - * total exceeds `capacity`. Used to keep the last few KB of child-process - * stderr around without unbounded growth. - */ class BoundedTail { private buffer = ''; constructor(private readonly capacity: number) {} @@ -224,15 +167,6 @@ function resolveStdioCwd(configCwd: string | undefined, defaultCwd: string | und return configCwd; } -// Inherit the parent's env so PATH/HOME/etc. survive — otherwise `npx`/`uvx` -// style stdio servers fail to launch even with a valid config. `config.env` -// overrides on conflict. A node child does not inherit our in-process undici -// dispatcher, so `proxyEnvForChild` adds `NODE_USE_ENV_PROXY` (and a -// loopback-protected `NO_PROXY`) to make it honor the proxy natively (on a Node -// version that supports the flag — ≥22.21 or ≥24.5). It is computed from the -// MERGED env so a proxy declared only in `config.env` is honored too. -// `reconcileChildNoProxy` then mirrors a single-casing `NO_PROXY` override onto -// both casings so it isn't shadowed by the injected value. export function mergeStdioEnv( configEnv?: Record, parentEnv: Readonly> = process.env, diff --git a/packages/agent-core-v2/src/agent/mcp/config-loader.ts b/packages/agent-core-v2/src/agent/mcp/config-loader.ts index f02b2128c0..2aef7d1481 100644 --- a/packages/agent-core-v2/src/agent/mcp/config-loader.ts +++ b/packages/agent-core-v2/src/agent/mcp/config-loader.ts @@ -36,17 +36,6 @@ export interface LoadMcpServersInput { readonly homeDir?: string; } -/** - * Load MCP server declarations from the user-global `~/.kimi-code/mcp.json`, - * the project-root `/.mcp.json`, and the project-local - * `/.kimi-code/mcp.json`. Entries in later files override earlier files - * with the same key, so a repo can specialise or replace a shared definition, - * and Kimi-specific project config wins over the Claude-compatible root file. - * - * Note: project-local entries may spawn stdio commands at session start, so - * opening a session inside an untrusted checkout will execute whatever its - * `mcp.json` declares. Only enable this in repos you trust. - */ export async function loadMcpServers( input: LoadMcpServersInput, ): Promise> { diff --git a/packages/agent-core-v2/src/agent/mcp/connection-manager.ts b/packages/agent-core-v2/src/agent/mcp/connection-manager.ts index 30e6126b4b..6ad83a386e 100644 --- a/packages/agent-core-v2/src/agent/mcp/connection-manager.ts +++ b/packages/agent-core-v2/src/agent/mcp/connection-manager.ts @@ -60,38 +60,11 @@ const defaultLog: Logger = { export interface McpConnectionManagerOptions { readonly envLookup?: (name: string) => string | undefined; - /** - * Session workspace cwd for stdio MCP servers whose config omits `cwd`. - * Relative `cwd` values are resolved from this base, matching v1 Session MCP. - */ readonly stdioCwd?: string; - /** - * Optional OAuth orchestrator. When provided, remote servers without a - * static bearer token participate in the OAuth-via-synthetic-tool flow: - * - If `oauthService.hasTokens(name, url)` is true, the provider is - * attached to the transport so the SDK can refresh tokens on 401. - * - Connection failures that look like 401 / `UnauthorizedError` flip - * the entry into `needs-auth` instead of `failed`; `/mcp-config` - * drives the browser flow through the synthetic auth tool. - */ readonly oauthService?: McpOAuthService; - /** - * Parent logger. The Session-scoped lifecycle injects the session logger - * (the Session binding of `ILogService`) so MCP events are written to the - * per-session log file; falls back to a no-op when omitted. - */ readonly log?: Logger; } -/** - * Owns the lifecycle of every configured MCP server for a Session. - * - * Servers are connected in parallel; per-server failures are isolated so a - * crashed or misconfigured entry never blocks Session startup. State - * transitions are surfaced through {@link onStatusChange} so callers (the - * Session) can react — registering tools onto the main agent, emitting - * wire events, or updating the TUI. - */ export class McpConnectionManager { private readonly entries = new Map(); private readonly listeners = new Set(); @@ -100,11 +73,6 @@ export class McpConnectionManager { private initialLoadStartedAt: number | undefined; private initialLoadFinishedAt: number | undefined; - /** - * OAuth orchestrator injected at construction time. Consumed by the - * {@link ToolManager} `needs-auth` branch to build the synthetic - * `authenticate` tool. - */ readonly oauthService: McpOAuthService | undefined; private readonly log: Logger; @@ -113,11 +81,6 @@ export class McpConnectionManager { this.log = options.log ?? defaultLog; } - /** - * Returns the URL of a remote MCP server by name, or `undefined` for - * unknown / non-remote / disabled entries. Used by the synthetic auth tool - * to drive OAuth discovery against the right base URL. - */ getRemoteServerUrl(name: string): string | undefined { const entry = this.entries.get(name); if (entry === undefined) return undefined; @@ -125,10 +88,6 @@ export class McpConnectionManager { return entry.config.url; } - /** - * @deprecated Use {@link getRemoteServerUrl}. Kept for in-repo callers that - * were written before legacy SSE support shared the same OAuth path. - */ getHttpServerUrl(name: string): string | undefined { return this.getRemoteServerUrl(name); } @@ -149,13 +108,6 @@ export class McpConnectionManager { return entry !== undefined ? toPublicEntry(entry) : undefined; } - /** - * Returns the MCP client, the discovered tools, and the allow-list of tool - * names for a given connected server, or `undefined` if the server is not - * currently connected. The allow-list combines the server's `enabledTools` - * and `disabledTools` filters; callers should only register names in the - * set. - */ resolved( name: string, ): @@ -299,7 +251,6 @@ export class McpConnectionManager { this.connectAndDiscoverTools(startupClient), timeoutMs, () => { - // Best-effort cleanup if the startup promise is still racing. void this.closeRuntimeClient(startupClient); }, ); @@ -329,7 +280,6 @@ export class McpConnectionManager { entry.tools = undefined; entry.enabledNames = undefined; entry.rawTools = undefined; - // Drop the client reference so a later reconnect builds a fresh one. await this.closeClient(entry); } if (!this.isCurrent(entry, attemptId)) return; @@ -342,8 +292,6 @@ export class McpConnectionManager { attemptId: number, ): void { client.onUnexpectedClose((reason) => { - // The client may have outlived its entry (shutdown / reconnect already - // moved on). Drop the event if so — the new attempt owns the state. if (!this.isCurrent(entry, attemptId)) return; if (entry.client !== client) return; entry.status = 'failed'; @@ -352,8 +300,6 @@ export class McpConnectionManager { entry.enabledNames = undefined; entry.rawTools = undefined; entry.client = undefined; - // Best-effort close; the transport is already gone, but this lets the - // SDK release timers and pending request handlers. void this.closeRuntimeClient(client); this.emit(entry); }); @@ -391,10 +337,6 @@ export class McpConnectionManager { if (oauthService === undefined) return undefined; if (!isRemoteMcpConfig(config)) return undefined; if (config.bearerTokenEnvVar !== undefined) return undefined; - // Only attach the provider once tokens have been minted; before that, - // the transport should propagate a clean 401 so we can flip the entry - // into `needs-auth` rather than getting tangled in the SDK's auth() - // flow (which would try DCR before we have an active redirect URL). if (!(await oauthService.hasTokens(name, config.url))) return undefined; return oauthService.getProvider(name, config.url); } @@ -403,10 +345,6 @@ export class McpConnectionManager { if (this.oauthService === undefined) return false; if (!isRemoteMcpConfig(entry.config)) return false; if (entry.config.bearerTokenEnvVar !== undefined) return false; - // If the user pinned a static `headers` block, treat 401s as a bad header - // rather than hijacking them into the OAuth flow — the real error is more - // actionable than "run /mcp-config login" for a server that doesn't speak - // OAuth. if (entry.config.headers !== undefined) return false; return isUnauthorizedLikeError(error); } @@ -437,8 +375,6 @@ export class McpConnectionManager { try { await client.close(); } catch { - // Suppress close errors — the server is going away regardless and we - // don't want them masking the original startup failure. } } @@ -460,7 +396,6 @@ export class McpConnectionManager { try { listener(view); } catch { - // Listener faults must not break the connection manager. } } } @@ -497,12 +432,9 @@ function computeEnabledNames(config: McpServerConfig, tools: readonly Tool[]): S function isUnauthorizedLikeError(error: unknown): boolean { if (!(error instanceof Error)) return false; if (error.name === 'UnauthorizedError') return true; - // SDK transport errors typically expose the HTTP status as `.code`. const code = (error as { code?: unknown }).code; if (typeof code === 'number' && code === 401) return true; if (typeof code === 'string' && code === '401') return true; - // Fall back to a message sniff so server-specific error shapes still flip - // us into needs-auth instead of failed. return /\b401\b/.test(error.message) || /unauthorized/i.test(error.message); } diff --git a/packages/agent-core-v2/src/agent/mcp/mcpService.ts b/packages/agent-core-v2/src/agent/mcp/mcpService.ts index cc361d22dd..c483534655 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpService.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpService.ts @@ -33,8 +33,6 @@ declare module '#/app/event/eventBus' { interface DomainEventMap { 'mcp.server.status': McpServerStatusEvent; 'tool.list.updated': ToolListUpdatedEvent; - // Canonical home of the shared `error` event (`IEventBus`); other domains - // (`turn`, `fullCompaction`) reuse it via interface-merge, not re-declared. error: ErrorEvent; } } diff --git a/packages/agent-core-v2/src/agent/mcp/oauth/callback-server.ts b/packages/agent-core-v2/src/agent/mcp/oauth/callback-server.ts index a1c902e28e..070b1546e1 100644 --- a/packages/agent-core-v2/src/agent/mcp/oauth/callback-server.ts +++ b/packages/agent-core-v2/src/agent/mcp/oauth/callback-server.ts @@ -19,12 +19,6 @@ export interface CallbackResult { export interface CallbackServer { readonly redirectUri: string; - /** - * Resolves with the OAuth callback payload, or rejects when: - * - `signal` aborts → AbortError - * - `timeoutMs` elapses → Error('OAuth callback timed out') - * - the user's authorization server returns an error → Error('OAuth error: ') - */ waitForCode(opts: { signal?: AbortSignal; timeoutMs?: number }): Promise; close(): Promise; } diff --git a/packages/agent-core-v2/src/agent/mcp/oauth/service.ts b/packages/agent-core-v2/src/agent/mcp/oauth/service.ts index 15e83f753c..1ee5e3c4d3 100644 --- a/packages/agent-core-v2/src/agent/mcp/oauth/service.ts +++ b/packages/agent-core-v2/src/agent/mcp/oauth/service.ts @@ -30,30 +30,17 @@ import { McpOAuthClientProvider } from './provider'; import { mcpOAuthStoreKey, type McpOAuthStore } from './store'; export interface McpOAuthServiceOptions { - /** Credential store backing the OAuth providers. */ readonly store: McpOAuthStore; - /** Override for the label embedded in DCR `client_name`. */ readonly clientLabel?: string; } export interface BeginAuthorizationOptions { - /** Override the `client_name` embedded in the DCR registration request. */ readonly clientLabel?: string; } export interface BeginAuthorizationResult { - /** The authorization URL the user must open in their browser. */ readonly authorizationUrl: URL; - /** - * Awaits the OAuth callback, validates `state`, exchanges the code for - * tokens, and persists them via the provider. Resolves on success; - * rejects on abort, timeout, or auth-server error. - */ complete(opts?: { signal?: AbortSignal; timeoutMs?: number }): Promise; - /** - * Tears down the callback listener without finishing the flow. Safe to - * call repeatedly; called automatically by `complete()`. - */ cancel(): Promise; } @@ -67,7 +54,6 @@ export class McpOAuthService { this.clientLabel = options.clientLabel; } - /** Returns the cached provider for `serverName` + `serverUrl`, constructing it on first use. */ getProvider(serverName: string, serverUrl: string | URL): McpOAuthClientProvider { const storeKey = mcpOAuthStoreKey(serverName, serverUrl); let provider = this.providers.get(storeKey); @@ -83,17 +69,10 @@ export class McpOAuthService { return provider; } - /** True once the provider has persisted tokens for this server/resource identity. */ async hasTokens(serverName: string, serverUrl: string | URL): Promise { return (await this.getProvider(serverName, serverUrl).tokens()) !== undefined; } - /** - * Drive the SDK `auth()` orchestrator far enough to surface an - * authorization URL. The caller is responsible for displaying the URL - * (typically via the synthetic authenticate tool) and then awaiting - * `complete()` to finish the code exchange. - */ async beginAuthorization( serverName: string, serverUrl: string | URL, @@ -127,7 +106,6 @@ export class McpOAuthService { try { const result = await auth(provider as OAuthClientProvider, { serverUrl }); if (result !== 'REDIRECT') { - // Tokens already valid (e.g. unexpired refresh). Nothing to do. await callbackServer.close(); throw new AlreadyAuthorizedError(serverName); } @@ -182,11 +160,6 @@ export class McpOAuthService { return { authorizationUrl, complete, cancel }; } - /** - * Clear stored credentials for a server. Use `'all'` after the user - * explicitly signs out; use `'tokens'` to force a re-auth while keeping - * the registered DCR client. - */ invalidate( serverName: string, serverUrl: string | URL, @@ -196,7 +169,6 @@ export class McpOAuthService { } } -/** Thrown by `beginAuthorization` when stored tokens already satisfy the server. */ export class AlreadyAuthorizedError extends Error { constructor(serverName: string) { super(`"${serverName}" is already authorized; no browser flow needed`); diff --git a/packages/agent-core-v2/src/agent/mcp/output.ts b/packages/agent-core-v2/src/agent/mcp/output.ts index 968b4df8b7..1cd373d744 100644 --- a/packages/agent-core-v2/src/agent/mcp/output.ts +++ b/packages/agent-core-v2/src/agent/mcp/output.ts @@ -39,28 +39,15 @@ import { persistOriginalImage } from '#/agent/media/image-originals'; import type { MCPContentBlock, MCPToolResult } from './types'; export interface McpOutputOptions { - /** - * Session-owned directory for pre-compression originals (typically - * `sessionMediaOriginalsDir(sessionDir)` threaded down from the agent). - * Falls back to the shared temp-dir cache when absent. - */ readonly originalsDir?: string; readonly telemetry?: ITelemetryService; } -// MCP servers can produce arbitrarily large outputs; cap what we feed back to -// the model so a single chatty server does not blow up the context window. The -// notice text is fed to the model verbatim so it can react (e.g. paginate), -// which is why the limits live in the agent layer rather than in kosong. export const MCP_MAX_OUTPUT_CHARS = 100_000; const MCP_OUTPUT_TRUNCATED_TEXT = `\n\n[Output truncated: exceeded ${String( MCP_MAX_OUTPUT_CHARS, )} character limit. Use pagination or more specific queries to get remaining content.]`; -// Binary parts (image_url / audio_url / video_url) have an independent per-part -// byte cap and do NOT share the text character budget. base64 length is not a -// useful proxy for multimodal model cost, and a single screenshot is enough to -// evict every text part if both compete for the same 100k budget. export const MCP_MAX_BINARY_PART_BYTES = 10 * 1024 * 1024; const MCP_MAX_BINARY_PART_CHARS = Math.ceil((MCP_MAX_BINARY_PART_BYTES * 4) / 3); @@ -70,12 +57,6 @@ function binaryPartTooLargeNotice(kind: 'image' | 'audio' | 'video', urlLength: return `[${kind}_url dropped: ~${approxMb} MB exceeds ${capMb} MB per-part limit. Try a smaller resource.]`; } -/** - * Convert a single MCP content block into a kosong {@link ContentPart}. - * - * Returns `null` for block types that cannot be represented (e.g. unknown - * resource shapes) so the caller can drop them. - */ export function convertMCPContentBlock(block: MCPContentBlock): ContentPart | null { if (block.type === 'text' && typeof block.text === 'string') { return { type: 'text', text: block.text }; @@ -97,8 +78,6 @@ export function convertMCPContentBlock(block: MCPContentBlock): ContentPart | nu }; } - // EmbeddedResource: payload is nested under `resource`, as - // TextResourceContents (`text`) or BlobResourceContents (`blob`). if (block.type === 'resource' && typeof block.resource === 'object' && block.resource !== null) { const res = block.resource; if (typeof res.text === 'string') { @@ -129,16 +108,9 @@ export function convertMCPContentBlock(block: MCPContentBlock): ContentPart | nu return null; } - // ResourceLink: URL reference, not an inline blob. if (block.type === 'resource_link' && typeof block.uri === 'string') { const mimeType = block.mimeType ?? 'application/octet-stream'; if (mimeType.startsWith('image/')) { - // The declared MIME is the only format signal for a remote image: an - // extensionless or signed URL gives the extension gate nothing to work - // with, and the provider fetches it server-side. When the server - // honestly declares a format providers reject (e.g. an image search - // tool returning AVIF links), drop the image for a notice that keeps - // the URL — the model can still fetch and convert it. if (!isModelAcceptedImageMime(mimeType)) { return { type: 'text', text: buildUnsupportedImageNotice(mimeType, block.uri) }; } @@ -156,14 +128,6 @@ export function convertMCPContentBlock(block: MCPContentBlock): ContentPart | nu return null; } -/** - * Convert an `MCPToolResult` into the success-shape `ExecutableToolResult` - * output the agent loop expects. - * - * `qualifiedToolName` is the agent-side qualified name (e.g. - * `mcp__github__create_pr`) — embedded into the `` - * wrap when the result is media-only, so the model can attribute binary parts. - */ export async function mcpResultToExecutableOutput( result: MCPToolResult, qualifiedToolName: string, @@ -183,18 +147,7 @@ export async function mcpResultToExecutableOutput( } const wrapped = wrapMediaOnly(converted, qualifiedToolName); - // Text budget FIRST, on the tool's own text only: captions inserted by the - // compression step below must never compete with a chatty tool's text for - // the budget — an evicted or mid-string-sliced caption silently - // reintroduces the downsampling this pipeline promises to announce. const budgeted = applyTextBudget(wrapped); - // Shrink oversized images BEFORE the per-part byte cap, so a large but - // compressible screenshot is downsampled and kept rather than dropped to a - // text notice. Compression is never silent: each re-encoded image gains a - // caption stating what the original was, and the original bytes are - // persisted (best effort, into the session's media-originals dir when - // known) so the model can read detail back via ReadMediaFile + region. - // Parts that cannot be compressed pass through. const compressed = await compressImageContentParts(budgeted.parts, { telemetry: options.telemetry === undefined @@ -221,11 +174,6 @@ export async function mcpResultToExecutableOutput( }; } -/** - * If `parts` contains media but no non-empty text, surround it with - * `` text tags so the model can attribute the - * binary content. Returns the input untouched otherwise. - */ function wrapMediaOnly(parts: readonly ContentPart[], qualifiedToolName: string): ContentPart[] { const hasMedia = parts.some( (p) => p.type === 'image_url' || p.type === 'audio_url' || p.type === 'video_url', @@ -239,16 +187,6 @@ function wrapMediaOnly(parts: readonly ContentPart[], qualifiedToolName: string) ]; } -/** - * Apply the 100K text/think budget. Runs before image compression, so only - * the tool's own text is charged — compression captions inserted afterwards - * are exempt by construction. Binary parts pass through untouched (their - * independent per-part cap is {@link applyBinaryPartCap}). - * - * When text/think parts get truncated, the truncation notice is appended to - * the last surviving text part — this keeps the single-text-part collapse - * working when the entire (oversized) input is a single text block. - */ function applyTextBudget(parts: readonly ContentPart[]): { readonly parts: ContentPart[]; readonly truncated: boolean; @@ -300,13 +238,6 @@ function applyTextBudget(parts: readonly ContentPart[]): { return { parts: out, truncated }; } -/** - * Apply the per-part 10 MB binary cap, independent of the text character - * budget. Oversized parts collapse into a per-part notice so the model can - * pick a smaller resource instead of silently losing the blob. Runs after - * image compression, so a large but compressible image has already been - * shrunk under the cap. - */ function applyBinaryPartCap(parts: readonly ContentPart[]): { readonly parts: ContentPart[]; readonly truncated: boolean; @@ -340,9 +271,6 @@ function applyBinaryPartCap(parts: readonly ContentPart[]): { } function appendTruncationNotice(out: ContentPart[]): void { - // Merge the notice into the last text part so the very common - // "single oversized text" case still collapses to a plain string. Falls - // back to a standalone notice part if there is no text part to merge with. for (let i = out.length - 1; i >= 0; i--) { const candidate = out[i]; if (candidate?.type === 'text') { diff --git a/packages/agent-core-v2/src/agent/mcp/tool-naming.ts b/packages/agent-core-v2/src/agent/mcp/tool-naming.ts index eda5a23484..111fa9cbdb 100644 --- a/packages/agent-core-v2/src/agent/mcp/tool-naming.ts +++ b/packages/agent-core-v2/src/agent/mcp/tool-naming.ts @@ -2,29 +2,12 @@ const MCP_NAME_PREFIX = 'mcp__'; const MCP_NAME_SEPARATOR = '__'; export { isMcpToolName } from '#/tool/toolContract'; -/** - * Most LLM providers cap tool names around 64 characters. Leave headroom - * for the prefix and a separator and truncate longer names with a stable - * hash suffix so collisions remain extremely unlikely. - */ const MAX_QUALIFIED_LENGTH = 64; -/** - * Replace any character outside the safe ASCII set with `_`, then collapse - * any run of `_` into a single underscore. The collapse step guarantees neither the sanitized server - * nor tool name contains the `__` separator used by {@link qualifyMcpToolName}, - * which lets {@link isMcpToolName}-aware decoders split unambiguously on the - * first `__` after the prefix. - */ export function sanitizeMcpNamePart(part: string): string { return part.replaceAll(/[^a-zA-Z0-9_-]/g, '_').replaceAll(/_+/g, '_'); } -/** - * Produce the qualified MCP tool name used inside the agent and on the wire. - * If the result would exceed {@link MAX_QUALIFIED_LENGTH}, a deterministic - * 8-char hash suffix replaces the tail so the prefix structure stays intact. - */ export function qualifyMcpToolName(serverName: string, toolName: string): string { const full = `${MCP_NAME_PREFIX}${sanitizeMcpNamePart(serverName)}${MCP_NAME_SEPARATOR}${sanitizeMcpNamePart(toolName)}`; if (full.length <= MAX_QUALIFIED_LENGTH) return full; @@ -35,9 +18,6 @@ export function qualifyMcpToolName(serverName: string, toolName: string): string } function stableHash8(input: string): string { - // 32-bit FNV-1a — enough to disambiguate truncated tool names within a - // single server's tool list. Not cryptographic; only used for collision - // resistance among a handful of strings. let hash = 0x811c9dc5; for (let i = 0; i < input.length; i++) { hash ^= input.codePointAt(i)!; diff --git a/packages/agent-core-v2/src/agent/mcp/tools/auth.ts b/packages/agent-core-v2/src/agent/mcp/tools/auth.ts index 36432c80bf..f53d18f73e 100644 --- a/packages/agent-core-v2/src/agent/mcp/tools/auth.ts +++ b/packages/agent-core-v2/src/agent/mcp/tools/auth.ts @@ -39,7 +39,7 @@ import { import { AlreadyAuthorizedError, type McpOAuthService } from '#/agent/mcp/oauth/service'; import { qualifyMcpToolName } from '#/agent/mcp/tool-naming'; -const DEFAULT_AUTH_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes +const DEFAULT_AUTH_TIMEOUT_MS = 15 * 60 * 1000; const AUTH_TOOL_TOOL_NAME = 'authenticate'; @@ -61,22 +61,10 @@ Take no arguments. Treat the URL as sensitive — do not modify it or strip query parameters.`; export interface CreateMcpAuthToolOptions { - /** Friendly MCP server name as configured in `mcp.json`. */ readonly serverName: string; - /** Base URL of the MCP server (used for OAuth resource metadata discovery). */ readonly serverUrl: string; - /** OAuth orchestrator, typically `Session`-scoped. */ readonly oauthService: McpOAuthService; - /** - * Triggers a manager-level reconnect once tokens land on disk. Implemented - * by the {@link McpConnectionManager} and bound in the {@link ToolManager} - * `needs-auth` branch. - */ readonly reconnect: (signal?: AbortSignal) => Promise; - /** - * Overrides the per-call OAuth wait timeout. Tests set this to a small - * number; production callers should accept the default. - */ readonly timeoutMs?: number; } @@ -84,7 +72,6 @@ export function createMcpAuthTool(options: CreateMcpAuthToolOptions): Executable const { serverName, serverUrl, oauthService, reconnect, timeoutMs } = options; const name = qualifyMcpToolName(serverName, AUTH_TOOL_TOOL_NAME); const description = DESCRIPTION_TEMPLATE(serverName); - // No arguments; an empty object schema keeps providers happy across SDKs. const parameters = toInputJsonSchema(z.object({})); const execute = async (ctx: ExecutableToolContext): Promise => { const { signal, onUpdate } = ctx; diff --git a/packages/agent-core-v2/src/agent/mcp/types.ts b/packages/agent-core-v2/src/agent/mcp/types.ts index dee8cf4eb6..33e2b46887 100644 --- a/packages/agent-core-v2/src/agent/mcp/types.ts +++ b/packages/agent-core-v2/src/agent/mcp/types.ts @@ -21,66 +21,29 @@ export interface MCPEmbeddedResourceContents { [key: string]: unknown; } -/** - * A content block as returned by an MCP tool call (`tools/call`). - * - * This is a structural subset of the MCP protocol `ContentBlock` union, - * covering the shapes that {@link convertMCPContentBlock} knows how to convert - * into kosong `ContentPart`s. Additional fields are ignored. - */ export interface MCPContentBlock { - // Known values: 'text' | 'image' | 'audio' | 'resource' | 'resource_link'. - // Declared as `string` to also accept future MCP content types without a - // type assertion. type: string; text?: string; data?: string; mimeType?: string; uri?: string; - // EmbeddedResource carries its payload nested under `resource`, per the - // MCP spec — never as top-level `data`/`mimeType`. resource?: MCPEmbeddedResourceContents; [key: string]: unknown; } -/** - * Result of a single MCP tool invocation. - * - * Matches the shape returned by the MCP protocol's `tools/call` method. - */ export interface MCPToolResult { content: MCPContentBlock[]; isError: boolean; } -/** - * An MCP tool definition as returned by an MCP server's `tools/list` method. - */ export interface MCPToolDefinition { name: string; description: string; inputSchema: unknown; } -/** - * Minimal MCP client interface consumed by {@link McpConnectionManager} and - * {@link ToolManager}. - * - * This is a transport-agnostic seam: implementations can wrap - * `@modelcontextprotocol/sdk`, a bespoke stdio client, an HTTP SSE client, - * or a mock for testing. Keeping the surface small lets tests inject fakes - * without pulling in the full SDK type graph. - */ export interface MCPClient { - /** List the tools advertised by the MCP server. */ listTools(): Promise; - /** - * Invoke a tool by name with the given JSON arguments. - * - * `signal`, when provided, is forwarded to the underlying transport so an - * abort from the loop (e.g. user cancellation) propagates all the way to - * the server instead of leaving the request running in the background. - */ callTool( name: string, args: Record, @@ -88,12 +51,6 @@ export interface MCPClient { ): Promise; } -/** - * Validate the `inputSchema` field of an MCP tool definition. MCP advertises - * input schemas as JSON Schema objects; reject anything that is not a plain - * object so the validator compiler downstream never sees `null` or a - * primitive. - */ export function assertMcpInputSchema( toolName: string, inputSchema: unknown, diff --git a/packages/agent-core-v2/src/agent/media/configSection.ts b/packages/agent-core-v2/src/agent/media/configSection.ts index 6c5f541277..6cdc23c527 100644 --- a/packages/agent-core-v2/src/agent/media/configSection.ts +++ b/packages/agent-core-v2/src/agent/media/configSection.ts @@ -27,29 +27,16 @@ import { registerConfigSection } from '#/app/config/configSectionContributions'; export const IMAGE_SECTION = 'image'; -/** Env var overriding the longest-edge ceiling (px). */ export const IMAGE_MAX_EDGE_ENV = 'KIMI_IMAGE_MAX_EDGE_PX'; -/** Env var overriding the read-image byte budget. */ export const IMAGE_READ_BYTE_BUDGET_ENV = 'KIMI_IMAGE_READ_BYTE_BUDGET'; export const ImageConfigSchema = z.object({ - /** - * Longest-edge ceiling (px) applied when compressing images for the model. - * Overrides the built-in default; `KIMI_IMAGE_MAX_EDGE_PX` wins over this. - */ maxEdgePx: z.number().int().min(1).optional(), - /** - * Raw-byte budget for images the model reads for itself (ReadMediaFile's - * default path). Overrides the built-in default; `KIMI_IMAGE_READ_BYTE_BUDGET` - * wins over this. Explicit region / full_resolution reads use the - * provider-scale per-image limit instead. - */ readByteBudget: z.number().int().min(1).optional(), }); export type ImageConfig = z.infer; -/** Parse an env value into a positive int, or `undefined` to ignore it. */ function parsePositiveInt(raw: string): number | undefined { const value = raw.trim(); if (value.length === 0 || !/^\d+$/.test(value)) return undefined; diff --git a/packages/agent-core-v2/src/agent/media/file-type.ts b/packages/agent-core-v2/src/agent/media/file-type.ts index b24a710141..52dced82c3 100644 --- a/packages/agent-core-v2/src/agent/media/file-type.ts +++ b/packages/agent-core-v2/src/agent/media/file-type.ts @@ -178,9 +178,6 @@ function sniffFtypBrand(header: Buffer): string | null { if (header.length < 12) return null; if (header.subarray(4, 8).toString('latin1') !== 'ftyp') return null; const raw = header.subarray(8, 12).toString('latin1').toLowerCase(); - // Python `.strip()` removes ASCII whitespace including trailing NULs via - // the `decode(..., errors="ignore")` semantics. We approximate: trim - // spaces and trailing NULs so brands like `qt ` → `qt`. // oxlint-disable-next-line no-control-regex return raw.replaceAll(/[\s\u0000]+$/g, '').trim(); } @@ -241,38 +238,16 @@ export function sniffMediaFromMagic(data: Buffer | Uint8Array): FileType | null export interface ImageDimensions { readonly width: number; readonly height: number; - /** - * Present (true) when a JPEG EXIF orientation of 5-8 swapped the reported - * width/height into display space. - */ readonly transposed?: boolean; } -/** - * Best-effort pixel-dimension reader for common raster formats. - * - * Inspects only the fixed region near the start of the file where each - * format records its dimensions (the IHDR/DIB header, the RIFF chunk - * after the `WEBP` tag, or the first JPEG SOFn segment). Returns `null` - * for formats whose dimensions are not locatable from that region, or - * when the supplied buffer is too short to cover it. - * - * JPEG dimensions are reported in DISPLAY space: an EXIF Orientation of - * 5-8 transposes the image at decode time, so the SOF width/height are - * swapped to match what decoders (and this codebase's crop regions and - * compression captions) actually operate in. - */ export function sniffImageDimensions(data: Buffer | Uint8Array): ImageDimensions | null { const buf = toBuffer(data); - // PNG — IHDR is the first chunk; width/height are big-endian uint32 - // at offsets 16 and 20. if (startsWith(buf, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) && buf.length >= 24) { return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; } - // GIF — logical-screen width/height are little-endian uint16 at - // offsets 6 and 8. if ( (startsWith(buf, Buffer.from('GIF87a')) || startsWith(buf, Buffer.from('GIF89a'))) && buf.length >= 10 @@ -280,14 +255,10 @@ export function sniffImageDimensions(data: Buffer | Uint8Array): ImageDimensions return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) }; } - // BMP — DIB header width/height are little-endian int32 at offsets 18 - // and 22 (height may be negative for top-down bitmaps). if (startsWith(buf, Buffer.from('BM')) && buf.length >= 26) { return { width: buf.readInt32LE(18), height: Math.abs(buf.readInt32LE(22)) }; } - // WEBP — RIFF container; VP8/VP8L/VP8X each store dimensions - // differently in the chunk that follows the 'WEBP' tag. if (startsWith(buf, Buffer.from('RIFF')) && buf.length >= 30) { const fourCc = buf.subarray(12, 16).toString('latin1'); if (fourCc === 'VP8 ') { @@ -310,9 +281,6 @@ export function sniffImageDimensions(data: Buffer | Uint8Array): ImageDimensions } } - // JPEG — scan segment markers for a Start-Of-Frame (SOFn) marker, - // whose payload carries height/width as big-endian uint16. An EXIF - // APP1 segment encountered on the way supplies the orientation. if (startsWith(buf, [0xff, 0xd8])) { let orientation: number | null = null; let offset = 2; @@ -322,7 +290,6 @@ export function sniffImageDimensions(data: Buffer | Uint8Array): ImageDimensions continue; } const marker = buf[offset + 1]!; - // SOFn markers carry frame dimensions; skip SOF4/SOF8/SOF12 (0xc4/0xc8/0xcc). if ( marker >= 0xc0 && marker <= 0xcf && @@ -336,7 +303,6 @@ export function sniffImageDimensions(data: Buffer | Uint8Array): ImageDimensions ? { width: height, height: width, transposed: true } : { width, height }; } - // Standalone markers (RSTn, SOI, EOI) carry no length field. if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { offset += 2; continue; @@ -353,15 +319,8 @@ export function sniffImageDimensions(data: Buffer | Uint8Array): ImageDimensions return null; } -/** - * Read the Orientation tag (0x0112) out of a JPEG APP1 payload spanning - * [`start`, `end`). Returns 1-8, or `null` when the payload is not EXIF, - * is truncated, or carries no valid orientation. Only IFD0 is examined — - * that is where the tag lives; nothing here follows nested IFDs. - */ function readExifOrientation(buf: Buffer, start: number, end: number): number | null { const boundedEnd = Math.min(end, buf.length); - // 'Exif\0\0' preamble, then the TIFF header. if (start + 6 > boundedEnd || buf.toString('latin1', start, start + 6) !== 'Exif\0\0') { return null; } @@ -380,8 +339,6 @@ function readExifOrientation(buf: Buffer, start: number, end: number): number | const entry = ifd + 2 + i * 12; if (entry + 12 > boundedEnd) return null; if (u16(entry) === 0x0112) { - // Type SHORT: the value sits in the first two bytes of the 4-byte - // value field, in the TIFF byte order. const value = u16(entry + 8); return value >= 1 && value <= 8 ? value : null; } @@ -392,7 +349,6 @@ function readExifOrientation(buf: Buffer, start: number, end: number): number | function getSuffix(path: string): string { const idx = path.lastIndexOf('.'); if (idx === -1) return ''; - // POSIX `.suffix` treats `foo.tar.gz` → `.gz` and `foo/.bashrc` → '' (leading-dot is "no suffix"). const lastSep = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); if (idx <= lastSep + 1) return ''; return path.slice(idx).toLowerCase(); @@ -413,10 +369,6 @@ export function detectFileType( mediaHint = { kind: 'video', mimeType: VIDEO_MIME_BY_SUFFIX[suffix]! }; } - // When a header is supplied, cross-validate against the ext hint by - // default: a kind mismatch reports `unknown` rather than blindly trusting - // either signal. Media readers treat bytes as authoritative and only fall - // back to media suffixes when the header cannot be sniffed. if (header !== undefined) { const buf = toBuffer(header); const sniffed = sniffMediaFromMagic(buf); @@ -430,27 +382,15 @@ export function detectFileType( } return sniffed; } - // Sniff failed. - // An image extension without confirming magic is not an image in any mode. - // Every image format the model accepts (PNG/JPEG/GIF/WebP) has a reliable - // signature, so trusting the extension would only mislead: in media mode it - // builds a mismatched data URL the model API rejects; in text mode it - // redirects the user to ReadMediaFile for a file that is not an image. if (mediaHint?.kind === 'image') { return { kind: 'unknown', mimeType: '' }; } - // In media mode, fall back to the extension for video: some containers - // (e.g. MPEG-PS `.mpg`) have no magic we recognise, so the extension is - // the only signal. Runs before the NUL check so a video extension wins - // even when the header happens to contain a 0x00 byte. if (type === 'media' && mediaHint?.kind === 'video') { return mediaHint; } if (buf.includes(0x00)) { return { kind: 'unknown', mimeType: '' }; } - // No sniff, not an image hint, no NUL: fall through to the - // hint / text / unknown logic. } if (mediaHint) return mediaHint; diff --git a/packages/agent-core-v2/src/agent/media/image-compress.ts b/packages/agent-core-v2/src/agent/media/image-compress.ts index 0e66c8cf81..ecaf1a01f1 100644 --- a/packages/agent-core-v2/src/agent/media/image-compress.ts +++ b/packages/agent-core-v2/src/agent/media/image-compress.ts @@ -53,74 +53,29 @@ import { } from './image-format-policy'; import { decodeWebp, isAnimatedWebp } from './webp-decode'; -/** - * Built-in longest-edge ceiling (px). Larger images are scaled down to fit. - * This is the default only: the effective ceiling is resolved per call by - * {@link resolveMaxImageEdgePx} (explicit option > `[image] max_edge_px` config - * > this). The config value is pushed by the media-domain image-config bridge, - * which reads the env-resolved `image` section (`KIMI_IMAGE_MAX_EDGE_PX` wins - * over the file value over there — this module never reads env directly). - */ export const MAX_IMAGE_EDGE_PX = 2000; -/** - * The `[image] max_edge_px` value, pushed by the image-config bridge on load - * and on config change. Processes that never load config (or have no `[image]` - * section) leave this unset and get the built-in ceiling. - */ let configuredMaxImageEdgePx: number | undefined; -/** Push (or clear, with `undefined`) the configured longest-edge ceiling. */ export function setConfiguredMaxImageEdgePx(value: number | undefined): void { configuredMaxImageEdgePx = value !== undefined && isPositiveInt(value) ? value : undefined; } -/** - * Effective default longest-edge ceiling (px), for calls that pass no explicit - * `maxEdge`. Precedence: configured `[image] max_edge_px` (env already folded - * in by the config layer) > built-in {@link MAX_IMAGE_EDGE_PX}. - */ export function resolveMaxImageEdgePx(): number { return configuredMaxImageEdgePx ?? MAX_IMAGE_EDGE_PX; } -/** - * Raw-byte budget for a single image. base64 inflates bytes by ~4/3, so a - * 3.75 MB raw payload stays under a 5 MB encoded ceiling. Tune to the active - * provider's per-image limit. - */ export const IMAGE_BYTE_BUDGET = 3.75 * 1024 * 1024; -/** - * Built-in raw-byte budget for images the model reads for itself - * (ReadMediaFile's default path). Far below {@link IMAGE_BYTE_BUDGET}: a - * session that keeps screenshotting and reading images accumulates every one - * of them in the request body on every turn, so per-image size — not the - * provider's per-image ceiling — is what keeps the total under the provider's - * request-size limit. 256 KB keeps a clean UI screenshot on the lossless fast - * path while capping dense content at a readable q80/1000px JPEG; fine detail - * stays reachable through the `region` readback, which deliberately ignores - * this budget. Overridden by `[image] read_byte_budget` (env - * `KIMI_IMAGE_READ_BYTE_BUDGET` folded in by the config layer) via - * {@link resolveReadImageByteBudget}. - */ export const READ_IMAGE_BYTE_BUDGET = 256 * 1024; -/** The `[image] read_byte_budget` value; see {@link setConfiguredMaxImageEdgePx}. */ let configuredReadImageByteBudget: number | undefined; -/** Push (or clear, with `undefined`) the configured read-image byte budget. */ export function setConfiguredReadImageByteBudget(value: number | undefined): void { configuredReadImageByteBudget = value !== undefined && isPositiveInt(value) ? value : undefined; } -/** - * Effective read-image byte budget. Precedence mirrors - * {@link resolveMaxImageEdgePx}: configured `[image] read_byte_budget` (env - * already folded in by the config layer) > built-in - * {@link READ_IMAGE_BYTE_BUDGET}. - */ export function resolveReadImageByteBudget(): number { return configuredReadImageByteBudget ?? READ_IMAGE_BYTE_BUDGET; } @@ -128,79 +83,25 @@ export function resolveReadImageByteBudget(): number { function isPositiveInt(value: number): boolean { return Number.isInteger(value) && value > 0; } -/** Progressively lower JPEG quality until the payload fits the byte budget. */ const JPEG_QUALITY_STEPS = [80, 60, 40, 20] as const; -/** - * Longest-edge step-downs tried when the budget cannot be met at the fitted - * size. With the built-in 2000px ceiling the first step is a no-op; it - * matters when a larger ceiling is configured (config/env/option). The - * sub-1000px tail exists for small (read-scale) budgets: JPEG bytes shrink - * roughly linearly with pixel count, so stepping down to 256px lets even - * entropy-upper-bound content (noise, photos) land within any budget of a - * few tens of KB instead of stalling at the q20@1000px floor. - */ const FALLBACK_EDGES_PX = [2000, 1000, 768, 512, 384, 256] as const; -/** - * Floor below which lossless PNG rescaling stops and the encoder switches to - * the lossy JPEG ladder: past ~1000px the PNG bytes barely shrink (entropy, - * not dimensions, dominates) while readability keeps dropping — going - * lossless all the way to 256px produced unreadable screenshots that a - * modest JPEG would have beaten on both counts. Sub-floor edges still get - * the JPEG ladder so small budgets converge. - */ const PNG_RESCALE_FLOOR_PX = 1000; -/** - * Pixel-count ceiling above which we skip compression entirely. A tiny-byte, - * huge-dimension image (e.g. a solid 30000×30000 PNG) would otherwise be fully - * decoded into a multi-gigabyte bitmap by Jimp before any resize — a - * decompression-bomb OOM vector, since the byte budget alone never catches it. - * The header sniff gives us the dimensions without decoding, so we gate on them - * first. Set well above any legitimate photo/screenshot/scan (~100 MP); larger - * images pass through uncompressed, exactly as they did before compression - * existed. - */ const MAX_DECODE_PIXELS = 100_000_000; -/** - * Raw-byte ceiling above which compression is skipped rather than decoded. The - * byte budget bounds the *output*, but the compressor still has to load the - * *input* first: a huge base64 payload (e.g. an oversized or invalid image from - * an MCP tool) would be `Buffer.from`-decoded — and possibly handed to Jimp — - * before any downstream cap (like the 10 MB MCP per-part limit) can drop it. - * This bounds that input allocation. Set well above legitimate - * screenshots/photos; larger images pass through uncompressed. - */ const MAX_DECODE_BYTES = 64 * 1024 * 1024; -/** Formats we can decode and re-encode. WebP decodes via the bundled wasm - * codec and re-encodes through the PNG/JPEG ladder (animated WebP is gated - * out per call site). */ const RECODABLE_MIME = new Set(['image/png', 'image/jpeg', 'image/webp']); export interface CompressImageOptions { - /** Override the longest-edge ceiling (px). */ readonly maxEdge?: number; - /** Override the raw-byte budget. */ readonly byteBudget?: number; - /** Override the raw-byte ceiling above which compression is skipped. */ readonly maxDecodeBytes?: number; - /** - * Report an `image_compress` event per compression call (and an - * `image_crop` event per {@link cropImageForModel} call). Absent → silent. - */ readonly telemetry?: ImageCompressionTelemetry; } -/** - * Telemetry sink for the compression events. Deliberately a loose local - * contract — this L0 support module must not import the app-layer telemetry - * registry, so the payload shapes are checked only where the registry lives - * (`#/app/telemetry/events`); any object with a compatible `track` slot - * (e.g. the app-layer `ITelemetryService`) satisfies it structurally. - */ export interface ImageCompressionTelemetryClient { track( event: string, @@ -208,21 +109,11 @@ export interface ImageCompressionTelemetryClient { ): void; } -/** Wiring for the optional compression telemetry events. */ export interface ImageCompressionTelemetry { readonly client: ImageCompressionTelemetryClient; - /** Where the image entered the pipeline, e.g. 'read_media', 'tui_paste'. */ readonly source: string; } -/** - * How a compression call ended, as reported in the `image_compress` event. - * Every `passthrough_*` variant returns the input bytes unchanged: `fast` is - * the within-budgets hot path, `guard` a decode-safety refusal (pixel bomb or - * byte cap), `unsupported` a format the codec cannot re-encode (or empty - * input), `unhelpful` a re-encode that saved neither bytes nor pixels, and - * `error` a decode/encode failure. - */ type CompressOutcome = | 'compressed' | 'passthrough_fast' @@ -232,35 +123,17 @@ type CompressOutcome = | 'passthrough_error'; export interface CompressImageResult { - /** Bytes to send: the re-encoded image, or the original when unchanged. */ readonly data: Uint8Array; - /** MIME of `data`. May differ from the input (e.g. png → jpeg). */ readonly mimeType: string; - /** Pixel width of `data`; falls back to the input size when unknown. */ readonly width: number; - /** Pixel height of `data`; falls back to the input size when unknown. */ readonly height: number; - /** - * Pixel width of the input image, in display space (EXIF orientation - * applied): the decoded width when re-encoded, the header sniff on - * passthrough (0 when it cannot be determined). - */ readonly originalWidth: number; - /** Pixel height of the input image; see {@link originalWidth}. */ readonly originalHeight: number; - /** True only when `data` differs from the input bytes. */ readonly changed: boolean; readonly originalByteLength: number; readonly finalByteLength: number; } -/** - * Downsample/re-encode `bytes` to fit the pixel + byte budget. - * - * Never throws: on any failure (unsupported format, decode error, a result - * that would be larger than the input) the original bytes are returned with - * `changed: false`. - */ export async function compressImageForModel( bytes: Uint8Array, mimeType: string, @@ -296,15 +169,11 @@ export async function compressImageForModel( }; if (bytes.length === 0) return finish('passthrough_unsupported', passthrough()); - // Only re-encode formats the codec handles; everything else passes through. if (!RECODABLE_MIME.has(normalizedMime)) return finish('passthrough_unsupported', passthrough()); - // Animated WebP would be flattened to one frame by decoding — pass it - // through whole, the same reason GIF is never re-encoded. if (normalizedMime === 'image/webp' && isAnimatedWebp(bytes)) { return finish('passthrough_unsupported', passthrough()); } - // Fast path: already within both budgets — no codec load, no allocation. const longestEdge = dims ? Math.max(dims.width, dims.height) : 0; const withinBytes = bytes.length <= byteBudget; const withinEdge = longestEdge > 0 && longestEdge <= maxEdge; @@ -312,30 +181,17 @@ export async function compressImageForModel( return finish('passthrough_fast', passthrough()); } - // Decompression-bomb guard: refuse to decode absurd pixel counts. The sniff - // above gave us the dimensions without decoding, so this costs nothing. if (dims && dims.width * dims.height > MAX_DECODE_PIXELS) { return finish('passthrough_guard', passthrough()); } - // Refuse to decode very large byte payloads (e.g. a huge or invalid image - // from an MCP tool) that would be loaded just to be dropped downstream. if (bytes.length > maxDecodeBytes) return finish('passthrough_guard', passthrough()); try { const image = await decodeToJimp(bytes, normalizedMime); - // WebP joins PNG on the lossless-first ladder: both carry alpha and - // screenshot-grade detail that the PNG rungs preserve. const preferLossless = normalizedMime !== 'image/jpeg'; - // The decoded bitmap is authoritative for the original size: jimp - // applies EXIF orientation while decoding, and this is the coordinate - // space the encoded result and any later crop region (see - // cropImageForModel, which decodes the same way) actually live in. The - // header sniff also reports display space, but can miss formats or - // nonconforming EXIF that the decoder still handles. const decodedWidth = image.width; const decodedHeight = image.height; - // Scale so the longest edge fits maxEdge (never enlarges). fitWithinEdge(image, maxEdge); const encoded = await encodeWithinBudget(image, { @@ -344,10 +200,6 @@ export async function compressImageForModel( fallbackEdges: FALLBACK_EDGES_PX, }); - // Keep the result when it actually helps: fewer bytes, or fewer pixels - // (a smaller image costs fewer vision tokens even if the byte count is - // flat, as with near-solid graphics). Otherwise the re-encode bought us - // nothing — send the original. const originalPixels = decodedWidth * decodedHeight; const finalPixels = encoded.width * encoded.height; const shrankBytes = encoded.data.length < bytes.length; @@ -366,7 +218,6 @@ export async function compressImageForModel( finalByteLength: encoded.data.length, }); } catch { - // Decode/encode failure — keep the original bytes. return finish('passthrough_error', passthrough()); } } @@ -374,36 +225,20 @@ export async function compressImageForModel( export interface CompressBase64Result { readonly base64: string; readonly mimeType: string; - /** Pixel width of the (possibly re-encoded) payload; 0 when unknown. */ readonly width: number; - /** Pixel height of the (possibly re-encoded) payload; 0 when unknown. */ readonly height: number; - /** - * Pixel width of the input image, in display space (EXIF orientation - * applied): the decoded width when re-encoded, the header sniff on - * passthrough (0 when it cannot be determined). - */ readonly originalWidth: number; - /** Pixel height of the input image; see {@link originalWidth}. */ readonly originalHeight: number; readonly changed: boolean; readonly originalByteLength: number; readonly finalByteLength: number; } -/** - * Convenience wrapper for call sites that already hold base64 (MCP results, - * data URLs). Decodes, compresses, and re-encodes to base64. Best effort: - * returns the original base64 unchanged on any failure. - */ export async function compressBase64ForModel( base64: string, mimeType: string, options: CompressImageOptions = {}, ): Promise { - // Skip very large payloads before allocating: base64 decodes to ~3/4 its - // length, so a payload whose decoded size would exceed the cap is passed - // through without the Buffer.from allocation (and without touching Jimp). const startedAt = Date.now(); const maxDecodeBytes = options.maxDecodeBytes ?? MAX_DECODE_BYTES; const approxBytes = Math.floor((base64.length * 3) / 4); @@ -452,7 +287,6 @@ export async function compressBase64ForModel( }); return result; } - // The event for this call is emitted inside compressImageForModel. const result = await compressImageForModel(bytes, mimeType, options); if (!result.changed) { return { @@ -481,60 +315,20 @@ export async function compressBase64ForModel( } export interface CompressedContentParts { - /** The input parts with oversized inline images re-encoded in place. */ readonly parts: ContentPart[]; - /** - * One {@link buildImageCompressionCaption} note per re-encoded image, in - * encounter order, when `annotate` is set. Returned as data — never - * inserted into `parts` — so the caller picks the channel (the MCP path - * joins them into the tool result's `note`) and quoted caption text in - * the tool's own output can never be mistaken for a generated one. - */ readonly captions: readonly string[]; } -/** - * Enforce the provider-accepted image format set (see ./image-format-policy) - * on a content-part list. Inline `data:` image parts whose MIME is outside - * the accepted set are dropped and replaced with a text notice, so one - * unsupported image cannot poison the session history. Accepted images are - * forwarded only as the byte-exact canonical data URL: an alias - * (`image/jpg`), case/whitespace variants, or MIME parameters - * (`image/jpeg;charset=utf-8`) all rebuild to the bare canonical form, - * because strict provider whitelists exact-match the full header. Remote - * (non-data) image URLs and non-image parts pass through — a URL carries no - * bytes to inspect. - * - * The BYTES are authoritative, not the declared MIME: the header of each - * inline image is sniffed, and a mismatch (e.g. AVIF bytes an MCP image - * search tool labels `image/png`) is gated on what the image IS — the - * provider decodes bytes, not labels. When the sniffer doesn't recognize - * the bytes (corrupt image, exotic container), the declared MIME stands - * and the 400-recovery path remains the backstop. - * - * This is the format gate shared by every ingestion point; run it BEFORE - * compression so unsupported bytes are never decoded. - */ export function gateImageFormatParts(parts: readonly ContentPart[]): ContentPart[] { const out: ContentPart[] = []; for (const part of parts) { if (part.type === 'image_url') { const parsed = parseImageDataUrl(part.imageUrl.url); if (parsed === null) { - // A `data:` URL that failed to parse (missing `;base64,` separator, - // empty MIME, …) is guaranteed to fail at the provider — Anthropic - // throws on it, OpenAI-compat servers 400. Drop it for a notice at - // ingestion instead of leaving it to poison the session and trigger - // the media-stripped resend on every later turn. if (isDataUrl(part.imageUrl.url)) { out.push({ type: 'text', text: buildMalformedImageNotice(part.imageUrl.url) }); continue; } - // Remote image URL (no bytes to sniff): reject when its path - // extension names a format providers reject (e.g. a search-tool - // link ending in `.avif`) — the notice keeps the URL so the model - // can still fetch and convert the image. Extensionless / unknown - // URLs pass through to the provider — and to the 400 recovery. const extMime = unsupportedImageMimeFromUrl(part.imageUrl.url); if (extMime !== null) { out.push({ @@ -565,26 +359,6 @@ export function gateImageFormatParts(parts: readonly ContentPart[]): ContentPart return out; } -/** - * Compress any inline base64 image parts in a content-part list — used by - * the MCP tool-result path. Image parts whose URL is not a `data:` URL - * (e.g. a remote http(s) image) are passed through, as are non-image parts. - * Best effort: a part that fails to compress is left unchanged. - * - * The format gate ({@link gateImageFormatParts}) runs first: parts whose - * MIME is outside the provider-accepted set are never forwarded — the part - * is dropped and a text notice stands in, so one unsupported image cannot - * poison the session history. This is the MCP funnel's enforcement point — - * MCP servers can return any `image/*` MIME (e.g. AVIF from an image search - * tool). - * - * With `annotate` set, every image that was actually re-encoded gets a - * caption in {@link CompressedContentParts.captions} so the model knows it - * is looking at a downsampled copy. `annotate.persistOriginal` additionally - * saves the pre-compression bytes and puts the returned path in the caption - * so the model can read the original back; persistence failures degrade to - * a caption without a path. - */ export async function compressImageContentParts( parts: readonly ContentPart[], options: CompressImageOptions & { readonly annotate?: CompressAnnotateOptions } = {}, @@ -642,19 +416,10 @@ export async function compressImageContentParts( } export interface CompressAnnotateOptions { - /** - * Persist the pre-compression original bytes somewhere the model can read - * them back; return the absolute path, or null when persistence failed. - */ readonly persistOriginal?: (bytes: Uint8Array, mimeType: string) => Promise; } -// ── crop ───────────────────────────────────────────────────────────── -/** - * Crop rectangle in ORIGINAL-image pixel coordinates — the decoded, - * EXIF-rotated space that compression results report as the original size. - */ export interface ImageCropRegion { readonly x: number; readonly y: number; @@ -663,11 +428,6 @@ export interface ImageCropRegion { } export interface CropImageOptions extends CompressImageOptions { - /** - * Keep the crop at native resolution (no edge-fit downscale). The byte - * budget still applies: a crop that cannot be encoded within it fails - * explicitly instead of being silently degraded. - */ readonly skipResize?: boolean; } @@ -675,15 +435,11 @@ export interface CropImageSuccess { readonly ok: true; readonly data: Uint8Array; readonly mimeType: string; - /** Pixel size of the encoded crop actually produced. */ readonly width: number; readonly height: number; - /** Pixel size of the source image the region was cut from. */ readonly originalWidth: number; readonly originalHeight: number; - /** The region actually applied, after clamping to the image bounds. */ readonly region: ImageCropRegion; - /** True when the crop was downscaled to fit the pixel/byte budget. */ readonly resized: boolean; readonly originalByteLength: number; readonly finalByteLength: number; @@ -691,24 +447,11 @@ export interface CropImageSuccess { export interface CropImageFailure { readonly ok: false; - /** Human/model-readable reason, safe to surface as a tool error. */ readonly error: string; } export type CropImageOutcome = CropImageSuccess | CropImageFailure; -/** - * Cut `region` out of `bytes` and encode it for the model. - * - * Unlike {@link compressImageForModel}, cropping is an explicit request: it - * never falls back to the full image. Anything that prevents an accurate crop - * (unsupported format, undecodable bytes, a region outside the image, a - * skipResize result over the byte budget) returns `ok: false` with a reason - * the caller can hand straight back to the model. - * - * The default path fits the crop to the usual pixel/byte budgets; a crop no - * larger than the edge cap is therefore delivered at native resolution. - */ export async function cropImageForModel( bytes: Uint8Array, mimeType: string, @@ -739,13 +482,9 @@ export async function cropImageForModel( `Cropping is only supported for PNG, JPEG, and WebP images; got ${mimeType}.`, ); } - // A crop is a still image by definition; decoding an animated WebP would - // silently crop a single frame, so refuse explicitly. if (normalizedMime === 'image/webp' && isAnimatedWebp(bytes)) { return fail('unsupported_format', 'Cropping is not supported for animated WebP images.'); } - // NaN slips past every = comparison in the bounds guard below, so gate - // on finiteness explicitly rather than surfacing a codec-internal error. if ( ![region.x, region.y, region.width, region.height].every((value) => Number.isFinite(value)) ) { @@ -784,14 +523,9 @@ export async function cropImageForModel( const h = Math.min(Math.floor(region.height), originalHeight - y); const applied: ImageCropRegion = { x, y, width: w, height: h }; image.crop({ x, y, w, h }); - // WebP joins PNG on the lossless side: both carry alpha and - // screenshot-grade detail that PNG output preserves. const preferLossless = normalizedMime !== 'image/jpeg'; if (options.skipResize === true) { - // Native resolution requested: encode once, favoring fidelity (lossless - // PNG, or high-quality JPEG), and refuse rather than degrade when the - // result cannot fit the byte budget. const buffer = preferLossless ? await image.getBuffer('image/png', { deflateLevel: 9 }) : await image.getBuffer('image/jpeg', { quality: 90 }); @@ -846,10 +580,8 @@ export async function cropImageForModel( } } -// ── compression caption ────────────────────────────────────────────── export interface ImageVariantDescription { - /** Pixel size; pass 0 when unknown to omit the dimensions. */ readonly width: number; readonly height: number; readonly byteLength: number; @@ -859,26 +591,9 @@ export interface ImageVariantDescription { export interface ImageCompressionCaptionInput { readonly original: ImageVariantDescription; readonly final: ImageVariantDescription; - /** Absolute path where the pre-compression original can be read back. */ readonly originalPath?: string | null; } -/** - * Render the shared `` note placed next to a compressed image so the - * model knows it is looking at a downsampled copy: what the original was, what - * was actually sent, and — when the original is on disk — where to read it - * back (via ReadMediaFile `region`) for full-fidelity detail. - * - * Two channels consume this note differently: - * - Tool results (MCP images): {@link compressImageContentParts} returns - * the captions as data and the MCP output pipeline joins them into the - * result's `note` side channel (rendered to the model at projection - * time, never to UIs). - * - User prompts must not render raw `` markup in the UI, so the - * prompt layer detects the caption via - * {@link extractImageCompressionCaptions} and reroutes it through the - * built-in system-reminder injection (hidden by its `injection` origin). - */ export function buildImageCompressionCaption(input: ImageCompressionCaptionInput): string { const sentences = [ `Image compressed to fit model limits: original ${describeImageVariant(input.original)} -> ` + @@ -897,36 +612,15 @@ export function buildImageCompressionCaption(input: ImageCompressionCaptionInput return `${sentences.join(' ')}`; } -/** - * Fixed opening every {@link buildImageCompressionCaption} note starts with — - * the anchor {@link extractImageCompressionCaptions} matches on. Keep the two - * in sync. - */ const CAPTION_OPENING = 'Image compressed to fit model limits:'; -/** - * A full caption embedded in arbitrary text. The body is sentences plus a - * quoted file path and never contains ``, so the non-greedy scan to - * the closing tag is exact. - */ const CAPTION_PATTERN = /(Image compressed to fit model limits:[\s\S]*?)<\/system>/g; export interface ImageCompressionCaptionExtraction { - /** Caption bodies found, in order, without the `` wrapper. */ readonly captions: readonly string[]; - /** The input text with every caption removed. */ readonly text: string; } -/** - * Find every {@link buildImageCompressionCaption} note embedded in `text` and - * return the unwrapped caption bodies plus the text without them. Prompt - * ingestion (server upload/base64 route, TUI paste, ACP) places the caption - * inline next to the image — sometimes merged into an adjacent text segment — - * and the prompt layer uses this to reroute the note through the built-in - * system-reminder injection instead of leaving raw `` markup in the - * user-visible message. - */ export function extractImageCompressionCaptions(text: string): ImageCompressionCaptionExtraction { if (!text.includes(CAPTION_OPENING)) return { captions: [], text }; const captions: string[] = []; @@ -945,16 +639,13 @@ function describeImageVariant(variant: ImageVariantDescription): string { return size; } -/** Human-readable byte size: `640 B`, `128 KB`, `3.8 MB`. */ export function formatByteSize(bytes: number): string { if (bytes < 1024) return `${String(bytes)} B`; if (bytes < 1024 * 1024) return `${String(Math.round(bytes / 1024))} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } -// ── internals ──────────────────────────────────────────────────────── -/** The concrete jimp image instance type, derived from the lazily-loaded module. */ type JimpImage = Awaited>; interface EncodedImage { @@ -965,20 +656,11 @@ interface EncodedImage { } interface EncodeOptions { - /** Lossless-first (PNG rungs before JPEG): PNG and WebP sources, which - * carry alpha and screenshot-grade detail. JPEG sources skip straight to - * the quality ladder — their detail is already lossy. */ readonly preferLossless: boolean; readonly byteBudget: number; readonly fallbackEdges: readonly number[]; } -/** - * Decode `bytes` into a jimp image. PNG/JPEG decode through jimp itself - * (which applies EXIF orientation); WebP decodes through the bundled wasm - * codec and enters jimp as a raw RGBA bitmap (WebP carries no EXIF-style - * orientation, so the decoded pixels are already display space). - */ async function decodeToJimp(bytes: Uint8Array, normalizedMime: string): Promise { const { Jimp } = await import('jimp'); if (normalizedMime === 'image/webp') { @@ -992,24 +674,6 @@ async function decodeToJimp(bytes: Uint8Array, normalizedMime: string): Promise< return Jimp.fromBuffer(Buffer.from(bytes)); } -/** - * Encode `image` (already fitted to the edge ceiling) under the byte budget. - * - * Strategy — prefer the source format so a downscaled screenshot stays lossless - * PNG (preserving text and transparency), and only fall back to lossy JPEG when - * PNG cannot meet the byte budget: - * - PNG source: PNG at the fitted size → smaller PNG rescales down to the - * {@link PNG_RESCALE_FLOOR_PX} floor → JPEG ladder at that size → JPEG - * ladder again at each sub-floor edge. - * - JPEG source: the full quality ladder at the fitted size, then again at - * each fallback edge — a smaller rescale must not skip the high-quality - * rungs its extra pixels just paid for. - * - * The sub-floor edges make the ladder converge for small (read-scale) - * budgets: any budget of a few tens of KB is met by q20 at 256px even for - * entropy-upper-bound content. Below that, the smallest buffer produced is - * still returned — the caller gates on whether it actually helped. - */ async function encodeWithinBudget(image: JimpImage, opts: EncodeOptions): Promise { const { preferLossless, byteBudget, fallbackEdges } = opts; let smallest: EncodedImage | null = null; @@ -1032,13 +696,10 @@ async function encodeWithinBudget(image: JimpImage, opts: EncodeOptions): Promis }; if (preferLossless) { - // Lossless PNG first: best for screenshots/UI (sharp text) and keeps alpha. const png = await image.getBuffer('image/png', { deflateLevel: 9 }); if (png.length <= byteBudget) return consider(png, 'image/png'); consider(png, 'image/png'); - // Over budget: progressively smaller PNGs (down to the floor) before - // going lossy. for (const edge of fallbackEdges) { if (edge < PNG_RESCALE_FLOOR_PX) break; if (!fitWithinEdge(image, edge)) continue; @@ -1047,8 +708,6 @@ async function encodeWithinBudget(image: JimpImage, opts: EncodeOptions): Promis consider(smallerPng, 'image/png'); } - // Lossy JPEG ladder (drops transparency) at the floored size, then at - // each sub-floor edge until the budget is met. const atFloor = await jpegLadder(); if (atFloor !== null) return atFloor; for (const edge of fallbackEdges) { @@ -1060,8 +719,6 @@ async function encodeWithinBudget(image: JimpImage, opts: EncodeOptions): Promis return smallest!; } - // JPEG source: quality ladder at the fitted size, then the full ladder - // again at each fallback rescale. const atFitted = await jpegLadder(); if (atFitted !== null) return atFitted; for (const edge of fallbackEdges) { @@ -1073,17 +730,6 @@ async function encodeWithinBudget(image: JimpImage, opts: EncodeOptions): Promis return smallest!; } -/** - * Scale `image` so its longest edge is at most `edge`, preserving aspect - * ratio. No-op (returns false) when the image already fits. - * - * Deliberately passes no `mode`: without one, jimp's default resizer - * downscales with a full-coverage area average (every source pixel - * contributes to the output), which does not alias. The named - * ResizeStrategy modes (BILINEAR, BICUBIC, …) switch to point-sampled - * interpolation that skips source pixels beyond ~2x reduction and produces - * moiré on text and fine patterns — do not "upgrade" this call to one. - */ function fitWithinEdge(image: JimpImage, edge: number): boolean { const longest = Math.max(image.width, image.height); if (longest <= edge) return false; @@ -1095,9 +741,7 @@ function fitWithinEdge(image: JimpImage, edge: number): boolean { return true; } -// ── telemetry ──────────────────────────────────────────────────────── -/** Failure classification carried by the `image_crop` event. */ type CropErrorKind = | 'empty' | 'unsupported_format' @@ -1107,7 +751,6 @@ type CropErrorKind = | 'budget' | 'decode_failed'; -/** The subset of a compression result the `image_compress` event reads. */ interface CompressEventResult { readonly mimeType: string; readonly width: number; @@ -1118,11 +761,6 @@ interface CompressEventResult { readonly finalByteLength: number; } -/** - * Emit the `image_compress` event. Properties are all numeric/enum — never - * paths or content — and a throwing client is swallowed so telemetry can - * never affect the compression result. - */ function reportCompressEvent( telemetry: ImageCompressionTelemetry | undefined, input: { @@ -1150,14 +788,9 @@ function reportCompressEvent( duration_ms: Date.now() - input.startedAt, }); } catch { - // Telemetry must never affect the compression result. } } -/** - * Emit the `image_crop` event. Reports the region as a share of the original - * pixel area rather than raw coordinates. - */ function reportCropEvent( telemetry: ImageCompressionTelemetry | undefined, input: { @@ -1187,6 +820,5 @@ function reportCropEvent( duration_ms: Date.now() - input.startedAt, }); } catch { - // Telemetry must never affect the crop outcome. } } diff --git a/packages/agent-core-v2/src/agent/media/image-format-policy.ts b/packages/agent-core-v2/src/agent/media/image-format-policy.ts index 7b83322d03..d8233b0756 100644 --- a/packages/agent-core-v2/src/agent/media/image-format-policy.ts +++ b/packages/agent-core-v2/src/agent/media/image-format-policy.ts @@ -31,7 +31,6 @@ import { IMAGE_MIME_BY_SUFFIX, sniffMediaFromMagic } from './file-type'; -/** Image MIME types every provider accepts. The closed set. */ export const MODEL_ACCEPTED_IMAGE_MIMES: ReadonlySet = new Set([ 'image/png', 'image/jpeg', @@ -39,23 +38,12 @@ export const MODEL_ACCEPTED_IMAGE_MIMES: ReadonlySet = new Set([ 'image/webp', ]); -/** Human-readable list of the accepted formats, for notices. */ const ACCEPTED_FORMATS_TEXT = 'PNG, JPEG, GIF, and WebP'; interface UnsupportedImageFormatInfo { - /** - * A format-specific Linux decoder named in the conversion guidance (e.g. - * heif-convert for HEIC/HEIF). Other OSes, and formats without a dedicated - * decoder, are guided to sips (macOS) or ImageMagick. - */ readonly linuxDecoder?: { readonly command: string; readonly packageName: string }; } -/** - * Unsupported formats worth tailoring the guidance for, by normalized MIME. - * A missing entry still means "refuse" — the entry only adds a - * format-specific conversion hint. - */ const UNSUPPORTED_IMAGE_FORMATS: Readonly> = Object.freeze({ 'image/avif': {}, @@ -66,13 +54,6 @@ const UNSUPPORTED_IMAGE_FORMATS: Readonly 80 ? `${url.slice(0, 80)}…` : url; return ( diff --git a/packages/agent-core-v2/src/agent/media/image-originals.ts b/packages/agent-core-v2/src/agent/media/image-originals.ts index c3db43fae7..321b02f488 100644 --- a/packages/agent-core-v2/src/agent/media/image-originals.ts +++ b/packages/agent-core-v2/src/agent/media/image-originals.ts @@ -30,8 +30,7 @@ import { mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -/** Per-store ceiling; the sweep evicts oldest files beyond this. */ -const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024; // 1 GiB +const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024; const MIME_EXTENSION: Readonly> = { 'image/png': 'png', @@ -44,36 +43,18 @@ const MIME_EXTENSION: Readonly> = { }; export interface PersistOriginalImageOptions { - /** - * Target directory — pass `sessionMediaOriginalsDir(sessionDir)` when the - * session is known. Defaults to the shared temp-dir fallback. - */ readonly dir?: string; - /** Override the store size cap in bytes (tests). */ readonly maxTotalBytes?: number; } -/** - * Fallback store used when a call site has no session context: - * `/kimi-code-original-images`. - */ export function originalImageCacheDir(): string { return join(tmpdir(), 'kimi-code-original-images'); } -/** - * The session-owned originals store: `/media-originals`. Sits - * next to the session's other artifacts (`tasks/`, `cron/`, `logs/`, - * `agents/`) and is removed with the session. - */ export function sessionMediaOriginalsDir(sessionDir: string): string { return join(sessionDir, 'media-originals'); } -/** - * Persist `bytes` into the originals store and return the absolute file - * path, or null on any failure. Idempotent for identical bytes. - */ export async function persistOriginalImage( bytes: Uint8Array, mimeType: string, @@ -89,14 +70,11 @@ export async function persistOriginalImage( await mkdir(dir, { recursive: true }); const existing = await stat(path).catch(() => null); - // Content-addressed: an existing entry with the right size IS this image. if (existing === null || existing.size !== bytes.length) { await writeFile(path, bytes); } await sweepCache(dir, maxTotalBytes); - // The just-written file may itself have been evicted by the sweep when a - // single original exceeds the cap; report persistence honestly. const persisted = await stat(path).catch(() => null); return persisted === null ? null : path; } catch { @@ -104,7 +82,6 @@ export async function persistOriginalImage( } } -/** Evict oldest files (by mtime) until the store fits `maxTotalBytes`. */ async function sweepCache(dir: string, maxTotalBytes: number): Promise { const names = await readdir(dir); const entries: { path: string; size: number; mtimeMs: number }[] = []; diff --git a/packages/agent-core-v2/src/agent/media/imageConfigBridge.ts b/packages/agent-core-v2/src/agent/media/imageConfigBridge.ts index 6ccb7001fa..416391e2ca 100644 --- a/packages/agent-core-v2/src/agent/media/imageConfigBridge.ts +++ b/packages/agent-core-v2/src/agent/media/imageConfigBridge.ts @@ -42,10 +42,6 @@ export class ImageConfigBridge extends Disposable implements IImageConfigBridge constructor(@IConfigService private readonly config: IConfigService) { super(); - // Push the current effective value immediately (covers the already-loaded - // case), then re-push whenever the `image` section changes (load / reload / - // set). The event carries the env-resolved effective value, so env overrides - // are reflected without this bridge reading env. this.push(this.config.get(IMAGE_SECTION)); this._register( this.config.onDidSectionChange((e) => { diff --git a/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts index c56e4f67fd..e8851b28bb 100644 --- a/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts +++ b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts @@ -38,7 +38,6 @@ export class AgentMediaToolsRegistrar extends Disposable implements IAgentMediaT declare readonly _serviceBrand: undefined; private registration: IDisposable | undefined; - /** `alias|image_in|video_in` of the last registration; re-register on change. */ private registeredKey: string | undefined; constructor( @@ -49,8 +48,6 @@ export class AgentMediaToolsRegistrar extends Disposable implements IAgentMediaT @IHostEnvironment private readonly env: IHostEnvironment, @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, @ITelemetryService private readonly telemetry: ITelemetryService, - // Optional so unit tests that construct the registrar directly (bypassing - // DI) keep working; always registered in production scopes. @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, ) { super(); @@ -76,10 +73,6 @@ export class AgentMediaToolsRegistrar extends Disposable implements IAgentMediaT this.registration = registerMediaTools(this.toolRegistry, { fs: this.fs, env: this.env, - // Live view: `workDir` is runtime-mutable (`/cwd`), and the tool keeps - // its WorkspaceConfig across calls, so a snapshot would go stale. Skill - // roots are merged per read for the same reason (the catalog loads - // asynchronously and gains roots on plugin reloads). workspace: { get workspaceDir() { return workspaceCtx.workDir; diff --git a/packages/agent-core-v2/src/agent/media/registerMediaTools.ts b/packages/agent-core-v2/src/agent/media/registerMediaTools.ts index afa0252432..521075be38 100644 --- a/packages/agent-core-v2/src/agent/media/registerMediaTools.ts +++ b/packages/agent-core-v2/src/agent/media/registerMediaTools.ts @@ -31,18 +31,9 @@ export interface RegisterMediaToolsDeps { readonly workspace: WorkspaceConfig; readonly capabilities: ModelCapability; readonly videoUploader?: VideoUploader; - /** Sink for the `image_compress` / `image_crop` events (source 'read_media'). */ readonly telemetry?: ITelemetryService; } -/** - * Register the media tools against the agent tool registry. - * - * Registers `ReadMediaFile` only when the active model supports image or - * video input. Returns an `IDisposable` that unregisters whatever was - * registered (a no-op when nothing matched), so the caller can tie it to a - * lifecycle and re-run registration cleanly on capability changes. - */ export function registerMediaTools( toolRegistry: IAgentToolRegistryService, deps: RegisterMediaToolsDeps, @@ -62,16 +53,6 @@ export function registerMediaTools( ); } -/** - * Bind a runnable Model's `uploadVideo` into the `VideoUploader` shape the - * media tool expects. Returns `undefined` when the Model does not support - * video upload, in which case the tool falls back to an inline data URL. - * - * With `telemetry` set, every upload reports a `video_upload` event — outcome - * (success/error), byte size, mime type, duration, and the caller's static - * props (model alias, protocol). A throwing telemetry client never affects - * the upload outcome. - */ export function createVideoUploader( model: Pick | undefined, telemetry?: VideoUploadTelemetry, @@ -92,7 +73,6 @@ export function createVideoUploader( try { telemetry.client.track2('video_upload', props); } catch { - // Telemetry must never affect the upload outcome. } }; try { @@ -111,9 +91,7 @@ export function createVideoUploader( }; } -/** Wiring for the optional `video_upload` telemetry events. */ export interface VideoUploadTelemetry { readonly client: ITelemetryService; - /** Static properties merged into every event, e.g. model alias and protocol. */ readonly props?: Pick; } diff --git a/packages/agent-core-v2/src/agent/media/tools/read-media.ts b/packages/agent-core-v2/src/agent/media/tools/read-media.ts index 92ed15cfb0..583381b907 100644 --- a/packages/agent-core-v2/src/agent/media/tools/read-media.ts +++ b/packages/agent-core-v2/src/agent/media/tools/read-media.ts @@ -66,7 +66,6 @@ import { literalRulePattern, matchesPathRuleSubject } from '#/tool/rule-match'; import { renderPrompt } from '#/_base/utils/render-prompt'; import readMediaDescriptionHead from './read-media.md?raw'; -// ── Constants ──────────────────────────────────────────────────────── const MAX_MEDIA_MEGABYTES = 100; const MAX_MEDIA_BYTES = MAX_MEDIA_MEGABYTES * 1024 * 1024; @@ -75,7 +74,6 @@ export type VideoUploadInput = ProviderVideoUploadInput; export type VideoUploader = (input: VideoUploadInput) => Promise; -// ── Input schema ───────────────────────────────────────────────────── export const ReadMediaFileInputSchema = z.object({ path: z @@ -110,7 +108,6 @@ export const ReadMediaFileInputSchema = z.object({ export type ReadMediaFileInput = z.infer; -// ── Tool description (capability-driven) ───────────────────────────── function buildDescription(capabilities: ModelCapability): string { const head = renderPrompt(readMediaDescriptionHead, { MAX_MEDIA_MEGABYTES }); @@ -135,38 +132,17 @@ function buildDescription(capabilities: ModelCapability): string { return lines.join('\n'); } -// ── System summary ─────────────────────────────────────────────────── -/** - * How the image payload placed after the summary relates to the file on disk. - * Reported verbatim so the model always knows when it is looking at a - * degraded copy (and how to get the detail back) — silent downsampling reads - * as "the image is just blurry" and quietly degrades the model's work. - */ interface ImageDelivery { readonly kind: 'untouched' | 'downsampled' | 'crop' | 'full'; - /** Pixel size of the payload actually sent; 0 when unknown. */ readonly width: number; readonly height: number; readonly byteLength: number; readonly mimeType: string; - /** The crop actually applied (clamped), for kind 'crop'. */ readonly region?: ImageCropRegion; - /** For kind 'crop': the crop was additionally downscaled to fit budgets. */ readonly resized?: boolean; } -/** - * Build the media summary returned as the tool result's `note` (model-only - * side channel). The `` wrapping is this tool's wording choice; the - * note channel itself adds nothing. - * - * Carries mime type, byte size and (for images) the original pixel - * dimensions, plus the delivery note above. When the dimensions are known it - * also guides the model to derive absolute coordinates from that original - * size (crops get offset-mapping guidance instead); it always reminds the - * model to re-read any media it generates or edits. - */ function buildMediaNote(input: { readonly kind: 'image' | 'video'; readonly mimeType: string; @@ -179,9 +155,6 @@ function buildMediaNote(input: { `Mime type: ${input.mimeType}.`, `Size: ${String(input.byteSize)} bytes.`, ]; - // Coordinate guidance is only emitted when the original size is actually - // known — sniffing fails for some image formats (TIFF/ICO/HEIC/…), and - // telling the model to use a size that is not in the block would mislead it. if (input.kind === 'image' && input.dimensions) { parts.push( `Original dimensions: ${String(input.dimensions.width)}x${String(input.dimensions.height)} pixels.`, @@ -224,7 +197,6 @@ function buildMediaNote(input: { return `${parts.join(' ')}`; } -// ── Implementation ─────────────────────────────────────────────────── export class ReadMediaFileTool implements BuiltinTool { readonly name = 'ReadMediaFile' as const; @@ -245,9 +217,6 @@ export class ReadMediaFileTool implements BuiltinTool { } resolveExecution(args: ReadMediaFileInput): ToolExecution { - // Validate before resolving the path: `resolvePathAccessPath` throws on an - // empty path, and returning a tool error result here gives the model a - // clear message instead of an opaque path-security failure. if (!args.path) { return { isError: true, output: 'File path cannot be empty.' }; } @@ -280,8 +249,6 @@ export class ReadMediaFileTool implements BuiltinTool { } try { - // For media input, the bytes are authoritative; the extension is only - // a fallback for formats that cannot be sniffed from the header. const header = await this.fs.readBytes(safePath, MEDIA_SNIFF_BYTES); const fileType = detectFileType(safePath, header, 'media'); @@ -308,14 +275,6 @@ export class ReadMediaFileTool implements BuiltinTool { 'Tell the user to use a model with image input capability.', }; } - // Formats outside the provider-accepted set (AVIF, HEIC, BMP, TIFF, - // ICO, …) must never reach the model: once the image_url lands in the - // history every subsequent request in the session is rejected. Refuse - // with a conversion command for the execution environment instead — - // the model can run it through Bash (under the normal permission flow) - // and read the converted file. The accepted set and guidance live in - // media/image-format-policy, the single source of truth every - // ingestion point shares. if (fileType.kind === 'image' && !isModelAcceptedImageMime(fileType.mimeType)) { return { isError: true, @@ -352,17 +311,11 @@ export class ReadMediaFileTool implements BuiltinTool { } const data = Buffer.from(await this.fs.readBytes(safePath)); - // The summary always reports the ORIGINAL pixel size and byte size: the - // model derives relative coordinates and scales them by the original - // dimensions, so it must see the pre-compression size even when the - // image_url below carries a downsampled copy. let dimensions = fileType.kind === 'image' ? sniffImageDimensions(data) : null; let mediaPart: ContentPart; let delivery: ImageDelivery | undefined; if (fileType.kind === 'image') { if (args.region !== undefined) { - // Explicit crop: read a rectangle of the original back, typically at - // full fidelity, so a prior downsampled view can be zoomed into. const outcome = await cropImageForModel(data, fileType.mimeType, args.region, { skipResize: args.full_resolution === true, telemetry: this.compressTelemetry, @@ -384,15 +337,8 @@ export class ReadMediaFileTool implements BuiltinTool { region: outcome.region, resized: outcome.resized, }; - // The decode is authoritative: it covers formats and nonconforming - // EXIF the header sniff cannot read, and region coordinates live - // in the decoded space, so the note must report it. dimensions = { width: outcome.originalWidth, height: outcome.originalHeight }; } else if (args.full_resolution === true) { - // Native resolution on request — but the provider's per-image byte - // ceiling is a hard limit, so refuse explicitly rather than degrade. - // Exact byte counts accompany the rounded sizes: a file a hair over - // budget would otherwise read "is 3.8 MB, over the 3.8 MB limit". if (data.length > IMAGE_BYTE_BUDGET) { return { isError: true, @@ -416,13 +362,6 @@ export class ReadMediaFileTool implements BuiltinTool { mimeType: fileType.mimeType, }; } else { - // Shrink oversized images so a large screenshot neither wastes context - // tokens nor trips the provider's per-image byte ceiling. Model-read - // images get the much tighter read budget: they accumulate in the - // request body on every turn, and detail stays reachable through the - // region readback (which ignores the budget). Best effort: on any - // failure compressImageForModel returns the original bytes, so the - // read still succeeds with the uncompressed image. const compressed = await compressImageForModel(data, fileType.mimeType, { byteBudget: resolveReadImageByteBudget(), telemetry: this.compressTelemetry, @@ -440,8 +379,6 @@ export class ReadMediaFileTool implements BuiltinTool { mimeType: compressed.mimeType, }; if (compressed.changed) { - // Same as the crop path: once a decode happened, its dimensions - // are authoritative over the header sniff. dimensions = { width: compressed.originalWidth, height: compressed.originalHeight }; } } diff --git a/packages/agent-core-v2/src/agent/media/webp-decode.ts b/packages/agent-core-v2/src/agent/media/webp-decode.ts index dd228c6e34..3674bb9ad5 100644 --- a/packages/agent-core-v2/src/agent/media/webp-decode.ts +++ b/packages/agent-core-v2/src/agent/media/webp-decode.ts @@ -46,28 +46,18 @@ async function loadDecoder(): Promise { await decodeModule.init(wasm as never); const decode = decodeModule.default; return async (bytes: Uint8Array) => { - const copy = new Uint8Array(bytes); // detach from any shared buffer + const copy = new Uint8Array(bytes); return (await decode(copy.buffer)) as unknown as DecodedWebp; }; })(); return decoderReady; } -/** - * Decode a (non-animated) WebP payload to RGBA. Throws on undecodable input — - * callers keep their existing best-effort catch semantics. - */ export async function decodeWebp(bytes: Uint8Array): Promise { const decode = await loadDecoder(); return decode(bytes); } -/** - * True when the payload is a WebP whose VP8X container header carries the - * ANIM flag. Animated WebP must be passed through, not re-encoded: decoding - * yields a single frame and would silently destroy the animation (the same - * reason GIF is passed through). - */ export function isAnimatedWebp(bytes: Uint8Array): boolean { if (bytes.length < 21) return false; return ( diff --git a/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts b/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts index e12f9fd3be..1d79437a56 100644 --- a/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts +++ b/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts @@ -276,11 +276,6 @@ export class AgentPermissionGate extends Disposable implements IAgentPermissionG return message; } - /** - * Rejection messages for agents driven by another agent (no user in the - * loop) carry extra "don't retry / don't bypass" guidance. Heuristic: any - * agent other than `main` is treated as worker-driven. - */ private usesWorkerRejectionGuidance(): boolean { return this.scopeContext.agentId !== 'main'; } diff --git a/packages/agent-core-v2/src/agent/permissionMode/permissionMode.ts b/packages/agent-core-v2/src/agent/permissionMode/permissionMode.ts index d3152024d3..1d04758748 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/permissionMode.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/permissionMode.ts @@ -13,7 +13,6 @@ export interface IAgentPermissionModeService { readonly mode: PermissionMode; setMode(mode: PermissionMode): void; - /** Fires when the effective mode actually changes (no-op re-dispatch stays silent). */ readonly onDidChangeMode: Event; } diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicy.ts b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicy.ts index 33776e88a7..89b0c7d465 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicy.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicy.ts @@ -17,12 +17,6 @@ export interface IAgentPermissionPolicyService { evaluate( context: ResolvedToolExecutionHookContext, ): Promise; - /** - * Register an additional policy that takes precedence over the built-in - * policies. Returns a disposable that removes it. Used by callers that need - * to tighten an agent's posture at runtime (e.g. side-question agents that - * must deny every tool call). - */ registerPolicy(policy: PermissionPolicy): IDisposable; } diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/deny-all.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/deny-all.ts index b760a3fc87..02f7d086ac 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/deny-all.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/deny-all.ts @@ -5,13 +5,6 @@ import type { const DEFAULT_MESSAGE = 'Tool calls are disabled for this agent.'; -/** - * Permission policy that denies every tool call with a fixed message. - * - * Used to construct "side question" agents (see `startBtw`) whose loop tools - * are kept visible for prompt-cache parity but must never execute: the model - * answers from projected history with text only. - */ export class DenyAllPermissionPolicyService implements PermissionPolicy { readonly name = 'deny-all'; diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/types.ts b/packages/agent-core-v2/src/agent/permissionPolicy/types.ts index d7448a73ac..a1a8d64413 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/types.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/types.ts @@ -2,15 +2,6 @@ import type { PrepareToolExecutionResult, ResolvedToolExecutionHookContext } fro import type { ToolInputDisplay } from '@moonshot-ai/protocol'; import type { PermissionRule } from '#/agent/permissionRules/permissionRules'; -/** - * Top-level user-facing permission posture. Controls how non-deny rules - * are treated when the closure is constructed. Independent of rule - * merging: deny rules always fire regardless of mode. - * - * - `manual` — rule set drives decision; unmatched tool calls ask - * - `yolo` — only deny rules can block; everything else allows - * - `auto` — caller may bypass rule checks entirely - */ export type PermissionMode = 'manual' | 'yolo' | 'auto'; diff --git a/packages/agent-core-v2/src/agent/permissionRules/configSection.ts b/packages/agent-core-v2/src/agent/permissionRules/configSection.ts index 7333647c31..782265a61b 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/configSection.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/configSection.ts @@ -56,7 +56,6 @@ function isValidPermissionPattern(pattern: string): boolean { } } -/** Read transform: merge `deny`/`allow`/`ask` and `rules` into a single `rules` array. */ export const permissionFromToml = (rawSnake: unknown): unknown => { if (!isPlainObject(rawSnake)) return rawSnake; const raw = transformPlainObject(rawSnake); @@ -100,7 +99,6 @@ function transformPermissionRule(value: unknown, decision?: 'allow' | 'deny' | ' return out; } -/** Write transform: drop the on-disk `deny`/`allow`/`ask` lists and write `rules`. */ export const permissionToToml = (value: unknown, rawSnake: unknown): unknown => { if (!isPlainObject(value)) return value; const out = cloneRecord(rawSnake); diff --git a/packages/agent-core-v2/src/agent/permissionRules/matchesRule.ts b/packages/agent-core-v2/src/agent/permissionRules/matchesRule.ts index 68bee10f24..f5614cbb9a 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/matchesRule.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/matchesRule.ts @@ -3,20 +3,6 @@ import picomatch from 'picomatch'; import type { RunnableToolExecution } from '#/tool/toolContract'; import type { PermissionRule } from './permissionRules'; -/** - * DSL parser for PermissionRule `pattern` strings. - * - * Grammar: - * pattern := toolName ( "(" argPattern ")" )? - * toolName := identifier characters (e.g. `Bash`, `mcp__github__*`) - * argPattern := any string interpreted only by a tool-provided matcher - * - * Examples: - * "Write" -> { toolName: "Write" } - * "Read(/etc/**)" -> { toolName: "Read", argPattern: "/etc/**" } - * "Bash(!rm *)" -> { toolName: "Bash", argPattern: "!rm *" } - * "mcp__github__*" -> { toolName: "mcp__github__*" } - */ export interface ParsedPattern { readonly toolName: string; readonly argPattern?: string; @@ -42,10 +28,6 @@ export interface PermissionRuleMatchInput { readonly execution: PermissionRuleMatchExecution; } -/** - * Parse a DSL pattern. Throws on malformed input (missing closing paren, - * empty tool name). The parser is the single source of truth for DSL syntax. - */ export function parsePattern(pattern: string): ParsedPattern { const trimmed = pattern.trim(); if (trimmed.length === 0) { @@ -66,8 +48,6 @@ export function parsePattern(pattern: string): ParsedPattern { if (toolName.length === 0) { throw new Error(`permission pattern: empty tool name in "${pattern}"`); } - // `Tool()` parses to no arg pattern so it stays tool-name-only - tools without - // a `matchesRule` matcher (user/MCP/custom) would otherwise stop matching it. if (argPattern.length === 0) { return { toolName }; } diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts index c5fe1ecd7f..92ad3240a9 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts @@ -12,18 +12,8 @@ export interface PermissionApprovalResultRecord { export type PermissionRuleDecision = 'allow' | 'deny' | 'ask'; -/** - * Rule provenance. `session-runtime` stores rules produced by - * "approve for session"; `turn-override`, `project`, and `user` are - * reserved for static-loaded rules surfaced by external callers. - */ export type PermissionRuleScope = 'turn-override' | 'session-runtime' | 'project' | 'user'; -/** - * A single permission rule. `pattern` is the DSL form (`Read(/etc/**)`, - * `Bash(rm *)`, or bare `Write`). Rule arguments are interpreted only by - * tools that provide a matcher; other tools match by name only. - */ export interface PermissionRule { readonly decision: PermissionRuleDecision; readonly scope: PermissionRuleScope; diff --git a/packages/agent-core-v2/src/agent/plan/planService.ts b/packages/agent-core-v2/src/agent/plan/planService.ts index 599c949493..db376f0d5e 100644 --- a/packages/agent-core-v2/src/agent/plan/planService.ts +++ b/packages/agent-core-v2/src/agent/plan/planService.ts @@ -145,7 +145,6 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { } function isMissingFileError(error: unknown): boolean { - // hostFs wraps raw errnos in `HostFsError`; classify the unwrapped cause. const unwrapped = unwrapErrorCause(error); if (unwrapped === null || typeof unwrapped !== 'object') return false; const code = (unwrapped as { readonly code?: unknown }).code; diff --git a/packages/agent-core-v2/src/agent/plan/tools/enter-plan-mode.ts b/packages/agent-core-v2/src/agent/plan/tools/enter-plan-mode.ts index fcdcaac630..72f3a94472 100644 --- a/packages/agent-core-v2/src/agent/plan/tools/enter-plan-mode.ts +++ b/packages/agent-core-v2/src/agent/plan/tools/enter-plan-mode.ts @@ -14,7 +14,6 @@ import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentPlanService } from '#/agent/plan/plan'; import DESCRIPTION from './enter-plan-mode.md?raw'; -// ── Input schema ───────────────────────────────────────────────────── export const EnterPlanModeInputSchema = z.object({}).strict(); export type EnterPlanModeInput = z.infer; @@ -34,7 +33,6 @@ export class EnterPlanModeTool implements BuiltinTool { description: 'Requesting to enter plan mode', approvalRule: this.name, execute: async () => { - // Guard: already in plan mode const before = await this.planMode.status(); if (before !== null) { return { diff --git a/packages/agent-core-v2/src/agent/plan/tools/exit-plan-mode.ts b/packages/agent-core-v2/src/agent/plan/tools/exit-plan-mode.ts index c31ca1a004..761fa5f398 100644 --- a/packages/agent-core-v2/src/agent/plan/tools/exit-plan-mode.ts +++ b/packages/agent-core-v2/src/agent/plan/tools/exit-plan-mode.ts @@ -17,14 +17,7 @@ import { IAgentPlanService } from '#/agent/plan/plan'; import type { PlanData } from '#/agent/plan/plan'; import DESCRIPTION from './exit-plan-mode.md?raw'; -// ── Input schema ───────────────────────────────────────────────────── -/** - * User-selectable option surfaced at plan approval time. The LLM supplies - * up to 3 of these when the plan contains multiple approaches; the host's - * ApprovalRuntime presents them to the user and returns the chosen `label` - * (or `{kind:'revise', feedback}` when the user asks for revisions). - */ export interface ExitPlanModeOption { label: string; description: string; @@ -78,7 +71,6 @@ type ResolvePlanResult = | { readonly ok: true; readonly plan: string; readonly path?: string | undefined } | { readonly ok: false; readonly error: ExecutableToolResult }; -// ── Implementation ─────────────────────────────────────────────────── export class ExitPlanModeTool implements BuiltinTool { readonly name = 'ExitPlanMode' as const; diff --git a/packages/agent-core-v2/src/agent/profile/context.ts b/packages/agent-core-v2/src/agent/profile/context.ts index 96f6404862..59f845c18f 100644 --- a/packages/agent-core-v2/src/agent/profile/context.ts +++ b/packages/agent-core-v2/src/agent/profile/context.ts @@ -22,21 +22,11 @@ import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import type { SystemPromptContext } from './profile'; -// Soft budget for the combined AGENTS.md content injected into the system -// prompt. ~32 KB is roughly 8K–20K tokens (≈1.5–3% of a 262144-token context), -// large enough to leave the bulk of the context window to the conversation -// while still catching accidental oversized instruction files. Exceeding it no -// longer truncates content; it only surfaces a user-visible warning so the user -// can trim oversized instruction files. export const AGENTS_MD_RECOMMENDED_MAX_BYTES = 32 * 1024; export const LIST_DIR_ROOT_WIDTH = 30; export const LIST_DIR_CHILD_WIDTH = 10; -/** - * Small dep bag threaded through the context helpers so they only depend on - * the filesystem primitive plus the host home directory, not on `IKaos`. - */ interface ProfileContextDeps { readonly fs: IHostFileSystem; readonly homeDir: string; @@ -46,7 +36,6 @@ export interface PreparedSystemPromptContext extends SystemPromptContext { readonly cwdListing?: string; readonly agentsMd?: string; readonly additionalDirsInfo?: string; - /** Present when the combined AGENTS.md content exceeds the recommended size. */ readonly agentsMdWarning?: string; } @@ -106,14 +95,10 @@ async function loadAgentsMdForRoots( return true; }; - // User-level files come first so any project-level AGENTS.md overrides them. - // The brand dir follows KIMI_CODE_HOME (default ~/.kimi-code); the generic - // .agents dir stays under the real OS home so it can be shared across tools. const realHome = deps.homeDir; const brandDir = brandHome ?? join(realHome, '.kimi-code'); await collect(join(brandDir, 'AGENTS.md')); - // Generic user-level dir (.agents) matches skill discovery. const genericDirs = [join(realHome, '.agents')]; const genericFiles = genericDirs.flatMap((dir) => ['AGENTS.md', 'agents.md'].map((name) => join(dir, name)), @@ -250,11 +235,6 @@ function dedupeDirs(dirs: readonly string[]): string[] { return result; } -// --------------------------------------------------------------------------- -// listDirectory — compact 2-level directory tree for LLM context. -// Port of v1 `packages/agent-core/src/tools/support/list-directory.ts`, driven -// through the os `IHostFileSystem` (`readdir` + `stat`). -// --------------------------------------------------------------------------- interface ListDirectoryOptions { readonly collapseHiddenDirs?: boolean; diff --git a/packages/agent-core-v2/src/agent/profile/profile.ts b/packages/agent-core-v2/src/agent/profile/profile.ts index b4c0481fa6..1a297c1aa5 100644 --- a/packages/agent-core-v2/src/agent/profile/profile.ts +++ b/packages/agent-core-v2/src/agent/profile/profile.ts @@ -21,13 +21,6 @@ export class ProfileError extends Error2 { } } -/** - * Data required to configure an agent: active model id, its capability - * matrix, profile, thinking level, system prompt, and working directory. - * Owned by `profile` (which assembles it); consumed by `replayBuilder` and - * `rpc` as a wire DTO. The runnable `Model` god-object is resolved on demand - * via `resolveModel()`; it does not travel through this DTO. - */ export interface AgentConfigData { cwd: string; modelAlias?: string; @@ -45,25 +38,10 @@ export type AgentConfigUpdateData = Partial<{ systemPrompt: string; }>; -/** - * Runtime context supplied to a profile's system-prompt renderer. Extends the - * catalog's {@link AgentProfileContext} (host OS/shell, cwd, AGENTS.md, skills, - * …) with the AGENTS.md size warning produced by `prepareSystemPromptContext`. - */ export interface SystemPromptContext extends AgentProfileContext { - /** - * Present when the combined AGENTS.md content exceeds the recommended soft - * budget. Surfaced through `getSessionWarnings` instead of truncating. - */ readonly agentsMdWarning?: string; } -/** - * Resolved profile consumed by {@link IAgentProfileService.useProfile} / - * {@link IAgentProfileService.applyProfile}. Alias of the catalog's - * {@link AgentProfile} — a profile is self-contained (full system prompt + - * tools), so the per-agent binding and the profile catalog share one type. - */ export type ResolvedAgentProfile = AgentProfile; export interface ProfileData extends AgentConfigData { @@ -86,10 +64,6 @@ export interface ProfileServiceOptions { } export interface ApplyProfileOptions { - /** - * Additional workspace directories whose listings are appended to the system - * prompt context. Defaults to the session workspace's additional dirs. - */ readonly additionalDirs?: readonly string[]; } @@ -108,16 +82,8 @@ export interface ProfileSetModelResult { readonly providerName?: string | undefined; } -/** - * Atomic binding input: a named Profile plus a Model id/alias. Binding the two - * (with optional run config) is what makes an Agent runnable — `Profile + - * Model ⇒ Agent`. `profile` defaults to the catalog's default profile when the - * caller only supplies a model (see {@link IAgentProfileService.setModel}). - */ export interface BindAgentInput { - /** Profile name from `IAgentProfileCatalogService` (e.g. 'agent', 'explore'). */ readonly profile: string; - /** Model id or routing alias resolved through `IModelResolver`. */ readonly model: string; readonly thinking?: string; readonly cwd?: string; @@ -128,64 +94,22 @@ export interface IAgentProfileService { configure(options: ProfileServiceOptions): void; update(changed: ProfileUpdateData): void; - /** - * Atomically bind a Profile + Model (plus optional run config) to this agent, - * rendering the profile's system prompt and activating its tool set. This is - * the production entry point that turns an agent scope into a runnable Agent. - * Throws `PROFILE_NOT_FOUND` / `MODEL_NOT_CONFIGURED` on unknown inputs. - */ bind(input: BindAgentInput): Promise; - /** - * Bind (or switch) the active Model. When no Profile is bound yet, the - * catalog's default profile is bound first (rendering its system prompt and - * tool set), so a fresh agent becomes runnable on its first `setModel`. - * Subsequent calls swap the model while keeping the existing profile. - */ setModel(model: string): Promise; setThinking(level: string): void; getModel(): string; useProfile(profile: ResolvedAgentProfile, context: SystemPromptContext): void; - /** - * Production entry point for applying a profile: assembles the - * {@link SystemPromptContext} (loading the AGENTS.md hierarchy, cwd listing, - * and additional-dir listings), renders the profile's system prompt via - * {@link useProfile}, and caches any AGENTS.md size warning for - * {@link getAgentsMdWarning} / `getSessionWarnings`. - */ applyProfile(profile: ResolvedAgentProfile, options?: ApplyProfileOptions): Promise; - /** - * Re-render the active profile's system prompt from freshly gathered runtime - * context without changing the active tool set. - */ refreshSystemPrompt(): Promise; - /** - * The AGENTS.md size warning produced by the most recent {@link applyProfile}, - * if the combined AGENTS.md content exceeded the recommended soft budget. - * `undefined` when no oversized content has been observed. - */ getAgentsMdWarning(): string | undefined; data(): ProfileData; resolveModelContext(): ProfileModelContext; - /** - * Return the runnable god-object `Model` for the currently-active model. - * Throws when no model is configured — use {@link hasModel} to feature-test. - */ getProvider(): Model; - /** - * Return the runnable god-object `Model` for the currently-active model, or - * `undefined` when no model is configured yet. Prefer this in code paths - * that may run before configuration is ready. - */ resolveModel(): Model | undefined; - /** - * Alias of {@link getProvider}, exposed as a property so media/video tooling - * (and tests) can read or override it directly. - */ readonly provider: Model; getModelCapabilities(): ModelCapability; getMaxOutputSize(): number | undefined; hasModel(): boolean; - /** True when both a Profile and a Model are bound — i.e. the agent can run a turn. */ isRunnable(): boolean; hasProvider(): boolean; getSystemPrompt(): string; diff --git a/packages/agent-core-v2/src/agent/profile/profileOps.ts b/packages/agent-core-v2/src/agent/profile/profileOps.ts index b43ee84613..1ae4033b53 100644 --- a/packages/agent-core-v2/src/agent/profile/profileOps.ts +++ b/packages/agent-core-v2/src/agent/profile/profileOps.ts @@ -101,13 +101,6 @@ function configUpdateThinkingLevel( return p.thinkingLevel; } -/** - * The agent's active-tool set. `undefined` means "every tool is active" (the - * unrestricted default before any `tools.set_active_tools`); a concrete array - * restricts the set. Kept distinct from `[]` (which would mean "no tools - * active"), so the initial `undefined` preserves the all-active default rather - * than collapsing it to an empty allowlist. - */ export type ActiveToolsState = readonly string[] | undefined; export const ActiveToolsModel = defineModel( diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index f221042a14..2573f0b70e 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -80,7 +80,6 @@ import { declare module '#/app/event/eventBus' { interface DomainEventMap { - // `warning` is owned by `profile` (the agents-md-oversized notice). warning: WarningEvent; } } @@ -89,14 +88,9 @@ export class AgentProfileService implements IAgentProfileService { declare readonly _serviceBrand: undefined; private optionsValue: ProfileServiceOptions = {}; - // Live overlay of ephemeral per-tool deltas (`addActiveTool` / - // `removeActiveTool`) on top of the persisted `ActiveToolsModel`. `undefined` - // means "no overlay — read the Model". Reset on every full `setActiveTools`. private activeToolNamesOverlay: readonly string[] | undefined; private agentsMdWarning: string | undefined; - // Effective active-tool set: the live overlay when present, else the persisted - // base rebuilt by `wire.replay`. `undefined` means every tool is active. private get activeToolNames(): ActiveToolsState { return ( this.activeToolNamesOverlay ?? @@ -154,8 +148,6 @@ export class AgentProfileService implements IAgentProfileService { if (profile === undefined) { throw new Error(`Unknown agent profile: "${input.profile}"`); } - // Resolve eagerly so an unknown model id fails the bind here rather than on - // the first turn. const model = this.modelFactory.resolve(input.model); const context = await this.buildSystemPromptContext(input.cwd); @@ -374,14 +366,12 @@ export class AgentProfileService implements IAgentProfileService { addActiveTool(name: string): void { const activeToolNames = this.activeToolNames; if (activeToolNames === undefined || activeToolNames.includes(name)) return; - // Ephemeral overlay: not persisted; re-derived on resume by `userTool`. this.activeToolNamesOverlay = [...activeToolNames, name]; } removeActiveTool(name: string): void { const activeToolNames = this.activeToolNames; if (activeToolNames === undefined || !activeToolNames.includes(name)) return; - // Ephemeral overlay: not persisted; re-derived on resume by `userTool`. this.activeToolNamesOverlay = activeToolNames.filter((candidate) => candidate !== name); } @@ -411,9 +401,6 @@ export class AgentProfileService implements IAgentProfileService { void this.optionsValue.chdir?.(changed.cwd); } if (changed.modelAlias !== undefined) { - // Mirror the resolved model protocol into the ambient telemetry context - // (v1 parity: both keys carry the protocol — v2 has no separate provider - // type). Unresolvable models yield undefined; never throw. const protocol = this.tryResolveRawModel()?.protocol; this.telemetryContext.set({ provider_type: protocol, protocol }); } @@ -421,8 +408,6 @@ export class AgentProfileService implements IAgentProfileService { } private setActiveTools(names: readonly string[]): void { - // Full replace: drop the ephemeral overlay (subsequent reads fall back to the - // Model) and persist the new base set through the wire. this.activeToolNamesOverlay = undefined; this.wire.dispatch(setActiveTools({ names: [...names] })); } @@ -472,8 +457,6 @@ export class AgentProfileService implements IAgentProfileService { private get thinkingLevel(): ThinkingEffort { const stored = this.profileState.thinkingLevel; if (stored === 'off' && this.alwaysThinkingModel) { - // Re-run the resolver so the always_thinking clamp restores the - // configured effort (or the model default) instead of a stale 'off'. return resolveThinkingEffort( stored, this.config.get(THINKING_SECTION), diff --git a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts b/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts index 66d81b7947..c4ec2ee3d8 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts @@ -30,12 +30,6 @@ abstract class UserMessageStepRequest extends StepRequest { options?: StepRequestOptions, ) { super(options); - // The last funnel before a prompt lands in the session history: images - // in formats providers reject (AVIF, HEIC, …) become text notices here, - // so no caller — REST, RPC, or tool-delivered inject — can poison the - // session. Upstream ingestion points already gate; this is the backstop, - // applied to both the recorded turn seed and the appended context - // message (v1 parity: the turn.prompt/steer gate). this.message = { ...message, content: gateImageFormatParts(message.content) }; } @@ -53,8 +47,6 @@ abstract class UserMessageStepRequest extends StepRequest { } resolveContextMessages(): readonly ContextMessage[] { - // A message whose content was caption-only is dropped entirely rather than - // appended empty (the reminders still landed). return this.message.content.length > 0 ? [this.message] : []; } } diff --git a/packages/agent-core-v2/src/agent/questionTools/tools/ask-user.ts b/packages/agent-core-v2/src/agent/questionTools/tools/ask-user.ts index a49a5f1fcf..bdc67f6cab 100644 --- a/packages/agent-core-v2/src/agent/questionTools/tools/ask-user.ts +++ b/packages/agent-core-v2/src/agent/questionTools/tools/ask-user.ts @@ -34,7 +34,6 @@ import type { import DESCRIPTION from './ask-user.md?raw'; import { QuestionBackgroundTask } from './question-background-task'; -// ── Input schema ───────────────────────────────────────────────────── const QuestionOptionSchema = z.object({ label: z @@ -76,13 +75,6 @@ export interface AskUserQuestionInput { const QUESTION_UNIQUENESS_MESSAGE = 'Question texts must be unique across questions, and option labels must be unique within each question.'; -/** - * Answers are keyed by question text with option labels as values, so both - * must be unambiguous: question texts unique across the call, option labels - * unique within their question. Runtime tool-arg validation is AJV against - * the JSON Schema (where zod refinements are unrepresentable), so the - * execution path re-runs this check itself. - */ function questionUniquenessError( questions: AskUserQuestionInput['questions'], ): string | null { @@ -133,7 +125,6 @@ const QUESTION_DISMISSED_MESSAGE = 'User dismissed the question without answerin const QUESTION_UNSUPPORTED_FAILURE_MESSAGE = 'The connected client does not support interactive questions. Do NOT call this tool again. Ask the user directly in your text response instead.'; -// ── Implementation ─────────────────────────────────────────────────── export class AskUserQuestionTool implements BuiltinTool { readonly name = 'AskUserQuestion' as const; @@ -164,8 +155,6 @@ export class AskUserQuestionTool implements BuiltinTool { args: AskUserQuestionInput, { toolCallId, signal, turnId }: ExecutableToolContext, ): Promise { - // AJV (the runtime arg validator) cannot express the uniqueness refine, - // so enforce it here before any UI interaction or task registration. const uniquenessError = questionUniquenessError(args.questions); if (uniquenessError !== null) { return { isError: true, output: uniquenessError }; diff --git a/packages/agent-core-v2/src/agent/replayBuilder/types.ts b/packages/agent-core-v2/src/agent/replayBuilder/types.ts index 5090a52505..d662fa2bbc 100644 --- a/packages/agent-core-v2/src/agent/replayBuilder/types.ts +++ b/packages/agent-core-v2/src/agent/replayBuilder/types.ts @@ -11,11 +11,6 @@ import type { SessionSummary } from '#/agent/rpc/core-api'; import type { UsageStatus } from '@moonshot-ai/protocol'; import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; -/** - * Wire projection of the agent's role in the resume DTO: `'main'` when - * `agentId === 'main'`, `'sub'` otherwise. Wire values kept for node-sdk - * compatibility; not a business concept. - */ type AgentType = 'main' | 'sub'; export type AgentReplayRecordPayload = diff --git a/packages/agent-core-v2/src/agent/rpc/core-api.ts b/packages/agent-core-v2/src/agent/rpc/core-api.ts index 99aed062d0..8538435654 100644 --- a/packages/agent-core-v2/src/agent/rpc/core-api.ts +++ b/packages/agent-core-v2/src/agent/rpc/core-api.ts @@ -77,11 +77,6 @@ export interface ResumeSessionPayload { export interface ReloadSessionPayload { readonly sessionId: string; - /** - * When true, the reloaded session force-appends a fresh plugin session-start - * reminder (or a neutralizing reminder when none are active) so the model - * picks up reloaded plugin guidance. Mirrors the `/reload` re-injection flow. - */ readonly forcePluginSessionStartReminder?: boolean | undefined; } @@ -120,11 +115,6 @@ export interface PromptPayload { } export interface RunShellCommandPayload { readonly command: string; - /** - * TUI-generated correlation id echoed back on every `shell.output` live event - * so the client can route chunks to the matching entry and drop stale events - * from a prior run. Optional for callers that don't stream. - */ readonly commandId?: string; } export interface ShellCommandResult { @@ -180,7 +170,6 @@ export interface SetActiveToolsPayload { } export interface StopTaskPayload { readonly taskId: string; - /** Free-form human-readable reason persisted with the task record. */ readonly reason?: string; } export interface DetachTaskPayload { @@ -191,13 +180,7 @@ export interface GetTaskOutputPayload { readonly tail?: number; } export interface GetTasksPayload { - /** - * When omitted, returns all tasks (including terminal/lost). Pass - * `true` to filter down to active-only — useful for model-facing - * surfaces. UI/TUI consumers should leave it undefined. - */ readonly activeOnly?: boolean; - /** Caps the number of tasks returned. When omitted, returns all matching tasks. */ readonly limit?: number; } export interface SkillSummary { @@ -283,10 +266,6 @@ export interface UpdateSessionMetadataPayload { readonly metadata: SessionMetadataPatch; } -// Goal lifecycle payloads and re-exported goal value types. These describe the -// deterministic user/SDK control surface; the goal's terminal status is decided -// by the model via the UpdateGoal tool (or the goal driver on budget/error), -// not set through this API. export type { GoalBudgetLimits, GoalBudgetReport, @@ -307,7 +286,6 @@ export interface GetKimiConfigPayload { } export interface ConfigDiagnostics { - /** Warnings from the most recent config.toml load attempt; empty when the config is fully valid. */ readonly warnings: readonly string[]; } @@ -317,13 +295,6 @@ export interface RemoveKimiProviderPayload { readonly providerId: string; } -/** - * Result returned when a prompt/steer submission is accepted. The turn is the - * submission's identity and lifecycle (`turn.started` / `turn.ended` carry the - * rest over the event stream), so the handle is just the turn id. `undefined` - * means no turn was launched (e.g. the agent was busy, or a prompt hook blocked - * before launch). - */ export interface PromptLaunchResult { readonly turn_id: number; } diff --git a/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts b/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts index 8297fab1bd..3e502fc4b7 100644 --- a/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts +++ b/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts @@ -102,9 +102,6 @@ export async function applyPromptMetadataUpdate( function promptPartText(part: ContentPart): string | undefined { switch (part.type) { case 'text': { - // Prompt ingestion may have annotated a compressed image with an inline - // caption (see buildImageCompressionCaption). It is harness metadata, - // not something the user typed, so keep it out of titles/lastPrompt. const { text } = extractImageCompressionCaptions(part.text); return text.trim().length === 0 ? undefined : text; } diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index 09755c8b15..92266fd50c 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -103,9 +103,6 @@ export class AgentRPCService implements IAgentRPCService { ) { } async prompt(payload: PromptPayload): Promise { - // Mirror v1: persist `lastPrompt` and derive an easy title from the first - // prompt BEFORE launching the turn, so the web session title is populated as - // soon as the conversation starts (gap closed — v2 used to leave it empty). await this.updatePromptMetadata(promptMetadataTextFromPayload(payload)); const handle = await this.promptService.enqueue({ message: { role: 'user', diff --git a/packages/agent-core-v2/src/agent/runtime/runtimeOps.ts b/packages/agent-core-v2/src/agent/runtime/runtimeOps.ts index 3711c0db49..f6ec4bc190 100644 --- a/packages/agent-core-v2/src/agent/runtime/runtimeOps.ts +++ b/packages/agent-core-v2/src/agent/runtime/runtimeOps.ts @@ -42,11 +42,6 @@ export const setRuntimePhase = RuntimeModel.defineOp('runtime.set_phase', { toEvent: (p) => ({ type: 'agent.status.updated' as const, phase: p.phase }), }); -/** - * Structural equality for phase transitions, ignoring the `since` / `at` - * timestamps so that re-entering the same logical phase (e.g. a burst of - * same-stream deltas) is treated as a no-op. - */ export function phaseEqual(a: AgentPhase, b: AgentPhase): boolean { if (a.kind !== b.kind) return false; switch (a.kind) { @@ -96,18 +91,6 @@ export function phaseEqual(a: AgentPhase, b: AgentPhase): boolean { import type { AgentActivitySnapshot } from '#/activity/activity'; -/** - * `runtime` domain (L5) — wire Model (`ActivityModel`) and the - * `activity.set_snapshot` Op that holds the agent's structured activity - * snapshot (`AgentActivitySnapshot`). - * - * Live-only (`persist: false`): nothing is persisted or replayed; a resumed - * agent starts back at `lane: idle`. The projector (`runtimeService`) is the - * sole dispatcher; `apply` returns the SAME reference when the snapshot is - * unchanged under `snapshotEqual` (ignoring timestamps) so high-frequency - * deltas collapse into one record. The Op's `toEvent` emits the native - * `agent.activity.updated` fact (published on `dispatch`, never on `replay`). - */ export const ActivityModel = defineModel('activity', () => ({ lane: 'idle', background: [], @@ -120,10 +103,6 @@ export const setActivitySnapshot = ActivityModel.defineOp('activity.set_snapshot toEvent: (p) => ({ type: 'agent.activity.updated' as const, ...p.next }), }); -/** - * Structural equality for snapshots, ignoring the `since` / `at` timestamps so - * re-entering the same logical state does not flood subscribers. - */ export function snapshotEqual(a: AgentActivitySnapshot, b: AgentActivitySnapshot): boolean { if (a.lane !== b.lane) return false; if (a.background.length !== b.background.length) return false; diff --git a/packages/agent-core-v2/src/agent/runtime/runtimeService.ts b/packages/agent-core-v2/src/agent/runtime/runtimeService.ts index 6c7f7a663a..36cefca096 100644 --- a/packages/agent-core-v2/src/agent/runtime/runtimeService.ts +++ b/packages/agent-core-v2/src/agent/runtime/runtimeService.ts @@ -275,7 +275,6 @@ export class AgentRuntimeService extends Disposable implements IAgentRuntimeServ const resume = this.priorForApproval; this.priorForApproval = undefined; if (this.pendingApprovals.size > 0) { - // Another approval is still pending — stay in `awaiting_approval` (矛盾 d). this.setPhase({ kind: 'awaiting_approval', turnId: this.cursor.turnId, diff --git a/packages/agent-core-v2/src/agent/scopeContext/scopeContext.ts b/packages/agent-core-v2/src/agent/scopeContext/scopeContext.ts index 1762bb3bc9..d74ffc0fd5 100644 --- a/packages/agent-core-v2/src/agent/scopeContext/scopeContext.ts +++ b/packages/agent-core-v2/src/agent/scopeContext/scopeContext.ts @@ -16,25 +16,12 @@ export interface IAgentScopeContext { readonly _serviceBrand: undefined; readonly agentId: string; - /** - * Persistence scope rooted at this agent. `scope()` returns the agent - * scope itself; `scope(subKey)` returns `${agentScope}/${subKey}` (e.g. - * `scope('cron')` → `sessions///agents//cron`). Business - * code passes the returned string straight to `IFileSystemStorageService` / - * `IAtomicDocumentStore` / `IAppendLogStore`. - */ scope(subKey?: string): string; } export const IAgentScopeContext: ServiceIdentifier = createDecorator('agentScopeContext'); -/** - * Build an `IAgentScopeContext` from an agent's persistence root, wiring the - * `scope(subKey?)` helper automatically. `agentScope` is typically - * `sessions///agents/`; a call like - * `scope('cron')` returns `${agentScope}/cron`. - */ export function makeAgentScopeContext(input: { readonly agentId: string; readonly agentScope: string; diff --git a/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts b/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts index 21001e0986..ae503ce805 100644 --- a/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts +++ b/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts @@ -48,10 +48,6 @@ export class AgentShellCommandService implements IAgentShellCommandService { ) { } async run(input: RunShellCommandInput): Promise { - // Record the command up front so the model sees it on the next turn even if - // resolution or execution fails below. Mirrors v1 `runShellCommand` - // (parity with claude-code's `shouldQuery: false`): a foreground `!` - // command is written into context but does NOT itself start a turn. this.appendShellInput(input.command); const controller = new AbortController(); @@ -94,11 +90,6 @@ export class AgentShellCommandService implements IAgentShellCommandService { const isError = result.isError === true; if (typeof result.output === 'string' && result.output.startsWith('task_id: ')) { - // Detached to background (ctrl+b): inject the background-task metadata - // (task_id / status / output path) as a user-invisible message and - // immediately notify the model — mirrors the background-task completion - // notification, but hidden. Not recorded as a `shell_command` output; - // the input above is the only user-visible trace. this.notifyBackgrounded(result.output); return { stdout: result.output, stderr: '', isError: false, backgrounded: true }; } @@ -108,10 +99,6 @@ export class AgentShellCommandService implements IAgentShellCommandService { this.appendShellOutput(stdout, stderr, isError); return { stdout, stderr, isError }; } catch (error) { - // Covers `ensureBashTool` throwing (Bash not registered) and any - // exception escaping `execute`. Surface the reason as stderr and record - // it so the model and replay see what went wrong instead of a bare RPC - // error. stderr += error instanceof Error ? error.message : String(error); this.appendShellOutput(stdout, stderr, true); return { stdout, stderr, isError: true }; diff --git a/packages/agent-core-v2/src/agent/skill/prompt.ts b/packages/agent-core-v2/src/agent/skill/prompt.ts index 0283c7e935..f4bf30a869 100644 --- a/packages/agent-core-v2/src/agent/skill/prompt.ts +++ b/packages/agent-core-v2/src/agent/skill/prompt.ts @@ -8,13 +8,6 @@ export interface RenderSkillPromptInput { readonly skillArgs: string; readonly skillContent: string; readonly skillSource?: SkillSource | undefined; - /** - * Absolute directory containing the skill's SKILL.md and any bundled - * resources (scripts, templates, data files). Surfaced on the loaded - * block so the agent can locate those resources with relative paths — - * without it, a skill that ships helper scripts is unusable unless the - * author manually embeds `${KIMI_SKILL_DIR}` in the body. - */ readonly skillDir?: string | undefined; } diff --git a/packages/agent-core-v2/src/agent/skill/skill.ts b/packages/agent-core-v2/src/agent/skill/skill.ts index eb873ef5d9..82c282a0e7 100644 --- a/packages/agent-core-v2/src/agent/skill/skill.ts +++ b/packages/agent-core-v2/src/agent/skill/skill.ts @@ -11,13 +11,6 @@ export interface IAgentSkillService { readonly _serviceBrand: undefined; activate(input: SkillActivationInput): Promise; - /** - * Records a model-tool skill activation (an inline skill loaded through the - * `Skill` tool) without opening a new turn — the tool returns a - * `delivery: 'steer'` for the executor to inject into the current turn. - * Publishes the activation and emits telemetry, matching the user-slash - * `activate` path's side effects. - */ recordModelToolActivation(origin: SkillActivationOrigin): void; } diff --git a/packages/agent-core-v2/src/agent/skill/tools/skill.ts b/packages/agent-core-v2/src/agent/skill/tools/skill.ts index 7e05176713..8c01a0bc90 100644 --- a/packages/agent-core-v2/src/agent/skill/tools/skill.ts +++ b/packages/agent-core-v2/src/agent/skill/tools/skill.ts @@ -81,11 +81,6 @@ export class SkillTool implements BuiltinTool { }); readonly parameters: Record = toInputJsonSchema(SkillToolInputSchema); - /** - * Current inline-skill recursion depth. Zero for the root tool; set on clones - * produced by `withInitialQueryDepth` so a Skill→Skill chain cannot recurse - * past `MAX_SKILL_QUERY_DEPTH`. - */ private queryDepth: number = 0; constructor( @@ -130,11 +125,6 @@ export async function executeModelSkill( queryDepth: number, sessionId: string, ): Promise { - // Recursion hard cap. Once `currentDepth` has reached - // MAX_SKILL_QUERY_DEPTH, firing another Skill call would push the - // child to depth+1 which violates the invariant. Throw a structured - // error (rather than a soft tool-error) so Runtime can distinguish - // "LLM mis-dispatched" from "safety net fired". const currentDepth = queryDepth; if (currentDepth >= MAX_SKILL_QUERY_DEPTH) { throw new NestedSkillTooDeepError(MAX_SKILL_QUERY_DEPTH, args.skill); @@ -146,8 +136,6 @@ export async function executeModelSkill( return errorResult(`Skill "${args.skill}" not found in the current skill listing.`); } if (skill.metadata.disableModelInvocation === true) { - // Keep the exact wording "can only be triggered by the user" so - // contract audits and integration tests stay deterministic. return errorResult( `Skill "${args.skill}" can only be triggered by the user (model invocation is disabled).`, ); diff --git a/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts b/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts index 5b01b150a3..47046b754b 100644 --- a/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts +++ b/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts @@ -112,8 +112,6 @@ export class AgentStepRetryService extends Disposable implements IAgentStepRetry }); await sleepForRetry(delayMs, context.signal); - // The driver is already materialized, so its messages are not appended a - // second time; re-running it drives another step over the same context. if (context.currentStep?.signal.aborted === true) return false; context.retry(driver, { at: 'head' }); return true; diff --git a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts b/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts index e14300cd87..3ecf30eaed 100644 --- a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts +++ b/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts @@ -5,10 +5,6 @@ import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; export interface IAgentSystemReminderService { readonly _serviceBrand: undefined; - /** - * Append a `` message to the end of the context memory. - * Returns the created message. - */ appendSystemReminder(content: string, origin: PromptOrigin): ContextMessage; } diff --git a/packages/agent-core-v2/src/agent/task/configSection.ts b/packages/agent-core-v2/src/agent/task/configSection.ts index bc7efcfe86..c8f7d5e312 100644 --- a/packages/agent-core-v2/src/agent/task/configSection.ts +++ b/packages/agent-core-v2/src/agent/task/configSection.ts @@ -24,10 +24,6 @@ export const LEGACY_BACKGROUND_SECTION = 'background'; export const AgentTaskConfigSchema = z.object({ maxRunningTasks: z.number().int().min(1).optional(), keepAliveOnExit: z.boolean().optional(), - /** - * When a foreground Bash command times out, move it to the background - * instead of killing it. Defaults to true when unset. - */ bashAutoBackgroundOnTimeout: z.boolean().optional(), killGracePeriodMs: z.number().int().min(0).optional(), printWaitCeilingS: z.number().int().min(1).optional(), diff --git a/packages/agent-core-v2/src/agent/task/persist.ts b/packages/agent-core-v2/src/agent/task/persist.ts index f0ad855246..9fc37d94fb 100644 --- a/packages/agent-core-v2/src/agent/task/persist.ts +++ b/packages/agent-core-v2/src/agent/task/persist.ts @@ -191,7 +191,6 @@ export class AgentTaskPersistence { try { task = await this.docs.get(this.tasksScope(root), key); } catch { - // Skip files that fail to read / parse (corrupt or partially written). continue; } if (task === undefined || !isReadablePersistedTask(task)) continue; diff --git a/packages/agent-core-v2/src/agent/task/task.ts b/packages/agent-core-v2/src/agent/task/task.ts index 7ca634e536..124b8c4e88 100644 --- a/packages/agent-core-v2/src/agent/task/task.ts +++ b/packages/agent-core-v2/src/agent/task/task.ts @@ -41,54 +41,29 @@ export interface AgentTaskOutputSnapshot { } export interface RegisterAgentTaskOptions { - /** - * When false, the task is tracked by the manager while a foreground tool call - * still waits for it. It can later be detached through RPC. - */ readonly detached?: boolean; - /** Deadline owned by the task manager. `0` and `undefined` do not arm a timer. */ readonly timeoutMs?: number; - /** Deadline to apply if a foreground task is detached. `0` and `undefined` do not arm a timer. */ readonly detachTimeoutMs?: number; - /** - * When true, a foreground task whose deadline fires is detached to the - * background (re-armed to `detachTimeoutMs`) instead of being killed. - * Only meaningful for non-detached registrations. - */ readonly autoBackgroundOnTimeout?: boolean; - /** Foreground caller signal. Ignored for tasks created already detached. */ readonly signal?: AbortSignal; } export type ForegroundTaskReleaseReason = 'detached' | 'timeout_detached' | 'terminal'; -/** - * Options for tracking a TaskHandle with the Agent task service. - * Callers create the handle via `taskService.run()`, then pass it here. - */ export interface AgentTaskTrackOptions { readonly idPrefix?: string; readonly description: string; - /** If `true`, the task is immediately detached (background). Default: `true`. */ readonly detached?: boolean; - /** Deadline after which the handle is cancelled. */ readonly timeoutMs?: number; - /** Deadline to apply if a foreground task is detached. */ readonly detachTimeoutMs?: number; - /** Foreground caller signal (ignored for detached tasks). */ readonly signal?: AbortSignal; - /** Callback to force-stop the underlying work (e.g., SIGKILL). */ readonly forceStop?: () => Promise; - /** Hook called when a foreground task is detached. */ readonly onDetach?: () => void; - /** Produce the typed `AgentTaskInfo` from the base fields. */ readonly toInfo: (base: AgentTaskInfoBase) => AgentTaskInfo; } -/** Returned by `track()` so callers can race `handle.result` against detach. */ export interface IAgentTaskEntry { readonly taskId: string; - /** Resolves with `'detached'` when the RPC layer detaches this task. */ readonly onDidDetach: Promise; } @@ -104,9 +79,7 @@ export interface AgentTaskNotificationContext { export interface IAgentTaskService { readonly _serviceBrand: undefined; - /** Track a `ITaskHandle` (from `taskService.run()`). */ track(handle: ITaskHandle, options: AgentTaskTrackOptions): IAgentTaskEntry; - /** @deprecated Use `taskService.run()` + `track()` instead. */ registerTask(task: AgentTask, options?: RegisterAgentTaskOptions): string; getTask(taskId: string): AgentTaskInfo | undefined; list(activeOnly?: boolean, limit?: number): readonly AgentTaskInfo[]; diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 2948ee9276..47e6a7eec9 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -118,10 +118,6 @@ interface ManagedTask { readonly outputChunks: string[]; outputSizeBytes: number; retainedOutputBytes: number; - /** - * True once a command has crossed `MAX_TASK_OUTPUT_BYTES` and termination has - * been requested. One-shot guard so the ceiling fires exactly once. - */ outputLimitTripped: boolean; status: AgentTaskStatus; options: RegisterAgentTaskOptions & { description?: string }; @@ -145,21 +141,10 @@ interface ManagedTask { handleSubscription?: { dispose(): void }; } -const MAX_OUTPUT_BYTES = 1024 * 1024; // 1 MiB +const MAX_OUTPUT_BYTES = 1024 * 1024; -/** - * Hard ceiling on the combined output a single shell command may stream before - * it is force-terminated (SIGTERM → grace → SIGKILL). It guards both the - * live-forward path and the on-disk `output.log` write chain from a runaway - * command (e.g. `b3sum --length `) whose output would otherwise grow - * without bound, filling the disk or retaining each pending-write chunk until - * Node aborts with an out-of-memory crash. Scoped to process tasks (foreground - * and background); subagent and user-question results are appended once and must - * always be persisted, so they are intentionally not capped here. - */ -const MAX_TASK_OUTPUT_BYTES = 16 * 1024 * 1024; // 16 MiB +const MAX_TASK_OUTPUT_BYTES = 16 * 1024 * 1024; -/** Terminal `stopReason` recorded when a command trips the output ceiling. */ function outputLimitReason(): string { const mib = Math.floor(MAX_TASK_OUTPUT_BYTES / (1024 * 1024)); return ( @@ -184,14 +169,6 @@ export function isAgentTaskTerminal(status: AgentTaskStatus): boolean { return TERMINAL_STATUSES.has(status); } -/** - * A manager-driven deadline (`timeoutMs` / `detachTimeoutMs`) sets - * `entry.timedOut` before aborting. A process task that self-settles on that - * abort reports `killed` (its signal was aborted); rewrite it to `timed_out` - * so the terminal status always reflects the deadline, matching v1's - * `settlementForOutcome` where a timeout outcome is forced to `timed_out` - * regardless of how the worker responded to SIGTERM. - */ function coerceTimeoutSettlement( entry: ManagedTask, settlement: AgentTaskSettlement, @@ -284,13 +261,6 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } private async restoreAfterReplay(): Promise { - // `wire.replay` has rebuilt `TaskModel` from the persisted task.started / - // task.terminated records. Seed the restored "ghosts" from it first (the - // wire-replay contribution), THEN load from disk and reconcile — all inside - // this single onRestored handler so the ordering (wire ghosts -> disk - // ghosts -> reconcile) holds. loadFromDisk / reconcile are async (disk - // I/O); awaiting them keeps restore observable only after task state has - // reached the same shape as v1's resumed background-task manager. this.restoreGhostsFromWire(); await this.loadFromDisk({ replace: false }); await this.reconcile(); @@ -583,11 +553,6 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { return this.detachEntry(entry, false); } - /** - * Move a foreground task to the background, releasing its tool-call waiter. - * `viaTimeout` marks an automatic detach triggered by the task deadline (vs. - * an explicit user/RPC detach) so the waiter can word its result. - */ private detachEntry(entry: ManagedTask, viaTimeout: boolean): AgentTaskInfo | undefined { if (TERMINAL_STATUSES.has(entry.status)) return this.toInfo(entry); @@ -604,7 +569,6 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { (entry.task === undefined ? undefined : entry.task.onDetach?.bind(entry.task)); onDetach?.(); } catch { - /* detach has already succeeded; hooks must not make RPC fail */ } this.startOutputPersist(entry); void this.persistLive(entry); @@ -626,12 +590,6 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } } - /** - * Arm a manager-owned deadline (`timeoutMs` / `detachTimeoutMs`). A - * foreground task opted into auto-background survives its first deadline by - * detaching to the background — `detachEntry` re-arms `detachTimeoutMs` — - * instead of being killed; every other deadline terminates with grace. - */ private armManagerTimeout(entry: ManagedTask, timeoutMs: number): void { entry.timeoutHandle = setTimeout(() => { entry.timeoutHandle = undefined; @@ -647,10 +605,6 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { entry.timeoutHandle.unref?.(); } - /** - * Foreground tasks opted into auto-background survive their first deadline - * by detaching to the background instead of being killed. - */ private canAutoBackgroundOnTimeout(entry: ManagedTask): boolean { return entry.options.autoBackgroundOnTimeout === true && !this.isDetached(entry); } @@ -721,7 +675,6 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { (entry.task === undefined ? undefined : entry.task.forceStop?.bind(entry.task)); await forceStop?.(); } catch { - /* best effort */ } } @@ -911,13 +864,6 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { entry.outputSizeBytes += chunkBytes; this.appendRetainedOutput(entry, chunk, chunkBytes); - // Output ceiling: a single shell command must not grow the unbounded - // live-forward buffer or the on-disk write chain until the process runs out - // of memory or fills the disk. Trip once, then request graceful termination - // through the shared stop path (SIGTERM → grace → SIGKILL). Scoped to - // process tasks (foreground and background): subagent and user-question tasks - // append their bounded result in one shot and must always persist it, so they - // are intentionally not capped here. if ( !entry.outputLimitTripped && entry.task?.kind === 'process' && @@ -927,11 +873,6 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { void this.stop(entry.taskId, outputLimitReason()); } - // Once the cap has tripped the task is being terminated: keep only the - // bounded in-memory ring buffer above and stop feeding the (unbounded) disk - // write chain. A producer that ignores SIGTERM could otherwise keep the - // chain — and the chunk strings each pending write retains — growing through - // the grace window until SIGKILL, re-introducing the OOM this cap prevents. if (entry.outputLimitTripped) return; if (!entry.outputPersistStarted) { diff --git a/packages/agent-core-v2/src/agent/task/tools/task-list.ts b/packages/agent-core-v2/src/agent/task/tools/task-list.ts index b4b2a58e31..2753cb1241 100644 --- a/packages/agent-core-v2/src/agent/task/tools/task-list.ts +++ b/packages/agent-core-v2/src/agent/task/tools/task-list.ts @@ -14,7 +14,6 @@ import type { AgentTaskInfo } from '#/agent/task/task'; import { formatPlainObject } from './format'; import TASK_LIST_DESCRIPTION from './task-list.md?raw'; -// ── Input schema ───────────────────────────────────────────────────── export const TaskListInputSchema = z.object({ active_only: z @@ -34,11 +33,8 @@ export const TaskListInputSchema = z.object({ export type TaskListInput = z.infer; -// ── Implementation ─────────────────────────────────────────────────── export function formatTaskList(tasks: readonly AgentTaskInfo[], activeOnly: boolean): string { - // `active_only=false` mixes in terminal/lost tasks, so the count is no - // longer purely "active" — use a neutral label to avoid mislabeling them. const label = activeOnly ? 'active_background_tasks' : 'background_tasks'; const header = `${label}: ${String(tasks.length)}`; if (tasks.length === 0) return `${header}\nNo background tasks found.`; diff --git a/packages/agent-core-v2/src/agent/task/tools/task-output.ts b/packages/agent-core-v2/src/agent/task/tools/task-output.ts index 80dc123efe..b4f8a98721 100644 --- a/packages/agent-core-v2/src/agent/task/tools/task-output.ts +++ b/packages/agent-core-v2/src/agent/task/tools/task-output.ts @@ -28,17 +28,10 @@ import { type AgentTaskStatus, TERMINAL_STATUSES } from '#/agent/task/types'; import { formatPlainObject } from './format'; import TASK_OUTPUT_DESCRIPTION from './task-output.md?raw'; -/** - * Maximum bytes of output included inline as a preview. Output larger - * than this is truncated to its tail; the full log is read separately - * via the `Read` tool with the returned `output_path`. - */ -const OUTPUT_PREVIEW_BYTES = 32 * 1024; // 32 KiB +const OUTPUT_PREVIEW_BYTES = 32 * 1024; -/** Number of lines the paging hint suggests reading per `Read` call. */ const PAGING_HINT_LINES = 300; -// ── Input schema ───────────────────────────────────────────────────── export const TaskOutputInputSchema = z.object({ task_id: z.string().describe('The background task ID to inspect.'), @@ -61,7 +54,6 @@ export const TaskOutputInputSchema = z.object({ export type TaskOutputInput = z.infer; -// ── Implementation ─────────────────────────────────────────────────── function retrievalStatus( status: AgentTaskStatus, @@ -125,15 +117,11 @@ export class TaskOutputTool implements BuiltinTool { await this.tasks.wait(args.task_id, (args.timeout ?? 30) * 1000, signal); } - // Re-fetch after potential wait. const current = this.tasks.getTask(args.task_id); if (!current) { return { isError: true, output: `Task not found: ${args.task_id}` }; } - // A single manager-owned snapshot drives the tail window and every - // reported metric below. Persisted logs remain authoritative when - // available; detached managers fall back to their live ring buffer. const output = await this.tasks.getOutputSnapshot(args.task_id, OUTPUT_PREVIEW_BYTES); const lines = [ @@ -149,7 +137,6 @@ export class TaskOutputTool implements BuiltinTool { fullOutputTool: output.fullOutputAvailable && output.outputPath !== undefined ? 'Read' : undefined, fullOutputHint: fullOutputHint(output), - // Nudge at the exact point of misuse: a blocking wait that timed out. nextStep: args.block === true && !TERMINAL_STATUSES.has(current.status) ? 'The task is still running after waiting. Do not block on it again — continue with other work or hand back to the user; you will be notified automatically when it completes.' @@ -158,9 +145,6 @@ export class TaskOutputTool implements BuiltinTool { '', ]; - // When the preview omits the head of the log, emit an explicit - // banner just before the `[output]` marker so the model knows it is - // looking at a tail, not the full output. if (output.truncated) { lines.push( output.fullOutputAvailable && output.outputPath !== undefined @@ -170,9 +154,6 @@ export class TaskOutputTool implements BuiltinTool { } lines.push('[output]', output.preview || '[no output available]'); - // Side-channel brief for the host UI / log readers. Distinct from - // the `output` body which is parsed by the LLM. Kept short so log - // readers can render it as a one-liner. return { output: lines.join('\n'), isError: false, diff --git a/packages/agent-core-v2/src/agent/task/tools/task-stop.ts b/packages/agent-core-v2/src/agent/task/tools/task-stop.ts index 2d898c4bb7..4d1b835fad 100644 --- a/packages/agent-core-v2/src/agent/task/tools/task-stop.ts +++ b/packages/agent-core-v2/src/agent/task/tools/task-stop.ts @@ -13,7 +13,6 @@ import { IAgentTaskService } from '#/agent/task/task'; import { TERMINAL_STATUSES } from '#/agent/task/types'; import TASK_STOP_DESCRIPTION from './task-stop.md?raw'; -// ── Input schema ───────────────────────────────────────────────────── export const TaskStopInputSchema = z.object({ task_id: z.string().describe('The background task ID to stop.'), @@ -26,7 +25,6 @@ export const TaskStopInputSchema = z.object({ export type TaskStopInput = z.infer; -// ── Implementation ─────────────────────────────────────────────────── export class TaskStopTool implements BuiltinTool { readonly name = 'TaskStop' as const; @@ -46,8 +44,6 @@ export class TaskStopTool implements BuiltinTool { return { isError: true, output: `Task not found: ${args.task_id}` }; } - // A blank or whitespace-only reason falls back to the default. `?? default` - // would not cover the empty-string case, so trim and coalesce explicitly. const trimmedReason = args.reason?.trim(); const reason = trimmedReason === undefined || trimmedReason.length === 0 @@ -55,14 +51,10 @@ export class TaskStopTool implements BuiltinTool { : trimmedReason; if (TERMINAL_STATUSES.has(info.status)) { - // Already-terminal tasks report their current state using the same - // structured multi-line format as the normal stop path below. return { output: `task_id: ${info.taskId}\n` + `status: ${info.status}\n` + - // A task persisted by an older build may carry a blank stopReason; - // `??` would not coalesce `''`, so trim-and-`||` to the placeholder. `reason: ${terminalStopReason(info.stopReason)}`, isError: false, }; diff --git a/packages/agent-core-v2/src/agent/task/types.ts b/packages/agent-core-v2/src/agent/task/types.ts index ed14c79a88..7e60395b31 100644 --- a/packages/agent-core-v2/src/agent/task/types.ts +++ b/packages/agent-core-v2/src/agent/task/types.ts @@ -17,7 +17,6 @@ export type AgentTaskSettlementStatus = 'completed' | 'failed' | 'timed_out' | ' export interface AgentTaskSettlement { readonly status: AgentTaskSettlementStatus; - /** Human-readable reason for the terminal status, when available. */ readonly stopReason?: string; } @@ -25,18 +24,11 @@ export interface AgentTaskInfoBase { readonly taskId: string; readonly description: string; readonly status: AgentTaskStatus; - /** - * `false` means a tool call is still waiting on this task in the - * foreground. Omitted legacy records should be treated as detached. - */ readonly detached?: boolean; readonly startedAt: number; readonly endedAt: number | null; - /** Human-readable reason for the terminal status, when available. */ readonly stopReason?: string; - /** Suppress automatic terminal notifications/reminders for this task. */ readonly terminalNotificationSuppressed?: boolean; - /** Deadline supplied at registration; surfaced via task info. */ readonly timeoutMs?: number; } diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index 2a5b07bd9b..b5a4ff1303 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -212,8 +212,6 @@ export class AgentToolDedupeService extends Disposable implements IAgentToolDedu args: unknown, dupType: ToolCallDupType, ): void { - // Tag the call so the executor's `tool_call` telemetry can carry dup_type; - // both same_step (placeholder path) and cross_step dups reach trackToolCall. this.toolExecutor.recordDupType(toolCallId, dupType); this.telemetry.track2('tool_call_dedup_detected', { turn_id: this.activeTurnId ?? 0, diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts index 30e905432b..cba1c45578 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts @@ -34,7 +34,6 @@ export interface ToolExecutionResult { export type MissingToolDescriber = (toolName: string) => string | undefined; export type UnavailableToolDescriber = (toolName: string) => string | undefined; -/** How a duplicate tool call relates to its original (dedupe telemetry). */ export type ToolCallDupType = 'same_step' | 'cross_step'; export interface IAgentToolExecutorService { @@ -47,24 +46,9 @@ export interface IAgentToolExecutorService { readonly onDidExecuteTool: OrderedHookSlot; }; - /** - * Record that a tool call is a duplicate so `tool_call` telemetry can tag - * it. Written by the `toolDedupe` plugin (which already injects this - * service — injecting the plugin here would cycle); the executor reads and - * clears the entry when the call's telemetry fires, defaulting to 'normal'. - */ recordDupType(toolCallId: string, dupType: ToolCallDupType): void; - /** - * Single-slot hook for the "registered but currently unavailable" preflight - * message. A second registration overwrites the first; disposing the returned - * handle clears the slot only when the same describer still occupies it. - */ registerUnavailableToolDescriber(describer: UnavailableToolDescriber): IDisposable; - /** - * Single-slot hook for the tool-miss preflight message (e.g. a loaded tool - * whose server dropped). Same single-slot semantics as above. - */ registerMissingToolDescriber(describer: MissingToolDescriber): IDisposable; } diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index bfcc5cab9b..246509c3cc 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -108,9 +108,6 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { private missingToolDescriber: MissingToolDescriber | undefined; private unavailableToolDescriber: UnavailableToolDescriber | undefined; - // Duplicate-call tags written by the `toolDedupe` plugin, consumed by - // `trackToolCall`. Pruned on turn change so entries from calls that never - // reached telemetry (e.g. an aborted batch) cannot leak across turns. private readonly toolCallDupTypes = new Map(); private dupTypeTurnId: number | undefined; @@ -599,9 +596,6 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { didCtx.stopTurn === true || effectiveResult.stopTurn === true, stopBatchAfterThis: result.stopBatchAfterThis, - // Thread the declared delivery through to the yielded result. An - // `onDidExecuteTool` hook (the agent/L4 layer) may have already consumed - // it by stripping it from `didCtx.result`; in that case this is undefined. delivery: coercedResult.delivery, }; return this.resultTruncation.truncateForModel({ @@ -897,7 +891,6 @@ async function raceWithAbortGrace( try { signal.removeEventListener('abort', onAbort); } catch { - // Some AbortSignal polyfills do not implement removeEventListener. } } } diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolScheduler.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolScheduler.ts index bebc1687d4..79e5d960c9 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolScheduler.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolScheduler.ts @@ -12,7 +12,6 @@ import { ToolAccesses } from '#/tool/toolContract'; -// Scheduler export interface ToolCallTask { readonly accesses: ToolAccesses; diff --git a/packages/agent-core-v2/src/agent/toolRegistry/toolContribution.ts b/packages/agent-core-v2/src/agent/toolRegistry/toolContribution.ts index 14fbb07812..82b5605eab 100644 --- a/packages/agent-core-v2/src/agent/toolRegistry/toolContribution.ts +++ b/packages/agent-core-v2/src/agent/toolRegistry/toolContribution.ts @@ -30,26 +30,8 @@ export type AnyExecutableTool = ExecutableTool; export type ToolCtor = new (...args: any[]) => T; export interface ToolContributionOptions { - /** - * Origin tag stored alongside the tool in the runtime registry. Defaults to - * `'builtin'` when omitted (mirroring the current runtime `register()` - * default). External contributors would pass `'user'`, `'plugin'`, etc. - */ readonly source?: ToolSource; - /** - * Optional per-Agent predicate. Evaluated once when the Agent's tool registry - * is constructed; if it returns `false`, the contribution is skipped for that - * Agent. Runs inside an `invokeFunction` so it can `.get()` any Agent-scope - * service. - */ readonly when?: (accessor: ServicesAccessor) => boolean; - /** - * Optional supplier of leading static arguments passed to the tool - * constructor before any DI-injected services (matching the SyncDescriptor - * convention). Also invoked with a `ServicesAccessor`, so runtime config - * values (`IConfigService.get(...)`) can flow through here without a - * dedicated wrapper class. - */ readonly staticArgs?: (accessor: ServicesAccessor) => readonly unknown[]; } @@ -71,11 +53,6 @@ export function getToolContributions(): readonly ToolContribution[] { return _toolContributions; } -/** - * Test hook. Clears the module-level contribution list so a test can register - * a bounded set (mirrors `_clearScopedRegistryForTests`). Not for production - * code — the tool table is meant to accumulate over the process lifetime. - */ export function _clearToolContributionsForTests(): void { _toolContributions.length = 0; } diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts index 7a1ab43dee..02529fc3f0 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts @@ -64,12 +64,6 @@ export class AgentToolSelectService extends Disposable implements IAgentToolSele this._register( eventBus.subscribe('context.spliced', (splice) => { if (splice.deleteCount === 0 || this.pendingLoaded.size === 0) return; - // The pending set is only a defer-window lead over the history-backed - // ledger, so any deletion splice can falsify it: v2's undo slices the - // tail wholesale (v1 keeps `injection`-origin schema messages in place), - // which makes full-prefix detection insufficient. Re-fold the pending - // set against the surviving history — the event is published after the - // memory service has rewritten it. const landed = collectLoadedDynamicToolNames(this.context.get()); for (const name of this.pendingLoaded) { if (!landed.has(name)) this.pendingLoaded.delete(name); diff --git a/packages/agent-core-v2/src/agent/usage/usage.ts b/packages/agent-core-v2/src/agent/usage/usage.ts index 445d81f0e2..833e5bd4e4 100644 --- a/packages/agent-core-v2/src/agent/usage/usage.ts +++ b/packages/agent-core-v2/src/agent/usage/usage.ts @@ -45,7 +45,6 @@ export interface IAgentUsageService { record(model: string, usage: TokenUsage, source?: LLMRequestSource): void; status(): UsageStatus; - /** Fires after each live usage record; replay stays silent. */ readonly onDidRecord: Event; } diff --git a/packages/agent-core-v2/src/agent/usage/usageOps.ts b/packages/agent-core-v2/src/agent/usage/usageOps.ts index 548b50baaf..ba47508525 100644 --- a/packages/agent-core-v2/src/agent/usage/usageOps.ts +++ b/packages/agent-core-v2/src/agent/usage/usageOps.ts @@ -26,8 +26,6 @@ export type UsageRecordScope = 'session' | 'turn'; declare module '#/app/event/eventBus' { interface DomainEventMap { - // Canonical declaration for the agent status-bar event (`IEventBus`); each - // domain derives/publishes a subset. 'agent.status.updated': { usage?: UsageStatus; swarmMode?: boolean; diff --git a/packages/agent-core-v2/src/agent/userTool/userToolService.ts b/packages/agent-core-v2/src/agent/userTool/userToolService.ts index 1185486b94..530d30edc6 100644 --- a/packages/agent-core-v2/src/agent/userTool/userToolService.ts +++ b/packages/agent-core-v2/src/agent/userTool/userToolService.ts @@ -77,11 +77,6 @@ export class AgentUserToolService extends Disposable implements IAgentUserToolSe } private restoreRegisteredTools(): void { - // The persisted `ActiveToolsModel` is the source of truth for the active - // set on resume. Re-activating a tool whose registration predates the - // final `tools.set_active_tools` would resurrect a stale ephemeral - // overlay on top of an explicit base, so only activate tools the base - // does not exclude. const persistedActive = this.profile.getActiveToolNames(); for (const registration of this.wire.getModel(UserToolModel).values()) { const activate = diff --git a/packages/agent-core-v2/src/agent/wireRecord/migration/migration.ts b/packages/agent-core-v2/src/agent/wireRecord/migration/migration.ts index 69d81dcb91..41e0fe5bb9 100644 --- a/packages/agent-core-v2/src/agent/wireRecord/migration/migration.ts +++ b/packages/agent-core-v2/src/agent/wireRecord/migration/migration.ts @@ -10,11 +10,6 @@ export { migrateV1_3ToV1_4, }; -// Wire protocol versions currently support only the `number.number` format. -// Bump this only for changes that require migration of existing records or -// change how existing records must be interpreted. Do not bump it only because -// a new feature adds a new wire record type: older versions do not implement -// that feature and do not need to understand the new record type. export const AGENT_WIRE_PROTOCOL_VERSION = '1.4'; export interface WireMigrationRecord { diff --git a/packages/agent-core-v2/src/agent/wireRecord/migration/v1.1.ts b/packages/agent-core-v2/src/agent/wireRecord/migration/v1.1.ts index 590dc6dc3a..960be4082f 100644 --- a/packages/agent-core-v2/src/agent/wireRecord/migration/v1.1.ts +++ b/packages/agent-core-v2/src/agent/wireRecord/migration/v1.1.ts @@ -1,11 +1,5 @@ import type { WireMigration, WireMigrationRecord } from './migration'; -/** - * Wire records before v1.1 used a nested `function` wrapper for each tool call: - * { function: { name: 'xxx', arguments: 'yyy' } } - * v1.1 flattens it to: - * { name: 'xxx', arguments: 'yyy' } - */ interface V1_0ContextAppendMessageRecord extends WireMigrationRecord { readonly type: 'context.append_message'; readonly message: V1_0ContextMessage; diff --git a/packages/agent-core-v2/src/agent/wireRecord/migration/v1.2.ts b/packages/agent-core-v2/src/agent/wireRecord/migration/v1.2.ts index 1467bc38d0..9e794a0973 100644 --- a/packages/agent-core-v2/src/agent/wireRecord/migration/v1.2.ts +++ b/packages/agent-core-v2/src/agent/wireRecord/migration/v1.2.ts @@ -25,11 +25,6 @@ const LEGACY_SESSION_APPROVAL_ACTION_TO_PATTERN: Readonly 'write file': 'Write', }; -// v1.1 cached these action labels directly but did not have enough stable data -// to reconstruct an equivalent v1.2 rule. Migrating to broad `Bash` would -// expand the approval, and there is no safe `Bash(...)` subject to recover — -// in particular, `run background command` would need to encode -// `run_in_background=true`, which `Bash`'s `matchesRule` cannot express. const LEGACY_SESSION_APPROVAL_UNRESTORABLE_ACTIONS = new Set([ 'run command in plan mode', 'run background command', diff --git a/packages/agent-core-v2/src/agent/wireRecord/migration/v1.3.ts b/packages/agent-core-v2/src/agent/wireRecord/migration/v1.3.ts index c28fdf87e8..ad9b8863fd 100644 --- a/packages/agent-core-v2/src/agent/wireRecord/migration/v1.3.ts +++ b/packages/agent-core-v2/src/agent/wireRecord/migration/v1.3.ts @@ -1,14 +1,5 @@ import type { WireMigration, WireMigrationRecord } from './migration'; -/** - * v1.2 -> v1.3 is a bump-only migration. - * - * v1.3 introduces blobref offloading for large base64 media payloads. - * Records written by v1.3+ may contain `blobref:;` URLs in - * message content instead of inline `data:` URIs. Wire records are still - * valid JSON and do not require transformation; the blobref format is - * transparently handled at read/write time by BlobStore. - */ export const migrateV1_2ToV1_3: WireMigration = { sourceVersion: '1.2', targetVersion: '1.3', diff --git a/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts b/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts index cb2f8b58c9..d10522584b 100644 --- a/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts +++ b/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts @@ -24,14 +24,6 @@ export interface WireRecordRestoreResult { export interface IAgentWireRecordService { readonly _serviceBrand: undefined; - /** - * Snapshot of every record held in memory, in order, excluding the leading - * `metadata` envelope: the records seeded by {@link restore} plus every record - * persisted by live dispatch afterwards (appended in dispatch order). Intended - * for callers that need to replay or reduce the same history without - * re-reading `wire.jsonl` (e.g. session fork, the messages/snapshot - * transcript). - */ 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 45e6f3c4a4..d48be558c0 100644 --- a/packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts +++ b/packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts @@ -44,21 +44,10 @@ export class AgentWireRecordService extends Disposable implements IAgentWireReco @IAgentWireService private readonly wire?: IWireService, ) { super(); - // The agent scope carries its persistence scope (`sessions/// - // agents/`); the wire log is the fixed `wire.jsonl` beneath it — - // the same scope `WireService` appends to, so `restore()` reads back - // what live dispatch wrote. this.wireScope = scopeContext.scope(); if (this.log !== undefined) { this._register(this.log.acquire(this.wireScope, WIRE_RECORD_FILENAME)); } - // Keep the in-memory journal current with live dispatch: `restore()` seeds - // it from disk and every persisted record afterwards is appended here in - // dispatch order, so transcript readers reduce memory instead of re-reading - // `wire.jsonl`. Metadata envelopes are excluded to honor `getRecords()`. - // `wire` is optional so direct construction (tests, migration round-trips) - // keeps the restore-only journal; live tracking is active whenever DI - // supplies the agent wire service. if (wire !== undefined) { this._register( wire.onEmission((emission) => { @@ -145,10 +134,6 @@ export class AgentWireRecordService extends Disposable implements IAgentWireReco } async flush(): Promise { - // Drain the wire service's async persist pipeline first: with a model blob - // codec, appends are queued on a microtask chain and only - // reach the log store once that queue settles. Flushing the log alone - // would miss records still in flight. await this.wire?.flush(); await this.log?.flush(); } @@ -170,22 +155,12 @@ function isWireRecordMetadata(record: PersistedWireRecord): record is WireRecord return record.type === 'metadata' && typeof record['protocol_version'] === 'string'; } -/** - * File name of every agent's wire log, written beneath the agent's homedir - * (`/sessions///agents//wire.jsonl`). - */ export const WIRE_RECORD_FILENAME = 'wire.jsonl'; export function missingWireMetadataError(): Error { return new Error('WireRecord restore expected metadata as the first record'); } -/** - * Store `scope` of an agent's wire log: its homedir made relative to the app - * `homeDir`. Paired with {@link WIRE_RECORD_FILENAME} by callers that read / - * rewrite a wire log through `IAppendLogStore` without holding a live agent - * handle (e.g. session fork). - */ export function wireRecordScope(homedir: string, homeDir: string): string { return relative(homeDir, homedir); } diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts index fec244be41..6ccc248fb5 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts @@ -27,7 +27,6 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio import type { ILogger } from '#/_base/log/log'; import type { ISessionProcessRunner } from '#/session/process/processRunner'; -/** Name of the builtin default profile (the top-level interactive agent). */ export const DEFAULT_AGENT_PROFILE_NAME = 'agent'; export interface AgentProfilePromptPrefixContext { @@ -37,81 +36,39 @@ export interface AgentProfilePromptPrefixContext { } export interface AgentProfileSummaryPolicy { - /** Minimum length (in characters) of the child's summary before it is - * considered acceptable. Shorter summaries trigger a continuation turn. */ readonly minChars: number; - /** Continuation prompt appended to the child agent when the summary is too - * short, asking it to expand. */ readonly continuationPrompt: string; - /** Number of continuation attempts before giving up. */ readonly retries: number; } -/** - * Runtime context supplied to a profile's system-prompt renderer. Captures - * everything determined at render time (working dir, AGENTS.md, host OS/shell, - * skills, …). Assembled by the `profile` domain and passed into - * {@link AgentProfile.systemPrompt}. - */ export interface AgentProfileContext { readonly cwd?: string; - /** 2-level tree listing of the working directory, for LLM orientation. */ readonly cwdListing?: string; - /** Concatenated AGENTS.md instruction hierarchy (user-level + project-level). */ readonly agentsMd?: string; - /** Rendered listings of additional workspace directories. */ readonly additionalDirsInfo?: string; - /** Host OS family (`macOS` / `Linux` / `Windows` / raw platform). */ readonly osKind?: string; readonly shellName?: string; readonly shellPath?: string; - /** ISO timestamp captured at render time. */ readonly now?: string; - /** Rendered model-facing listing of available skills. */ readonly skills?: string; readonly [key: string]: unknown; } export interface AgentProfile { - /** Stable identifier; must be unique across contributions. */ readonly name: string; - /** Short human-readable label; surfaced to the caller (LLM) as "Available agent types". */ readonly description?: string; - /** When-to-use hint appended to `description` in the caller's tool spec. */ readonly whenToUse?: string; - /** Tool names (and MCP glob patterns) the agent may use under this profile. */ readonly tools: readonly string[]; - /** - * Render the complete system prompt for this profile given the runtime - * context. Self-contained — includes the base prompt and any role overlay. - */ systemPrompt(context: AgentProfileContext): string; - /** - * Optional per-invocation prompt prefix produced from the caller's context - * (e.g. `explore`'s `` block). Prepended to the caller-supplied - * prompt before the child's first turn. Best-effort — a thrown error / empty - * return skips the prefix. - */ readonly promptPrefix?: (ctx: AgentProfilePromptPrefixContext) => Promise; - /** - * Optional summary distillation policy applied by the caller after the - * child's turn ends. Undefined = accept whatever the child returned. - */ readonly summaryPolicy?: AgentProfileSummaryPolicy; } export interface IAgentProfileCatalogService { readonly _serviceBrand: undefined; - /** Return the profile with the given name, or `undefined` when unknown. */ get(name: string): AgentProfile | undefined; - /** - * Return the builtin default profile ({@link DEFAULT_AGENT_PROFILE_NAME}). - * Throws when no default profile is registered (a programming-time invariant - * violation, not a request failure). - */ getDefault(): AgentProfile; - /** Enumerate every registered profile. Stable order (insertion order). */ list(): readonly AgentProfile[]; } diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/contribution.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/contribution.ts index 5e894a3b50..cfdebf9625 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/contribution.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/contribution.ts @@ -25,10 +25,6 @@ export function getAgentProfileContributions(): readonly AgentProfile[] { return _profileContributions; } -/** - * Test hook. Clears the module-level contribution list so a test can register - * a bounded set (mirrors `_clearToolContributionsForTests`). - */ export function _clearAgentProfileContributionsForTests(): void { _profileContributions.length = 0; } diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts index 9c61f1f5d2..60776414b4 100644 --- a/packages/agent-core-v2/src/app/auth/authService.ts +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -86,7 +86,6 @@ interface FlowState { readonly provider: string; readonly controller: AbortController; readonly oauthRef: OAuthRef | undefined; - /** Base URL of the environment the login targeted (env-aware); drives the provisioned provider entry. */ readonly loginBaseUrl: string | undefined; device: DeviceAuthorization | undefined; status: OAuthFlowStatus; @@ -100,12 +99,6 @@ export class OAuthService extends Disposable implements IOAuthService { declare readonly _serviceBrand: undefined; private readonly flows = new Map(); - /** - * Serializes managed-provider model refreshes so a refresh triggered by - * login completion and a manual `:refresh_oauth` (or two overlapping manual - * ones) never race on reading/patching the persisted config. Mirrors v1's - * `_refreshChain`. - */ private refreshChain: Promise = Promise.resolve(); constructor( @@ -234,8 +227,6 @@ export class OAuthService extends Disposable implements IOAuthService { } async logout(provider = KIMI_CODE_PROVIDER_NAME): Promise { - // Delete the token from the slot the runtime actually reads (v1 parity): - // env-aware for kimi-code, so an env-scoped login's token is removed too. const oauthRef = provider === KIMI_CODE_PROVIDER_NAME ? this.resolveRuntimeOAuthRef(provider) @@ -380,14 +371,6 @@ export class OAuthService extends Disposable implements IOAuthService { }; } - /** - * Resolve the environment the login should target (v1's - * `managedAuth.login`): env-aware via `resolveKimiCodeLoginAuth`, so - * `KIMI_CODE_BASE_URL` / `KIMI_CODE_OAUTH_HOST` steer the credential slot - * the token is written to the same way they steer the runtime token reads - * (`resolveKimiCodeRuntimeAuth`). A mismatched slot is the "login succeeds - * but every call 401s" bug. - */ private resolveLoginAuth(provider: string): { readonly oauthRef: OAuthRef | undefined; readonly baseUrl: string | undefined; @@ -401,12 +384,6 @@ export class OAuthService extends Disposable implements IOAuthService { configuredBaseUrl: config?.baseUrl, configuredOAuthRef: config?.oauth, }); - // Always resolve to a concrete ref for kimi-code: when the login env - // overrides the configured one, the provisioned entry must record the - // env-scoped slot explicitly (v1's `provisionManagedKimiCodeConfig` - // writes the login (oauthKey, oauthHost)) — not only so the runtime can - // find it, but so `isKimiOAuthProvider` still holds and the post-login - // model refresh runs. const oauthRef = loginAuth.oauthRef ?? resolveKimiCodeOAuthRef({ @@ -442,10 +419,6 @@ export class OAuthService extends Disposable implements IOAuthService { } private invalidateFlows(event: ProvidersChangedEvent): void { - // Only abort flows whose OAuth provider was actually removed or whose - // config changed. Refreshes that merely rewrite the `providers` section - // (e.g. model catalog refreshes on startup) must not trip in-flight logins - // for unaffected providers. const affected = new Set([...event.removed, ...event.changed]); if (affected.size === 0) return; for (const state of this.flows.values()) { @@ -494,10 +467,6 @@ export class OAuthService extends Disposable implements IOAuthService { oauthRef: OAuthRef | undefined, loginBaseUrl: string | undefined, ): Promise { - // `baseUrl` comes from the login environment (env-aware), not a stale - // configured one, and `oauth` records the login credential slot — v1 - // parity: `provisionManagedKimiCodeConfig` rewrites both from the login - // auth. Non-kimi providers without a ref keep the old skip. if (oauthRef === undefined && provider !== KIMI_CODE_PROVIDER_NAME) return; const baseUrl = loginBaseUrl ?? this.providerService.get(provider)?.baseUrl ?? kimiCodeBaseUrl(); @@ -695,7 +664,6 @@ function providerNameFromFlatModel(model: ModelAlias): string | undefined { return baseUrl === undefined ? undefined : deriveProviderId(baseUrl); } -/** Structural view of a managed-config model alias (the fields the refresh reads/writes). */ interface ManagedModel { readonly provider: string; readonly model: string; @@ -840,8 +808,6 @@ function restoreDefaultSelection( ): void { if (defaultModel === undefined || config.models?.[defaultModel] === undefined) return; config.defaultModel = defaultModel; - // A refresh may have just learned that the default model cannot disable - // thinking — never restore a stale thinking-off selection onto it. const capabilities = managedModel(config, defaultModel)?.capabilities ?? []; const enabled = capabilities.includes('always_thinking') ? true : defaultEnabled; if (enabled !== undefined) { diff --git a/packages/agent-core-v2/src/app/auth/configSection.ts b/packages/agent-core-v2/src/app/auth/configSection.ts index 42e50066a5..abfd7326c2 100644 --- a/packages/agent-core-v2/src/app/auth/configSection.ts +++ b/packages/agent-core-v2/src/app/auth/configSection.ts @@ -50,7 +50,6 @@ export const ServicesConfigSchema = z export type ServicesConfig = z.infer; -/** Read transform: snake_case file → camelCase in-memory `services` object. */ export const servicesFromToml = (rawSnake: unknown): unknown => { if (!isPlainObject(rawSnake)) return rawSnake; const out: Record = {}; @@ -75,11 +74,8 @@ function serviceEntryFromToml(data: Record): Record { if (!isPlainObject(value)) return value; - // Preserve unknown top-level entries (e.g. a user-defined service) verbatim, - // mirroring v1's `servicesToToml` cloning `rawServices`. const out = cloneRecord(rawSnake); writeService(out, 'moonshot_search', value['moonshotSearch']); writeService(out, 'moonshot_fetch', value['moonshotFetch']); diff --git a/packages/agent-core-v2/src/app/auth/webSearch/tools/web-search.ts b/packages/agent-core-v2/src/app/auth/webSearch/tools/web-search.ts index 82c3d5436a..f5ce5da372 100644 --- a/packages/agent-core-v2/src/app/auth/webSearch/tools/web-search.ts +++ b/packages/agent-core-v2/src/app/auth/webSearch/tools/web-search.ts @@ -28,7 +28,6 @@ import { registerTool } from '#/agent/toolRegistry/toolContribution'; import { IWebSearchProviderService } from '../webSearch'; import DESCRIPTION from './web-search.md?raw'; -// ── Provider interface (host-injected) ─────────────────────────────── export interface WebSearchResult { title: string; @@ -48,7 +47,6 @@ export interface WebSearchProvider { ): Promise; } -// ── Input schema ───────────────────────────────────────────────────── export const WebSearchInputSchema = z.object({ query: z.string().describe('The query text to search for.'), @@ -56,7 +54,6 @@ export const WebSearchInputSchema = z.object({ export type WebSearchInput = z.infer; -// ── Implementation ─────────────────────────────────────────────────── export class WebSearchTool implements BuiltinTool { readonly name = 'WebSearch' as const; @@ -102,18 +99,12 @@ export class WebSearchTool implements BuiltinTool { builder.write(`Snippet: ${result.snippet}\n\n`); } - // Keep the citation reminder next to the data (not just in the static tool - // description), so it is present on every search. Cite the page actually - // relied on — after a FetchURL follow-up, that is the fetched page. builder.write( 'When you rely on a result in your answer, cite it inline as a markdown link, e.g. [title](url).', ); return builder.ok(); } catch (error) { - // Propagate in-flight cancellation so the executor can classify it - // (including user cancellation) instead of surfacing it as a generic - // search error that the model may retry. if (signal.aborted) throw error; return { isError: true, @@ -123,15 +114,7 @@ export class WebSearchTool implements BuiltinTool { } } -// ── Error classification ───────────────────────────────────────────── -/** - * Maps a thrown search error to a categorised, human-readable message. - * - * The original error text is always preserved so the model can still see the - * underlying detail; the prefix only adds a category so failures are easier to - * reason about (e.g. retry vs. surface to the user). - */ function classifySearchError(error: unknown): string { const name = error instanceof Error ? error.name : ''; const message = error instanceof Error ? error.message : String(error); diff --git a/packages/agent-core-v2/src/app/authLegacy/authLegacy.ts b/packages/agent-core-v2/src/app/authLegacy/authLegacy.ts index b14b109686..ac9c959f50 100644 --- a/packages/agent-core-v2/src/app/authLegacy/authLegacy.ts +++ b/packages/agent-core-v2/src/app/authLegacy/authLegacy.ts @@ -16,11 +16,6 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio export interface IAuthLegacyService { readonly _serviceBrand: undefined; - /** - * Compute the v1 readiness snapshot (`GET /api/v1/auth`). Cheap (one provider - * list + one config read + one cached-token probe); safe to call on every - * request. Never throws on provider state — the probe returns 200 regardless. - */ get(): Promise; } diff --git a/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts b/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts index 5fa8f7dabe..072632a096 100644 --- a/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts +++ b/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts @@ -34,8 +34,6 @@ export class AuthLegacyService implements IAuthLegacyService { ) {} async get(): Promise { - // Config loads asynchronously during bootstrap; mirror the catalog route's - // guard so a first-paint probe never observes a not-yet-loaded snapshot. await this.config.ready; const providers = this.providerService.list(); @@ -63,8 +61,6 @@ export class AuthLegacyService implements IAuthLegacyService { try { return (await this.oauth.status(MANAGED_PROVIDER_NAME)).loggedIn; } catch { - // Token-storage failures must not block the readiness probe; treat any - // error as "no usable token" (matches v1's `_hasCachedToken`). return false; } } diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index 967a49e2b9..9c54118cff 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -36,20 +36,12 @@ export interface IBootstrapOptions { readonly arch: string; readonly cwd: string; readonly env: NodeJS.ProcessEnv; - /** Host application version (e.g. the CLI release version). */ readonly clientVersion: string; } export const IBootstrapOptions: ServiceIdentifier = createDecorator('bootstrapOptions'); -/** - * Well-known top-level persistence areas. The bootstrap layer owns the mapping - * from each semantic name to concrete backend addressing; business code passes - * a scope string to `IFileSystemStorageService` / `IAtomicDocumentStore` / `IAppendLogStore` - * without caring whether the byte layer talks to a filesystem, a database, or - * a blob store. - */ export type PersistenceScopeName = | 'config' | 'sessions' @@ -69,7 +61,6 @@ export interface IBootstrapService { readonly osHomeDir: string; readonly homeDir: string; readonly configPath: string; - /** Host application version (e.g. the CLI release version). */ readonly clientVersion: string; readonly sessionsDir: string; readonly blobsDir: string; @@ -77,33 +68,11 @@ export interface IBootstrapService { readonly cacheDir: string; readonly logsDir: string; getEnv(name: string): string | undefined; - /** - * Scope string for a well-known top-level persistence area. Business code - * passes this to `IFileSystemStorageService` / `IAtomicDocumentStore` / `IAppendLogStore` - * — the backend layer converts it to concrete addressing. - */ scope(name: PersistenceScopeName): string; - /** - * Scope string for a session's persistence root. - * Equivalent to `${scope('sessions')}/${workspaceId}/${sessionId}`. - */ sessionScope(workspaceId: string, sessionId: string): string; - /** - * Scope string for a specific agent's persistence root under a session. - * Equivalent to `${sessionScope(wsId, sId)}/agents/${agentId}`. - */ agentScope(workspaceId: string, sessionId: string, agentId: string): string; - /** - * File-only: absolute on-disk directory for a session. - * Prefer `sessionScope(...)` — this exists for legacy APIs (session logs, - * task output files). Non-file bootstraps may throw. - */ sessionDir(workspaceId: string, sessionId: string): string; - /** - * File-only: absolute on-disk directory for a specific agent. Same caveat. - */ agentHomedir(workspaceId: string, sessionId: string, agentId: string): string; - /** Key of the config document under `scope('config')` (file: `'config.toml'`). */ readonly configKey: string; } @@ -163,9 +132,6 @@ function storageSeed(options: IBootstrapOptions): ScopeSeed { } function skillSeed(): ScopeSeed { - // The skill catalog Store is bound to the filesystem backend so skill - // discovery reads from disk. Tests rely on the in-memory backend registered - // in the skill domain (this `extra` seed overrides it in production). return [ [ ISkillDiscovery as ServiceIdentifier, diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts index 3a445290e5..381b00ddd8 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts @@ -58,9 +58,6 @@ export class BootstrapService implements IBootstrapService { this.storeDir = join(options.homeDir, 'store'); this.cacheDir = join(options.homeDir, 'cache'); this.logsDir = join(options.homeDir, 'logs'); - // The config document sits at `/`; scope('config') is - // the empty string (join skips empty segments) so `` addresses the - // homeDir directly. this.configKey = basename(options.configPath); this.scopes = { config: '', diff --git a/packages/agent-core-v2/src/app/config/configOverlayContributions.ts b/packages/agent-core-v2/src/app/config/configOverlayContributions.ts index 0ebc9a460a..a79a12693d 100644 --- a/packages/agent-core-v2/src/app/config/configOverlayContributions.ts +++ b/packages/agent-core-v2/src/app/config/configOverlayContributions.ts @@ -19,7 +19,6 @@ import type { ConfigEffectiveOverlay } from './config'; const _overlays: ConfigEffectiveOverlay[] = []; -/** Record a config-overlay contribution for `ConfigRegistry` to drain. */ export function registerConfigOverlay(overlay: ConfigEffectiveOverlay): void { _overlays.push(overlay); } @@ -28,7 +27,6 @@ export function getConfigOverlayContributions(): readonly ConfigEffectiveOverlay return _overlays; } -/** Test isolation — mirrors `_clearConfigSectionContributionsForTests`. */ export function _clearConfigOverlayContributionsForTests(): void { _overlays.length = 0; } diff --git a/packages/agent-core-v2/src/app/config/configSectionContributions.ts b/packages/agent-core-v2/src/app/config/configSectionContributions.ts index 2b96290029..62ee58dc07 100644 --- a/packages/agent-core-v2/src/app/config/configSectionContributions.ts +++ b/packages/agent-core-v2/src/app/config/configSectionContributions.ts @@ -20,11 +20,6 @@ export interface ConfigSectionContribution { const _contributions: ConfigSectionContribution[] = []; -/** - * Record a config-section contribution. Generic so `envBindings(...)` / - * `stripEnv` keep their owner-specific types at the call site; the contribution - * is stored in its erased form for `ConfigRegistry` to drain. - */ export function registerConfigSection( domain: string, schema: ConfigSchema, @@ -41,7 +36,6 @@ export function getConfigSectionContributions(): readonly ConfigSectionContribut return _contributions; } -/** Test isolation — mirrors `_clearScopedRegistryForTests`. */ export function _clearConfigSectionContributionsForTests(): void { _contributions.length = 0; } diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 3b68a41523..82a01f10c4 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -59,8 +59,6 @@ import { transformTomlData, } from './toml'; -// Empty scope resolves to `/` (join skips empty segments), -// preserving the historical `/config.toml` location. const CONFIG_SCOPE = ''; type GetEnv = (name: string) => string | undefined; @@ -144,16 +142,9 @@ export class ConfigRegistry implements IConfigRegistry { this._onDidRegisterOverlay.event; constructor() { - // Drain module-level contributions registered at import time by owner - // `configSection.ts` modules (see `configSectionContributions.ts`). This - // makes every statically-imported section available before `IConfigService` - // is first resolved, independent of owning-Service construction. for (const c of getConfigSectionContributions()) { this.registerSection(c.domain, c.schema, c.options); } - // Drain module-level overlay contributions (see - // `configOverlayContributions.ts`) for the same reason: an overlay must - // take effect even if its owning Service is never instantiated. for (const overlay of getConfigOverlayContributions()) { this.registerEffectiveOverlay(overlay); } @@ -166,11 +157,6 @@ export class ConfigRegistry implements IConfigRegistry { ): void { const existing = this.sections.get(domain); if (existing !== undefined) { - // A section's owner may live in a child scope (Session/Agent) that is - // instantiated more than once per process (e.g. one Agent scope per - // session), so the same owner can register its section again. Treat an - // identical re-registration as a no-op; only a conflicting registration - // from a different owner is an error. if ( isSameSection( existing, @@ -236,18 +222,6 @@ export class ConfigService extends Disposable implements IConfigService { readonly onDidSectionChange: Event = this._onDidSectionChange.event; readonly ready: Promise; - /** - * Serializes config state transitions (User-target writes and reloads). - * - * A User-target `set`/`replace` mutates `raw`/`rawSnake`, awaits `persist()`, - * and only then rebuilds `effective`; a `load()` replaces all three wholesale - * from the on-disk document. Without serialization, a reload whose file read - * resolves inside a write's persist window (the atomic rename has not landed - * yet) restores the stale pre-write state, and the write's post-persist - * `rebuildEffective` then drops the just-written domain from `effective` — - * observable e.g. as a `POST /config` response missing the field it just - * wrote when the startup model-catalog refresh's `reload()` races the write. - */ private stateChain: Promise = Promise.resolve(); private rawSnake: ResolvedConfig = {}; @@ -280,11 +254,6 @@ export class ConfigService extends Disposable implements IConfigService { if (Object.prototype.hasOwnProperty.call(this.memory, domain)) { return this.memory[domain] as T; } - // Re-apply the env overlay on every read for env-bound sections so - // operational toggles driven purely by the environment (e.g. - // `KIMI_DISABLE_CRON`) take effect without a `config.toml` change to - // trigger a rebuild. `applySectionEnv` is a pure function and only - // runs for sections that actually declare env bindings. const section = this.registry.getSection(domain); if (section?.env !== undefined) { const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); @@ -292,8 +261,6 @@ export class ConfigService extends Disposable implements IConfigService { const next = applySectionEnv(this.effective[domain], section.env, getEnv); this.effective[domain] = this.registry.validate(domain, next); } catch { - // Re-evaluation failed (e.g. a malformed env value); keep the last - // good effective value rather than throwing from a getter. } } return this.effective[domain] as T; @@ -310,9 +277,6 @@ export class ConfigService extends Disposable implements IConfigService { } getAll(): ResolvedConfig { - // Keep `getAll()` consistent with `get()`: re-apply env overlays so a - // caller reading the whole effective config observes the same live - // env values as a per-domain `get()`. const effective: ResolvedConfig = { ...this.effective }; const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); for (const section of this.registry.listSections()) { @@ -323,7 +287,6 @@ export class ConfigService extends Disposable implements IConfigService { applySectionEnv(effective[section.domain], section.env, getEnv), ); } catch { - // Keep the last good effective value for this domain. } } return { ...effective, ...this.memory }; @@ -412,12 +375,6 @@ export class ConfigService extends Disposable implements IConfigService { await this.enqueueStateTransition(() => this.load('reload')); } - /** - * Run `fn` after every previously enqueued state transition settles, so - * User-target writes and reloads can never interleave (see `stateChain`). - * The chain itself never rejects: a failed transition propagates to its own - * caller but does not poison later transitions. - */ private enqueueStateTransition(fn: () => Promise): Promise { const run = this.stateChain.then(() => fn()); this.stateChain = run.then( @@ -547,8 +504,6 @@ export class ConfigService extends Disposable implements IConfigService { const section = this.registry.getSection(domain); if (section === undefined) return; - // A late-registered section's `raw` was produced by the generic transform; - // re-apply its custom `fromToml` against the preserved snake_case value. if (section.fromToml !== undefined) { const rawSnakeValue = this.rawSnake[camelToSnake(domain)]; if (rawSnakeValue !== undefined) { @@ -560,7 +515,6 @@ export class ConfigService extends Disposable implements IConfigService { try { this.effective[domain] = this.registry.validate(domain, this.raw[domain]); } catch { - // Invalid value was already reported as a diagnostic at load time. return; } } else if (section.defaultValue !== undefined && this.effective[domain] === undefined) { diff --git a/packages/agent-core-v2/src/app/cron/clock.ts b/packages/agent-core-v2/src/app/cron/clock.ts index 3be2cc1c64..43105a75e3 100644 --- a/packages/agent-core-v2/src/app/cron/clock.ts +++ b/packages/agent-core-v2/src/app/cron/clock.ts @@ -30,57 +30,18 @@ import { closeSync, openSync, readSync } from 'node:fs'; export interface ClockSources { - /** - * Wall-clock epoch milliseconds. May be overridden in tests / bench - * via `KIMI_CRON_CLOCK`. Used for cron matching, `createdAt`, stale - * judgment. - */ wallNow(): number; - /** - * Strictly monotonic millisecond counter. Never overridden. Used for - * the 1-second poll cadence and the lock-heartbeat liveness window. - */ monoNowMs(): number; } const systemMonoNowMs = (): number => Number(process.hrtime.bigint() / 1_000_000n); -/** - * Production default — `Date.now()` + `process.hrtime.bigint()`. Used - * whenever `KIMI_CRON_CLOCK` is unset, set to `"system"`, or set to a - * spec that fails to parse. - */ export const SYSTEM_CLOCKS: ClockSources = { wallNow: () => Date.now(), monoNowMs: systemMonoNowMs, }; -/** - * Resolve a `ClockSources` implementation from a spec string (typically - * `process.env.KIMI_CRON_CLOCK`). - * - * unset / `"system"` → {@link SYSTEM_CLOCKS} - * `"file:"` → `wallNow` reads the first line of `` - * on every call (sync — the tick path is not - * async) and parses it as `Number(...)`. A - * missing file or bad parse falls back to - * `Date.now()` for that call. Used so a - * multi-process bench can share a single - * file-backed simulated clock. - * - * `monoNowMs` ALWAYS uses `process.hrtime.bigint()`. No spec overrides - * it — see file header. - * - * Each `wallNow()` call re-reads its source. We deliberately do NOT - * cache, because a multi-process bench tick mutating the file must be - * picked up by every reader immediately; a cache would silently lock - * each process to its first observation. - * - * Unrecognised specs fall back to {@link SYSTEM_CLOCKS} (with a - * debug-log on stderr). This is deliberate — bricking the agent on a - * typoed bench env var would be worse than running with system time. - */ export function resolveClockSources(spec?: string, debug = false): ClockSources { if (spec === undefined || spec === '' || spec === 'system') { return SYSTEM_CLOCKS; @@ -102,9 +63,6 @@ export function resolveClockSources(spec?: string, debug = false): ClockSources return SYSTEM_CLOCKS; } -// Epoch-ms is always under 20 characters in practice; 64 bytes leaves -// slack for a leading newline / `\r` and prevents OOM on a hostile or -// accidentally-huge clock file (e.g. a `/dev/zero` redirect). const MAX_CLOCK_FILE_BYTES = 64; function readFileWall(filePath: string): number { @@ -124,7 +82,6 @@ function readFileWall(filePath: string): number { try { closeSync(fd); } catch { - /* swallow close errors */ } } const raw = buf.subarray(0, bytesRead).toString('utf8'); @@ -136,10 +93,6 @@ function readFileWall(filePath: string): number { } function debugInvalidSpec(spec: string, reason: string, debug: boolean): void { - // We do not pull in a logger here — `clock.ts` is the lowest layer of - // the cron module and must stay dependency-free so it can be imported - // from anywhere (including lint rules, type files). A stderr write - // gated on KIMI_CRON_DEBUG is enough — production is silent. if (debug) { process.stderr.write( `[cron/clock] invalid KIMI_CRON_CLOCK spec ${JSON.stringify(spec)}: ${reason} — falling back to system clock\n`, diff --git a/packages/agent-core-v2/src/app/cron/cron-expr.ts b/packages/agent-core-v2/src/app/cron/cron-expr.ts index 6569da3050..c289af3931 100644 --- a/packages/agent-core-v2/src/app/cron/cron-expr.ts +++ b/packages/agent-core-v2/src/app/cron/cron-expr.ts @@ -25,7 +25,6 @@ export interface ParsedCronExpression { readonly daysOfMonth: ReadonlySet; readonly months: ReadonlySet; readonly daysOfWeek: ReadonlySet; - /** True if the source field was `*` — needed so cron's dom/dow OR rule fires only when both are restricted. */ readonly daysOfMonthWildcard: boolean; readonly daysOfWeekWildcard: boolean; } @@ -34,16 +33,10 @@ const MINUTE_RANGE = { min: 0, max: 59 } as const; const HOUR_RANGE = { min: 0, max: 23 } as const; const DOM_RANGE = { min: 1, max: 31 } as const; const MONTH_RANGE = { min: 1, max: 12 } as const; -const DOW_RANGE = { min: 0, max: 7 } as const; // 7 → 0 fold after parse +const DOW_RANGE = { min: 0, max: 7 } as const; const MS_PER_MINUTE = 60_000; -/** - * Parse a 5-field cron expression. Throws with a message naming the - * offending field on any syntax error. Whitespace-separated; exactly 5 - * fields. Tokens supported per field: `*`, integers, ranges (`a-b`), - * lists (`a,b,c`), and step (e.g. star-slash-n or `a-b/n`). - */ export function parseCronExpression(expr: string): ParsedCronExpression { if (typeof expr !== 'string') { throw new TypeError('cron expression must be a string'); @@ -87,9 +80,6 @@ export function parseCronExpression(expr: string): ParsedCronExpression { } function isWildcard(field: string): boolean { - // `*` and `*/n` both leave the field unconstrained in the - // "every value" sense — but only bare `*` should suppress the dom/dow - // OR rule. cron's tradition treats `*/n` as a restriction. return field === '*'; } @@ -111,11 +101,6 @@ function parseField(field: string, min: number, max: number, name: string): Set< return out; } -// Cron numeric fields are digit-only. `Number(...)` would otherwise -// accept `''` (→ 0), `'1e1'`, `'0x10'`, `'+5'`, `' 3 '`, etc. — none -// of which are valid cron syntax. This regex gate runs before the -// conversion to surface a typo as a parse error instead of silently -// rescheduling the task. const DIGIT_ONLY = /^\d+$/; function parseCronInt(raw: string, name: string, role: string): number { @@ -159,9 +144,6 @@ function addTerm(out: Set, term: string, min: number, max: number, name: if (single < min || single > max) { throw new Error(`cron ${name} value ${single} out of range ${min}..${max}`); } - // A bare single value with a step (`5/10`) is unusual; treat as - // "from value through max stepping by N", which is what most cron - // dialects do. if (slash !== -1) { lo = single; hi = max; @@ -187,34 +169,10 @@ function addTerm(out: Set, term: string, min: number, max: number, name: } } -/** - * Find the next wall-clock epoch ms strictly greater than `fromMs` that - * satisfies `expr`, using local-time semantics. Returns `null` if no - * match exists inside the default 5-year search window — defensive - * against legal-but-never-fires expressions like `0 0 31 2 *`. - * - * Uses an O(transitions) field-by-field skip algorithm rather than a - * minute-by-minute scan — month mismatch advances by months, day - * mismatch by days, etc., so the worst case for `0 12 1 1 *` is a - * handful of iterations, not 43 200. - * - * Termination is bounded by a wall-time deadline on the candidate - * date — not an iteration count — so a pathological expression that - * spends every iteration on `advanceMonth` still bails inside the - * documented window. A secondary `HARD_ITERATION_CAP` guards against - * a future refactor that fails to advance the date. - */ export function computeNextCronRun(expr: ParsedCronExpression, fromMs: number): number | null { return nextRunWithinMinutes(expr, fromMs, 5 * 366 * 24 * 60); } -/** - * True iff at least one fire exists within `years` years of `fromMs`. - * Used by CronCreate validation to reject `0 0 31 2 *` and friends up - * front, with the same wall-time deadline {@link computeNextCronRun} - * uses (so the validator never says yes to something the scheduler - * will later refuse to compute). - */ export function hasFireWithinYears( expr: ParsedCronExpression, years: number, @@ -229,36 +187,21 @@ function nextRunWithinMinutes( fromMs: number, capMinutes: number, ): number | null { - // Seek strictly into the next minute: drop seconds/ms and add one - // minute. This guarantees we never return `fromMs` itself. const start = new Date(fromMs); start.setSeconds(0, 0); const date = new Date(start.getTime() + MS_PER_MINUTE); - // Wall-clock deadline. Each loop body only advances `date` forward - // (month / day / hour / minute), so a single deadline check on - // `date.getTime()` bounds total work regardless of which granularity - // dominates — including the pathological case where `advanceMonth` - // is the dominant op (e.g. `0 0 30 2 *` never matches February). const deadlineMs = fromMs + capMinutes * MS_PER_MINUTE; - // Secondary safety net: if a future refactor accidentally fails to - // advance `date`, this prevents an infinite loop. Generous enough to - // cover any minute-by-minute walk within a sane window, and many - // orders of magnitude below the previous iteration bound. let iterations = 0; const HARD_ITERATION_CAP = 10_000_000; while (date.getTime() <= deadlineMs && iterations++ < HARD_ITERATION_CAP) { - // Month — coarsest. If wrong, jump to day 1 of the next allowed - // month and restart the day check. if (!expr.months.has(date.getMonth() + 1)) { advanceMonth(date); continue; } - // Day. Cron-style OR: when both dom and dow are restricted, match - // either; when one is `*`, only the other constrains. if (!dayMatches(expr, date)) { advanceDay(date); continue; @@ -289,13 +232,10 @@ function dayMatches(expr: ParsedCronExpression, date: Date): boolean { if (expr.daysOfMonthWildcard && expr.daysOfWeekWildcard) return true; if (expr.daysOfMonthWildcard) return dowOk; if (expr.daysOfWeekWildcard) return domOk; - // Both restricted: cron-style OR. return domOk || dowOk; } function advanceMonth(date: Date): void { - // Jump to the 1st of the next month at 00:00. Date's wrap-around - // handles year rollover for us. date.setDate(1); date.setHours(0, 0, 0, 0); date.setMonth(date.getMonth() + 1); @@ -341,12 +281,6 @@ const DAY_NAMES = [ 'Saturday', ] as const; -/** - * Cheap human-readable summary of an expression. Falls back to the raw - * string when the shape isn't one of the patterns we recognise — the - * caller (CronList) uses this purely for display, so a wordy fallback - * is fine and we don't try to be exhaustive. - */ export function cronToHuman(expr: ParsedCronExpression): string { const allMin = isFullRange(expr.minutes, 0, 59); const allHour = isFullRange(expr.hours, 0, 23); @@ -354,7 +288,6 @@ export function cronToHuman(expr: ParsedCronExpression): string { const allMonth = isFullRange(expr.months, 1, 12); const allDow = expr.daysOfWeekWildcard; - // every N minutes — common LLM pattern (`*/5 * * * *`). if (allHour && allDom && allMonth && allDow) { const step = detectStep(expr.minutes, 0, 59); if (step !== null && step > 1) return `every ${step} minutes`; @@ -365,7 +298,6 @@ export function cronToHuman(expr: ParsedCronExpression): string { } } - // every N hours. if (expr.minutes.size === 1 && allDom && allMonth && allDow) { const m = [...expr.minutes][0]!; const step = detectStep(expr.hours, 0, 23); @@ -374,7 +306,6 @@ export function cronToHuman(expr: ParsedCronExpression): string { } } - // at HH:MM every day, optional dow restriction. if ( expr.minutes.size === 1 && expr.hours.size === 1 && @@ -388,7 +319,6 @@ export function cronToHuman(expr: ParsedCronExpression): string { if (dowStr !== null) return `at ${pad(h)}:${pad(m)} on ${dowStr}`; } - // at HH:MM on day N of . if ( expr.minutes.size === 1 && expr.hours.size === 1 && @@ -413,10 +343,6 @@ function isFullRange(set: ReadonlySet, min: number, max: number): boolea return true; } -/** - * If the set looks like `{min, min+step, ..., <=max}` with a constant - * step, return `step`. Otherwise null. Used to pretty-print star-slash-N. - */ function detectStep(set: ReadonlySet, min: number, max: number): number | null { const values = [...set].toSorted((a, b) => a - b); if (values.length < 2) return null; @@ -428,7 +354,6 @@ function detectStep(set: ReadonlySet, min: number, max: number): number if (v !== expected) return null; expected += step; } - // The last expected value should exceed `max` by less than `step`. if (expected - step > max) return null; return step; } @@ -436,7 +361,6 @@ function detectStep(set: ReadonlySet, min: number, max: number): number function formatDows(set: ReadonlySet): string | null { const values = [...set].toSorted((a, b) => a - b); if (values.length === 0) return null; - // Mon-Fri shortcut. if (values.length === 5 && values.every((v, i) => v === i + 1)) { return 'weekdays'; } diff --git a/packages/agent-core-v2/src/app/cron/cronTask.ts b/packages/agent-core-v2/src/app/cron/cronTask.ts index 7a2569dbaa..5bb508b15a 100644 --- a/packages/agent-core-v2/src/app/cron/cronTask.ts +++ b/packages/agent-core-v2/src/app/cron/cronTask.ts @@ -20,9 +20,4 @@ export interface CronTask { export type CronTaskInit = Omit; -/** - * `tags` key carrying the id of the session a task belongs to. The Session - * projection (`ISessionCronService`) filters the workspace-level store on - * this tag, and session fork rewrites it when cloning tasks for the fork. - */ export const CRON_SESSION_TAG = 'sessionId'; diff --git a/packages/agent-core-v2/src/app/cron/format.ts b/packages/agent-core-v2/src/app/cron/format.ts index bef090fe2d..86d990f268 100644 --- a/packages/agent-core-v2/src/app/cron/format.ts +++ b/packages/agent-core-v2/src/app/cron/format.ts @@ -9,11 +9,6 @@ import type { CronJobOrigin } from '@moonshot-ai/protocol'; -/** - * Render a wall-clock epoch-ms value in local time with an explicit numeric - * offset. Cron expressions are evaluated in local time, so tool output keeps - * that mental model while staying unambiguous and ISO-8601-parseable. - */ export function formatLocalIsoWithOffset(ms: number): string { const date = new Date(ms); const offsetMin = -date.getTimezoneOffset(); @@ -29,12 +24,6 @@ export function formatLocalIsoWithOffset(ms: number): string { )}${offset}`; } -/** - * Render the chat-history injection text delivered when a cron task fires. - * Attribute values are escape-safe via `stringAttr`; the body inside `` - * is verbatim — double-escaping would be noisier than literal punctuation in an - * LLM-visible transcript. - */ export function renderCronFireXml(origin: CronJobOrigin, prompt: string): string { const jobId = stringAttr(origin.jobId, 'unknown'); const cron = stringAttr(origin.cron, 'unknown'); diff --git a/packages/agent-core-v2/src/app/cron/jitter.ts b/packages/agent-core-v2/src/app/cron/jitter.ts index b47f789576..9b55489289 100644 --- a/packages/agent-core-v2/src/app/cron/jitter.ts +++ b/packages/agent-core-v2/src/app/cron/jitter.ts @@ -30,13 +30,9 @@ import type { ParsedCronExpression } from './cron-expr'; import { computeNextCronRun } from './cron-expr'; -/** Tunables for {@link jitteredNextCronRunMs} / {@link oneShotJitteredNextCronRunMs}. */ export interface JitterConfig { - /** Recurring offset cap as a fraction of the cron period (0..1). */ readonly recurringMaxFractionOfPeriod: number; - /** Absolute cap on the recurring offset, in ms. */ readonly recurringMaxMs: number; - /** Absolute cap on the one-shot pull-forward, in ms. */ readonly oneShotMaxMs: number; } @@ -49,30 +45,17 @@ export const DEFAULT_CRON_JITTER_CONFIG: JitterConfig = { const MS_PER_DAY = 24 * 60 * 60_000; const MS_PER_MINUTE = 60_000; -/** - * Map a task id to a deterministic fraction in `[0, 1)`. Legacy cron - * task ids are 8 hex chars (`/^[0-9a-f]{8}$/`); for those, - * `parseInt(id, 16)` / `2^32` lands neatly in range. Current ids are - * ULIDs (26 Crockford-base32 chars), which fall through to the - * djb2-style reduction below — still deterministic per id, so a given - * task always lands at the same jittered point regardless of which id - * format it carries. - */ function fractionFromId(id: string): number { if (/^[0-9a-f]{8}$/i.test(id)) { const n = Number.parseInt(id, 16); if (Number.isFinite(n)) { - // 2^32 keeps the result strictly < 1. return n / 0x1_0000_0000; } } - // djb2 reduction — overflow-safe in JS (operates on int32) and - // good enough spread for non-hex test ids. let hash = 5381; for (let i = 0; i < id.length; i++) { hash = ((hash << 5) + hash + id.charCodeAt(i)) | 0; } - // Map signed int32 to [0, 1). const unsigned = hash >>> 0; return unsigned / 0x1_0000_0000; } @@ -81,17 +64,6 @@ function jitterDisabled(noJitter: boolean | undefined): boolean { return noJitter === true; } -/** - * Apply recurring-job jitter to an already-computed ideal fire time. - * - * The shift is **forward only** (≥ 0), bounded by both the relative - * fraction-of-period cap and the absolute ms cap. We discover the - * period by asking {@link computeNextCronRun} for the run *after* - * `idealMs`; if that returns `null` (legal-but-never-fires - * expression — should have been rejected upstream) we fall back to a - * 24-hour assumption so we still produce some sensible offset rather - * than spiking on the original `idealMs`. - */ export function jitteredNextCronRunMs( task: { id: string; cron: string; recurring?: boolean }, parsed: ParsedCronExpression, @@ -114,28 +86,6 @@ export function jitteredNextCronRunMs( return idealMs + offset; } -/** - * Apply one-shot pull-forward jitter to an ideal fire time. - * - * Only fires on `:00` and `:30` of the hour — the minute marks the - * model is most likely to pick out of habit. Other minutes pass - * through verbatim so a user who said "remind me at 2:07" gets - * 2:07 exactly. The shift is in `[-oneShotMaxMs, 0)`; never exactly - * 0 unless the deterministic hash happens to land on 0 (which is - * fine — it just means this task is the unlucky one that pays the - * full delay). - * - * When the deterministic offset would land before `task.createdAt`, - * the jitter budget is too small to safely pull forward: a previous - * version clamped to `createdAt` itself, but the scheduler condition - * `now >= nextFireAt` then fires on the very next tick — for the - * canonical 08:59:30-created `0 9 * * *` case, that means firing - * ~29 s before the ideal 09:00 mark. We skip jitter instead and - * return `idealMs` unchanged; the task fires at the ideal time, no - * earlier. Callers without `createdAt` (legacy test fixtures) get - * the unclamped pulled-forward value, preserving the previous - * behaviour for them. - */ export function oneShotJitteredNextCronRunMs( task: { id: string; createdAt?: number | undefined }, idealMs: number, @@ -145,12 +95,6 @@ export function oneShotJitteredNextCronRunMs( if (jitterDisabled(noJitter)) { return idealMs; } - // `idealMs % MS_PER_MINUTE === 0` is a UTC minute-boundary check. - // It coincides with a local minute boundary in every modern timezone - // because all offsets are minute-aligned — there are no sub-minute - // offsets in current use. Cron firings are always on the minute, so - // this gate is almost always true; it remains as a guard against - // synthetic idealMs values from tests that aren't on the minute. if (idealMs % MS_PER_MINUTE !== 0) { return idealMs; } @@ -163,10 +107,6 @@ export function oneShotJitteredNextCronRunMs( } const offset = -config.oneShotMaxMs * fractionFromId(task.id); const shifted = idealMs + offset; - // Skip jitter when the budget is insufficient: the previous version - // clamped to `createdAt`, but `now >= nextFireAt` then fired on the - // very next tick — ~29 s before ideal for the 08:59:30 → 09:00 case. - // Returning `idealMs` keeps the fire on schedule, never earlier. if (task.createdAt !== undefined && shifted < task.createdAt) { return idealMs; } diff --git a/packages/agent-core-v2/src/app/edit/editService.ts b/packages/agent-core-v2/src/app/edit/editService.ts index 904e8237bd..d3c87ae792 100644 --- a/packages/agent-core-v2/src/app/edit/editService.ts +++ b/packages/agent-core-v2/src/app/edit/editService.ts @@ -11,7 +11,6 @@ import type { TextModel } from './textModel'; export interface EditApplyInput { - /** Display path used in error messages (the user-facing path, not necessarily absolute). */ readonly path: string; readonly old_string: string; readonly new_string: string; @@ -35,16 +34,6 @@ function notUniqueMessage(path: string, count: number): string { } export class EditService { - /** - * Apply the edit business rules to `model`. - * - * - `replace_all`: replace every occurrence; error when none are found. - * - otherwise: require exactly one occurrence; error on zero (not found) or - * more than one (not unique). - * - * The no-op case (`old_string === new_string`) is intentionally not handled - * here — `EditTool` rejects it before any file IO. - */ apply(model: TextModel, input: EditApplyInput): EditApplyResult { if (input.replace_all) { const { text, count } = model.replaceAll(input.old_string, input.new_string); diff --git a/packages/agent-core-v2/src/app/edit/fileEdit.ts b/packages/agent-core-v2/src/app/edit/fileEdit.ts index a6f292d118..c8f7c3c08d 100644 --- a/packages/agent-core-v2/src/app/edit/fileEdit.ts +++ b/packages/agent-core-v2/src/app/edit/fileEdit.ts @@ -12,9 +12,7 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; export interface FileEditInput { - /** Absolute, access-checked path to read and write. */ readonly path: string; - /** User-facing path used in messages. */ readonly displayPath: string; readonly old_string: string; readonly new_string: string; diff --git a/packages/agent-core-v2/src/app/edit/fileEditService.ts b/packages/agent-core-v2/src/app/edit/fileEditService.ts index abb50649e9..85282eb0f6 100644 --- a/packages/agent-core-v2/src/app/edit/fileEditService.ts +++ b/packages/agent-core-v2/src/app/edit/fileEditService.ts @@ -28,9 +28,6 @@ export class FileEditService implements IFileEditService { async edit(input: FileEditInput): Promise { try { - // Strict decoding matches v1 (kaos): a non-UTF-8 file must fail here - // instead of being silently decoded with U+FFFD and rewritten, which - // would corrupt every invalid byte in the file — even far from the edit. const raw = await this.fs.readText(input.path, { errors: 'strict' }); const model = new TextModel(raw); const result = this.editor.apply(model, { @@ -45,8 +42,6 @@ export class FileEditService implements IFileEditService { await this.fs.writeText(input.path, result.rawContent); return { ok: true, count: result.count }; } catch (error) { - // hostFs translates raw errnos into `HostFsError` at its boundary, so the - // errno lives on the unwrapped cause, not on the thrown error itself. const code = (unwrapErrorCause(error) as { code?: unknown } | null)?.code; if (code === 'EISDIR') { return { ok: false, error: `${input.displayPath} is not a file.` }; diff --git a/packages/agent-core-v2/src/app/edit/textModel.ts b/packages/agent-core-v2/src/app/edit/textModel.ts index 44cfc79010..1217512dd1 100644 --- a/packages/agent-core-v2/src/app/edit/textModel.ts +++ b/packages/agent-core-v2/src/app/edit/textModel.ts @@ -16,9 +16,7 @@ import { } from '#/_base/text/line-endings'; export class TextModel { - /** Line-ending style detected in the raw file. */ readonly lineEndingStyle: LineEndingStyle; - /** LF-normalized view used for matching. */ readonly text: string; constructor(raw: string) { @@ -27,10 +25,6 @@ export class TextModel { this.lineEndingStyle = view.lineEndingStyle; } - /** - * Count the non-overlapping occurrences of `needle` in the model view. - * `needle` must be non-empty — `indexOf("", pos)` would loop forever. - */ countOccurrences(needle: string): number { let count = 0; let pos = 0; @@ -43,34 +37,17 @@ export class TextModel { return count; } - /** - * Replace the first occurrence of `needle` with `replacement` in the model - * view and return the new model text. Returns the model text unchanged when - * `needle` is not present (callers that need uniqueness should check - * {@link countOccurrences} first). - */ replaceOnce(needle: string, replacement: string): string { const index = this.text.indexOf(needle); if (index === -1) return this.text; return this.text.slice(0, index) + replacement + this.text.slice(index + needle.length); } - /** - * Replace every occurrence of `needle` with `replacement` in the model view. - * Returns the new model text and the number of replacements made. Dollar - * sequences in `replacement` are treated literally (split/join, not - * `String#replace`). - */ replaceAll(needle: string, replacement: string): { text: string; count: number } { const parts = this.text.split(needle); return { text: parts.join(replacement), count: parts.length - 1 }; } - /** - * Re-materialize a model-view string back to the raw on-disk line-ending - * style — pure CRLF files round-trip to CRLF, mixed/lone-CR files stay on - * the exact raw (LF) path. - */ materialize(modelText: string): string { return materializeModelText(modelText, this.lineEndingStyle); } diff --git a/packages/agent-core-v2/src/app/edit/tools/edit.ts b/packages/agent-core-v2/src/app/edit/tools/edit.ts index 672a9fd8f0..8eda6b50a0 100644 --- a/packages/agent-core-v2/src/app/edit/tools/edit.ts +++ b/packages/agent-core-v2/src/app/edit/tools/edit.ts @@ -41,9 +41,6 @@ import { registerTool } from '#/agent/toolRegistry/toolContribution'; import editDescriptionTemplate from './edit.md?raw'; -// `old_string` must be non-empty: the non-replace_all branch walks -// occurrences with `content.indexOf("", pos)`, which would loop forever -// on an empty search string. export const EditInputSchema = z.object({ path: z .string() @@ -78,14 +75,10 @@ export class EditTool implements BuiltinTool { @IFileEditService private readonly editor: IFileEditService, @IHostEnvironment private readonly env: IHostEnvironment, @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, - // Optional so unit tests that construct the tool directly (bypassing DI) - // keep working; always registered in production scopes. @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, ) {} private get workspaceConfig(): WorkspaceConfig { - // Skill roots are merged per call (v1 merged once at tool construction): - // the catalog loads asynchronously and gains roots on plugin reloads. return extendWorkspaceWithSkillRoots( { workspaceDir: this.workspaceCtx.workDir, diff --git a/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunner.ts b/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunner.ts index 7e052ebe43..6bae5f8ec7 100644 --- a/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunner.ts +++ b/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunner.ts @@ -17,12 +17,7 @@ export interface ExternalHooksRunnerTriggerArgs { readonly matcherValue?: HookMatcherValue; readonly inputData?: Record; readonly signal?: AbortSignal; - /** - * Working directory passed to hooks without their own `cwd`. Defaults to the - * app bootstrap cwd when the caller omits it. - */ readonly cwd?: string; - /** Session id written into the hook input payload. Defaults to `''`. */ readonly sessionId?: string; } diff --git a/packages/agent-core-v2/src/app/externalHooksRunner/runner.ts b/packages/agent-core-v2/src/app/externalHooksRunner/runner.ts index 48e3ecbc6a..2b065e0ca0 100644 --- a/packages/agent-core-v2/src/app/externalHooksRunner/runner.ts +++ b/packages/agent-core-v2/src/app/externalHooksRunner/runner.ts @@ -34,7 +34,6 @@ export interface HookRunCallbacks { ) => void; } -/** Group hook definitions by event name, preserving declaration order. */ export function indexHooks(hooks: readonly HookDef[]): Map { const byEvent = new Map(); for (const hook of hooks) { @@ -45,7 +44,6 @@ export function indexHooks(hooks: readonly HookDef[]): Map { return byEvent; } -/** Run every hook in `byEvent` whose matcher matches `args.matcherValue`. */ export async function runMatchedHooks( hostProcess: IHostProcessService, byEvent: ReadonlyMap, @@ -103,7 +101,6 @@ export async function runMatchedHooks( return results; } -/** Reduce a trigger's results into a single block/allow decision. */ export function blockDecision( event: string, results: readonly HookResult[], diff --git a/packages/agent-core-v2/src/app/file/fileService.ts b/packages/agent-core-v2/src/app/file/fileService.ts index 5667d90db7..ed0b98dee1 100644 --- a/packages/agent-core-v2/src/app/file/fileService.ts +++ b/packages/agent-core-v2/src/app/file/fileService.ts @@ -14,24 +14,16 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; import { Error2 } from '#/_base/errors/errors'; -/** Hard upload cap mirrored from the v1 server (50 MiB). */ export const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024; export interface SaveOptions { - /** Display name override; defaults to the uploaded filename. */ readonly name?: string; - /** MIME type; defaults to `application/octet-stream`. */ readonly mimeType?: string; - /** Optional TTL in seconds; recorded as `expires_at` on the metadata. */ readonly expiresInSec?: number; } export interface GetResult { readonly meta: FileMeta; - /** - * Open a fresh stream over the stored blob. `range` is inclusive and lets - * callers serve byte ranges without first buffering the whole file. - */ readonly stream: (range?: FileReadRange) => Readable; } @@ -50,9 +42,6 @@ export interface IFileService { export const IFileService: ServiceIdentifier = createDecorator('fileService'); -// --------------------------------------------------------------------------- -// Error domain -// --------------------------------------------------------------------------- export const FileErrors = { codes: { diff --git a/packages/agent-core-v2/src/app/git/git.ts b/packages/agent-core-v2/src/app/git/git.ts index 63af4de7a4..86565f25ef 100644 --- a/packages/agent-core-v2/src/app/git/git.ts +++ b/packages/agent-core-v2/src/app/git/git.ts @@ -15,21 +15,7 @@ import type { FsDiffResponse, FsGitStatusResponse } from '@moonshot-ai/protocol' export interface IGitService { readonly _serviceBrand: undefined; - /** - * `git status` for the repo at `cwd`. `pathFilter`, when provided, restricts - * `entries` to the given repo-relative posix paths; `branch` / `ahead` / - * `behind` / `additions` / `deletions` / `pullRequest` always reflect the - * the whole tree. Throws `FS_GIT_UNAVAILABLE` when `cwd` is not a git work - * tree or git itself fails. - */ status(cwd: string, pathFilter?: ReadonlySet): Promise; - /** - * `git diff HEAD -- ` for the repo at `cwd`. `relPath` is the - * repo-relative posix path passed to git; `absPath` is the confined absolute - * path used only to tell "clean file" apart from "path does not exist". - * Throws `FS_GIT_UNAVAILABLE` on git failure, `FS_PATH_NOT_FOUND` when the path - * is missing. - */ diff(cwd: string, relPath: string, absPath: string): Promise; } diff --git a/packages/agent-core-v2/src/app/git/gitParsers.ts b/packages/agent-core-v2/src/app/git/gitParsers.ts index 11a1a97605..b223bd5698 100644 --- a/packages/agent-core-v2/src/app/git/gitParsers.ts +++ b/packages/agent-core-v2/src/app/git/gitParsers.ts @@ -49,11 +49,6 @@ export function parsePorcelain( return { branch, ahead, behind, entries, additions: 0, deletions: 0, pullRequest: null }; } -/** - * Sum added/deleted line counts from `git diff --numstat` output. Each line is - * `\t\t`; a binary file reports `-` for both counts, - * which we treat as 0. Returns the aggregate across all files. - */ export function parseNumstat(stdout: string): { additions: number; deletions: number; @@ -132,8 +127,6 @@ function collapseXY(xy: string): FsGitStatus { } function posix(p: string): string { - // Git porcelain always emits `/`-separated paths, even on Windows; normalize - // the stray backslash for safety without depending on the host path style. return p.replaceAll('\\', '/'); } diff --git a/packages/agent-core-v2/src/app/git/gitService.ts b/packages/agent-core-v2/src/app/git/gitService.ts index fc288b7a65..293fa47c8a 100644 --- a/packages/agent-core-v2/src/app/git/gitService.ts +++ b/packages/agent-core-v2/src/app/git/gitService.ts @@ -21,8 +21,6 @@ import { IHostProcessService } from '#/os/interface/hostProcess'; import { IGitService } from './git'; import { parseNumstat, parsePorcelain, parsePullRequest } from './gitParsers'; -/** Cap a single file's unified diff so a runaway generated file cannot blow up - * the envelope; the response carries `truncated` so the UI can say so. */ const DIFF_MAX_BYTES = 1_048_576; const PR_SPAWN_TIMEOUT_MS = 5_000; @@ -54,11 +52,6 @@ export class GitService implements IGitService { const result = parsePorcelain(porc.stdout, pathFilter); - // Aggregate line stats against HEAD. Only worth a second spawn when the - // tree is dirty AND there is a HEAD to diff against (a repo with no commits - // yet has neither side); otherwise the stats stay 0. Dirtiness is read from - // the UNFILTERED porcelain and the numstat is NOT scoped by `pathFilter` — - // the header counter reflects the whole working tree. const dirty = porc.stdout .split('\n') .some((line) => line.length > 0 && !line.startsWith('## ')); @@ -90,15 +83,11 @@ export class GitService implements IGitService { } const untracked = statusRes.stdout.startsWith('??'); - // A repo with no commits yet has no HEAD to diff against — every changed - // file is all-new there, same as the untracked case. const headRes = await this.runCommand('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], cwd); const hasHead = headRes.exitCode === 0; let diffStdout: string; if (untracked || !hasHead) { - // An untracked file has no HEAD side; diff it against /dev/null so the UI - // gets an all-added hunk. `git diff --no-index` exits 1 when files differ. const res = await this.runCommand( 'git', ['diff', '--no-color', '--no-index', '--', '/dev/null', relPath], @@ -114,8 +103,6 @@ export class GitService implements IGitService { throw this.gitUnavailable(cwd, res.stderr.trim() || `git diff exit ${res.exitCode}`); } if (res.stdout.length === 0 && statusRes.stdout.length === 0) { - // Not changed at all — distinguish "clean file" (empty diff is fine) - // from a path that does not exist anywhere. const exists = await this.fs.stat(absPath).then( () => true, () => false, @@ -171,9 +158,6 @@ export class GitService implements IGitService { () => ({ ok: false as const }), ); if (!spawned.ok) { - // The binary is missing or failed to start (e.g. ENOENT). Mirror the old - // "exit code -1" so callers surface FS_GIT_UNAVAILABLE / skip the - // optional PR lookup uniformly instead of leaking HostProcessError. return { exitCode: -1, stdout: '', stderr: '' }; } const { proc } = spawned; @@ -183,7 +167,6 @@ export class GitService implements IGitService { collect(proc.stderr), proc.wait().catch(() => -1), ] as const); - // Keep the rejection handled if the timeout race below abandons `work`. work.catch(() => {}); let timer: ReturnType | undefined; diff --git a/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowser.ts b/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowser.ts index e7d48e02eb..35d2026e13 100644 --- a/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowser.ts +++ b/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowser.ts @@ -18,7 +18,6 @@ import type { FsBrowseResponse, FsHomeResponse } from '@moonshot-ai/protocol'; export type { FsBrowseResponse, FsHomeResponse }; -/** Thrown by `browse` when the requested path is not absolute. */ export class HostFolderNotAbsoluteError extends Error { readonly path: string; constructor(path: string) { @@ -28,7 +27,6 @@ export class HostFolderNotAbsoluteError extends Error { } } -/** Thrown by `browse` when the requested path does not exist or is not a directory. */ export class HostFolderNotFoundError extends Error { readonly path: string; constructor(path: string) { @@ -38,7 +36,6 @@ export class HostFolderNotFoundError extends Error { } } -/** Thrown by `browse` when the process lacks permission to read the path. */ export class HostFolderPermissionError extends Error { readonly path: string; constructor(path: string) { @@ -51,18 +48,11 @@ export class HostFolderPermissionError extends Error { export interface IHostFolderBrowser { readonly _serviceBrand: undefined; - /** - * List the immediate sub-directories of `absPath` (defaults to `$HOME`), - * annotated with git metadata. The returned `path` is the realpath of the - * target. - */ browse(absPath?: string): Promise; - /** `$HOME` plus the most recently opened workspace roots. */ home(): Promise; } export const IHostFolderBrowser: ServiceIdentifier = createDecorator('hostFolderBrowser'); -/** Maximum number of recent workspace roots returned by `home()`. */ export const RECENT_ROOTS_LIMIT = 8; diff --git a/packages/agent-core-v2/src/app/llmProtocol/capability.ts b/packages/agent-core-v2/src/app/llmProtocol/capability.ts index dd1807ffd5..52a3950126 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/capability.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/capability.ts @@ -20,11 +20,6 @@ export interface ModelCapability { const UNKNOWN_CAPABILITY_MARKER = Symbol.for('moonshot-ai.kosong.UNKNOWN_CAPABILITY'); -/** - * Shared read-only default returned when a provider has not catalogued a - * given model. Frozen so accidental mutation at one call site cannot leak - * into another. - */ export const UNKNOWN_CAPABILITY: ModelCapability = Object.freeze( Object.defineProperty( { diff --git a/packages/agent-core-v2/src/app/llmProtocol/catalog.ts b/packages/agent-core-v2/src/app/llmProtocol/catalog.ts index 8751ead217..744de6229c 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/catalog.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/catalog.ts @@ -1,11 +1,6 @@ import type { ModelCapability } from './capability'; import type { ProviderType } from './providers/providers'; -/** - * models.dev-style catalog: a public map of provider/model metadata. Callers - * consume a snapshot of this shape to populate provider + model configuration - * without hand-writing context windows or capabilities. - */ export interface CatalogModelEntry { readonly id?: string; readonly name?: string; @@ -24,21 +19,15 @@ export interface CatalogModelEntry { export interface CatalogProviderEntry { readonly id?: string; readonly name?: string; - /** Base URL for the provider; may be empty (some SDKs hardcode it). */ readonly api?: string; - /** Env var names carrying credentials — surfaced as a hint by callers. */ readonly env?: readonly string[]; - /** models.dev SDK package id; used to infer the wire type when `type` is absent. */ readonly npm?: string; - /** Explicit wire type extension; inferred from `npm`/`id` when absent. */ readonly type?: string; readonly models?: Record; } -/** Top-level catalog: `{ [providerId]: ProviderEntry }` (e.g. models.dev/api.json). */ export type Catalog = Record; -/** A normalized catalog model: identity plus its {@link ModelCapability}. */ export interface CatalogModel { readonly id: string; readonly name?: string; @@ -76,11 +65,6 @@ function isUsableChatModel(model: CatalogModelEntry): boolean { ); } -/** - * Resolves a catalog provider entry to a supported wire type. Honors an - * explicit `type`, otherwise infers from `npm`/`id`. Unknown providers return - * `undefined` so callers can omit them instead of writing an invalid config. - */ export function inferWireType(entry: CatalogProviderEntry): ProviderType | undefined { if (isWireType(entry.type)) return entry.type; const npm = (entry.npm ?? '').toLowerCase(); @@ -96,17 +80,6 @@ export function inferWireType(entry: CatalogProviderEntry): ProviderType | undef return undefined; } -/** - * Resolves the base URL to store for a catalog provider, adapting the catalog's - * `api` to the wire's SDK convention. - * - * models.dev `api` URLs are written for the SDK named in `npm` (e.g. - * `@ai-sdk/anthropic`), whose base already includes the `/v1` version segment. - * We route the `anthropic` wire through the official `@anthropic-ai/sdk`, which - * appends `/v1/messages` itself — so a catalog `api` ending in `/v1` would POST - * to `/v1/v1/messages` (404). Strip the trailing `/v1` for anthropic. OpenAI - * family SDKs append `/chat/completions` to a `/v1` base, so those pass through. - */ export function catalogBaseUrl( entry: CatalogProviderEntry, wire: ProviderType, @@ -117,7 +90,6 @@ export function catalogBaseUrl( return api; } -/** Normalizes one catalog model entry into a {@link CatalogModel}; skips invalid entries. */ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel | undefined { if (typeof model.id !== 'string' || model.id.length === 0) return undefined; const context = model.limit?.context; @@ -143,16 +115,12 @@ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel } function catalogReasoningKey(interleaved: CatalogModelEntry['interleaved']): string | undefined { - // models.dev allows `interleaved: true` as "general support" — read it as - // the default `reasoning_content` field so providers without an explicit - // field name (e.g. some openai-compatible gateways) still round-trip. if (interleaved === true) return 'reasoning_content'; if (typeof interleaved !== 'object' || interleaved === null) return undefined; const field = interleaved.field?.trim(); return field !== undefined && field.length > 0 ? field : undefined; } -/** Extracts the valid, normalized models from a catalog provider entry. */ export function catalogProviderModels(entry: CatalogProviderEntry): CatalogModel[] { const models = entry.models ?? {}; return Object.values(models) diff --git a/packages/agent-core-v2/src/app/llmProtocol/errors.ts b/packages/agent-core-v2/src/app/llmProtocol/errors.ts index 649fa7a621..0fc0b33019 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/errors.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/errors.ts @@ -1,8 +1,5 @@ import type { FinishReason } from './provider'; -/** - * Base error for all chat provider errors. - */ export class ChatProviderError extends Error { constructor(message: string) { super(message); @@ -10,9 +7,6 @@ export class ChatProviderError extends Error { } } -/** - * Network-level connection failure. - */ export class APIConnectionError extends ChatProviderError { constructor(message: string) { super(message); @@ -20,9 +14,6 @@ export class APIConnectionError extends ChatProviderError { } } -/** - * Request timed out. - */ export class APITimeoutError extends ChatProviderError { constructor(message: string) { super(message); @@ -30,9 +21,6 @@ export class APITimeoutError extends ChatProviderError { } } -/** - * HTTP status error from the API. - */ export class APIStatusError extends ChatProviderError { readonly statusCode: number; readonly requestId: string | null; @@ -52,10 +40,6 @@ export class APIStatusError extends ChatProviderError { } } -/** - * HTTP status error that specifically means the request exceeded the model - * context window. - */ export class APIContextOverflowError extends APIStatusError { constructor( statusCode: number, @@ -68,12 +52,6 @@ export class APIContextOverflowError extends APIStatusError { } } -/** - * HTTP 413 that specifically means the serialized request body exceeded the - * provider's byte ceiling (e.g. accumulated base64 images), as opposed to a - * token-count overflow. Token overflow is recoverable by compaction; a body - * size rejection is not — it needs media to be dropped or shrunk. - */ export class APIRequestTooLargeError extends APIStatusError { constructor( statusCode: number, @@ -86,10 +64,6 @@ export class APIRequestTooLargeError extends APIStatusError { } } -/** - * HTTP status error that specifically means the provider rate-limited the - * request. - */ export class APIProviderRateLimitError extends APIStatusError { constructor(message: string, requestId?: string | null, retryAfterMs?: number | null) { super(429, message, requestId, retryAfterMs); @@ -97,12 +71,6 @@ export class APIProviderRateLimitError extends APIStatusError { } } -/** - * HTTP status error that specifically means the provider is overloaded / at - * capacity (Anthropic 529 `overloaded_error`, OpenAI 503 "server is currently - * overloaded"). Distinct from rate limiting: the caller's quota is not the - * constraint, the provider is simply saturated — retry with backoff. - */ export class APIProviderOverloadedError extends APIStatusError { constructor( statusCode: number, @@ -115,9 +83,6 @@ export class APIProviderOverloadedError extends APIStatusError { } } -/** - * The API returned an empty response (no content, no tool calls). - */ export class APIEmptyResponseError extends ChatProviderError { readonly finishReason: FinishReason | null; readonly rawFinishReason: string | null; @@ -136,35 +101,13 @@ export class APIEmptyResponseError extends ChatProviderError { } } -// Client-side image rejections thrown before the request is sent (the -// provider adapter's own media whitelist). const IMAGE_FORMAT_PROVIDER_MESSAGE_PATTERNS = [ /unsupported media type for base64 image/, /invalid data url for image/, ] as const; -// Server-side image rejections that are safe to recover by stripping media: -// an unsupported/invalid media type or undecodable image data. These are -// deliberately narrow and grounded in the documented messages of the major -// providers (Anthropic, OpenAI, Moonshot/Kimi, Gemini) — image COUNT/SIZE -// limits or image-input-disabled errors also mention "image", but stripping -// media either over-recovers or hides a real configuration problem the user -// should see; only format/data rejections are guaranteed to be fixed by -// removing the offending image. -// -// Matching on provider message text is inherently best-effort: these strings -// are not a stable contract, so a novel phrasing is missed and the error -// propagates (the pre-recovery behavior). The entry-point format gate is the -// structural defense; this recovery only backstops the residue. -// Every pattern mentions "image" literally, and MEDIA_TYPE_FIELD_PATTERN is -// separately gated on an "image" anchor — so audio/video media rejections -// ("unsupported media type", "invalid media type") can never be classified -// as image errors here. All documented provider image rejections mention -// "image", so the restriction costs no known match. const IMAGE_FORMAT_STATUS_MESSAGE_PATTERNS = [ - // Unsupported format — OpenAI / Moonshot "unsupported image …". /unsupported image (?:url|format|type)/, - // Undecodable / corrupt image data. /does not represent a valid image/, /could not (?:process|decode) (?:the |input )?image/, /unable to process (?:the |input )?image/, @@ -172,26 +115,8 @@ const IMAGE_FORMAT_STATUS_MESSAGE_PATTERNS = [ /invalid image(?: data| type| format)?/, ] as const; -// Anthropic `media_type` & Gemini `mime_type` enum violations name the field -// — recoverable only when the message is about an IMAGE. A video/audio -// `media_type` rejection must surface instead of being blindly -// media-stripped: unlike images there is no conversion-guidance path for -// video today, so dropping the user's video silently would hide the real -// error. Every documented image media_type message also mentions "image", -// so the anchor costs nothing on the known cases. const MEDIA_TYPE_FIELD_PATTERN = /(?:media|mime)_?type/; -/** - * Whether the provider rejected an IMAGE in the request because of its - * FORMAT or DATA — an unsupported media type or undecodable image bytes. - * The rejection is deterministic for a given history (the same image is - * re-sent on every request, so the session would fail every turn), and the - * only recovery is to resend once with all media stripped (see the - * media-stripped resend in `llmRequester`). Body-size (413), context - * overflow, image count/size limits, image-input-disabled rejections, and - * non-image (audio/video) media rejections are excluded — the first two - * have their own recoveries, and the rest are not fixed by stripping media. - */ export function isImageFormatError(error: unknown): boolean { if (error instanceof APIStatusError) { if (error instanceof APIContextOverflowError) return false; @@ -223,13 +148,6 @@ export function isRetryableGenerateError(error: unknown): boolean { if (error instanceof APIStatusError) { return [408, 409, 429, 500, 502, 503, 504, 529].includes(error.statusCode); } - // Fallback safety net: an unclassified provider failure — typically an - // upstream gateway that forwards the original error only as text, with no - // usable HTTP status — lands here as a base `ChatProviderError`. Retrying - // beats failing the run on the first transient blip. Image-format - // rejections are excluded: they are deterministic per history and recovered - // by the media-stripped resend (see isImageFormatError), so retrying the - // identical request first would only burn the retry budget. return error instanceof ChatProviderError && !isImageFormatError(error); } @@ -267,32 +185,15 @@ const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [ /rate-limited/, ] as const; -// Wordings that mean the provider itself is saturated rather than throttling -// this caller. Anchored on "overload" (Anthropic's `overloaded_error`, OpenAI's -// "server is currently overloaded", Gemini's "model is overloaded") so a bare -// proxy 503 ("Service Unavailable") does not get misclassified as overload. const PROVIDER_OVERLOAD_MESSAGE_PATTERNS = [/overload/] as const; -// Wordings that mean the serialized request BODY was too big, matched against -// the lowercased message of a 413. Kept separate from the context-overflow -// patterns above: those describe token counts, these describe bytes. A 413 -// whose message matches neither family stays a plain `APIStatusError` — -// Vertex phrases prompt-too-long as a 413, so the status alone is not proof -// of a body-size rejection. const REQUEST_TOO_LARGE_MESSAGE_PATTERNS = [ - // Moonshot / Kimi: "Request exceeds the maximum size". /request exceeds the maximum size/, - // Reverse proxies (nginx-style HTML body): "413 Request Entity Too Large". /request entity too large/, - // Anthropic: error type `request_too_large`, message "Request exceeds the - // maximum allowed number of bytes". /request_too_large/, /exceeds? the maximum allowed number of bytes/, - // RFC 9110 reason phrase (both the pre-2022 and current names). /payload too large/, /content too large/, - // Plain wordings: generic gateways say "request too large"; Go's - // http.MaxBytesReader (common in Go proxies) says "request body too large". /request (?:body )?too large/, ] as const; @@ -309,8 +210,6 @@ export function normalizeAPIStatusError( if (statusCode === 429) { return new APIProviderRateLimitError(message, requestId, retryAfterMs); } - // Context overflow first: Vertex returns prompt-too-long as a 413, and a - // token overflow must keep routing to compaction even on that status. if (isContextOverflowStatusError(statusCode, message)) { return new APIContextOverflowError(statusCode, message, requestId, retryAfterMs); } @@ -343,7 +242,6 @@ export function isContextOverflowStatusError(statusCode: number, message: string } export function isProviderOverloadStatusError(statusCode: number, message: string): boolean { - // 529 is Anthropic's dedicated overloaded status — always overload. if (statusCode === 529) return true; if (statusCode !== 500 && statusCode !== 503) return false; const lowerMessage = message.toLowerCase(); diff --git a/packages/agent-core-v2/src/app/llmProtocol/generate.ts b/packages/agent-core-v2/src/app/llmProtocol/generate.ts index a1417eb714..bc33baa7b7 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/generate.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/generate.ts @@ -14,70 +14,19 @@ import type { TokenUsage } from './usage'; type StoredToolCall = Omit; -/** - * The result of a single {@link generate} call. - * - * Contains the fully-assembled assistant {@link message}, an optional - * provider-assigned {@link id}, and token {@link usage} statistics. - */ export interface GenerateResult { - /** Provider-assigned response identifier, or `null` if unavailable. */ readonly id: string | null; - /** The fully-assembled assistant message with merged content parts and tool calls. */ readonly message: Message; - /** Token usage for this generation, or `null` if not reported. */ readonly usage: TokenUsage | null; - /** - * Normalized finish reason reported by the provider, or `null` if no - * finish_reason was emitted (for example, the stream was interrupted - * before the final event). - */ readonly finishReason: FinishReason | null; - /** - * Raw provider-specific finish_reason string preserved verbatim. - * `null` if the provider did not emit one. - */ readonly rawFinishReason: string | null; } export interface GenerateCallbacks { onMessagePart?: (part: StreamedMessagePart) => void | Promise; - /** - * Fires once per fully-assembled tool call after the stream drains, in the - * order tool calls appear in the final assistant message. - * - * Tool calls are deliberately deferred until after the stream completes: - * parallel-tool-call streams may interleave argument deltas across calls - * (e.g. tc0-header → tc1-header → tc0-args → tc1-args), so firing mid-stream - * would dispatch a tool with half-parsed arguments and trigger toolParseError. - */ onToolCall?: (toolCall: ToolCall) => void | Promise; } -/** - * Generate one assistant message by streaming from the given provider. - * - * Parts of the message are streamed and merged: consecutive compatible parts - * (e.g. TextPart + TextPart, ToolCall + ToolCallPart) are merged in-place so - * the returned message always contains fully-assembled parts. - * - * **Tool call completion** is inferred from merge boundaries (a non-merging - * next part flushes the pending tool call into `message.toolCalls`) and from - * stream end. Provider adapters translate native "done" signals into this - * unified form; the generate loop never sees a separate done event. - * - * @param provider - The chat provider to generate from. - * @param systemPrompt - System-level instruction prepended to the request. - * @param tools - Tool definitions the model may invoke. - * @param history - The conversation history sent as context. - * @param callbacks - Optional streaming callbacks. - * @param options - Optional per-call settings (e.g. an {@link AbortSignal}). - * - * @throws {DOMException} with name `"AbortError"` when `options.signal` is - * aborted before or during streaming. - * @throws {APIEmptyResponseError} when the response contains no content and - * no tool calls, or only thinking content without any text or tool calls. - */ export async function generate( provider: ChatProvider, systemPrompt: string, @@ -89,15 +38,8 @@ export async function generate( const message: Message = { role: 'assistant', content: [], toolCalls: [] }; let pendingPart: StreamedMessagePart | null = null; - // Map from provider streaming index (e.g. OpenAI Chat `index`, Responses - // `item_id`) to the position inside `message.toolCalls`. Used to route - // interleaved argument deltas from parallel tool calls to the correct call. const toolCallIndexMap = new Map(); - // Pre-flight abort check: if the caller's signal is already aborted, we - // must not issue the provider request at all. Providers that do not - // themselves honor `signal` would otherwise emit a network call that the - // caller has explicitly cancelled. if (options?.signal?.aborted) { throwAbortError(); } @@ -109,18 +51,8 @@ export async function generate( options?.onRequestStart?.(); const stream = await provider.generate(systemPrompt, wireTools, history, options); - // Post-await abort check: `provider.generate()` may have resolved before - // noticing a mid-flight abort. Reject immediately rather than draining - // the stream. await throwIfAborted(options?.signal, stream); - // Decode-phase accounting. We split the window from the first streamed part - // to stream end into time spent awaiting the next part (server + network) vs. - // time spent processing each part in-process (deep copy, host callback, part - // merge). `lastResumeAt` marks the end of the previous part's processing, so - // the gap until the next part arrives is attributed to the server. The - // per-part processing is wrapped in try/finally so the accounting stays - // correct across `continue` and thrown aborts. let serverDecodeMs = 0; let clientConsumeMs = 0; let firstPartAt: number | undefined; @@ -137,17 +69,11 @@ export async function generate( try { await throwIfAborted(options?.signal, stream); - // Notify raw part callback (deep copy to avoid aliasing mutations). if (callbacks?.onMessagePart !== undefined) { await callbacks.onMessagePart(deepCopyPart(part)); await throwIfAborted(options?.signal, stream); } - // Index-based routing for parallel tool call argument deltas. - // When a ToolCallPart arrives with an index referring to a tool call - // that is NOT the currently-pending one, append it directly to the - // correct ToolCall in message.toolCalls instead of relying on sequential - // merging. This prevents argument cross-contamination across parallel calls. if ( isToolCallPart(part) && part.index !== undefined && @@ -164,16 +90,11 @@ export async function generate( } continue; } - // Unknown index — fall through to the sequential logic as a safety net. } if (pendingPart === null) { pendingPart = part; } else if (!mergeInPlace(pendingPart, part)) { - // Could not merge — flush the pending part and start a new one. - // For parallel tool calls this happens when a new ToolCall header arrives - // while a previous ToolCall is still pending; the flush finalizes the - // previous tool call into `message.toolCalls`. flushPart(message, pendingPart, toolCallIndexMap); pendingPart = part; } @@ -185,14 +106,12 @@ export async function generate( await throwIfAborted(options?.signal, stream); if (firstPartAt !== undefined) { - // Tail wait: from the last processed part to the stream's done signal. serverDecodeMs += Date.now() - lastResumeAt; } options?.onStreamEnd?.( firstPartAt === undefined ? undefined : { serverDecodeMs, clientConsumeMs }, ); - // Flush the last pending part. if (pendingPart !== null) { flushPart(message, pendingPart, toolCallIndexMap); } @@ -208,7 +127,6 @@ export async function generate( ); } - // Think-only response (no real text, no tool calls) is treated as incomplete. const hasThink = message.content.some((p) => p.type === 'think'); const hasText = message.content.some((p) => p.type === 'text' && p.text.trim().length > 0); const hasToolCalls = message.toolCalls.length > 0; @@ -228,7 +146,6 @@ export async function generate( ); } - // Fire onToolCall for every fully-assembled tool call, in final order. if (callbacks?.onToolCall !== undefined) { for (const toolCall of message.toolCalls) { await throwIfAborted(options?.signal, stream); @@ -278,7 +195,6 @@ async function throwIfAborted(signal?: AbortSignal, stream?: StreamedMessage): P throwAbortError(); } -/** True when `pending` is a ToolCall whose _streamIndex equals `index`. */ function isPendingToolCallAtIndex( pending: StreamedMessagePart | null, index: number | string, @@ -286,14 +202,6 @@ function isPendingToolCallAtIndex( return pending !== null && isToolCall(pending) && pending._streamIndex === index; } -/** - * Append a fully-merged part to the message. - * - * - ContentPart -> message.content - * - ToolCall -> message.toolCalls (the `_streamIndex` routing key is - * registered in the map and stripped before storage). - * - ToolCallPart -> ignored (orphaned delta without a matching pending call) - */ function flushPart( message: Message, part: StreamedMessagePart, @@ -318,7 +226,6 @@ function flushPart( toolCallIndexMap.set(streamIndex, ordinal); } } - // ToolCallPart: orphaned delta — silently ignore. } function formatFinishReasonHint(stream: StreamedMessage): string { @@ -334,12 +241,6 @@ function formatFinishReasonHint(stream: StreamedMessage): string { return ` Provider stop details: finishReason=${stream.finishReason ?? 'unknown'}${raw}.${filteredHint}`; } -/** - * Produce a shallow-ish copy of a StreamedMessagePart. - * - * This is intentionally minimal: we only need isolation for the mutable - * string fields that `mergeInPlace` mutates (text, think, arguments). - */ function deepCopyPart(part: StreamedMessagePart): StreamedMessagePart { return structuredClone(part); } diff --git a/packages/agent-core-v2/src/app/llmProtocol/message.ts b/packages/agent-core-v2/src/app/llmProtocol/message.ts index 1670345dbe..2743c9e7d6 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/message.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/message.ts @@ -10,7 +10,7 @@ export interface TextPart { export interface ThinkPart { type: 'think'; think: string; - encrypted?: string; // Provider-specific reasoning signature + encrypted?: string; } export interface ImageURLPart { @@ -28,13 +28,6 @@ export interface VideoURLPart { videoUrl: { url: string; id?: string | undefined }; } -/** - * A single piece of content within a {@link Message}. - * - * The union covers text, model reasoning ("think"), images, audio, and video. - * Providers convert these to their native content-block format during - * {@link ChatProvider.generate}. - */ export type ContentPart = TextPart | ThinkPart | ImageURLPart | AudioURLPart | VideoURLPart; export interface ToolCall { @@ -43,69 +36,27 @@ export interface ToolCall { name: string; arguments: string | null; extras?: Record; - /** - * Provider-specific streaming index used to route argument deltas to the - * correct parallel tool call. Set by streaming providers (OpenAI Chat - * Completions `index`, Responses API `item_id`). Consumed internally by - * {@link generate} and stripped before the ToolCall is stored on a Message. - * - * @internal - */ _streamIndex?: number | string; } -/** Streaming delta for tool call arguments. */ export interface ToolCallPart { type: 'tool_call_part'; argumentsPart: string | null; - /** - * Provider-specific index for routing this streaming delta to the correct - * parallel tool call. Used by OpenAI Chat Completions (`index`) and - * Responses API (`item_id`/`output_index`). When absent, the delta is - * appended to the most-recently-seen ToolCall (single-tool-call fallback). - */ index?: number | string; } -/** - * A single chunk yielded by {@link StreamedMessage}'s async iterator. - * - * During streaming, the generate loop receives a sequence of these parts and - * merges compatible consecutive parts (e.g. TextPart + TextPart) in-place so - * the final {@link Message} contains fully-assembled content. - * - * Tool-call completion is inferred from merge boundaries (a non-merging next - * part flushes the pending tool call) and from stream end. Provider adapters - * are responsible for translating their native "done" signals into this - * shape; they do not emit a separate done event. - */ export type StreamedMessagePart = ContentPart | ToolCall | ToolCallPart; -/** - * A single message in a conversation. - * - * Messages carry a {@link role} (system, user, assistant, or tool), an array - * of {@link ContentPart} content blocks, and optional {@link ToolCall} entries. - * Tool result messages set {@link toolCallId} to correlate with the originating - * call. - */ export interface Message { - /** The role of the message sender. */ readonly role: Role; - /** Optional display name for the sender (used by some providers). */ readonly name?: string; - /** Ordered content parts (text, images, thinking, etc.). */ readonly content: ContentPart[]; - /** Tool calls requested by the assistant in this message. */ readonly toolCalls: ToolCall[]; - /** For `tool` role messages, the ID of the tool call this result answers. */ readonly toolCallId?: string; - /** When `true`, indicates the message was not fully received (e.g. stream interrupted). */ readonly partial?: boolean; readonly tools?: readonly Tool[]; } -/** Check if a streamed part is a ContentPart (text, think, image_url, audio_url, video_url). */ export function isContentPart(part: StreamedMessagePart): part is ContentPart { const t = part.type; return ( @@ -122,42 +73,20 @@ export function isToolDeclarationOnlyMessage(message: Message): boolean { ); } -/** Check if a streamed part is a ToolCall. */ export function isToolCall(part: StreamedMessagePart): part is ToolCall { return part.type === 'function'; } -/** Check if a streamed part is a ToolCallPart (streaming argument delta). */ export function isToolCallPart(part: StreamedMessagePart): part is ToolCallPart { return part.type === 'tool_call_part'; } -/** - * Merge `source` into `target` in-place for streaming accumulation. - * - * Supported combinations: - * - TextPart + TextPart -> concatenate text - * - ThinkPart + ThinkPart -> concatenate think (refuse if target.encrypted already set) - * - ToolCall + ToolCallPart -> append arguments - * - * **Routing for parallel tool calls**: When OpenAI (or compatible) APIs stream - * multiple tool calls in parallel, argument deltas may interleave across calls. - * To handle this, {@link generate} routes ToolCallParts by their optional - * {@link ToolCallPart.index} field (mirroring the provider's streaming index) - * to the correct pending ToolCall, rather than relying on sequential ordering. - * This function still performs sequential merging as a fallback when the - * pending part matches the incoming one. - * - * Returns `true` if the merge was performed, `false` otherwise. - */ export function mergeInPlace(target: StreamedMessagePart, source: StreamedMessagePart): boolean { - // TextPart + TextPart if (target.type === 'text' && source.type === 'text') { target.text += source.text; return true; } - // ThinkPart + ThinkPart if (target.type === 'think' && source.type === 'think') { if (target.encrypted !== undefined) { return false; @@ -169,7 +98,6 @@ export function mergeInPlace(target: StreamedMessagePart, source: StreamedMessag return true; } - // ToolCall + ToolCallPart if (target.type === 'function' && source.type === 'tool_call_part') { if (source.argumentsPart !== null) { target.arguments = @@ -183,12 +111,6 @@ export function mergeInPlace(target: StreamedMessagePart, source: StreamedMessag return false; } -/** - * Extract the concatenated text from a message's content parts. - * - * @param message The message to extract text from. - * @param sep Separator between text parts. Defaults to empty string. - */ export function extractText(message: Message, sep: string = ''): string { return message.content .filter((part): part is TextPart => part.type === 'text') @@ -196,14 +118,10 @@ export function extractText(message: Message, sep: string = ''): string { .join(sep); } -/** - * @deprecated Use `extractText` instead. - */ export function getTextContent(message: Message): string { return extractText(message); } -/** Create a simple user message with a single text part. */ export function createUserMessage(content: string): Message { return { role: 'user', @@ -212,7 +130,6 @@ export function createUserMessage(content: string): Message { }; } -/** Create an assistant message from content parts and optional tool calls. */ export function createAssistantMessage(content: ContentPart[], toolCalls?: ToolCall[]): Message { return { role: 'assistant', @@ -221,7 +138,6 @@ export function createAssistantMessage(content: ContentPart[], toolCalls?: ToolC }; } -/** Create a tool result message. */ export function createToolMessage(toolCallId: string, output: string | ContentPart[]): Message { const content: ContentPart[] = typeof output === 'string' ? [{ type: 'text', text: output }] : output; diff --git a/packages/agent-core-v2/src/app/llmProtocol/provider.ts b/packages/agent-core-v2/src/app/llmProtocol/provider.ts index 2eac04abfd..62c73b6c53 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/provider.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/provider.ts @@ -22,43 +22,11 @@ export interface JsonSchemaResponseFormat { export type ResponseFormat = JsonObjectResponseFormat | JsonSchemaResponseFormat; -/** - * Optional context passed to {@link ChatProvider.withMaxCompletionTokens} so a - * provider can tighten the caller-supplied cap to its own transport - * constraints. - */ export interface MaxCompletionTokensOptions { - /** - * Tokens already consumed by the current context (API-reported input + - * output of the latest completed step). Chat-completions providers use it - * to size the cap to the remaining context window. - */ readonly usedContextTokens?: number; - /** Model context-window size in tokens (`max_context_size`). */ readonly maxContextTokens?: number; } -/** - * Normalized finish-reason signal indicating why a generation stopped. - * - * Each provider's native stop value is mapped to one of these, and the - * unmapped original string is preserved in `rawFinishReason` as an escape - * hatch. `null` means the provider did not emit a finish_reason (e.g. the - * stream was cut off before the final event). - * - * - `'completed'`: normal completion (OpenAI `'stop'`, Anthropic - * `'end_turn'` / `'stop_sequence'`, Gemini `'STOP'`). - * - `'tool_calls'`: generation paused so the caller can dispatch tool - * calls and feed their results back. Note that the OpenAI Responses API - * and Google GenAI report `'completed'` here; only the Chat - * Completions–style providers and Anthropic surface a dedicated value. - * - `'truncated'`: token budget exhausted (OpenAI `'length'`, Anthropic - * `'max_tokens'`, Gemini `'MAX_TOKENS'`, Responses `'max_output_tokens'`). - * - `'filtered'`: content filter or safety policy blocked the response. - * - `'paused'`: Anthropic-specific `'pause_turn'`. - * - `'other'`: recognized non-null reason that does not fit the categories - * above. - */ export type FinishReason = | 'completed' | 'tool_calls' @@ -67,168 +35,54 @@ export type FinishReason = | 'paused' | 'other'; -/** - * An async-iterable stream of message parts produced by a single LLM response. - * - * Consumers iterate over the stream with `for await..of` to receive - * {@link StreamedMessagePart} chunks. After the iteration completes, the - * {@link id}, {@link usage}, {@link finishReason}, and - * {@link rawFinishReason} properties reflect the final values reported by - * the provider. - */ export interface StreamedMessage { [Symbol.asyncIterator](): AsyncIterator; - /** Provider-assigned response identifier, or `null` if not available. */ readonly id: string | null; - /** Token usage statistics, populated after the stream completes. */ readonly usage: TokenUsage | null; - /** - * Normalized finish reason, populated after the stream completes. - * - * `null` if the provider did not emit a finish_reason (for example, the - * stream was interrupted before the final event arrived). - */ readonly finishReason: FinishReason | null; - /** - * Raw provider-specific finish_reason string, preserved verbatim as an - * escape hatch for callers that need the original wire value. - * - * `null` if the provider did not emit a finish_reason. - */ readonly rawFinishReason: string | null; } -/** - * Options that can be forwarded to a single {@link ChatProvider.generate} call. - */ export interface ProviderRequestAuth { - /** Bearer/API token resolved for this specific provider request. */ apiKey?: string; - /** Request-scoped headers. These override constructor-level default headers. */ headers?: Record; } export interface GenerateOptions { - /** - * An {@link AbortSignal} that, when aborted, requests cancellation of the - * in-flight generate call. Providers that accept a signal will forward it - * to their underlying HTTP client; the generate loop in - * {@link generate | generate()} also checks the signal between streamed - * parts. - */ signal?: AbortSignal; - /** - * Request-scoped provider auth. Hosts should resolve this immediately before - * each request/retry so providers never retain mutable credential state. - */ auth?: ProviderRequestAuth; - /** - * Optional model-output format constraint. Providers map this to their native - * structured-output field when supported. - */ responseFormat?: ResponseFormat; - /** - * Host-side instrumentation hook fired immediately before invoking the - * provider adapter's generate call. - */ onRequestStart?: () => void; - /** - * Host-side instrumentation hook fired by the provider adapter immediately - * before it dispatches the network request to the upstream API. The window - * between {@link onRequestStart} and this hook is in-process request-building - * time (message serialization, param assembly) spent by the client; the - * window between this hook and the first streamed part is network + server - * time. Splitting time-to-first-token across this boundary lets hosts - * attribute latency to the client vs. the API server. - */ onRequestSent?: () => void; - /** - * Host-side instrumentation hook fired after the provider stream is fully - * drained, before post-processing the assembled response. Receives the - * {@link StreamDecodeStats} accounting accumulated across the stream when at - * least one part was streamed, or `undefined` for an empty stream. - */ onStreamEnd?: (stats?: StreamDecodeStats) => void; } -/** - * Decode-phase accounting for a single streamed generation. Splits the window - * from the first streamed part to stream end into the time spent waiting on the - * provider for the next part (server + network) versus the time spent - * processing each part in-process (deep copy, host callbacks, part merging). - * - * Because both buckets are wall-clock measured on the single JS thread, a - * stop-the-world GC pause that lands while awaiting the next part is counted in - * {@link serverDecodeMs}; a non-trivial {@link clientConsumeMs} share is the - * unambiguous signal that the host's per-part processing is throttling decode. - */ export interface StreamDecodeStats { - /** Cumulative time spent awaiting the next streamed part (server + network). */ readonly serverDecodeMs: number; - /** Cumulative time spent processing streamed parts in-process (client). */ readonly clientConsumeMs: number; } -/** - * In-memory video bytes for providers that require an uploaded file - * reference instead of an inline data URL. - */ export interface VideoUploadInput { readonly data: Uint8Array; readonly mimeType: string; readonly filename?: string | undefined; } -/** - * Unified interface for an LLM chat provider. - * - * Each provider implementation (Kimi, OpenAI, Anthropic, Google GenAI, etc.) - * converts the common {@link Message} / {@link Tool} types into the - * provider-specific wire format, streams back a {@link StreamedMessage}, and - * exposes configuration helpers such as {@link withThinking}. - */ export interface ChatProvider { - /** Short identifier for the provider backend (e.g. `"kimi"`, `"anthropic"`). */ readonly name: string; - /** Model name passed to the upstream API (e.g. `"moonshot-v1-auto"`). */ readonly modelName: string; - /** Current thinking-effort level, or `null` if thinking is not configured. */ readonly thinkingEffort: ThinkingEffort | null; readonly maxCompletionTokens?: number; - /** - * Send a conversation to the LLM and return a streamed response. - * - * @param systemPrompt - System-level instruction prepended to the request. - * @param tools - Tool definitions the model may invoke. - * @param history - The conversation history (user, assistant, tool messages). - * @param options - Optional per-call settings such as an {@link AbortSignal}. - */ generate( systemPrompt: string, tools: Tool[], history: Message[], options?: GenerateOptions, ): Promise; - /** Return a shallow copy of this provider with the given thinking effort. */ withThinking(effort: ThinkingEffort): ChatProvider; - /** - * Return a shallow copy of this provider with the per-request completion - * budget clamped to `maxCompletionTokens`. Optional because not every - * backend benefits from a client-computed cap. - * - * When `options` are provided, implementations may further tighten the cap - * based on their own transport constraints — e.g. chat-completions - * endpoints size the cap to the remaining context window - * (`maxContextTokens - usedContextTokens`) and/or clamp to a fixed ceiling. - * - * Implementations MUST NOT mutate or replace internal HTTP clients on the - * returned clone — the clone is expected to share transport state with the - * original. See `KimiChatProvider._clone()` for the rationale. - */ withMaxCompletionTokens?( maxCompletionTokens: number, options?: MaxCompletionTokensOptions, ): ChatProvider; - /** Upload a video and return a content part that can be sent to this provider. */ uploadVideo?(input: string | VideoUploadInput, options?: GenerateOptions): Promise; } diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts index ee64d0fa2c..ac6ac18c6a 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts @@ -49,13 +49,6 @@ import { type ToolCallIdPolicy, } from './tool-call-id'; -/** - * Normalize an Anthropic `stop_reason` string to the unified - * {@link FinishReason} enum. - * - * Source: `message.stop_reason` (non-stream) or the last `message_delta` - * event's `delta.stop_reason` (stream). - */ function normalizeAnthropicStopReason(raw: string | null | undefined): { finishReason: FinishReason | null; rawFinishReason: string | null; @@ -87,23 +80,8 @@ export interface AnthropicOptions { betaFeatures?: string[] | undefined; defaultHeaders?: Record; metadata?: Record | undefined; - /** Use streaming API. Defaults to true. Set to false for non-streaming (test/fallback). */ stream?: boolean | undefined; - /** - * Explicitly declare whether the model supports adaptive thinking - * (`thinking: { type: 'adaptive' }`), overriding the model-name version - * inference. Useful for custom-named endpoints whose model name does not - * encode a parseable Claude version. Leave undefined to infer from the name. - */ adaptiveThinking?: boolean | undefined; - /** - * Use the Anthropic **beta** Messages API (`client.beta.messages.create`, - * `POST /v1/messages?beta=true`) instead of the standard Messages API. - * - * Beta features (`betaFeatures`) are then sent via the request `betas` - * field rather than the `anthropic-beta` header. Defaults to false, which - * keeps the standard endpoint + header behavior. - */ betaApi?: boolean | undefined; clientFactory?: (auth: ProviderRequestAuth) => Anthropic; } @@ -156,24 +134,8 @@ function applyResponseFormat( kwargs['output_config'] = outputConfig; } -/** - * Per-version default output ceilings sourced from Anthropic's Messages - * API model cards (platform.claude.com/docs/en/about-claude/models/overview). - * Values are the documented synchronous Messages-API maximum — we send - * the full ceiling because Claude 4 + interleaved-thinking shares this - * budget with encrypted reasoning, so anything below the documented cap - * can silently truncate mid-`tool_use`. - * - * Keys are `-[-]`. Lookups try the most specific - * key first, then the nearest lower catalogued minor of the same - * family/major (a not-yet-catalogued `opus-4-8` reuses `opus-4-7`'s - * ceiling), and finally the family/major-only baseline entry. - */ const CEILING_BY_FAMILY_VERSION: Readonly> = { - // Claude Fable 5 documents a 128k output ceiling. 'fable-5': 128000, - // Claude Opus per minor version. 4.6 through 4.8 document a 128k cap; - // 4.5 ships at 64k; 4.1 and the dated 4.0 release stay at 32k. 'opus-4-8': 128000, 'opus-4-7': 128000, 'opus-4-6': 128000, @@ -181,21 +143,16 @@ const CEILING_BY_FAMILY_VERSION: Readonly> = { 'opus-4-1': 32000, 'opus-4-0': 32000, 'opus-4': 32000, - // Claude Sonnet 4.x: 4.0 / 4.5 / 4.6 all document a 64k ceiling. 'sonnet-4-6': 64000, 'sonnet-4-5': 64000, 'sonnet-4-0': 64000, 'sonnet-4': 64000, - // Claude Haiku 4.5 is 64k; the family-only entry keeps future dated - // 4.x Haiku releases on the same ceiling. 'haiku-4-5': 64000, 'haiku-4': 64000, - // Claude 3.5 / 3.7 documented at 8192 (standard endpoint). 'opus-3-5': 8192, 'sonnet-3-5': 8192, 'sonnet-3-7': 8192, 'haiku-3-5': 8192, - // Original Claude 3 generation. 'opus-3': 4096, 'sonnet-3': 4096, 'haiku-3': 4096, @@ -211,32 +168,11 @@ interface ClaudeVersion { minor: number | null; } -// Family-first form: "opus-4-7", "sonnet-4.6", "haiku-4-5-20251001", -// "fable-5" (single version component — Fable ids carry no minor). -// Version numbers are capped at 1–2 digits with a non-digit lookahead so -// 8-digit date suffixes (e.g. `-20251001`) don't get consumed as version -// components. const FAMILY_FIRST_RE = /(opus|sonnet|haiku|fable)[-._](\d{1,2})(?!\d)(?:[-._](\d{1,2})(?!\d))?/; -// Legacy version-first form: "3-5-sonnet", "3.7.opus" — used by older -// Anthropic model ids and Bedrock variants of Claude 3.x. const VERSION_FIRST_RE = /(\d{1,2})[-._](\d{1,2})[-._](opus|sonnet|haiku)/; -// Bare family form for base Claude 3 (no minor): "3-opus", "3.haiku". const BARE_FAMILY_RE = /(\d{1,2})[-._](opus|sonnet|haiku)/; -/** - * Extract Claude family + version from a model id. - * - * Designed to survive the naming variants we see across vendors: - * vendor prefixes (`anthropic.`, `aws/`, `openrouter/`, - * `online-`), suffixes (date stamps like `-20251001`, build tags - * like `-construct`, `-v1:0`), and `.` vs `-` separators between - * the family and version components. - * - * Returns `null` when the id contains no Claude marker or no - * recognizable family/version, in which case the resolver should fall - * back to the override or {@link FALLBACK_MAX_TOKENS}. - */ function parseClaudeVersion(model: string): ClaudeVersion | null { return parseClaudeFamilyVersion(model, true); } @@ -247,11 +183,6 @@ function parseClaudeAliasVersion(model: string): ClaudeVersion | null { function parseClaudeFamilyVersion(model: string, requireClaudeMarker: boolean): ClaudeVersion | null { const normalized = model.toLowerCase(); - // Guard against false positives on non-Claude models that happen to - // contain an `opus-4-7`-like substring (e.g. fine-tunes named after a - // checkpoint). The Anthropic provider might still be configured for - // non-Claude endpoints, so without this guard we'd quietly apply - // Claude ceilings to unrelated models. if (requireClaudeMarker && !normalized.includes('claude')) return null; const familyFirst = FAMILY_FIRST_RE.exec(normalized); @@ -284,12 +215,6 @@ function parseClaudeFamilyVersion(model: string, requireClaudeMarker: boolean): function lookupClaudeCeiling(version: ClaudeVersion): number | undefined { const { family, major, minor } = version; if (minor !== null) { - // Exact minor first, then walk down to the nearest catalogued minor: - // a newer minor release inherits at least its predecessor's ceiling - // (Anthropic has never lowered the cap within a major), so a - // not-yet-catalogued 4.8 reuses 4.7's value instead of dropping to - // the family baseline. The regex caps minors at two digits, so this - // walk is bounded. for (let candidate = minor; candidate >= 0; candidate--) { const ceiling = CEILING_BY_FAMILY_VERSION[`${family}-${major}-${candidate}`]; if (ceiling !== undefined) return ceiling; @@ -298,20 +223,6 @@ function lookupClaudeCeiling(version: ClaudeVersion): number | undefined { return CEILING_BY_FAMILY_VERSION[`${family}-${major}`]; } -/** - * Resolve the default `max_tokens` for an Anthropic request. - * - * Precedence: - * 1. Caller-provided `override` (e.g. `models..maxOutputSize` - * from the harness config) — honored when present so users can - * intentionally lower the budget (handy for forcing truncation - * in tests) or raise it on a model we don't yet know about. - * 2. When the model id parses to a known Claude family + version, - * the override is clamped to the documented Messages-API ceiling - * so we never send a value the server would reject. - * 3. With no override and no recognized version, fall back to - * {@link FALLBACK_MAX_TOKENS}. - */ export function resolveDefaultMaxTokens(model: string, override?: number): number { const parsed = parseClaudeVersion(model); const ceiling = parsed === null ? undefined : lookupClaudeCeiling(parsed); @@ -345,8 +256,6 @@ function supportsAdaptiveThinking(model: string): boolean { if (version === null) { return false; } - // A missing minor is a bare family-major id: "claude-fable-5" (5.0 ≥ 4.6, - // adaptive-only) or "claude-opus-4" (4.0 < 4.6, budget-based). return versionAtLeast( { major: version.major, minor: version.minor ?? 0 }, ADAPTIVE_MIN_VERSION, @@ -419,9 +328,6 @@ function shouldPreserveUnsignedThinking(model: string): boolean { return parseClaudeAliasVersion(model) === null; } -/** - * Content block types that support cache_control injection. - */ const CACHEABLE_TYPES = new Set([ 'text', 'image', @@ -464,10 +370,6 @@ interface AnthropicVideoBlock { | { type: 'url'; url: string }; } -// The Messages API has no representation for audio input. Instead of -// silently dropping such parts (the model would not even know an attachment -// existed), emit a placeholder text block so it can acknowledge the gap. -// Consecutive parts of the same kind collapse into a single placeholder. const OMITTED_MEDIA_PLACEHOLDER = { audio_url: '(audio omitted: not supported by this provider)', } as const; @@ -574,7 +476,6 @@ function toolResultToBlock(toolCallId: string, content: ContentPart[]): ToolResu function convertMessage(message: Message, model: string): MessageParam { const role = message.role; - // system role -> ... wrapped user message if (role === 'system') { const text = message.content .filter((p) => p.type === 'text') @@ -586,7 +487,6 @@ function convertMessage(message: Message, model: string): MessageParam { }; } - // tool role -> ToolResultBlockParam in user message if (role === 'tool') { if (message.toolCallId === undefined) { throw new ChatProviderError('Tool message missing `toolCallId`.'); @@ -595,7 +495,6 @@ function convertMessage(message: Message, model: string): MessageParam { return { role: 'user', content: [block as ContentBlockParam] }; } - // user or assistant const blocks: ContentBlockParam[] = []; for (const part of message.content) { if (part.type === 'text') { @@ -603,20 +502,6 @@ function convertMessage(message: Message, model: string): MessageParam { } else if (part.type === 'image_url') { blocks.push(imageUrlPartToAnthropic(part.imageUrl.url) as unknown as ContentBlockParam); } else if (part.type === 'think') { - // ThinkPart -> ThinkingBlockParam. - // - // Signed: emit the block with its signature. api.anthropic.com requires a - // valid signature and always supplies one, so Anthropic-sourced history - // always takes this branch. - // - // Unsigned: still PRESERVE the thinking, emitted *without* a `signature` - // field. Anthropic-compatible backends (e.g. Kimi) stream thinking with - // no signature_delta, yet reject a tool-call turn whose thinking is gone - // ("thinking is enabled but reasoning_content is missing"). Dropping it - // here is what broke multi-step tool use on those backends. Claude - // models reject unsigned thinking blocks, so those are only preserved - // for non-Claude Anthropic-compatible models. An unsigned part with no - // text carries nothing, so it is skipped. if (part.encrypted !== undefined) { blocks.push({ type: 'thinking', @@ -637,7 +522,6 @@ function convertMessage(message: Message, model: string): MessageParam { } } - // Tool calls -> ToolUseBlockParam if (message.toolCalls.length > 0) { for (const tc of message.toolCalls) { let toolInput: Record = {}; @@ -666,14 +550,12 @@ function convertMessage(message: Message, model: string): MessageParam { return { role: role, content: blocks }; } export function convertAnthropicError(error: unknown): ChatProviderError { - // Check timeout before connection (APIConnectionTimeoutError extends APIConnectionError) if (error instanceof AnthropicTimeoutError) { return new APITimeoutError(error.message); } if (error instanceof AnthropicConnectionError) { return new APIConnectionError(error.message); } - // APIError with a status code => status error if (error instanceof AnthropicAPIError && typeof error.status === 'number') { const reqId = error.requestID ?? null; return normalizeAPIStatusError( @@ -872,9 +754,6 @@ class AnthropicStreamedMessage implements StreamedMessage { id: block.id, name: block.name, arguments: '', - // Carry the Anthropic block index so parallel tool_use - // blocks' interleaved input_json_delta chunks can be routed - // to the correct ToolCall by the generate loop. _streamIndex: blockIndex, } satisfies ToolCall; break; @@ -895,8 +774,6 @@ class AnthropicStreamedMessage implements StreamedMessage { yield { type: 'tool_call_part', argumentsPart: delta.partial_json, - // Carry the Anthropic block index so this delta is routed - // to the matching ToolCall (parallel tool_use support). index: blockIndex, }; break; @@ -909,12 +786,7 @@ class AnthropicStreamedMessage implements StreamedMessage { break; } } else if (eventType === 'content_block_stop') { - // No-op: the generate loop infers tool-call completion from the - // next non-merging part (typically the next content_block_start) - // or from stream end. Anthropic's block boundary is therefore - // absorbed inside the adapter rather than surfaced upstream. } else if (eventType === 'message_delta') { - // Update usage from delta const deltaUsage = (evt as { usage?: Record }).usage; if (deltaUsage !== undefined) { if (typeof deltaUsage['output_tokens'] === 'number') { @@ -930,13 +802,6 @@ class AnthropicStreamedMessage implements StreamedMessage { this._usage.inputOther = deltaUsage['input_tokens']; } } - // The terminal `stop_reason` lives on `delta.stop_reason` of the - // last `message_delta` event for this response. Capture it here. - // - // Accept `null` explicitly: if the key is present we forward the - // value (including null) to `_captureStopReason`, which maps it to - // `{null, null}`. Only a missing key skips the capture. This avoids - // a stale prior capture persisting after an explicit null reset. const messageDeltaPayload = (evt as { delta?: Record }).delta; if (messageDeltaPayload !== undefined && 'stop_reason' in messageDeltaPayload) { this._captureStopReason( @@ -944,7 +809,6 @@ class AnthropicStreamedMessage implements StreamedMessage { ); } } - // message_stop: nothing to do } } catch (error: unknown) { throw convertAnthropicError(error); @@ -1012,7 +876,6 @@ export class AnthropicChatProvider implements ChatProvider { return effort; } } - // budget-based const budget = (thinkingConfig as { budget_tokens?: number }).budget_tokens ?? 0; if (budget <= 1024) { return 'low'; @@ -1040,7 +903,6 @@ export class AnthropicChatProvider implements ChatProvider { history: Message[], options?: GenerateOptions, ): Promise { - // Build system param const system: TextBlockParam[] | undefined = systemPrompt ? [ { @@ -1071,11 +933,8 @@ export class AnthropicChatProvider implements ChatProvider { }, ); - // Inject cache_control on last content block of last message (after merge, - // so it lands on the final tool_result block in the merged user message). injectCacheControlOnLastBlock(messages); - // Build generation kwargs (excluding betaFeatures) const kwargs: Record = {}; if (this._generationKwargs.max_tokens !== undefined) { kwargs['max_tokens'] = this._generationKwargs.max_tokens; @@ -1089,10 +948,6 @@ export class AnthropicChatProvider implements ChatProvider { if (this._generationKwargs.top_p !== undefined) { kwargs['top_p'] = this._generationKwargs.top_p; } - // Fable rejects an explicit `disabled` thinking config (HTTP 400, unlike - // Opus 4.7/4.8 which accept it), so omit the field instead. Note thinking - // cannot actually be turned off on Fable: adaptive thinking is always on, - // and an omitted `thinking` field still runs with it. const thinking = this._generationKwargs.thinking; if (thinking !== undefined && !(thinking.type === 'disabled' && isFableModel(this._model))) { kwargs['thinking'] = thinking; @@ -1105,17 +960,12 @@ export class AnthropicChatProvider implements ChatProvider { } applyResponseFormat(kwargs, options?.responseFormat); - // Build the beta feature list. On the standard Messages API these travel - // via the `anthropic-beta` header; on the beta Messages API (`betaApi`) the - // SDK reads them from the request `betas` field and sets the header itself, - // so we must not also set the header (that would duplicate it). const betas = this._generationKwargs.betaFeatures ?? []; const extraHeaders: Record = {}; if (!this._betaApi && betas.length > 0) { extraHeaders['anthropic-beta'] = betas.join(','); } - // Convert tools const anthropicTools: AnthropicToolParam[] = tools.map((t) => convertTool(t)); if (anthropicTools.length > 0) { const lastTool = anthropicTools.at(-1); @@ -1124,7 +974,6 @@ export class AnthropicChatProvider implements ChatProvider { } } - // Build the create params const createParams: Record = { model: this._model, messages, @@ -1160,9 +1009,6 @@ export class AnthropicChatProvider implements ChatProvider { options?.onRequestSent?.(); if (this._stream) { - // Use the raw Messages stream instead of the SDK MessageStream helper. - // The helper reparses accumulated input_json_delta buffers on every chunk, - // which becomes synchronous O(n^2) work for large streamed tool arguments. try { const stream = this._betaApi ? await client.beta.messages.create( @@ -1179,7 +1025,6 @@ export class AnthropicChatProvider implements ChatProvider { } } - // Non-streaming fallback try { const response = this._betaApi ? await client.beta.messages.create( @@ -1241,15 +1086,6 @@ export class AnthropicChatProvider implements ChatProvider { return defaultHeaders; } - // We use the Anthropic SDK purely as a transport to arbitrary - // anthropic-compatible endpoints (`baseUrl` may point anywhere). Left to its - // defaults the SDK auto-discovers credentials from the shell environment - // (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, ANTHROPIC_CUSTOM_HEADERS), which - // would leak an out-of-band bearer/headers to a third-party endpoint even when - // an explicit apiKey is set. So we hard-disable every auto-discovery channel. - // These `null`s — and the nulled headers in _buildDefaultHeaders — are NOT - // redundant: removing them reintroduces credential leakage. Regression cover: - // test/e2e/anthropic-adapter.test.ts. private _buildClient(apiKey: string): Anthropic { return new Anthropic({ apiKey, @@ -1260,8 +1096,6 @@ export class AnthropicChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): AnthropicChatProvider { - // Resolve once: an explicit `adaptiveThinking` option overrides the - // model-name version inference, so custom-named endpoints can opt in/out. const adaptive = this._adaptiveThinking ?? supportsAdaptiveThinking(this._model); if (effort === 'off') { diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/capability-registry.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/capability-registry.ts index 5f0343bd3e..120be4f62a 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/capability-registry.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/capability-registry.ts @@ -28,14 +28,9 @@ const OPENAI_VISION_TOOL_PREFIXES = [ 'gpt-4.5', ] as const; -// Claude prefixes are grouped by capability set, not by version family: -// a new model joins the group whose capability it matches (e.g. Fable sits -// with Opus/Sonnet/Haiku 4), rather than getting a per-version group. -// Vision + tool use, no thinking (-> ANTHROPIC_VISION_TOOL_CAPABILITY). const CLAUDE_VISION_TOOL_PREFIXES = ['claude-3-', 'claude-3.5-', 'claude-3.7-'] as const; -// Vision + tool use + thinking (-> ANTHROPIC_THINKING_VISION_TOOL_CAPABILITY). const CLAUDE_THINKING_VISION_TOOL_PREFIXES = [ 'claude-opus-4', 'claude-sonnet-4', diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/chat-completions-stream.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/chat-completions-stream.ts index 4ca210102d..74e92d3dfe 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/chat-completions-stream.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/chat-completions-stream.ts @@ -17,15 +17,6 @@ export interface BufferedChatCompletionToolCall { emitted: boolean; } -/** - * Convert an OpenAI Chat Completions-style streamed tool-call delta into the - * normalized kosong stream part protocol. - * - * OpenAI-compatible providers may emit argument chunks before the function name - * for a stream index. Buffer those early argument chunks until the first named - * header arrives, then emit subsequent chunks as indexed `tool_call_part`s so - * the shared generate loop can route interleaved parallel calls. - */ export function convertChatCompletionStreamToolCall( toolCall: ChatCompletionStreamToolCallDelta, bufferedByIndex: Map, diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts index 4910e5df29..a7a88c5455 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts @@ -22,16 +22,6 @@ import { mergeConsecutiveUserMessages } from './merge-user-messages'; import { requireProviderApiKey, resolveAuthBackedClient } from './request-auth'; -/** - * Normalize a Google GenAI (Gemini) `finishReason` value to the unified - * {@link FinishReason} enum. - * - * Source: `candidates[0].finishReason` (works for both stream and - * non-stream — the SDK normalizes them). Gemini does not emit a - * `tool_calls`-style reason; tool calls come via `parts[].functionCall` - * and `finishReason` stays `'completed'` even when the model produces - * function calls. - */ function normalizeGoogleGenAIFinishReason(raw: unknown): { finishReason: FinishReason | null; rawFinishReason: string | null; @@ -39,10 +29,6 @@ function normalizeGoogleGenAIFinishReason(raw: unknown): { if (raw === null || raw === undefined) { return { finishReason: null, rawFinishReason: null }; } - // The SDK normally hands us a plain string but older builds wrap it in - // an enum-like object. Accept both shapes and uppercase to match the - // documented constants. Anything else collapses to "no signal" so we - // never emit a junk `[object Object]` raw value. let rawString: string; if (typeof raw === 'string') { rawString = raw.toUpperCase(); @@ -154,22 +140,10 @@ interface GooglePart { function toolCallIdToName(toolCallId: string, toolNameById: Map): string { const name = toolNameById.get(toolCallId); if (name !== undefined) return name; - // Fallback: ids produced by this provider follow the format - // "{tool_name}_{id_suffix}" where `tool_name` may itself contain - // underscores (e.g. `fetch_image`) and `id_suffix` is a single trailing - // token without underscores (e.g. a random hex / UUID fragment). We strip - // the last "_" segment by matching it explicitly — splitting on - // the first underscore would truncate multi-word tool names like - // `fetch_image_` to just `fetch`. const match = /^(.+)_[^_]+$/.exec(toolCallId); return match?.[1] ?? toolCallId; } -/** - * Convert a data URL or HTTP URL to a Google GenAI inline/file data part. - * - data: URLs are parsed into { inlineData: { mimeType, data } } - * - http(s): URLs use { fileData: { fileUri, mimeType } } - */ function convertMediaUrl( url: string, fallbackMimeType: string, @@ -191,7 +165,6 @@ function convertMediaUrl( : fallbackMimeType; return { inlineData: { mimeType, data } }; } - // For HTTP(S) URLs, try to guess mime type from extension let mimeType = fallbackMimeType; try { const pathname = new URL(url).pathname.toLowerCase(); @@ -203,7 +176,6 @@ function convertMediaUrl( else if (pathname.endsWith('.wav')) mimeType = 'audio/wav'; else if (pathname.endsWith('.ogg')) mimeType = 'audio/ogg'; } catch { - // URL parsing failed, use fallback } return { fileData: { fileUri: url, mimeType } }; } @@ -215,7 +187,6 @@ function createAbortError(): DOMException { async function abortPromise(signal: AbortSignal | undefined): Promise { if (signal === undefined) { return new Promise(() => { - // Intentionally never settles when no signal is provided. }); } if (signal.aborted) { @@ -239,18 +210,15 @@ function messageToGoogleGenAI(message: Message): GoogleContent { ); } - // GoogleGenAI uses "model" instead of "assistant" const role = message.role === 'assistant' ? 'model' : message.role; const parts: GooglePart[] = []; - // Handle content parts for (const part of message.content) { switch (part.type) { case 'text': parts.push({ text: part.text }); break; case 'think': - // Skip think parts (synthetic) break; case 'image_url': parts.push(convertMediaUrl(part.imageUrl.url, 'image/jpeg')); @@ -264,7 +232,6 @@ function messageToGoogleGenAI(message: Message): GoogleContent { } } - // Handle tool calls for (const toolCall of message.toolCalls) { let args: Record = {}; if (toolCall.arguments) { @@ -298,15 +265,6 @@ function messageToGoogleGenAI(message: Message): GoogleContent { return { role, parts }; } -/** - * Convert a tool message into a list of Google GenAI parts. - * - * Returns a `functionResponse` part carrying the text output, followed by - * independent media parts (`inlineData` / `fileData`) for any image/audio/video - * content in the tool result. This preserves multimodal tool outputs so the - * next Gemini/Vertex turn can see them — returning only the text would silently - * drop media and break tool chains that rely on images or audio. - */ function toolMessageToFunctionResponseParts( message: Message, toolNameById: Map, @@ -318,7 +276,6 @@ function toolMessageToFunctionResponseParts( throw new ChatProviderError('Tool response is missing `toolCallId`.'); } - // Separate text output from media parts let textOutput = ''; const mediaParts: GooglePart[] = []; for (const part of message.content) { @@ -336,7 +293,6 @@ function toolMessageToFunctionResponseParts( mediaParts.push(convertMediaUrl(part.videoUrl.url, 'video/mp4')); break; case 'think': - // Skip — handled separately via reasoning channel. break; } } @@ -389,7 +345,6 @@ export function messagesToGoogleGenAIContents(messages: Message[]): GoogleConten expectedToolCallIds.push(toolCall.id); } - // Collect consecutive tool messages let j = i + 1; const toolMessages: Message[] = []; while (j < messages.length) { @@ -400,10 +355,6 @@ export function messagesToGoogleGenAIContents(messages: Message[]): GoogleConten } if (toolMessages.length > 0) { - // Sort tool results to match the order of tool calls in the assistant - // message, and reject incomplete / duplicated / unexpected results. - // Gemini/Vertex expects the next user turn to contain a matching set of - // function responses for the preceding function calls. const toolMsgById = new Map(); const seenToolCallIds = new Set(); for (const toolMsg of toolMessages) { @@ -432,9 +383,6 @@ export function messagesToGoogleGenAIContents(messages: Message[]): GoogleConten ); } - // Pack all tool results into a single user Content. - // Each tool result may expand to multiple parts (functionResponse + - // media parts for image/audio/video outputs). const parts: GooglePart[] = []; for (const toolMsg of sortedToolMessages) { parts.push(...toolMessageToFunctionResponseParts(toolMsg, toolNameById)); @@ -449,7 +397,6 @@ export function messagesToGoogleGenAIContents(messages: Message[]): GoogleConten } if (message.role === 'tool') { - // Tool message without preceding assistant message const parts: GooglePart[] = toolMessageToFunctionResponseParts(message, toolNameById); contents.push({ role: 'user', parts }); i += 1; @@ -524,16 +471,12 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage { return; } const normalized = normalizeGoogleGenAIFinishReason(raw); - // Only overwrite when we got a definitive signal — early stream - // chunks may contain `FINISH_REASON_UNSPECIFIED` while the model is - // still generating, and we treat those as "not yet known". if (normalized.finishReason !== null || normalized.rawFinishReason !== null) { this._finishReason = normalized.finishReason; this._rawFinishReason = normalized.rawFinishReason; } } - /** Yield parts from a single (non-streamed) GenerateContentResponse. */ private _extractChunkParts(response: Record): StreamedMessagePart[] { const parts: StreamedMessagePart[] = []; @@ -573,7 +516,6 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage { return parts; } - /** Extract usage metadata from a response chunk. */ private _extractUsage(response: Record): void { const usageMetadata = response['usageMetadata'] as Record | undefined; if (usageMetadata) { @@ -594,7 +536,6 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage { } } - /** Extract response ID from a response chunk. */ private _extractId(response: Record): void { if (response['responseId'] !== undefined) { this._id = response['responseId'] as string; @@ -602,9 +543,6 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage { } private _throwIfAborted(signal: AbortSignal | undefined): void { - // Helper kept small so TypeScript's control-flow narrowing does not - // collapse `signal.aborted` to `false | undefined` at call sites that - // check the signal repeatedly between async steps. if (signal !== undefined && signal.aborted) { throw createAbortError(); } @@ -630,9 +568,6 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage { ): AsyncGenerator { try { for await (const chunk of response) { - // Check abort at each chunk boundary so users who pass an - // AbortSignal see cancellation honored promptly even though the - // Google GenAI SDK does not forward it to the underlying fetch. this._throwIfAborted(signal); this._extractUsage(chunk); this._extractId(chunk); @@ -643,8 +578,6 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage { } } } catch (error: unknown) { - // Preserve AbortError identity so the retry/generate loop can - // distinguish it from transient provider errors. if (error instanceof DOMException && error.name === 'AbortError') { throw error; } @@ -655,25 +588,18 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage { const NETWORK_RE = /network|connection|connect|disconnect|fetch failed/i; const TIMEOUT_RE = /timed?\s*out|timeout|deadline/i; -/** - * Convert a Google GenAI SDK error (or raw Error) to a kosong `ChatProviderError`. - */ export function convertGoogleGenAIError(error: unknown): ChatProviderError { - // Google SDK's exported ApiError carries an HTTP status code if (error instanceof GoogleApiError) { return normalizeAPIStatusError(error.status, error.message); } if (error instanceof Error) { const msg = error.message; - // Timeout takes priority over network (a timeout is also a connection issue) if (TIMEOUT_RE.test(msg)) { return new APITimeoutError(msg); } - // Network / fetch errors (e.g. TypeError: fetch failed) if (NETWORK_RE.test(msg) || (error instanceof TypeError && msg.includes('fetch'))) { return new APIConnectionError(msg); } - // Try to extract status code from unknown error shapes const statusCode = (error as { code?: number }).code; if (typeof statusCode === 'number') { return normalizeAPIStatusError(statusCode, msg); @@ -786,8 +712,6 @@ export class GoogleGenAIChatProvider implements ChatProvider { history: Message[], options?: GenerateOptions, ): Promise { - // Short-circuit if the caller has already aborted — the Google GenAI - // SDK will not honor the signal natively, so we must check manually. if (options?.signal?.aborted === true) { throw createAbortError(); } @@ -810,10 +734,6 @@ export class GoogleGenAIChatProvider implements ChatProvider { const params = { model: this._model, contents, config }; - // The Google GenAI SDK does not accept an AbortSignal, so we must race - // the initial SDK request against the caller's abort signal ourselves. - // Once we have a response/stream object, the wrapper below continues to - // check the signal at each chunk boundary. options?.onRequestSent?.(); if (this._stream) { const stream = await Promise.race([ @@ -849,12 +769,6 @@ export class GoogleGenAIChatProvider implements ChatProvider { { cachedClient: this._client, clientFactory: this._clientFactory }, auth, (a) => { - // Vertex AI auth flows through google-auth-library service credentials, - // not a request-scoped apiKey, and the @google/genai SDK has no - // perRequest header channel — so neither `auth.apiKey` nor - // `auth.headers` is propagated in vertexai mode. Callers that need - // request-scoped credentials should instead point their service - // account at the right principal. if (this._vertexai) return this._buildClient(this._apiKey); return this._buildClient(requireProviderApiKey('GoogleGenAIChatProvider', a, this._apiKey)); }, diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi-files.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi-files.ts index 282b6b2492..4c29f1e717 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi-files.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi-files.ts @@ -27,16 +27,6 @@ export interface KimiFilesOptions { clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } -/** - * Kimi-specific file upload client. - * - * Wraps the underlying OpenAI-compatible `files.create` API to upload videos - * to Moonshot's file service and return them as {@link VideoURLPart} values - * suitable for use in chat messages. - * - * A `KimiFiles` instance is typically obtained from - * {@link KimiChatProvider.files}. - */ export class KimiFiles { private readonly _apiKey: string | undefined; private readonly _baseUrl: string; @@ -59,19 +49,6 @@ export class KimiFiles { }); } - /** - * Upload a video file to Kimi/Moonshot for use in chat messages. - * - * Accepts either a local filesystem path or an in-memory - * {@link VideoUploadInput}. Returns a {@link VideoURLPart} referencing the - * uploaded file by its Moonshot file id. - * - * @param input - Local path string or `{ data, mimeType }` object. - * @returns A `VideoURLPart` whose `url` references the uploaded file - * by its Moonshot file id (e.g. `ms://`). - * @throws {ChatProviderError} if the input is not a video or the upload - * fails. - */ async uploadVideo( input: string | VideoUploadInput, options?: KimiUploadOptions, @@ -79,27 +56,16 @@ export class KimiFiles { let file: unknown; if (typeof input === 'string') { - // Validate the path eagerly so callers get a clear synchronous-ish - // failure rather than a generic stream error from the upload pipeline. if (!fs.existsSync(input)) { throw new ChatProviderError(`Video file not found: ${input}`); } const filename = path.basename(input); - // Infer mime type from the file extension and reject anything that is - // not a recognised video type. Without this check, callers passing a - // non-video file (e.g. `note.txt`) would still hit the upload API and - // fail with a confusing server error; surfacing the issue here keeps - // the API contract honest and matches the `VideoUploadInput` branch. const mimeType = guessMimeTypeFromExt(filename); if (mimeType === undefined || !mimeType.startsWith('video/')) { throw new ChatProviderError( `KimiFiles.uploadVideo: file extension does not indicate a video type: ${filename}`, ); } - // Read the file into memory and wrap it in a File/Blob. We avoid - // `fs.createReadStream` here because a still-open stream would race - // with callers that delete the source file after `uploadVideo` - // resolves (also common in tests with tmp directories). const data = await fs.promises.readFile(input); const blob = new Blob([new Uint8Array(data)], { type: mimeType }); file = new File([blob], filename, { type: mimeType }); @@ -108,10 +74,6 @@ export class KimiFiles { throw new ChatProviderError(`Expected a video mime type, got ${input.mimeType}`); } const filename = input.filename ?? guessFilename(input.mimeType); - // The OpenAI SDK's `Uploadable` accepts a File-like object. We build - // one via the standard Web `File` constructor (available in Node 20+). - // `Blob` and `File` are available as globals in Node 20+. The cast via - // `Uint8Array` satisfies `BlobPart` in both Node and DOM lib contexts. const bytes = input.data instanceof Uint8Array ? input.data : new Uint8Array(input.data); const blob = new Blob([bytes], { type: input.mimeType }); file = new File([blob], filename, { type: input.mimeType }); @@ -156,10 +118,6 @@ export class KimiFiles { } } -/** - * Guess a filename for an upload from a video MIME type. - * Falls back to `upload.bin` for unknown types. - */ function guessFilename(mimeType: string): string { const ext = MIME_TO_EXT[mimeType.toLowerCase()] ?? 'bin'; return `upload.${ext}`; @@ -180,10 +138,6 @@ const EXT_TO_MIME: Record = Object.fromEntries( Object.entries(MIME_TO_EXT).map(([mime, ext]) => [ext, mime]), ); -/** - * Guess a MIME type from a filename extension. Only recognises the video - * types listed in {@link MIME_TO_EXT}; returns `undefined` otherwise. - */ function guessMimeTypeFromExt(filename: string): string | undefined { const dot = filename.lastIndexOf('.'); if (dot < 0) return undefined; diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi-schema.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi-schema.ts index 93b428c642..0f78fdaa86 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi-schema.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi-schema.ts @@ -11,9 +11,6 @@ export function derefJsonSchema(schema: Record): Record(); const result = resolveNode(schema, schema, visited) as Record; - // Only delete definition buckets if no refs into them remain in the result. - // Cyclic refs are intentionally preserved by resolveNode() and still need - // their definition buckets; dropping them would leave dangling pointers. if (!hasUnresolvedDefinitionRef(result, '$defs')) { delete result['$defs']; } @@ -44,9 +41,6 @@ const TYPE_COMPLETION_SKIP_KEYS = new Set([ 'then', ]); -// Child-schema positions that this Kimi normalizer knows how to walk. This is -// also the source of truth for child-schema keywords that imply the parent -// schema's type. It is not a list of keywords that Moonshot accepts on the wire. const CHILD_SCHEMA_SLOTS = [ { key: '$defs', kind: 'map' }, { key: 'definitions', kind: 'map' }, @@ -107,18 +101,6 @@ const NUMERIC_STRUCTURE_KEYS = new Set([ 'multipleOf', ]); -/** - * Return a deep-cloned JSON Schema with missing `type` fields filled in for - * Kimi tool compatibility. - * - * Moonshot's tool validator rejects some valid JSON Schema shapes when nested - * property schemas omit `type` (for example enum-only MCP properties). This is - * a provider-compatibility normalizer, not a complete JSON Schema compiler: - * it resolves local refs, preserves combinator nodes, infers obvious - * scalar/object/array types, and falls back to `string` only for nested - * typeless property schemas. The root schema object is treated as a container - * and is not itself normalized. - */ export function normalizeKimiToolSchema(schema: Record): Record { return ensureKimiPropertyTypes(derefJsonSchema(schema)); } @@ -143,8 +125,6 @@ function hasUnresolvedDefinitionRef(node: unknown, bucketKey: string): boolean { return true; } for (const [key, value] of Object.entries(obj)) { - // Skip the definition bucket itself when walking the result — we only - // care about `$ref` pointers living elsewhere in the schema. if (key === bucketKey) continue; if (hasUnresolvedDefinitionRef(value, bucketKey)) return true; } @@ -161,12 +141,10 @@ function resolveNode(node: unknown, root: Record, visited: Set< if (typeof node === 'object' && node !== null) { const obj = node as Record; - // Handle $ref if (typeof obj['$ref'] === 'string') { const ref = obj['$ref']; if (isLocalJsonPointerRef(ref)) { if (visited.has(ref)) { - // Circular reference — return the $ref as-is to avoid infinite recursion return obj; } const resolvedRef = resolveLocalJsonPointer(root, ref); @@ -174,11 +152,6 @@ function resolveNode(node: unknown, root: Record, visited: Set< visited.add(ref); const resolved = resolveNode(resolvedRef.value, root, visited); visited.delete(ref); - // Preserve sibling keywords (JSON Schema 2020-12 semantics): - // a node may contain `$ref` alongside other fields like - // `description`, `default`, or local constraints. Python's deref - // implementation merges these with the resolved definition; - // sibling keys on the local node take precedence. if (typeof resolved === 'object' && resolved !== null && !Array.isArray(resolved)) { const merged: Record = { ...(resolved as Record) }; for (const [key, value] of Object.entries(obj)) { @@ -190,7 +163,6 @@ function resolveNode(node: unknown, root: Record, visited: Set< return resolved; } } - // Unknown $ref — return as-is return obj; } @@ -310,13 +282,6 @@ function normalizeProperty(node: unknown): void { node['type'] = inferTypeFromStructure(node); } } else if (!hasAnyKey(node, TYPE_COMPLETION_SKIP_KEYS) && typeof node['type'] === 'string') { - // Some MCP servers emit schemas where a $ref merge or a generator bug - // leaves an explicit type that contradicts the enum/const values (e.g. - // type: 'object' alongside string enum values). Moonshot rejects these - // as invalid, so repair the type when it disagrees with the values. - // - // Known trigger: Xcode MCP (xcrun mcpbridge) starting with - // Version 26.5 (17F42) generates this bug for String-backed Swift enums. const enumValues = node['enum']; if (Array.isArray(enumValues) && enumValues.length > 0) { try { @@ -326,8 +291,6 @@ function normalizeProperty(node: unknown): void { removeIrrelevantStructureKeys(node, inferred); } } catch { - // Mixed or uninferable enum types — leave the explicit type as-is - // and let the provider validator surface the error. } } else if (hasOwn(node, 'const')) { try { @@ -337,7 +300,6 @@ function normalizeProperty(node: unknown): void { removeIrrelevantStructureKeys(node, inferred); } } catch { - // Same as above. } } } diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts index 66ce209697..ad6097b702 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts @@ -52,14 +52,6 @@ export interface KimiOptions { } export interface GenerationKwargs { - /** - * Legacy completion-budget alias. The Moonshot Kimi API still accepts - * `max_tokens`, but for reasoning models it shares the budget with - * `reasoning_content` and a small value can cause a 200 response with no - * `content`. Prefer `max_completion_tokens`. When both are set - * `max_completion_tokens` wins; this provider normalizes by sending only - * `max_completion_tokens` on the wire. - */ max_tokens?: number | undefined; max_completion_tokens?: number | undefined; temperature?: number | undefined; @@ -124,14 +116,12 @@ function convertMessage(message: Message): OpenAIMessage { } } - // Build the OpenAI message. const result: OpenAIMessage = { role: message.role }; const hasToolCalls = message.toolCalls.length > 0; const shouldOmitContent = message.role === 'assistant' && hasToolCalls && isEffectivelyEmptyContent(nonThinkParts); if (!shouldOmitContent) { - // content: serialize to string if single text, array otherwise const firstPart = nonThinkParts[0]; if (nonThinkParts.length === 1 && firstPart?.type === 'text') { result.content = firstPart.text; @@ -176,7 +166,6 @@ function convertMessage(message: Message): OpenAIMessage { } function convertTool(tool: Tool): OpenAIToolParam { if (tool.name.startsWith('$')) { - // Kimi builtin functions start with `$` return { type: 'builtin_function', function: { name: tool.name }, @@ -207,14 +196,9 @@ function responseFormatToOpenAI(format: ResponseFormat): Record }; } -/** - * Extract usage from a streaming chunk. Moonshot may place usage in - * `choices[0].usage` in addition to the top-level `usage` field. - */ export function extractUsageFromChunk( chunk: Record, ): Record | null { - // Top-level usage if ( chunk['usage'] !== null && chunk['usage'] !== undefined && @@ -222,7 +206,6 @@ export function extractUsageFromChunk( ) { return chunk['usage'] as Record; } - // choices[0].usage (Moonshot proprietary) const choices = chunk['choices']; if (!Array.isArray(choices) || choices.length === 0) { return null; @@ -296,7 +279,6 @@ class KimiStreamedMessage implements StreamedMessage { const message = response.choices[0]?.message; if (!message) return; - // reasoning_content (Moonshot proprietary) const rc = (message as unknown as Record)['reasoning_content']; if (typeof rc === 'string' && rc) { yield { type: 'think', think: rc } satisfies StreamedMessagePart; @@ -330,7 +312,6 @@ class KimiStreamedMessage implements StreamedMessage { this._id = chunk.id; } - // Extract usage from chunk (supports top-level and choices[0].usage) const rawChunk = chunk as unknown as Record; const rawUsage = extractUsageFromChunk(rawChunk); if (rawUsage) { @@ -344,29 +325,21 @@ class KimiStreamedMessage implements StreamedMessage { const choice = chunk.choices[0]; if (!choice) continue; - // Capture finish_reason whenever the chunk carries one. The Chat - // Completions API only sets it on the final chunk for a given - // choice, but defensively re-capturing on every non-null value - // keeps the latest signal available even if upstream re-emits. if (choice.finish_reason !== null && choice.finish_reason !== undefined) { this._captureFinishReason(choice.finish_reason); } const delta = choice.delta; - // reasoning_content (Moonshot proprietary) const rc = (delta as unknown as Record)['reasoning_content']; if (typeof rc === 'string' && rc) { yield { type: 'think', think: rc } satisfies StreamedMessagePart; } - // text content if (delta.content) { yield { type: 'text', text: delta.content } satisfies StreamedMessagePart; } - // tool calls — preserve `index` on every yielded part so the generate - // loop can route interleaved argument deltas from parallel tool calls. for (const toolCall of delta.tool_calls ?? []) { for (const part of convertChatCompletionStreamToolCall(toolCall, bufferedToolCalls)) { yield part; @@ -416,13 +389,6 @@ export class KimiChatProvider implements ChatProvider { return this._model; } - /** - * File upload client for Kimi/Moonshot. - * - * Use this to upload videos (and other media in the future) to the file - * service and receive a content part that can be embedded in chat - * messages. - */ get files(): KimiFiles { this._files ??= new KimiFiles({ apiKey: this._apiKey, @@ -475,7 +441,6 @@ export class KimiChatProvider implements ChatProvider { ...this._generationKwargs, }; - // Remove undefined values from kwargs for (const key of Object.keys(kwargs)) { if (kwargs[key] === undefined) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete @@ -483,11 +448,6 @@ export class KimiChatProvider implements ChatProvider { } } - // Normalize the legacy `max_tokens` alias to Kimi's preferred - // `max_completion_tokens`. When both are set, `max_completion_tokens` - // wins (confirmed against the live Moonshot API). When neither is - // set, send no cap — the upstream loop is responsible for clamping - // against the current input size and model context window. if ( kwargs['max_completion_tokens'] === undefined && kwargs['max_tokens'] !== undefined @@ -607,17 +567,7 @@ export class KimiChatProvider implements ChatProvider { this, ); clone._generationKwargs = { ...this._generationKwargs }; - // Do not share the memoized KimiFiles instance with the clone; let it be - // lazily re-created on first access. clone._files = undefined; - // `_client` is intentionally shared with the original instance. Per-step - // budget clamping (see KosongLLM.chatOnce) relies on this clone being - // cheap. If a future change introduces a retry path that REPLACES - // `clone._client` with a freshly built client (and closes the old one), - // the original instance's `_client` would become a dangling reference to - // a closed socket. Keep `_client` shared and never mutate it after - // construction; instead build a new KimiChatProvider when a real new - // client is required. return clone; } } diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts index ce42dfe759..6538845b94 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts @@ -25,16 +25,11 @@ export interface OpenAIContentPart { video_url?: { url: string; id?: string | null } | undefined; } -/** - * Convert a kosong `ContentPart` to OpenAI-compatible content part. - * Returns `null` for think parts (handled separately as reasoning_content). - */ export function convertContentPart(part: ContentPart): OpenAIContentPart | null { switch (part.type) { case 'text': return { type: 'text', text: part.text }; case 'think': - // Think parts are handled separately as reasoning_content — skip them here. return null; case 'image_url': return { @@ -73,9 +68,6 @@ export interface OpenAIToolParam { }; } -/** - * Convert a kosong `Tool` to OpenAI tool format. - */ export function toolToOpenAI(tool: Tool): OpenAIToolParam { return { type: 'function', @@ -87,21 +79,16 @@ export function toolToOpenAI(tool: Tool): OpenAIToolParam { }; } -/** - * Convert an OpenAI SDK error (or raw Error) to a kosong `ChatProviderError`. - */ export function convertOpenAIError(error: unknown): ChatProviderError { if (error instanceof ChatProviderError) { return error; } - // v6: APIConnectionTimeoutError extends APIConnectionError, check timeout first if (error instanceof OpenAITimeoutError) { return new APITimeoutError(error.message); } if (error instanceof OpenAIConnectionError) { return new APIConnectionError(error.message); } - // APIError with a status code => status error if (error instanceof OpenAIAPIError && typeof error.status === 'number') { const reqId = error.requestID ?? null; return normalizeAPIStatusError( @@ -111,9 +98,6 @@ export function convertOpenAIError(error: unknown): ChatProviderError { parseRetryAfterMs(error.headers), ); } - // Base APIError with no status and no body => transport-layer failure. - // When the error has a body (e.g. SSE error events from the server), - // skip the heuristic to avoid misclassifying server-side errors. if ( error instanceof OpenAIAPIError && error.constructor === OpenAIAPIError && @@ -124,36 +108,22 @@ export function convertOpenAIError(error: unknown): ChatProviderError { if (error instanceof OpenAIError) { return new ChatProviderError(`Error: ${error.message}`); } - // Raw, non-SDK errors (e.g. undici's `TypeError: terminated` raised when a - // streaming response body is dropped mid-flight) never get wrapped by the - // OpenAI SDK during stream iteration. Route them through the same - // transport-layer heuristic so genuine connection failures become - // retryable instead of fatal generic errors. if (error instanceof Error) { return classifyBaseApiError(error.message); } return new ChatProviderError(`Error: ${String(error)}`); } -/** Shape of a function-type tool call (subset used by the guard). */ export interface FunctionToolCallShape { type: 'function'; id: string; function: { name: string; arguments: string | null }; } -/** - * Type guard: narrow a tool call union to the function-type variant. - * Works with OpenAI SDK's `ChatCompletionMessageToolCall` as well as - * any object carrying `{ type: string }`. - */ export function isFunctionToolCall( tc: T, ): tc is T & FunctionToolCallShape { return tc.type === 'function'; } -/** - * Map kosong `ThinkingEffort` to OpenAI `reasoning_effort` string. - */ export function thinkingEffortToReasoningEffort(effort: ThinkingEffort): string | undefined { switch (effort) { case 'off': @@ -172,9 +142,6 @@ export function thinkingEffortToReasoningEffort(effort: ThinkingEffort): string } } -/** - * Map OpenAI `reasoning_effort` string back to kosong `ThinkingEffort`. - */ export function reasoningEffortToThinkingEffort( reasoning: string | undefined, ): ThinkingEffort | null { @@ -198,9 +165,6 @@ export function reasoningEffortToThinkingEffort( return 'off'; } } -/** - * Extract `TokenUsage` from an OpenAI-compatible usage object. - */ export function extractUsage(usage: unknown): TokenUsage | null { if (usage === null || usage === undefined || typeof usage !== 'object') { return null; @@ -210,7 +174,6 @@ export function extractUsage(usage: unknown): TokenUsage | null { const completionTokens = typeof u['completion_tokens'] === 'number' ? u['completion_tokens'] : 0; let cached = 0; - // Moonshot proprietary: top-level cached_tokens if (typeof u['cached_tokens'] === 'number') { cached = u['cached_tokens']; } else if ( @@ -230,23 +193,6 @@ export function extractUsage(usage: unknown): TokenUsage | null { inputCacheCreation: 0, }; } -/** - * Normalize an OpenAI Chat Completions–style `finish_reason` string to the - * unified {@link FinishReason} enum. - * - * Used by both the Kimi and OpenAI Legacy adapters because they share the - * Chat Completions wire format. Returns `{ finishReason: null, - * rawFinishReason: null }` when the upstream value is missing or `null` so - * callers can treat "no signal" uniformly. - * - * Mapping: - * - `'stop'` → `'completed'` - * - `'tool_calls'` → `'tool_calls'` - * - `'function_call'` → `'tool_calls'` (legacy alias) - * - `'length'` → `'truncated'` - * - `'content_filter'` → `'filtered'` - * - any other non-null string → `'other'` - */ export function normalizeOpenAIFinishReason(raw: string | null | undefined): { finishReason: FinishReason | null; rawFinishReason: string | null; @@ -268,30 +214,15 @@ export function normalizeOpenAIFinishReason(raw: string | null | undefined): { return { finishReason: 'other', rawFinishReason: raw }; } } -/** - * Strategy for converting tool-role message content. - * - * - `'extract_text'`: flatten all content parts into a single text string - * (some providers require tool results as plain text). - * - `null`: convert content parts to the standard OpenAI content-part array. - */ export type ToolMessageConversion = 'extract_text' | null; -/** - * Shared wording for tool-result media that cannot live inside the tool - * message itself and is reattached as a follow-up user message instead. - */ export const TOOL_RESULT_MEDIA_PROMPT = 'Attached media from tool result:'; export const TOOL_RESULT_MEDIA_PLACEHOLDER = '(see attached media)'; -/** A content part that is neither plain text nor reasoning. */ export function isMediaPart(part: ContentPart): boolean { return part.type !== 'text' && part.type !== 'think'; } -/** - * Convert tool-role message content according to the chosen strategy. - */ export function convertToolMessageContent( message: Message, conversion: ToolMessageConversion, diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts index 4b7ada386d..f11dae0f14 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts @@ -44,17 +44,9 @@ import { type ToolCallIdPolicy, } from './tool-call-id'; -// Inbound: scan in priority order; first string value wins. Outbound: the first -// entry doubles as the default field we serialize ThinkPart back into. Both -// arms can be overridden by an explicit `reasoningKey` on the provider config. const KNOWN_REASONING_KEYS = ['reasoning_content', 'reasoning_details', 'reasoning'] as const; const DEFAULT_OUTBOUND_REASONING_KEY = KNOWN_REASONING_KEYS[0]; -/** - * Hard upper bound on `max_tokens` for OpenAI-compatible chat-completions - * endpoints. Many third-party providers reject `max_tokens` above this limit - * (the documented range is `[1, 131072]`). - */ const CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING = 128 * 1024; const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { normalize: (id) => sanitizeToolCallId(id, 64), @@ -173,17 +165,9 @@ function convertMessage( } } - // Build the OpenAI message. const result: OpenAIMessage = { role: message.role }; if (message.role === 'tool') { - // OpenAI Chat Completions `tool` messages only accept text content. - // Any non-text content parts (image_url, audio_url, video_url) would be - // rejected by the API with a 400. Detect multimodal tool output and - // force the `extract_text` path in that case, regardless of the caller's - // `toolMessageConversion` setting. For pure-text tool results we honor - // the configured strategy (or fall through to the default content-part - // array when it is unset). const hasNonTextPart = message.content.some((p) => p.type !== 'text' && p.type !== 'think'); const effectiveConversion: ToolMessageConversion = hasNonTextPart ? 'extract_text' @@ -192,8 +176,6 @@ function convertMessage( if (effectiveConversion !== null) { result.content = convertToolMessageContentForChat(message, effectiveConversion); } else { - // Pure-text tool result with no conversion configured: serialize via the - // generic content-part path so single-text messages become a plain string. const firstPart = nonThinkParts[0]; if (nonThinkParts.length === 1 && firstPart?.type === 'text') { result.content = firstPart.text; @@ -204,7 +186,6 @@ function convertMessage( } } } else { - // content: serialize to string if single text, array otherwise const firstPart = nonThinkParts[0]; if (nonThinkParts.length === 1 && firstPart?.type === 'text') { result.content = firstPart.text; @@ -231,11 +212,6 @@ function convertMessage( result.tool_call_id = message.toolCallId; } - // Round-trip thinking content back to the server. Default to the de facto - // `reasoning_content` field so OpenAI-compatible reasoners (DeepSeek, Qwen, - // One API gateways) work without per-provider configuration. Servers that - // don't understand the field ignore it; servers that require a specific - // field can override via the explicit `reasoningKey`. if (reasoningContent) { result[reasoningKey ?? DEFAULT_OUTBOUND_REASONING_KEY] = reasoningContent; } @@ -243,9 +219,6 @@ function convertMessage( return result; } -// Chat Completions has no url-based audio/video content part (only base64 -// `input_audio`), so unlike images these cannot be reattached as user input. -// Note the omission inline in the tool message text instead. const OMITTED_AUDIO_PLACEHOLDER = '(audio omitted: not supported by this provider)'; const OMITTED_VIDEO_PLACEHOLDER = '(video omitted: not supported by this provider)'; @@ -380,8 +353,6 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { const message = response.choices[0]?.message; if (!message) return; - // Reasoning content: honor the explicit key when set, otherwise scan the - // de facto field set so hand-written configs work without it. const reasoning = extractReasoningContent(message, reasoningKey); if (reasoning) { yield { type: 'think', think: reasoning } satisfies StreamedMessagePart; @@ -427,28 +398,21 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { const choice = chunk.choices[0]; if (!choice) continue; - // Capture finish_reason whenever the chunk carries one. Chat - // Completions only sets it on the final chunk for a given choice. if (choice.finish_reason !== null && choice.finish_reason !== undefined) { this._captureFinishReason(choice.finish_reason); } const delta = choice.delta; - // Reasoning content: honor the explicit key when set, otherwise scan - // the de facto field set so hand-written configs work without it. const reasoning = extractReasoningContent(delta, reasoningKey); if (reasoning) { yield { type: 'think', think: reasoning } satisfies StreamedMessagePart; } - // text content if (delta.content) { yield { type: 'text', text: delta.content } satisfies StreamedMessagePart; } - // tool calls — preserve `index` on every yielded part so the generate - // loop can route interleaved argument deltas from parallel tool calls. for (const toolCall of delta.tool_calls ?? []) { for (const part of convertChatCompletionStreamToolCall(toolCall, bufferedToolCalls)) { yield part; @@ -483,10 +447,6 @@ export class OpenAILegacyChatProvider implements ChatProvider { this._defaultHeaders = options.defaultHeaders; this._model = options.model; this._stream = options.stream ?? true; - // Normalize blank/whitespace reasoningKey to unset. ModelAliasSchema - // accepts `z.string().optional()`, so `reasoning_key = ""` in config.toml - // would otherwise disable the default field scan and route reads/writes - // through an empty property name. const normalizedReasoningKey = options.reasoningKey?.trim(); this._reasoningKey = normalizedReasoningKey !== undefined && normalizedReasoningKey.length > 0 @@ -545,15 +505,8 @@ export class OpenAILegacyChatProvider implements ChatProvider { this._generationKwargs, ); - // Determine reasoning_effort let reasoningEffort: string | undefined = this._reasoningEffort; - // Auto-enable reasoning_effort when the history contains ThinkPart but reasoning - // was not explicitly configured. This prevents server validation errors from APIs - // (e.g. One API) that require reasoning_effort when messages contain reasoning_content. - // Skip when the caller already pinned reasoning_effort via withGenerationKwargs — - // their value would otherwise be silently overwritten below. - // See: https://github.com/MoonshotAI/kimi-code/issues/1616 if (reasoningEffort === undefined && kwargs['reasoning_effort'] === undefined) { const hasThinkPart = history.some((message) => message.content.some((part) => part.type === 'think'), @@ -563,7 +516,6 @@ export class OpenAILegacyChatProvider implements ChatProvider { } } - // Remove undefined values from kwargs for (const key of Object.keys(kwargs)) { if (kwargs[key] === undefined) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete @@ -571,7 +523,6 @@ export class OpenAILegacyChatProvider implements ChatProvider { } } - // Build the create params const createParams: Record = { model: this._model, messages, diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts index 4ce3f9db95..4e96e9bd48 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts @@ -40,15 +40,6 @@ import { type ToolCallIdPolicy, } from './tool-call-id'; -/** - * Normalize the Responses API status / incomplete_details into the unified - * {@link FinishReason} enum. - * - * Note: the Responses API has no `tool_calls`-style status. When a response - * completes with `function_call` items inline the status is still - * `'completed'`; callers detect tool calls via `message.toolCalls.length`, - * not via finishReason. - */ function normalizeResponsesFinishReason( status: string | null | undefined, incompleteReason: string | null | undefined, @@ -385,9 +376,6 @@ function responseFormatToResponsesText(format: ResponseFormat): Record function_call_output if (role === 'tool') { const callId = message.toolCallId ?? ''; let output: string | unknown[]; if (toolMessageConversion === 'extract_text') { - // Plain-string output for backends that reject structured - // function_call_output. Media parts are reattached as a user message - // by `convertHistoryMessages`; when the result carries no text at - // all, point the model at that follow-up message. const text = extractText(message); output = text.length === 0 && message.content.some(isMediaPart) @@ -529,7 +505,6 @@ function convertMessage( const result: ResponseInputItem[] = []; - // Process content parts if (message.content.length > 0) { const pendingParts: ContentPart[] = []; @@ -557,9 +532,7 @@ function convertMessage( const part = message.content[i]; if (part === undefined) break; if (part.type === 'think') { - // Flush accumulated non-reasoning parts first flushPendingParts(); - // Aggregate consecutive ThinkParts with the same `encrypted` value const encryptedValue = part.encrypted; const summaries: unknown[] = [{ type: 'summary_text', text: part.think || '' }]; i += 1; @@ -582,11 +555,9 @@ function convertMessage( } } - // Handle remaining trailing non-reasoning parts flushPendingParts(); } - // Handle tool calls for (const toolCall of message.toolCalls) { result.push({ arguments: toolCall.arguments ?? '{}', @@ -609,12 +580,6 @@ function convertTool(tool: Tool): ResponseToolParam { }; } -/** - * Convert the history, buffering tool-result media when `extract_text` - * flattens tool outputs to plain strings. The buffered media items are - * reattached as a single user message after each run of consecutive tool - * messages — mirroring the OpenAI Chat Completions provider. - */ function convertHistoryMessages( history: readonly Message[], modelName: string, @@ -863,10 +828,6 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { case 'response.created': case 'response.in_progress': { const responseObject = requireObjectField(chunk, 'response', type); - // Initial events carry the Responses API `response.id`. Record it - // here so callers that inspect `stream.id` before the stream - // completes see the actual response id rather than a later - // output-item identifier. const respId = readStringField(responseObject, 'id'); if (respId !== undefined) { this._id = respId; @@ -876,15 +837,7 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { case 'response.output_item.added': { const item = readResponseOutputItem(chunk['item'], `${type}.item`); const outputIndex = readNumberField(chunk, 'output_index'); - // NOTE: `item.id` here is an output-item identifier, not the - // Responses API `response.id`. Do NOT overwrite `this._id` — it - // would clobber the real response id (or leave it undefined for - // tool-call items that have no `item.id`). if (item.type === 'function_call') { - // The Responses API routes streaming argument deltas via - // `item_id`, which matches `item.id` on output_item.added. - // Preserve it so the generate loop can dispatch interleaved - // deltas across parallel function calls correctly. const streamIndex = responseStreamIndex(item.itemId, outputIndex); setFunctionCallArguments(streamIndex, item.arguments ?? ''); const tc: ToolCall = { @@ -903,7 +856,6 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { case 'response.output_item.done': { const item = readResponseOutputItem(chunk['item'], `${type}.item`); const outputIndex = readNumberField(chunk, 'output_index'); - // Same as output_item.added: `item.id` is not the response id. if (item.type === 'reasoning') { const thinkPart: StreamedMessagePart = { type: 'think', think: '' }; if (item.encryptedContent !== undefined) { @@ -917,8 +869,6 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { break; } case 'response.function_call_arguments.delta': { - // `item_id` uniquely identifies the function_call output item this - // delta belongs to; use it as the streaming index. const streamIndex = responseStreamIndex( readStringField(chunk, 'item_id'), readNumberField(chunk, 'output_index'), @@ -953,8 +903,6 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { case 'response.completed': case 'response.incomplete': { const responseObject = requireObjectField(chunk, 'response', type); - // Final event confirms the Responses API `response.id`. Prefer - // it over any earlier value in case the API refines it. const respId = readStringField(responseObject, 'id'); if (respId !== undefined) { this._id = respId; @@ -991,7 +939,6 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { ); } default: - // Unknown future event types carry no data we currently consume. break; } } @@ -1020,7 +967,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { this._baseUrl = options.baseUrl ?? 'https://api.openai.com/v1'; this._defaultHeaders = options.defaultHeaders; this._model = options.model; - this._stream = true; // Responses API always supports streaming + this._stream = true; this._generationKwargs = {}; this._toolMessageConversion = options.toolMessageConversion ?? null; this._httpClient = options.httpClient; @@ -1081,7 +1028,6 @@ export class OpenAIResponsesChatProvider implements ChatProvider { kwargs['include'] = ['reasoning.encrypted_content']; } - // Remove undefined values for (const key of Object.keys(kwargs)) { if (kwargs[key] === undefined) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/providers.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/providers.ts index d95e9c58e9..9ff7dfff63 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/providers.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/providers.ts @@ -43,14 +43,6 @@ export function createProvider(config: ProviderConfig): ChatProvider { } } -/** - * Look up the declared {@link ModelCapability} for a `(wire, model)` pair. - * - * This is a pure static table lookup — it does not instantiate a provider. - * Unknown / uncatalogued models (and the Kimi wire, whose capabilities come - * from the host's catalog/config rather than the model name) return - * {@link UNKNOWN_CAPABILITY} so capability checks stay non-fatal. - */ export function getModelCapability(wire: ProviderType, modelName: string): ModelCapability { switch (wire) { case 'anthropic': diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/request-auth.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/request-auth.ts index ad307cd74f..ae0bdaa004 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/request-auth.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/request-auth.ts @@ -29,28 +29,6 @@ export function mergeRequestHeaders( return Object.keys(merged).length > 0 ? merged : undefined; } -/** - * Resolve the SDK client to use for a single provider request, applying the - * standard precedence shared by every provider adapter: - * - * 1. If a `clientFactory` was supplied, delegate to it (it receives the - * per-request {@link ProviderRequestAuth}, defaulting to `{}`). - * 2. Otherwise, if no per-request auth is needed AND a constructor-time - * client was cached, reuse the cached instance. - * 3. Otherwise, call `build(auth)` to construct a fresh client for this - * request — typically using `requireProviderApiKey` plus - * `mergeRequestHeaders`. - * - * Note: when per-request `auth` is provided (e.g. an OAuth bearer token - * resolved immediately before each call), step 3 fires and a brand-new SDK - * client is constructed per request. This is intentional — it keeps short-lived - * credentials out of any long-lived shared state and avoids racing concurrent - * requests on a mutable client. The trade-off is that connection-pool / keep- - * alive state inside the SDK client isn't reused across requests on the OAuth - * path. For the current agent-CLI workload (one LLM call per turn step) this - * is fine; if a future host needs high-throughput per-request auth, the - * obvious optimization is a small LRU keyed on `(apiKey, headers digest)`. - */ export function resolveAuthBackedClient( state: { readonly cachedClient: TClient | undefined; diff --git a/packages/agent-core-v2/src/app/llmProtocol/tool.ts b/packages/agent-core-v2/src/app/llmProtocol/tool.ts index d45bfccaa8..0706ea5dfd 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/tool.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/tool.ts @@ -6,11 +6,8 @@ * tool-use, Google function declarations). */ export interface Tool { - /** Unique tool name used to match invocations. */ name: string; - /** Human-readable description shown to the model. */ description: string; - /** JSON Schema describing the tool's parameters. */ parameters: Record; deferred?: true; } diff --git a/packages/agent-core-v2/src/app/llmProtocol/usage.ts b/packages/agent-core-v2/src/app/llmProtocol/usage.ts index c6d2690214..effc64e243 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/usage.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/usage.ts @@ -5,33 +5,20 @@ * callers can aggregate costs without caring about the backend. */ export interface TokenUsage { - /** Input tokens that were neither cache-read nor cache-created. */ inputOther: number; - /** Output (completion) tokens generated by the model. */ output: number; - /** Input tokens served from the provider's prompt cache. */ inputCacheRead: number; - /** Input tokens written into the provider's prompt cache. */ inputCacheCreation: number; } -/** - * Compute total input tokens (other + cache read + cache creation). - */ export function inputTotal(usage: TokenUsage): number { return usage.inputOther + usage.inputCacheRead + usage.inputCacheCreation; } -/** - * Compute grand total tokens (input total + output). - */ export function grandTotal(usage: TokenUsage): number { return inputTotal(usage) + usage.output; } -/** - * Create a zero-valued TokenUsage. - */ export function emptyUsage(): TokenUsage { return { inputOther: 0, @@ -41,9 +28,6 @@ export function emptyUsage(): TokenUsage { }; } -/** - * Sum two TokenUsage values. - */ export function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage { return { inputOther: a.inputOther + b.inputOther, diff --git a/packages/agent-core-v2/src/app/messageLegacy/messageLegacy.ts b/packages/agent-core-v2/src/app/messageLegacy/messageLegacy.ts index ec20db95f6..e36ca0834e 100644 --- a/packages/agent-core-v2/src/app/messageLegacy/messageLegacy.ts +++ b/packages/agent-core-v2/src/app/messageLegacy/messageLegacy.ts @@ -30,7 +30,6 @@ import type { import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -/** Listing query — v1 `cursorQuery` plus an optional role filter. */ export interface MessageListQuery extends CursorQuery { readonly role?: MessageRole; } @@ -38,16 +37,7 @@ export interface MessageListQuery extends CursorQuery { export interface IMessageLegacyService { readonly _serviceBrand: undefined; - /** - * `GET /sessions/{sid}/messages` — paginated, newest-first message history. - * Throws `session.not_found` when `sid` is unknown. - */ list(sessionId: string, query: MessageListQuery): Promise>; - /** - * `GET /sessions/{sid}/messages/{mid}` — single message by id. - * Throws `session.not_found` when `sid` is unknown, `message.not_found` when - * the session is known but `mid` is missing, mismatched, or out of range. - */ get(sessionId: string, messageId: string): Promise; } diff --git a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts index 020d7db1f6..46f726dac0 100644 --- a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts +++ b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts @@ -55,7 +55,6 @@ export class MessageLegacyService implements IMessageLegacyService { async list(sessionId: string, query: MessageListQuery): Promise> { const all = await this.loadMessages(sessionId); - // v1 / SCHEMAS §1.3: newest first (`created_at desc`). const desc = [...all].reverse(); let pivotIndex = -1; @@ -67,13 +66,10 @@ export class MessageLegacyService implements IMessageLegacyService { let slice: Message[]; if (query.before_id !== undefined && pivotIndex >= 0) { - // before_id = older entries → tail of the desc array, exclusive of pivot. slice = desc.slice(pivotIndex + 1); } else if (query.after_id !== undefined && pivotIndex >= 0) { - // after_id = newer entries → head of the desc array, exclusive of pivot. slice = desc.slice(0, pivotIndex); } else { - // Unknown cursor → fall through to the full list, matching v1. slice = desc; } @@ -82,15 +78,12 @@ export class MessageLegacyService implements IMessageLegacyService { const page = slice.slice(0, pageSize); const hasMore = slice.length > pageSize; - // Role filter is applied AFTER pagination, matching v1. const filtered = query.role !== undefined ? page.filter((m) => m.role === query.role) : page; return { items: filtered, has_more: hasMore }; } async get(sessionId: string, messageId: string): Promise { - // Resolve the session first: an unknown sid maps to 40401 even when the - // message id is malformed or belongs to another session (40403). const all = await this.loadMessages(sessionId); const entry = all.find((m) => m.id === messageId); if (entry === undefined) { @@ -102,12 +95,6 @@ export class MessageLegacyService implements IMessageLegacyService { return entry; } - /** - * Full main-agent transcript projected into the v1 `Message` wire shape, - * oldest-first. Throws `session.not_found` (→ 40401) when the session is - * unknown. An unreachable cold session (workspace gone) yields an empty - * transcript rather than an error. - */ private async loadMessages(sessionId: string): Promise { const summary = await this.index.get(sessionId); if (summary === undefined) { @@ -116,15 +103,8 @@ export class MessageLegacyService implements IMessageLegacyService { const session = await this.lifecycle.resume(sessionId); if (session === undefined) return []; - // Materialize the main agent so the live context is available for the - // unflushed-tail merge below. `resume` already restored + replayed the - // wire for a cold session; a live session is already current. const agent = await ensureMainAgent(session); - // Reduce the transcript from the main agent's in-memory record journal - // (seeded by `resume` from disk and kept current by live dispatch) instead - // of re-reading `wire.jsonl`. The journal is always at least as new as the - // live context, so the tail merge below can only append (mirrors v1). const transcript = this.readTranscript(agent); const contextMessages = agent.accessor.get(IAgentContextMemoryService).get(); const merged = mergeLiveTail(transcript, contextMessages); @@ -163,7 +143,6 @@ export class MessageLegacyService implements IMessageLegacyService { return changed ? out : messages; } - /** Reduce the main agent's in-memory record journal into the full transcript. */ private readTranscript(agent: IAgentScopeHandle): ContextTranscript { const records = agent .accessor.get(IAgentWireRecordService) @@ -172,13 +151,6 @@ export class MessageLegacyService implements IMessageLegacyService { } } -/** - * Append the unflushed live tail: when the in-memory (folded) context is - * longer than the journal-derived `foldedLength`, the surplus is records that - * have landed in the live context within the same dispatch but not yet in the - * journal, and must be appended so a read on a live session does not trail - * memory. Tail entries have no source wire record, hence no record time. - */ function mergeLiveTail( transcript: ContextTranscript, contextMessages: readonly ContextMessage[], diff --git a/packages/agent-core-v2/src/app/model/configSection.ts b/packages/agent-core-v2/src/app/model/configSection.ts index 77e0eeb249..04475a34a0 100644 --- a/packages/agent-core-v2/src/app/model/configSection.ts +++ b/packages/agent-core-v2/src/app/model/configSection.ts @@ -18,7 +18,6 @@ import { import { MODELS_SECTION, ModelsSectionSchema } from './model'; -/** Read transform: preserve alias names; camelCase each alias's fields. */ export const modelsFromToml = (rawSnake: unknown): unknown => { if (!isPlainObject(rawSnake)) return rawSnake; const out: Record = {}; @@ -36,7 +35,6 @@ export const modelsFromToml = (rawSnake: unknown): unknown => { return out; }; -/** Write transform: preserve alias names; snake_case each alias's fields. */ export const modelsToToml = (value: unknown, rawSnake: unknown): unknown => { if (!isPlainObject(value)) return value; const rawSub = cloneRecord(rawSnake); diff --git a/packages/agent-core-v2/src/app/model/envOverlay.ts b/packages/agent-core-v2/src/app/model/envOverlay.ts index 65a9f341dc..f2eaf86b78 100644 --- a/packages/agent-core-v2/src/app/model/envOverlay.ts +++ b/packages/agent-core-v2/src/app/model/envOverlay.ts @@ -19,20 +19,15 @@ import { registerConfigOverlay } from '#/app/config/configOverlayContributions'; import { ErrorCodes, Error2 } from '#/errors'; import { ENV_MODEL_PROVIDER_KEY } from '#/app/provider/provider'; -/** Reserved key for the env-driven synthetic model alias. */ export const ENV_MODEL_ALIAS_KEY = '__kimi_env_model__'; -/** Default context window (256K) used when KIMI_MODEL_MAX_CONTEXT_SIZE is unset. */ const DEFAULT_MAX_CONTEXT_SIZE = 262144; -/** Default capabilities when KIMI_MODEL_CAPABILITIES is unset. */ const DEFAULT_CAPABILITIES = ['image_in', 'thinking']; -/** Default base URL per provider type when KIMI_MODEL_BASE_URL is unset. */ const DEFAULT_BASE_URL: Partial> = { kimi: 'https://api.moonshot.ai/v1', openai: 'https://api.openai.com/v1', - // anthropic: omitted -> let the Anthropic SDK pick its default }; function trimmed(value: string | undefined): string | undefined { @@ -78,8 +73,6 @@ function parseCapabilities(raw: string | undefined): string[] | undefined { return caps.length === 0 ? undefined : caps; } -// Treat a non-empty but unparseable value (e.g. a typo like `flase`) as a -// config error so it fails fast like the other KIMI_MODEL_* values. function parseBooleanVar(raw: string | undefined, varName: string): boolean | undefined { const value = trimmed(raw); if (value === undefined) return undefined; @@ -240,7 +233,4 @@ function collectModelOverrides(input: { return Object.keys(modelOverrides).length > 0 ? modelOverrides : undefined; } -// Self-register at module load so the overlay takes effect even when -// `ModelService` is never instantiated (the DI layer does not auto-instantiate -// `Eager` services). Drained by `ConfigRegistry` on construction. registerConfigOverlay(kimiModelEnvOverlay); diff --git a/packages/agent-core-v2/src/app/model/hostRequestHeaders.ts b/packages/agent-core-v2/src/app/model/hostRequestHeaders.ts index 62bf37632a..617b9942a1 100644 --- a/packages/agent-core-v2/src/app/model/hostRequestHeaders.ts +++ b/packages/agent-core-v2/src/app/model/hostRequestHeaders.ts @@ -25,7 +25,6 @@ export class HostRequestHeaders implements IHostRequestHeaders { constructor(readonly headers: Readonly> = {}) {} } -/** Seed the host-provided outbound identity headers into an App scope. */ export function hostRequestHeadersSeed(headers: Readonly>): ScopeSeed { return [[IHostRequestHeaders as ServiceIdentifier, new HostRequestHeaders(headers)]]; } diff --git a/packages/agent-core-v2/src/app/model/model.ts b/packages/agent-core-v2/src/app/model/model.ts index 548a9b2796..bd26ab80bb 100644 --- a/packages/agent-core-v2/src/app/model/model.ts +++ b/packages/agent-core-v2/src/app/model/model.ts @@ -34,30 +34,17 @@ import { ProtocolSchema } from '#/app/protocol/protocol'; export const MODELS_SECTION = 'models'; const ModelBaseSchema = z.object({ - // Structured path — reference a Provider (which references a Platform). providerId: z.string().optional(), - // Flat path — inline endpoint + optional inline auth overrides. When - // providerId is absent, the resolver synthesizes a Provider from the - // baseUrl origin. When both are present, providerId wins and baseUrl - // acts as a per-Model override. baseUrl: z.string().optional(), apiKey: z.string().optional(), oauth: OAuthRefSchema.optional(), - // Wire protocol. Every Model declares exactly one; if the same physical - // model is served over two protocols (e.g. Anthropic direct + OpenAI- - // compat), that is two Model entries with different ids and a shared - // `name` (via `aliases`). protocol: ProtocolSchema.optional(), - // Wire-facing model identifier and routing aliases. name: z.string().optional(), aliases: z.array(z.string()).optional(), - // Existing capability / budget knobs — carried forward unchanged so - // legacy configs continue to load. Phase 4 migration lifts the old - // `provider`+`model` pair into the new `providerId`+`name` shape. provider: z.string().optional(), model: z.string().optional(), maxContextSize: z.number().int().min(1).optional(), @@ -90,10 +77,7 @@ export const ModelSchema = ModelBaseSchema.extend({ export type ModelConfig = z.infer; -/** @deprecated Legacy alias retained during the Phase 2 additive migration. */ export const ModelAliasSchema = ModelSchema; -/** @deprecated Use `ModelConfig` for the config-record type; use `Model` - * (from `#/app/model/modelInstance`) for the runnable god-object type. */ export type ModelAlias = ModelConfig; export const ModelsSectionSchema = z.record(z.string(), ModelSchema); diff --git a/packages/agent-core-v2/src/app/model/modelImpl.ts b/packages/agent-core-v2/src/app/model/modelImpl.ts index e9c883b072..23c06985ee 100644 --- a/packages/agent-core-v2/src/app/model/modelImpl.ts +++ b/packages/agent-core-v2/src/app/model/modelImpl.ts @@ -82,12 +82,6 @@ export class ModelImpl implements Model { private readonly protocolRegistry: ProtocolAdapterRegistry; private readonly providerOptions: ProtocolProviderOptions; - /** - * Chain of transforms applied to the raw kosong `ChatProvider` before use. - * `withThinking` / `withMaxCompletionTokens` / `withGenerationKwargs` - * append to this chain; the actual `ChatProvider` is materialized lazily - * on the first `.request()` and cached. - */ private readonly transforms: readonly ((p: ChatProvider) => ChatProvider)[]; private cachedChatProvider: ChatProvider | undefined; @@ -111,9 +105,6 @@ export class ModelImpl implements Model { this.transforms = transforms; this.alwaysThinking = init.alwaysThinking; this.providerName = init.providerName; - // thinkingEffort is materialized via `withThinking` — the transform chain - // owns the actual value applied to the underlying ChatProvider; we track - // the most recent effort on the wrapper so callers can inspect it. this.thinkingEffort = null; } @@ -189,7 +180,6 @@ export class ModelImpl implements Model { }); } - /** Materialize the transformed kosong ChatProvider. Cached per Model instance. */ private resolveChatProvider(): ChatProvider { if (this.cachedChatProvider !== undefined) return this.cachedChatProvider; let provider = this.protocolRegistry.createChatProvider({ @@ -280,15 +270,10 @@ export class ModelImpl implements Model { ); }); } catch (error) { - // Cancellation is control flow, not a provider failure — abort shapes - // pass through untouched. Everything else crosses the provider boundary - // here, so it is translated into a coded `Error2` exactly once. if (isAbortError(error) || signal?.aborted === true) throw error; throw translateProviderError(error); } - // Non-streaming providers still populate `result.message`; surface its - // content and tool calls as parts so downstream consumers see them. if (!streamedAnyPart) { for (const part of result.message.content) { firstChunkAt ??= Date.now(); @@ -420,19 +405,12 @@ export function buildStreamTiming( return timing; } -/** - * Simple bearer/api-key AuthProvider suitable for the flat-Model case. - * Wraps a static or provider-backed token retriever with optional force- - * refresh semantics. - */ export class StaticAuthProvider implements AuthProvider { readonly canRefresh = false; constructor(private readonly apiKey: string | undefined) {} async getAuth(): Promise { if (this.apiKey === undefined || this.apiKey.trim().length === 0) return undefined; - // kosong's provider adapters read the bearer/api token from `apiKey` - // (see `requireProviderApiKey`); a headers-only shape is rejected. return { apiKey: this.apiKey }; } } diff --git a/packages/agent-core-v2/src/app/model/modelInstance.ts b/packages/agent-core-v2/src/app/model/modelInstance.ts index 6cbb052e1f..411a95824c 100644 --- a/packages/agent-core-v2/src/app/model/modelInstance.ts +++ b/packages/agent-core-v2/src/app/model/modelInstance.ts @@ -28,24 +28,12 @@ import type { Tool } from '#/app/llmProtocol/tool'; import type { TokenUsage } from '#/app/llmProtocol/usage'; import type { Protocol, ProtocolProviderOptions } from '#/app/protocol/protocol'; -/** - * Closure that produces a fresh `ProviderRequestAuth` on demand. Wraps an - * OAuth token provider (with force-refresh on 401) or a static API key. - * Reading it always returns the current material — callers must not cache. - */ export interface AuthProvider { - /** Whether this auth source can force-refresh credentials after an upstream 401. */ readonly canRefresh?: boolean; - /** - * Get a `ProviderRequestAuth` for the next request. Returns `undefined` - * when no auth material is available (anonymous endpoint, or the caller - * passes secrets via `apiKey` directly on the config). - */ getAuth(options?: { readonly force?: boolean }): Promise; } -/** Per-request input for `Model.request(...)`. */ export interface LLMRequestInput { readonly systemPrompt: string; readonly tools: readonly Tool[]; @@ -53,17 +41,11 @@ export interface LLMRequestInput { readonly responseFormat?: ResponseFormat; } -/** - * Streamed events emitted by `Model.request(...)`. `part` carries incremental - * content / tool-call fragments; `usage` and `finish` are terminal signals; - * `timing` reports request-level latency when available. - */ export type LLMEvent = | { readonly type: 'part'; readonly part: StreamedMessagePart } | { readonly type: 'usage'; readonly usage: TokenUsage; readonly model?: string } | { readonly type: 'finish'; - /** Fully-assembled assistant message for this request. */ readonly message: Message; readonly providerFinishReason?: FinishReason; readonly rawFinishReason?: string; @@ -80,11 +62,8 @@ export type LLMEvent = }; export interface Model { - /** Globally-unique Model id (the key in `[models.]`). */ readonly id: string; - /** Wire-facing model name sent to the endpoint. Required, per Phase 2 (e). */ readonly name: string; - /** Free-form routing aliases; a name-based lookup matches these. */ readonly aliases: readonly string[]; readonly protocol: Protocol; readonly baseUrl?: string; @@ -99,51 +78,23 @@ export interface Model { readonly defaultEffort?: string; readonly thinkingEffort: ThinkingEffort | null; readonly maxCompletionTokens?: number; - /** - * True when this Model's capabilities include `always_thinking` — the - * runtime should force a thinking pass even if the user's requested - * `thinkingLevel` is `off`. - */ readonly alwaysThinking: boolean; - /** - * The config-side Provider id this Model resolves against (the entry in - * `[providers.*]`). For flat-case Models, this is the origin derived from - * `baseUrl` (e.g. `api.openai.com`). - */ readonly providerName: string; - /** - * Fresh auth material for every request. The Model closes over the - * resolved `AuthProvider` so callers never handle raw tokens. - */ readonly authProvider: AuthProvider; - /** Return a new Model wrapper with the given thinking effort applied. */ withThinking(effort: ThinkingEffort): Model; - /** Return a new Model wrapper with a completion-token cap applied. */ withMaxCompletionTokens(n: number, options?: MaxCompletionTokensOptions): Model; - /** Return a new Model wrapper with additional generation kwargs applied. */ withGenerationKwargs(kwargs: GenerationKwargs): Model; - /** Return a new Model wrapper with additional protocol-constructor options applied. */ withProviderOptions(options: ProtocolProviderOptions): Model; withThinkingKeep(keep: string): Model; - /** - * Drive one LLM request end-to-end. Streams `LLMEvent`s until the stream - * terminates (either normally with `usage`+`finish`, or with an error). - * Cancellation is via the optional `AbortSignal`. - */ request(input: LLMRequestInput, signal?: AbortSignal): AsyncIterable; - /** - * Upload a video for multi-modal input. Present only when the underlying - * protocol adapter supports it (currently Kimi). Callers should feature- - * detect via `capabilities.video_in`. - */ uploadVideo?( input: string | VideoUploadInput, options?: { readonly signal?: AbortSignal }, diff --git a/packages/agent-core-v2/src/app/model/modelResolver.ts b/packages/agent-core-v2/src/app/model/modelResolver.ts index f5606cd04f..ee8b426c10 100644 --- a/packages/agent-core-v2/src/app/model/modelResolver.ts +++ b/packages/agent-core-v2/src/app/model/modelResolver.ts @@ -21,9 +21,7 @@ import type { Model } from './modelInstance'; export interface IModelResolver { readonly _serviceBrand: undefined; - /** Resolve a Model id into a runnable god-object Model instance. */ resolve(id: string): Model; - /** All Model ids whose `name` or `aliases` match the given routing key. */ findByName(name: string): readonly string[]; } diff --git a/packages/agent-core-v2/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index 653e0d41f8..436199f752 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -47,8 +47,6 @@ import { IModelResolver } from './modelResolver'; import { ModelImpl, StaticAuthProvider } from './modelImpl'; import { resolveThinkingEffortForModel } from './thinking'; -/** Shape of the `thinking` config section (owned by `profile`); only the - * fields the resolver needs to mirror the production default are read here. */ interface ThinkingSection { readonly enabled?: boolean; readonly effort?: string; @@ -95,9 +93,6 @@ export class ModelResolverService extends Disposable implements IModelResolver { const authProvider = this.buildAuthProvider(providerName, auth); const protocol = this.resolveProtocol(id, model, providerConfig); - // Match production v1: strip a trailing `/v1` only when the model explicitly - // overrides into the Anthropic transport. Native Anthropic providers keep - // their configured `/v1` because the old provider manager did too. const resolvedBaseUrl = model.protocol === 'anthropic' && rawBaseUrl !== undefined ? stripTrailingV1(rawBaseUrl) @@ -156,23 +151,10 @@ export class ModelResolverService extends Disposable implements IModelResolver { providerOptions, }); - // Apply the production default thinking effort so a plain `model.request()` - // behaves like the agent path (which routes through `profile` and reads the - // same `thinking` config). Required for models whose - // endpoint rejects a request that omits thinking (e.g. kimi-k2.7 over the - // Anthropic protocol returns 400 unless `thinking.type === 'enabled'`). const effort = this.resolveDefaultThinking(model, alwaysThinking); return effort === 'off' ? impl : impl.withThinking(effort); } - /** - * Mirror `profile`'s `resolveThinkingEffort` so the god-object's default - * matches the production agent path: - * - `thinking.enabled === false` turns thinking off; - * - otherwise the configured `thinking.effort` is used, falling back to the - * model's declared default effort / middle supported effort / boolean `on`; - * - an `always_thinking` model clamps an explicit "off" back to on. - */ private resolveDefaultThinking( model: ModelConfig, alwaysThinking: boolean, @@ -200,14 +182,6 @@ export class ModelResolverService extends Disposable implements IModelResolver { return out; } - /** - * Return the ProviderConfig this Model resolves against, plus the URL to - * hit at runtime. Structured path reads `[providers.]`; flat - * path synthesizes a Provider record from the Model's inline baseUrl. A - * structured Model with no baseUrl anywhere (config, provider, env) yields - * `undefined` — the wire provider then applies its protocol default - * endpoint, matching v1's `provider-manager`. - */ private resolveProviderContext( id: string, model: ModelConfig, @@ -216,10 +190,6 @@ export class ModelResolverService extends Disposable implements IModelResolver { readonly providerName: string; readonly resolvedBaseUrl: string | undefined; } { - // Structured path — Model references a Provider (which may reference a - // Platform). Legacy configs still use `provider` in place of `providerId`, - // and the top-level `defaultProvider` config is the v1-compatible fallback - // when a Model pins neither. const providerId = model.providerId ?? model.provider ?? this.config.get('defaultProvider'); if (providerId !== undefined) { @@ -240,8 +210,6 @@ export class ModelResolverService extends Disposable implements IModelResolver { return { providerConfig, providerName: providerId, resolvedBaseUrl: baseUrl }; } - // Flat path — Model carries its own baseUrl. Synthesize a Provider id - // from the URL's origin so two flat Models on the same host converge. const modelBaseUrl = nonEmpty(model.baseUrl); if (modelBaseUrl === undefined) { throw new Error2( @@ -303,18 +271,6 @@ export class ModelResolverService extends Disposable implements IModelResolver { } } -/** - * Resolve the outbound `defaultHeaders` for a Model, layering lowest to highest - * precedence (matches v1's `provider-manager`): - * - * 1. `KIMI_CODE_CUSTOM_HEADERS` env (re-read on every resolve so env changes - * take effect without restarting the session); - * 2. host identity headers — the full set (`User-Agent` + `X-Msh-*`) for a - * Kimi provider, only the `User-Agent` for every other provider so device - * identity never leaks to third-party endpoints (a Kimi provider routed - * through the Anthropic protocol still gets the full set, matching v1); - * 3. provider `customHeaders` (always win on conflict). - */ export function resolveOutboundHeaders( providerType: string | undefined, customHeaders: Readonly> | undefined, @@ -348,9 +304,6 @@ function resolveModelCapabilities( }; } -/** Strip a trailing `/v1` (with optional trailing slash) from a baseUrl, matching - * production v1's anthropic-transport normalization so the Anthropic SDK's - * `/v1/messages` suffix does not produce a double `/v1/v1/messages`. */ function stripTrailingV1(baseUrl: string): string { return baseUrl.replace(/\/v1\/?$/, ''); } diff --git a/packages/agent-core-v2/src/app/model/thinking.ts b/packages/agent-core-v2/src/app/model/thinking.ts index 3c373d11bf..fc8b3afce4 100644 --- a/packages/agent-core-v2/src/app/model/thinking.ts +++ b/packages/agent-core-v2/src/app/model/thinking.ts @@ -85,9 +85,6 @@ export function resolveThinkingEffortForModel( const normalized = nonEmpty(requested)?.toLowerCase(); let effort: ThinkingEffort; if (normalized !== undefined) { - // A requested effort is taken verbatim — including 'on', which is a valid - // wire value for boolean thinking models. Normalizing 'on' to a concrete - // effort is the UI boundary's job, not the resolver's (v1 parity). effort = normalized as ThinkingEffort; } else if (defaults?.enabled === false) { effort = 'off'; @@ -96,10 +93,6 @@ export function resolveThinkingEffortForModel( } if (effort === 'off' && model?.alwaysThinking === true) { - // always_thinking forces thinking on, but an explicitly configured effort - // is still honored — `enabled = false` only expresses the intent to - // disable, it should not also discard a chosen effort. Fall back to the - // model default only when no effort is configured. return configured ?? defaultThinkingEffortForModel(model); } return effort; diff --git a/packages/agent-core-v2/src/app/modelCatalog/configSection.ts b/packages/agent-core-v2/src/app/modelCatalog/configSection.ts index ae2b735ca2..94fa5c8a41 100644 --- a/packages/agent-core-v2/src/app/modelCatalog/configSection.ts +++ b/packages/agent-core-v2/src/app/modelCatalog/configSection.ts @@ -21,9 +21,7 @@ import { registerConfigSection } from '#/app/config/configSectionContributions'; export const MODEL_CATALOG_SECTION = 'modelCatalog'; export const ModelCatalogConfigSchema = z.object({ - /** Interval (ms) between automatic provider-model refreshes. `0` disables. */ refreshIntervalMs: z.number().int().min(0).optional(), - /** Refresh once shortly after the daemon starts. */ refreshOnStart: z.boolean().optional(), }); diff --git a/packages/agent-core-v2/src/app/modelCatalog/modelCatalog.ts b/packages/agent-core-v2/src/app/modelCatalog/modelCatalog.ts index 17d2be6007..be358af25b 100644 --- a/packages/agent-core-v2/src/app/modelCatalog/modelCatalog.ts +++ b/packages/agent-core-v2/src/app/modelCatalog/modelCatalog.ts @@ -27,7 +27,6 @@ export type RefreshProviderModelsScope = 'all' | 'oauth'; export interface RefreshProviderModelsOptions { readonly scope?: RefreshProviderModelsScope; - /** Refresh only this provider id. When set, `scope` is ignored. */ readonly providerId?: string; } @@ -38,17 +37,6 @@ export interface IModelCatalogService { listProviders(): Promise; getProvider(providerId: string): Promise; setDefaultModel(modelId: string): Promise; - /** - * Refresh remote model metadata for the configured providers. Defaults to - * every refreshable provider (`scope: 'all'`); pass `scope: 'oauth'` for the - * managed OAuth provider only, or `providerId` for a single provider. Throws - * `provider.not_found` when `providerId` is unknown. Publishes - * `event.model_catalog.changed` when the catalog actually changes. - * - * Only providers with a discoverable catalog endpoint are refreshed - * (managed OAuth, open platforms, custom registries); plain API-key - * providers have no server-side catalog and are a no-op, matching v1. - */ refreshProviderModels( options?: RefreshProviderModelsOptions, ): Promise; diff --git a/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts b/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts index b656b949a7..0fd0fe6bf6 100644 --- a/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts +++ b/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts @@ -54,11 +54,6 @@ const THINKING_SECTION = 'thinking'; export class ModelCatalogService implements IModelCatalogService { declare readonly _serviceBrand: undefined; - /** - * Serializes refresh runs so a scheduled refresh and a manual one (or two - * manual ones with different options) never race on reading/patching the - * persisted config. Mirrors v1's `_refreshChain`. - */ private refreshChain: Promise = Promise.resolve(); constructor( @@ -140,8 +135,6 @@ export class ModelCatalogService implements IModelCatalogService { }); const response = mapRefreshResult(result); if (response.changed.length > 0) { - // Broadcasts to every connected client via the core event bus → - // `SessionEventBroadcaster` (any provider kind, not just OAuth). this.events.publish({ type: 'event.model_catalog.changed', payload: response }); } return response; @@ -153,17 +146,10 @@ export class ModelCatalogService implements IModelCatalogService { removeProvider: (providerId) => this.removeProviderForRefresh(providerId), setConfig: (patch) => this.applyRefreshPatch(patch), resolveOAuthToken: (providerName, oauthRef) => this.resolveOAuthToken(providerName, oauthRef), - // Mirrors ModelResolverService: only the User-Agent leaves the host, so - // device identity never reaches third-party registry endpoints. userAgent: this.hostRequestHeaders.headers['User-Agent'], }; } - /** - * User-layer config shape the orchestrator diffs and edits. Mirrors - * `OAuthService.readUserConfigShape` so both refresh paths edit the same - * persisted user config (never env/memory-overlaid effective values). - */ private readUserConfigShape(): ManagedKimiConfigShape { const providers = this.config.inspect>(PROVIDERS_SECTION).userValue ?? {}; diff --git a/packages/agent-core-v2/src/app/platform/configSection.ts b/packages/agent-core-v2/src/app/platform/configSection.ts index 2b5c4f86b8..c78cf5cf61 100644 --- a/packages/agent-core-v2/src/app/platform/configSection.ts +++ b/packages/agent-core-v2/src/app/platform/configSection.ts @@ -21,7 +21,6 @@ import { import { PLATFORMS_SECTION, PlatformsSectionSchema } from './platform'; -/** Read transform: snake_case file → camelCase in-memory platforms record. */ export const platformsFromToml = (rawSnake: unknown): unknown => { if (!isPlainObject(rawSnake)) return rawSnake; const out: Record = {}; @@ -59,7 +58,6 @@ function authFromToml(data: Record): Record { return out; } -/** Write transform: camelCase in-memory platforms record → snake_case file. */ export const platformsToToml = (value: unknown, rawSnake: unknown): unknown => { if (!isPlainObject(value)) return value; const rawSub = cloneRecord(rawSnake); diff --git a/packages/agent-core-v2/src/app/platform/platform.ts b/packages/agent-core-v2/src/app/platform/platform.ts index 677d280b36..31036d5f64 100644 --- a/packages/agent-core-v2/src/app/platform/platform.ts +++ b/packages/agent-core-v2/src/app/platform/platform.ts @@ -46,11 +46,6 @@ export type PlatformConfig = z.infer; export const PLATFORMS_SECTION = 'platforms'; -/** - * Sentinel used by the flat-Model path when no Platform is declared. Auth is - * resolved from the Model itself (Model.apiKey / Model.oauth) rather than - * from a Platform. - */ export const UNKNOWN_PLATFORM_KEY = '__unknown__'; export const PlatformsSectionSchema = z.record(z.string(), PlatformConfigSchema); diff --git a/packages/agent-core-v2/src/app/plugin/commands.ts b/packages/agent-core-v2/src/app/plugin/commands.ts index da59348c05..6983aadbdd 100644 --- a/packages/agent-core-v2/src/app/plugin/commands.ts +++ b/packages/agent-core-v2/src/app/plugin/commands.ts @@ -48,11 +48,6 @@ export async function loadPluginCommand(input: { } } -/** - * Expand `$ARGUMENTS` placeholders in a plugin command body with the typed args. - * If the body has no placeholder but args are present, append them so nothing - * is silently dropped. - */ export function expandCommandArguments(body: string, args: string): string { const replaced = body.replaceAll('$ARGUMENTS', args); if (!body.includes('$ARGUMENTS') && args.length > 0) { diff --git a/packages/agent-core-v2/src/app/plugin/manifest.ts b/packages/agent-core-v2/src/app/plugin/manifest.ts index 32c20aebeb..7ce3e150a1 100644 --- a/packages/agent-core-v2/src/app/plugin/manifest.ts +++ b/packages/agent-core-v2/src/app/plugin/manifest.ts @@ -16,9 +16,6 @@ import { const KIMI_PLUGIN_ROOT_PATH = 'kimi.plugin.json'; const KIMI_PLUGIN_DIR_PATH = '.kimi-plugin/plugin.json'; -// Fields that look like third-party runtime extensions (Claude / Codex / old -// Kimi CLI). We do not run them; emit an info diagnostic so plugin authors and -// users can see why a field is silently ignored. const UNSUPPORTED_RUNTIME_FIELDS = [ 'tools', 'apps', diff --git a/packages/agent-core-v2/src/app/plugin/types.ts b/packages/agent-core-v2/src/app/plugin/types.ts index 36d291d6f1..89d470cc9c 100644 --- a/packages/agent-core-v2/src/app/plugin/types.ts +++ b/packages/agent-core-v2/src/app/plugin/types.ts @@ -33,7 +33,7 @@ export interface PluginManifest { readonly author?: PluginAuthor; readonly homepage?: string; readonly license?: string; - readonly skills?: readonly string[]; // resolved absolute paths + readonly skills?: readonly string[]; readonly sessionStart?: PluginSessionStart; readonly mcpServers?: Readonly>; readonly hooks?: readonly HookDefConfig[]; @@ -71,14 +71,6 @@ export interface PluginCommandDef { readonly path: string; } -/** - * A resolved command file plus its namespace-preserving name. - * - * `name` is the path of the file relative to the declared `commands` entry - * (without the `.md` extension, using `/` separators), so a file at - * `commands/frontend/component.md` yields the name `frontend/component`. - * Frontmatter `name` in the file itself takes precedence over this at load time. - */ export interface PluginCommandEntry { readonly path: string; readonly name: string; diff --git a/packages/agent-core-v2/src/app/protocol/errors.ts b/packages/agent-core-v2/src/app/protocol/errors.ts index 11385fe361..220777be9a 100644 --- a/packages/agent-core-v2/src/app/protocol/errors.ts +++ b/packages/agent-core-v2/src/app/protocol/errors.ts @@ -70,19 +70,10 @@ export const ProtocolErrors = { }, } as const satisfies ErrorDomain; -/** @deprecated Use `ProtocolErrors` — same codes, renamed with the domain. */ export const ChatProviderErrors = ProtocolErrors; registerErrorDomain(ProtocolErrors); -/** - * Boundary translation from raw `llmProtocol` provider errors into coded - * `Error2`s. Idempotent: a `Error2` passes through untouched. The raw - * error is preserved as `cause` and HTTP fields ride in `details`, so - * up-stack consumers branch on `code` + `details` (or unwrap `cause`) - * instead of importing provider classes. Abort-shaped errors are control - * flow, not provider failures — callers must branch on them before calling. - */ export function translateProviderError(error: unknown): Error2 { if (isError2(error)) { return error; diff --git a/packages/agent-core-v2/src/app/protocol/protocol.ts b/packages/agent-core-v2/src/app/protocol/protocol.ts index f1499d1894..c808afea5f 100644 --- a/packages/agent-core-v2/src/app/protocol/protocol.ts +++ b/packages/agent-core-v2/src/app/protocol/protocol.ts @@ -42,13 +42,6 @@ export interface ProtocolProviderOptions { readonly location?: string; } -/** - * Configuration passed to the protocol adapter to produce a request handler. - * Keep this shape wire-agnostic: identity comes from `protocol` + `baseUrl`, - * secrets come from `auth` (resolved by the caller from Platform / Model - * overrides), constructor-level headers come from `defaultHeaders`, and - * provider-specific knobs are isolated under `providerOptions`. - */ export interface ProtocolAdapterConfig { readonly protocol: Protocol; readonly baseUrl?: string; @@ -61,7 +54,6 @@ export interface ProtocolAdapterConfig { export interface IProtocolAdapterRegistry { readonly _serviceBrand: undefined; - /** Protocols this registry can build adapters for. */ supportedProtocols(): readonly Protocol[]; } diff --git a/packages/agent-core-v2/src/app/protocol/protocolAdapterRegistry.ts b/packages/agent-core-v2/src/app/protocol/protocolAdapterRegistry.ts index dae34e2aee..139aac4f91 100644 --- a/packages/agent-core-v2/src/app/protocol/protocolAdapterRegistry.ts +++ b/packages/agent-core-v2/src/app/protocol/protocolAdapterRegistry.ts @@ -11,15 +11,6 @@ import { type ProtocolAdapterConfig, } from './protocol'; -/** - * `protocol` domain (L1) — `IProtocolAdapterRegistry` implementation. - * - * Owns the current mapping from a Protocol identifier to a request-handler - * factory. Delegates to `createProvider` from `llmProtocol/providers` (the - * kosong wire source, kept flat under `llmProtocol`); this is v2's only - * runtime kosong boundary - * (Phase 8 replaces it with native adapters). Bound at App scope. - */ const SUPPORTED: readonly Protocol[] = [ 'kimi', @@ -40,12 +31,6 @@ export class ProtocolAdapterRegistry return SUPPORTED; } - /** - * Package-internal: create a kosong-shaped `ChatProvider` from the - * wire-agnostic config. Exposed as a plain method (not part of the public - * contract) so `IModelResolver` can build a Model god object from it while - * the public `ChatProvider` type remains internal to v2. - */ createChatProvider(input: ProtocolAdapterConfig): ChatProvider { const kosongConfig = toKosongProviderConfig(input); return createProvider(kosongConfig); diff --git a/packages/agent-core-v2/src/app/provider/configSection.ts b/packages/agent-core-v2/src/app/provider/configSection.ts index 08eca73bd3..38ab6349de 100644 --- a/packages/agent-core-v2/src/app/provider/configSection.ts +++ b/packages/agent-core-v2/src/app/provider/configSection.ts @@ -46,7 +46,6 @@ export const stripProvidersEnv: ConfigStripEnv> = (value return out; }; -/** Read transform: snake_case file → camelCase in-memory providers record. */ export const providersFromToml = (rawSnake: unknown): unknown => { if (!isPlainObject(rawSnake)) return rawSnake; const out: Record = {}; @@ -71,7 +70,6 @@ function providerEntryFromToml(data: Record): Record { if (!isPlainObject(value)) return value; const rawSub = cloneRecord(rawSnake); diff --git a/packages/agent-core-v2/src/app/provider/provider.ts b/packages/agent-core-v2/src/app/provider/provider.ts index 9a27c81c6f..f3d892e68f 100644 --- a/packages/agent-core-v2/src/app/provider/provider.ts +++ b/packages/agent-core-v2/src/app/provider/provider.ts @@ -51,18 +51,13 @@ export const ModelSourceSchema = z.enum(['static', 'discover', 'oauth-catalog']) export type ModelSource = z.infer; export const ProviderConfigSchema = z.object({ - // New (Phase 2) — reference to an entry in [platforms.*] for shared auth. platformId: z.string().optional(), - // New (Phase 2) — how to enumerate the models this Provider serves. modelSource: ModelSourceSchema.optional(), - // Endpoint and per-endpoint knobs. baseUrl: z.string().optional(), customHeaders: StringRecordSchema.optional(), defaultModel: z.string().optional(), - // Legacy fields — retained so pre-migration configs continue to load. - // Phase 4 migration lifts these into a synthesized Platform entry. type: ProviderTypeSchema.optional(), apiKey: z.string().optional(), oauth: OAuthRefSchema.optional(), @@ -74,7 +69,6 @@ export type ProviderConfig = z.infer; export const PROVIDERS_SECTION = 'providers'; -/** Reserved key for the env-driven synthetic provider (`KIMI_MODEL_API_KEY` …). */ export const ENV_MODEL_PROVIDER_KEY = '__kimi_env__'; export const ProvidersSectionSchema = z.record(z.string(), ProviderConfigSchema); diff --git a/packages/agent-core-v2/src/app/provider/providerService.ts b/packages/agent-core-v2/src/app/provider/providerService.ts index 0bd78909f2..c08b49860c 100644 --- a/packages/agent-core-v2/src/app/provider/providerService.ts +++ b/packages/agent-core-v2/src/app/provider/providerService.ts @@ -21,7 +21,6 @@ import { PROVIDERS_SECTION, } from './provider'; -/** Top-level scalar config section naming the fallback provider (v1 `default_provider`). */ const DEFAULT_PROVIDER_SECTION = 'defaultProvider'; export class ProviderService extends Disposable implements IProviderService { @@ -64,7 +63,6 @@ export class ProviderService extends Disposable implements IProviderService { if (!(name in current)) return; const { [name]: _removed, ...rest } = current; await this.config.replace(PROVIDERS_SECTION, rest); - // v1 parity: a removed provider must not stay pinned as the default. if (this.config.get(DEFAULT_PROVIDER_SECTION) === name) { await this.config.set(DEFAULT_PROVIDER_SECTION, undefined); } diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts index 876992729a..7caef435e8 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts @@ -20,15 +20,8 @@ export interface ShellEnvironment { export interface ExportSessionPayload { readonly sessionId: string; readonly outputPath?: string | undefined; - /** - * When true, the active global diagnostic log (`$KIMI_CODE_HOME/logs/kimi-code.log`) - * is copied into the zip at `logs/global/kimi-code.log`. Off by default to - * avoid bundling events from concurrent sessions / other projects. - */ readonly includeGlobalLog?: boolean | undefined; - /** Host version to record in the export manifest. */ readonly version: string; - /** How the CLI was installed (e.g. 'npm-global', 'native'). */ readonly installSource?: string | undefined; readonly shellEnv?: ShellEnvironment | undefined; } @@ -44,11 +37,8 @@ export interface ExportSessionManifest { readonly sessionLastActivity?: string | undefined; readonly title?: string | undefined; readonly workspaceDir?: string | undefined; - /** zip-relative path to the session diagnostic log when present. */ readonly sessionLogPath?: string | undefined; - /** zip-relative path to the bundled global diagnostic log (only when --include-global-log). */ readonly globalLogPath?: string | undefined; - /** How the CLI was installed (e.g. 'npm-global', 'native'). */ readonly installSource?: string | undefined; readonly shellEnv?: ShellEnvironment | undefined; } diff --git a/packages/agent-core-v2/src/app/sessionExport/zip.ts b/packages/agent-core-v2/src/app/sessionExport/zip.ts index 5fd561f41b..a2c891e3de 100644 --- a/packages/agent-core-v2/src/app/sessionExport/zip.ts +++ b/packages/agent-core-v2/src/app/sessionExport/zip.ts @@ -31,14 +31,11 @@ export async function collectFilesRecursive(root: string): Promise { export type ExtraZipEntry = | { - /** Absolute path on disk. */ readonly source: string; - /** zip-relative target path. */ readonly target: string; } | { readonly data: Buffer; - /** zip-relative target path. */ readonly target: string; }; diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts index 3f60376609..f135781274 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts @@ -13,47 +13,21 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { Page } from '#/persistence/interface/queryStore'; -/** - * v1 `custom` metadata key linking a forked session back to its parent - * (`packages/agent-core/.../sessionService.ts`). Written by - * `ISessionLifecycleService.createChild`; read here to answer child queries. - */ export const PARENT_SESSION_ID_KEY = 'parent_session_id'; -/** - * v1 `custom` metadata key tagging a fork as a direct "child" (as opposed to a - * plain fork). Only sessions carrying both {@link PARENT_SESSION_ID_KEY} and - * `child_session_kind === CHILD_SESSION_KIND` count as children. - */ export const CHILD_SESSION_KIND_KEY = 'child_session_kind'; -/** The `child_session_kind` value that marks a direct child session. */ export const CHILD_SESSION_KIND = 'child'; export interface SessionSummary { readonly id: string; readonly workspaceId: string; - /** - * Absolute working directory frozen at session creation (wire - * `metadata.cwd`). Sourced from the session's own metadata document so it is - * independent of the workspace registry — sessions whose workspace was - * unregistered still surface their original cwd (closes gap G3; matches v1's - * `summary.workDir`). Optional only for sessions written before `cwd` was - * persisted; the edge falls back to the workspace registry for those. - */ readonly cwd?: string; readonly title?: string; readonly lastPrompt?: string; readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; - /** - * Free-form custom metadata read from the session's `state.json` (wire - * `Session.metadata` minus reserved keys such as `goal`). Surfaced so the v1 - * edge can project it into `Session.metadata` and filter child sessions by - * the `parent_session_id` / `child_session_kind` markers without a per-session - * document read. - */ readonly custom?: Record; } @@ -63,23 +37,14 @@ export interface SessionListQuery { readonly includeArchived?: boolean; readonly cursor?: string; readonly limit?: number; - /** - * Restrict to direct child sessions of this parent id: summaries whose - * `custom` carries both `parent_session_id === childOf` and - * `child_session_kind === 'child'` (the v1 child markers). A plain fork - * (no `child_session_kind`) is excluded. - */ readonly childOf?: string; } export interface ISessionIndex { readonly _serviceBrand: undefined; - /** List persisted sessions, optionally filtered by workspace. */ list(query: SessionListQuery): Promise>; - /** Fetch a single persisted session by id. */ get(id: string): Promise; - /** Count non-archived sessions for a workspace id. */ countActive(workspaceId: string): Promise; } diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index 00ac2f7e06..d3f7a0944a 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -54,7 +54,6 @@ const META_KEY = 'state.json'; const SESSION_COLLECTION = 'session'; const READ_MODEL_FLAG = 'persistence_minidb_readmodel'; -/** Accept both v2 (epoch ms number) and v1 (ISO string) timestamps. */ function parseTime(value: unknown): number { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string') { @@ -64,13 +63,6 @@ function parseTime(value: unknown): number { return 0; } -/** - * Recover the session's frozen working directory from its metadata document. - * - * Precedence: v2 `cwd` → v1 `workDir` → older v1 `custom.cwd`. Returns - * `undefined` only for documents predating every cwd record; the edge falls - * back to the workspace registry for those. - */ function recoverCwd(meta: Record): string | undefined { if (typeof meta['cwd'] === 'string' && meta['cwd'].length > 0) return meta['cwd']; if (typeof meta['workDir'] === 'string' && meta['workDir'].length > 0) { @@ -84,12 +76,6 @@ function recoverCwd(meta: Record): string | undefined { return undefined; } -/** - * Whether a summary is a direct child of `parentId` per the v1 child markers: - * `custom.parent_session_id === parentId` AND `custom.child_session_kind === - * 'child'`. A missing/blank `parentId` (no `childOf` filter) matches every - * summary. A spoofed kind is ignored. - */ function matchesChildOf(summary: SessionSummary, parentId: string | undefined): boolean { if (parentId === undefined) return true; const custom = summary.custom; @@ -103,11 +89,6 @@ export class FileSessionIndex implements ISessionIndex { declare readonly _serviceBrand: undefined; private indexesEnsured = false; - /** - * Set when the read model reports `storage.locked` (another process holds - * the query-store writer lock): warn once, then serve every call from the - * legacy disk path for the rest of the process lifetime. - */ private readModelDisabled = false; constructor( @@ -143,13 +124,6 @@ export class FileSessionIndex implements ISessionIndex { ); } - /** - * Run a read-model operation, falling back to the legacy disk path when the - * query store is locked by another process (`storage.locked`). The fallback - * is sticky: the first lock disables the read model for the process - * lifetime, so the warning is emitted once and a contended lock is not - * hammered. Other errors propagate. - */ private async withReadModelFallback(op: () => Promise, legacy: () => Promise): Promise { if (this.readModelDisabled) return legacy(); try { @@ -194,7 +168,6 @@ export class FileSessionIndex implements ISessionIndex { private async getFromReadModel(id: string): Promise { const cached = await this.queryStore.get(SESSION_COLLECTION, id); if (cached !== undefined) return cached; - // Cold miss: locate the session on disk, then read + backfill. for (const workspaceId of await this.listWorkspaceIds()) { if (!(await this.hasSession(workspaceId, id))) continue; return this.getCachedSummary(workspaceId, id); @@ -231,10 +204,6 @@ export class FileSessionIndex implements ISessionIndex { this.indexesEnsured = true; } - /** - * Resolve a summary through the read model, backfilling from disk on a cold - * miss. The read model is keyed by session id (globally unique). - */ private async getCachedSummary( workspaceId: string, sessionId: string, @@ -322,10 +291,6 @@ export class FileSessionIndex implements ISessionIndex { sessionId: string, ): Promise { const base = `${this.sessionsScope}/${workspaceId}/${sessionId}`; - // `/state.json` is the unified metadata document: v2 (tagged - // `version: 2`) and v1 (no version) both write here. Fall back to the - // legacy v2 `session-meta/` subdir for sessions written before the layouts - // were unified. const meta = (await this.readMeta(base)) ?? (await this.readMeta(`${base}/${META_SCOPE}`)); if (meta === undefined) return undefined; const rawCustom = meta['custom']; diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts index 955b4a1f49..d765aef05f 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts @@ -30,14 +30,9 @@ import type { import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -/** - * Raw fields the route projects into the wire `Session` (via `toWireSession`). - * Kept protocol-free so the edge projection stays in the server layer. - */ export interface SessionWireFields { readonly id: string; readonly workspaceId: string; - /** Workspace root — used as `cwd` when projecting to the wire `Session`. */ readonly root: string; readonly title?: string; readonly lastPrompt?: string; @@ -52,7 +47,6 @@ export interface ISessionLegacyService { updateProfile(sessionId: string, body: UpdateSessionProfileRequest): Promise; status(sessionId: string): Promise; - /** Current goal snapshot, or null when the session has no active goal. */ goal(sessionId: string): Promise; } diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index 81d59594a3..c800c91cd8 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -58,10 +58,6 @@ export class SessionLegacyService implements ISessionLegacyService { await metadata.setTitle(body.title); } - // v1 `ISessionService.update` writes the wire metadata patch straight into - // `custom` (replace, not deep-merge); `toProtocolSession` then spreads - // `custom` back onto the wire `Session.metadata`. An empty patch is a no-op - // (matches v1's `Object.keys(...).length > 0` guard). const metadataPatch = body.metadata; if (metadataPatch !== undefined && Object.keys(metadataPatch).length > 0) { await metadata.update({ custom: { ...(metadataPatch as Record) } }); @@ -74,9 +70,6 @@ export class SessionLegacyService implements ISessionLegacyService { } const meta = await metadata.read(); - // `ISessionContext` carries the frozen work dir (gap G3 closed), so an - // unregistered workspace does not collapse `cwd` here — matches v1, which - // stores `workDir` on the session itself. const ctx = session.accessor.get(ISessionContext); return { id: meta.id, @@ -91,18 +84,7 @@ export class SessionLegacyService implements ISessionLegacyService { }; } - // --- internals ------------------------------------------------------------- - - /** - * Apply the v1 `agent_config` patch onto the main agent. Mirrors v1's - * `IPromptService.applyAgentState` (`promptService.ts:650-743`) in both order - * (model → thinking → permission → plan → swarm → goal) and diff behaviour: - * the non-idempotent `plan.enter` / `swarm.enter` are guarded behind a state - * read so a repeated `true` does not throw ('Already in plan mode'); the - * idempotent setters (model / thinking / permission) fire directly. Goal - * actions are one-shot and let domain errors (`goal.*`) propagate to the - * route's `sendMappedError`. - */ + private async applyAgentConfig( agent: IAgentScopeHandle, agentConfig: NonNullable, @@ -156,12 +138,6 @@ export class SessionLegacyService implements ISessionLegacyService { } private async resolveMainAgent(sessionId: string): Promise { - // `resume` (not `get`) so a persisted-but-cold session — freshly opened in - // the web UI before any prompt, or created by a previous process — is loaded - // from disk instead of being reported as `session.not_found`. Mirrors v1's - // `SessionService.undo`/`compact`, which call `resumeSession` first; `resume` - // returns `undefined` only when the session is unknown or its workspace is - // gone, so a genuinely missing session still 404s. const session = await this.lifecycle.resume(sessionId); if (session === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); @@ -188,17 +164,8 @@ export class SessionLegacyService implements ISessionLegacyService { const profileData = profile.data(); const model = profile.getModel(); const caps = profile.getModelCapabilities() as { max_context_tokens?: number }; - // v1 binds the default model to the main agent at session creation, so its - // status always reports a real context window. v2 creates the main agent - // lazily without binding a model until the first prompt/profile update, so a - // fresh session has no model and `max_context_tokens` resolves to 0 — the - // status line then shows "0/0". Mirror v1 by falling back to the configured - // default model's context window whenever the agent has no model bound yet. const maxTokens = model === '' ? resolveDefaultModelContextTokens(agent) : (caps.max_context_tokens ?? 0); - // `size` (measured + estimated) mirrors v1's `context.tokenCount`: it - // reflects the live context even before the first measured exchange, whereas - // `measured` stays 0 until the first LLM response lands. const tokens = contextSize.get().size; const planData = await plan.status(); @@ -221,12 +188,6 @@ export class SessionLegacyService implements ISessionLegacyService { } } -/** - * Resolve the configured default model's context window for the status line - * when the main agent has no model bound yet (fresh session before the first - * prompt). Returns 0 when no default model is configured or it cannot be - * resolved (e.g. auth not ready), matching v1's "unknown" fallback. - */ function resolveDefaultModelContextTokens(agent: IAgentScopeHandle): number { const defaultModel = agent.accessor.get(IConfigService).get('defaultModel'); if (typeof defaultModel !== 'string' || defaultModel.length === 0) return 0; diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts index ff6178c39c..965cf96be9 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts @@ -21,43 +21,23 @@ import type { McpServerConfig } from '#/agent/mcp/config-schema'; import type { Hooks } from '#/hooks'; export interface CreateSessionOptions { - /** - * Caller-supplied session id. When omitted, the lifecycle mints one in the - * canonical `session_` form (matches v1's `createSessionId`). - * Pass an explicit id only to resume/recreate a session under a known id. - */ readonly sessionId?: string; readonly workDir: string; - /** Extra workspace roots for this session; relative paths resolve against workDir. */ readonly additionalDirs?: readonly string[]; - /** - * Caller-supplied MCP servers for this session (v1's - * `CreateSessionPayload.mcpServers`): merged over the file config and under - * plugin servers when the session's MCP connections are established. Only - * `create` carries them; resumes and forks connect from the file config alone. - */ readonly mcpServers?: Readonly>; } export interface ForkSessionOptions { readonly sourceSessionId: string; readonly newSessionId?: string; - /** Title for the forked session. Defaults to `Fork: `. */ readonly title?: string; - /** Custom metadata merged (minus reserved `goal`) into the forked session. */ readonly metadata?: Record; } export interface CreateChildSessionOptions { readonly sourceSessionId: string; readonly newSessionId?: string; - /** Title for the child session. Defaults to `Child: `. */ readonly title?: string; - /** - * Custom metadata merged into the child session. The `parent_session_id` and - * `child_session_kind` markers are added automatically (and win over any - * caller-supplied values) so the child is discoverable via the session index. - */ readonly metadata?: Record; } @@ -105,44 +85,13 @@ export interface ISessionLifecycleService { readonly onDidForkSession: Event; readonly hooks: Hooks; create(opts: CreateSessionOptions): Promise; - /** - * Return the live handle for `sessionId`, or `undefined` when it is not open. - * A session whose cold {@link resume} is still in flight is intentionally NOT - * returned — its main agent has not finished restore + replay, so the handle - * is half-initialized. Callers that must obtain the handle should - * `await resume(sessionId)` instead. This invisibility is a service - * invariant, not caller discipline: every read path (`get` / {@link list} / - * {@link resume}) agrees a resuming session is not yet observable. - */ get(sessionId: string): ISessionScopeHandle | undefined; - /** - * Snapshot of every fully-initialized live session. Excludes sessions still - * mid-{@link resume} for the same reason as {@link get}. - */ list(): readonly ISessionScopeHandle[]; - /** - * Load a persisted session into the live scope tree and restore its main - * agent from the persisted wire log. Returns the existing handle when the - * session is already live (a no-op in that case — live agents are never - * re-restored). Returns `undefined` when the session is unknown to the index - * or neither the persisted session summary nor the workspace registry can - * provide a workdir (mirrors the cold-source limitation of `fork`). - * - * Lets the read edges (snapshot / messages) serve cold sessions — created by - * a previous process or by v1 — without requiring a prior `create` in this - * process. Restores only the main agent; sub-agents are materialized lazily. - */ resume(sessionId: string): Promise; close(sessionId: string): Promise; archive(sessionId: string): Promise; restore(sessionId: string): Promise; fork(opts: ForkSessionOptions): Promise; - /** - * Fork a session and tag it as a direct child of its source (writes the - * `parent_session_id` / `child_session_kind` markers into `custom`). The - * default title is `Child: `. Throws `session.not_found` - * when the source is unknown (delegates to {@link fork}). - */ createChild(opts: CreateChildSessionOptions): Promise; } diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 25a0e47492..d2b8d210f7 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -112,12 +112,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec 'onDidCreateSession', 'onWillCloseSession', ]); - /** In-flight `resume` promises, keyed by session id. De-dupes concurrent cold - * loads so a hot read path (e.g. snapshot retry) cannot materialize the same - * session twice and leak a handle — and doubles as the visibility gate for - * `get` / `list`: while an id is present here its materialized handle is - * half-initialized (main agent not yet restored + replayed) and must not be - * observable. */ private readonly resuming = new Map>(); constructor( @@ -156,9 +150,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const workspaceId = opts.workspaceId ?? workspace.id; const sessionScope = this.bootstrap.sessionScope(workspaceId, opts.sessionId); const sessionDir = this.bootstrap.sessionDir(workspaceId, opts.sessionId); - // Metadata lives at `/state.json` (shared with v1's layout; the - // v2 document is tagged with `version: 2`). `metaScope` is therefore the - // session directory itself, homeDir-relative. const metaScope = sessionScope; const ctx: ISessionContext = { _serviceBrand: undefined, @@ -170,21 +161,12 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec scope: (subKey?: string): string => subKey === undefined || subKey === '' ? sessionScope : `${sessionScope}/${subKey}`, }; - // Merge the project-local `.kimi-code/local.toml` additional dirs with the - // caller-supplied ones (relative paths resolve against workDir), mirroring - // v1's createSession/resumeSession. A broken local.toml fails the create - // loudly with CONFIG_INVALID, same as v1. const localWorkspaceDirs = await this.workspaceLocalConfig.readAdditionalDirs(opts.workDir); const callerAdditionalDirs = await this.workspaceLocalConfig.resolveAdditionalDirs( opts.workDir, opts.additionalDirs ?? [], ); const additionalDirs = [...localWorkspaceDirs.additionalDirs, ...callerAdditionalDirs]; - // Wait for the host-environment probe to complete before creating any - // Session scope — Session/Agent-scope services (bash, permission policies, - // path-access) read `IHostEnvironment.osKind` / `pathClass` / `homeDir` - // synchronously in their constructors, so the probe must have landed by - // the time the first Session-scoped service is resolved. await this.hostEnv.ready; const handle = createScopedChildHandle( this.instantiation, @@ -194,21 +176,13 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec extra: [...sessionContextSeed(ctx)], }, ) as ISessionScopeHandle; - // Construct the Session activity kernel eagerly so its lane is `restoring` - // for the whole materialize / replay window — edge commands that arrive - // before `markActive()` are rejected with `activity.session_rejected`. handle.accessor.get(ISessionActivityKernel); if (additionalDirs.length > 0) { - // De-duplication happens inside setAdditionalDirs (resolve + Set), - // matching v1's normalizeAdditionalDirs. handle.accessor.get(ISessionWorkspaceContext).setAdditionalDirs(additionalDirs); } this.sessions.set(opts.sessionId, handle); await handle.accessor.get(ISessionMetadata).ready; void handle.accessor.get(ISessionSkillCatalog).ready; - // First `ensureMcpReady` call for the session — it starts the initial MCP - // load, so the caller-supplied servers must ride on it (later calls, e.g. - // from agent creation, only await the in-flight load). await handle.accessor.get(IAgentLifecycleService).ensureMcpReady(opts.mcpServers); handle.accessor.get(ISessionExternalHooksService); return handle; @@ -228,30 +202,16 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private async announceCreated(event: SessionCreatedEvent): Promise { await this.hooks.onDidCreateSession.run(event); this._onDidCreateSession.fire(event); - // Deliberately broader than v1: resumes also emit, with `resumed: true` — - // the flag exists precisely to distinguish them (v1's resume path never - // emitted despite the schema having the flag). this.telemetry.track2('session_started', { resumed: event.source === 'resume' }); event.handle.accessor.get(ISessionActivityKernel).markActive(); } get(sessionId: string): ISessionScopeHandle | undefined { - // A session mid-resume is already materialized in `this.sessions` (so - // close/archive can still find it) but its main agent has not finished - // restore + replay — exposing it would hand callers a half-initialized - // handle. Hide it until `resume` settles; callers that need the handle - // should `await resume(sessionId)`. if (this.resuming.has(sessionId)) return undefined; return this.sessions.get(sessionId); } resume(sessionId: string): Promise { - // Check in-flight resumes FIRST: `materializeSession` adds the session to - // `this.sessions` before `doResume` finishes restore/replay, so a concurrent - // caller that checks `sessions` first would get a half-initialized handle - // whose main agent has no context. Checking `resuming` first ensures - // concurrent callers wait for the full resume (including restore + replay) - // to complete. const inflight = this.resuming.get(sessionId); if (inflight !== undefined) return inflight; const live = this.sessions.get(sessionId); @@ -269,8 +229,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } private async doResume(sessionId: string): Promise { - // Re-check after the serialized entry: a prior `resume` for the same id may - // have already materialized the session while this call was queued. const live = this.sessions.get(sessionId); if (live !== undefined) return live; @@ -289,9 +247,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const agents = handle.accessor.get(IAgentLifecycleService); if (agents.getHandle(MAIN_AGENT_ID) === undefined) { const main = await ensureMainAgent(handle); - // Resolve context memory BEFORE restoring so its reducers are registered; - // otherwise the wire replay applies context records into a void and the - // restored transcript never lands in context memory. main.accessor.get(IAgentContextMemoryService); const mainWireRecord = main.accessor.get(IAgentWireRecordService); await mainWireRecord.restore(); @@ -303,8 +258,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } list(): readonly ISessionScopeHandle[] { - // Exclude sessions still mid-resume for the same reason as `get`: the handle - // exists but is not yet restored, so it must not be observable. const ready: ISessionScopeHandle[] = []; for (const [id, handle] of this.sessions) { if (!this.resuming.has(id)) ready.push(handle); @@ -361,8 +314,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec async fork(opts: ForkSessionOptions): Promise { const sourceId = opts.sourceSessionId; - // 1. Resolve the source: prefer a live handle, otherwise fall back to the - // persisted index (so a closed session can still be forked, like v1). const sourceHandle = this.sessions.get(sourceId); const indexSummary = await this.index.get(sourceId); if (sourceHandle === undefined && indexSummary === undefined) { @@ -373,10 +324,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ? sourceHandle.accessor.get(ISessionContext).workspaceId : indexSummary!.workspaceId; - // 2. Quiesce the live source so no new turn begins while the fork copies - // its wire logs — this closes the check-then-act window (矛盾 k) that the - // old `status() !== 'idle'` check suffered from. A closed source has no - // kernel to quiesce. const quiesce = sourceHandle !== undefined ? await sourceHandle.accessor.get(ISessionActivityKernel).quiesce('fork') @@ -385,19 +332,16 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec let target: ISessionScopeHandle | undefined; let targetSessionDir: string | undefined; try { - // 3. Resolve the work dir the fork inherits (same workspace as the source). const workspace = await this.workspaceRegistry.get(workspaceId); if (workspace === undefined) { throw new Error2('workspace.not_found', `workspace ${workspaceId} does not exist`); } - // 4. Read the source metadata (live handle or disk). const sourceMeta = sourceHandle !== undefined ? await sourceHandle.accessor.get(ISessionMetadata).read() : await this.readMetaFromDisk(workspaceId, sourceId); - // 5. Mint the target id and reject collisions. targetId = opts.newSessionId ?? createSessionId(); if (this.sessions.has(targetId) || (await this.index.get(targetId)) !== undefined) { throw new Error2( @@ -406,7 +350,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ); } - // 6. Materialize the target session scope (fresh metadata + storage). target = await this.materializeSession({ sessionId: targetId, workDir: workspace.root, @@ -415,19 +358,11 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec targetSessionDir = targetCtx.sessionDir; const targetMeta = target.accessor.get(ISessionMetadata); - // 7. Copy the source session's on-disk state into the target — per-agent - // `blobs/` and `plans/`, background-task output, and media originals. - // v1 achieved this with `cp -r` of the whole session dir; the wire logs - // (step 8) and `state.json` (step 9) are rewritten by the fork flow - // itself, and `logs/` is the source's debug log, so those are excluded. await this.copySessionFiles( this.bootstrap.sessionDir(workspaceId, sourceId), targetCtx.sessionDir, ); - // 8. Copy every source agent's wire log into the target's per-agent log - // (BEFORE the target agents are created, so the logs are in place when - // their AgentWireRecordService restores them in step 11). const sourceAgents = sourceMeta?.agents ?? {}; const agentIds = Object.keys(sourceAgents); for (const agentId of agentIds) { @@ -441,7 +376,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec }); } - // 9. Rewrite the target metadata to reflect fork provenance. const title = opts.title ?? `Fork: ${sourceMeta?.title || sourceId}`; await targetMeta.update({ title, @@ -452,13 +386,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec custom: forkCustomMetadata(sourceMeta?.custom, opts.metadata), }); - // 10. Clone the source session's cron tasks for the target. v1 kept cron - // records inside the session dir so `cp -r` carried them; v2 persists - // them at workspace level tagged with the owning session id. await this.duplicateCronTasks(workspaceId, sourceId, targetId); - // 11. Create the target agents (same ids) and restore each from its copied - // log. Creating them registers fresh agent entries with TARGET homedirs. for (const agentId of agentIds) { const sourceAgent = sourceAgents[agentId]!; const agentHandle = await target.accessor.get(IAgentLifecycleService).create({ @@ -481,10 +410,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await this.announceCreated({ sessionId: targetId, handle: target, source: 'fork' }); return target; } catch (error) { - // Roll back the half-fork, mirroring v1's `rm -rf` of the target dir: - // drop the materialized handle from the live registry (otherwise a - // retry with the same id trips SESSION_ALREADY_EXISTS on the in-memory - // check) and delete whatever was copied to disk. if (targetId !== undefined) { this.sessions.delete(targetId); } @@ -492,7 +417,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec try { target.dispose(); } catch { - // best effort — the session dir is removed below regardless } } if (targetSessionDir !== undefined) { @@ -508,8 +432,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const title = opts.title ?? `Child: ${(await this.resolveSourceTitle(opts.sourceSessionId)) ?? opts.sourceSessionId}`; - // The child markers win over any caller-supplied values so a forged - // `parent_session_id` / `child_session_kind` cannot reparent a session. const metadata = { ...opts.metadata, [PARENT_SESSION_ID_KEY]: opts.sourceSessionId, @@ -523,12 +445,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec }); } - /** - * Best-effort source title for the default `Child: ` name. Reads the - * live handle first, then the persisted index. A missing source yields - * `undefined`; `fork` still throws `session.not_found` for the real - * existence check. - */ private async resolveSourceTitle(sourceId: string): Promise<string | undefined> { const live = this.sessions.get(sourceId); if (live !== undefined) { @@ -537,11 +453,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return (await this.index.get(sourceId))?.title; } - /** - * Copy one agent's wire log from the source into the target session's - * per-agent log, appending a `forked` boundary record. Works for both live - * sources (flush then read) and closed sources (read the persisted log). - */ private async copyAgentWire(args: { readonly sourceHandle: ISessionScopeHandle | undefined; readonly sourceHomedir: string; @@ -549,7 +460,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec readonly targetWorkspaceId: string; readonly targetSessionId: string; }): Promise<void> { - // Flush the live agent so its persisted log is current before reading. if (args.sourceHandle !== undefined) { const agentHandle = args.sourceHandle.accessor .get(IAgentLifecycleService) @@ -565,7 +475,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec WIRE_RECORD_FILENAME, ), ); - // Ensure the log starts with a metadata envelope (restore() requires it). if (records.length === 0) { records.push(freshMetadataRecord()); } else if (records[0]?.type !== 'metadata') { @@ -585,18 +494,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ); } - /** - * Copy the source session's on-disk state into the target session dir — - * everything the fork flow does not regenerate itself: per-agent `blobs/` - * and `plans/`, background-task output, and session media originals. v1 - * achieved this with `cp -r` of the whole session dir; these live under - * the v2 session dir too (per-agent scopes and hostFs paths), so the fork - * must carry them explicitly or the target's blob refs resolve to - * `[media missing]` and its active plan file is gone. - * - * A missing source dir means there is nothing on disk to carry over (the - * wire logs are read through the append-log store, not this walk). - */ private async copySessionFiles(sourceDir: string, targetDir: string): Promise<void> { let entries: readonly HostDirEntry[]; try { @@ -616,13 +513,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ): Promise<void> { for (const entry of entries) { const rel = relBase === '' ? entry.name : `${relBase}/${entry.name}`; - // `state.json` is rewritten with fork provenance, the per-agent wire - // logs are copied by `copyAgentWire` (with a fork boundary record), and - // `logs/` is the source's debug log — the fork writes its own. if (rel === 'state.json' || rel === 'logs' || entry.name === WIRE_RECORD_FILENAME) { continue; } - // Never follow symlinks out of the session dir. if (entry.isSymbolicLink === true) continue; const sourcePath = join(sourceDir, entry.name); const targetPath = join(targetDir, entry.name); @@ -644,14 +537,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } } - /** - * Clone the source session's cron tasks for the fork. v1 kept cron records - * inside the session dir, so its `cp -r` fork carried them; v2 persists - * cron at workspace level keyed by a session-id tag, so fork duplicates - * the source's tasks with fresh ids pointing at the target. Fired - * one-shot tasks are already gone from the store (removed on delivery), - * so everything cloned here is still live. - */ private async duplicateCronTasks( workspaceId: string, sourceId: string, @@ -694,7 +579,6 @@ async function collect<T>(iterable: AsyncIterable<T>): Promise<T[]> { return items; } -/** hostFs wraps raw errnos in `HostFsError`; classify the unwrapped cause. */ function isMissingFileError(error: unknown): boolean { const unwrapped = unwrapErrorCause(error); if (unwrapped === null || typeof unwrapped !== 'object') return false; @@ -702,14 +586,6 @@ function isMissingFileError(error: unknown): boolean { return code === 'ENOENT'; } -/** - * Mint a session id in the canonical `session_<lowercase-uuid>` form, matching - * v1's `createSessionId` (`packages/agent-core/src/rpc/core-impl.ts`). - * `randomUUID` already returns lowercase hex, so the result is lowercase by - * construction. Used as the default for both `create` and `fork` when the - * caller does not supply an id, so every session id shares one format and the - * edge layers never mint their own. - */ function createSessionId(): string { return `session_${randomUUID()}`; } @@ -726,10 +602,6 @@ function forkedRecord(): PersistedWireRecord { return { type: 'forked', time: Date.now() } as PersistedWireRecord; } -/** - * Merge the source session's custom metadata with the caller-supplied metadata, - * dropping the reserved `goal` key from both (matches v1's `forkCustomMetadata`). - */ function forkCustomMetadata( source: Record<string, unknown> | undefined, input: Record<string, unknown> | undefined, diff --git a/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts b/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts index 0662663695..5d40eca819 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts @@ -17,8 +17,6 @@ import type { SkillDiscoveryResult, ISkillDiscovery } from './skillDiscovery'; import type { SkillDefinition, SkillRoot, SkippedSkill } from './types'; import { normalizeSkillName } from './types'; -// Bounds recursion so a directory symlink cycle inside a skill root cannot -// loop forever. Real skill trees are 1-3 levels deep. const MAX_SKILL_SCAN_DEPTH = 8; export class FileSkillDiscovery implements ISkillDiscovery { @@ -51,8 +49,6 @@ export async function discoverFileSkills( let entries: readonly string[]; try { - // Sorted so first-wins collision resolution across sibling directories - // is deterministic rather than dependent on filesystem readdir order. entries = [...(await fs.readdir(dirPath))].toSorted(); } catch { return; @@ -62,8 +58,6 @@ export async function discoverFileSkills( const subdirs: string[] = []; for (const entry of entries) { const entryPath = path.join(dirPath, entry); - // A directory holding SKILL.md is a skill bundle: register it, then keep - // descending so nested SKILL.md bundles remain discoverable as sub-skills. if (await isFile(path.join(entryPath, 'SKILL.md'))) { directorySkills.add(entry); } @@ -87,12 +81,7 @@ export async function discoverFileSkills( } } - // Flat .md skills count only at a root's top level; deeper .md files are - // skill payload (e.g. references/foo.md), not skills. if (isTopLevel) { - // A SKILL.md placed directly at a plugin skill root (e.g. plugin root - // fallback) is treated as a single skill bundle. This only applies to - // plugin-derived roots, not to user/project skill directories. if (root.plugin !== undefined) { const rootSkillMd = path.join(dirPath, 'SKILL.md'); if (await isFile(rootSkillMd)) { diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillCatalogRuntimeOptions.ts b/packages/agent-core-v2/src/app/skillCatalog/skillCatalogRuntimeOptions.ts index 40d6150ac7..a1ffef6d8e 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillCatalogRuntimeOptions.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillCatalogRuntimeOptions.ts @@ -29,11 +29,6 @@ export class SkillCatalogRuntimeOptions implements ISkillCatalogRuntimeOptions { constructor(readonly explicitDirs?: readonly string[]) {} } -/** - * Seed {@link ISkillCatalogRuntimeOptions} with caller-supplied explicit skill - * dirs (v1's SDK `skillDirs`). Empty/absent input seeds nothing so the - * registered default (no explicit dirs) stays in effect. - */ export function skillCatalogRuntimeOptionsSeed( explicitDirs: readonly string[] | undefined, ): ScopeSeed { diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts index 275a4ab001..c0a1c76330 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts @@ -15,8 +15,6 @@ import path from 'pathe'; import type { SkillRoot, SkillSource } from './types'; -// Relative to brandHomeDir, which already IS the brand data dir (~/.kimi-code or -// $KIMI_CODE_HOME) — no '.kimi-code' segment here, or it would nest twice. const USER_BRAND_DIRS = ['skills'] as const; const USER_GENERIC_DIRS = ['.agents/skills'] as const; const PROJECT_BRAND_DIRS = ['.kimi-code/skills'] as const; @@ -33,7 +31,6 @@ export async function userRoots( ): Promise<readonly SkillRoot[]> { const roots: SkillRoot[] = []; const mergeAllAvailableSkills = options.mergeAllAvailableSkills ?? true; - // homeDir is already the brand data dir, so brand skills live at <homeDir>/skills. await pushBrandGroup(roots, USER_BRAND_DIRS, homeDir, 'user', mergeAllAvailableSkills); await pushFirstExisting(roots, USER_GENERIC_DIRS, osHomeDir, 'user'); return roots; diff --git a/packages/agent-core-v2/src/app/task/task.ts b/packages/agent-core-v2/src/app/task/task.ts index eac2c3a0b6..faa205a924 100644 --- a/packages/agent-core-v2/src/app/task/task.ts +++ b/packages/agent-core-v2/src/app/task/task.ts @@ -49,20 +49,7 @@ export interface IDeferredHandle<T = unknown> extends ITaskHandle<T> { export interface ITaskService { readonly _serviceBrand: undefined; - /** - * Create a task that actively runs `fn`. The function receives an - * `AbortSignal` (cancelled when the handle is cancelled/disposed) and - * an `output` callback for streaming data (e.g. process stdout). - * - * State: pending → running → completed | failed | cancelled. - */ run<T>(fn: (signal: AbortSignal, output: (data: string) => void) => Promise<T>): ITaskHandle<T>; - /** - * Create a passive task whose settlement is controlled by the caller - * through the returned `resolve` / `reject` methods. - * - * State: pending → completed | failed | cancelled. - */ defer<T>(): IDeferredHandle<T>; } diff --git a/packages/agent-core-v2/src/app/task/taskService.ts b/packages/agent-core-v2/src/app/task/taskService.ts index 7fe4b1ec07..643fca0a82 100644 --- a/packages/agent-core-v2/src/app/task/taskService.ts +++ b/packages/agent-core-v2/src/app/task/taskService.ts @@ -67,7 +67,6 @@ class RunHandle<T> implements ITaskHandle<T> { }, ); - // Prevent unhandled rejection warnings when nobody has attached a handler yet. void this.result.catch(() => {}); } diff --git a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts index c5abe5f5ff..0b5b16ebaf 100644 --- a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts +++ b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts @@ -12,24 +12,15 @@ import { createDecorator } from '#/_base/di/instantiation'; export type AgentTelemetryContext = { - /** Current agent mode; owned by the `plan` domain. */ mode: 'agent' | 'plan'; - /** - * Resolved model protocol, mirrored to v1's `provider_type` — v2 has no - * separate provider type, so both keys carry the protocol. Undefined when - * the bound model is unresolvable. Owned by the `profile` domain. - */ provider_type?: string; - /** Resolved model protocol; undefined when the bound model is unresolvable. */ protocol?: string; }; export interface IAgentTelemetryContextService { readonly _serviceBrand: undefined; - /** Current ambient telemetry properties for this agent. */ get(): AgentTelemetryContext; - /** Merge a patch into the ambient telemetry context. */ set(patch: Partial<AgentTelemetryContext>): void; } diff --git a/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts b/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts index ee64c31da7..1c99fc3f92 100644 --- a/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts +++ b/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts @@ -51,10 +51,6 @@ export interface CloudAppenderOptions { readonly now?: () => number; } -/** - * Host identity facts the engine cannot resolve on its own. Everything else - * (storage, client version, env, platform) comes from the accessor. - */ export interface CloudAppenderHostOptions { readonly deviceId: string; readonly appName: string; @@ -65,11 +61,6 @@ export interface CloudAppenderHostOptions { readonly getAccessToken?: () => string | null | Promise<string | null>; } -/** - * Assemble a `CloudAppender` from the accessor's registered services plus - * host identity facts. The accessor is only read synchronously during this - * call — never stash it. - */ export function createCloudAppender( accessor: ServicesAccessor, host: CloudAppenderHostOptions, @@ -191,7 +182,6 @@ function buildContext(options: CloudAppenderOptions): CloudContext { const context: CloudContext = { app_name: options.appName, client_version: bootstrap.clientVersion, - // `version` is kept as a backward-compatible alias of `client_version`. version: bootstrap.clientVersion, core_version: resolveCoreVersion(), runtime: 'node', diff --git a/packages/agent-core-v2/src/app/telemetry/coreVersion.ts b/packages/agent-core-v2/src/app/telemetry/coreVersion.ts index 7daaf26538..80ddd68eb6 100644 --- a/packages/agent-core-v2/src/app/telemetry/coreVersion.ts +++ b/packages/agent-core-v2/src/app/telemetry/coreVersion.ts @@ -43,7 +43,6 @@ function walkForPackageVersion(): string { dir = parent; } } catch { - // Best effort: version resolution must never break telemetry. } return UNKNOWN_VERSION; } diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index fc1f57197c..bb1a982f9a 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -23,7 +23,6 @@ export interface TelemetryEventMeta { export interface TelemetryEventDefinition<P> { readonly meta: TelemetryEventMeta; - /** Type-only phantom field carrying `P`; never present at runtime. */ readonly _properties?: P; } @@ -45,9 +44,7 @@ export type StrictPropertyCheck<T, E> = string extends keyof T export interface TurnStartedEvent { mode: 'agent' | 'plan'; - /** Resolved model protocol; v2 has no separate provider type (v1 parity). */ provider_type?: string; - /** Resolved model protocol. */ protocol?: string; } @@ -55,9 +52,7 @@ export interface TurnInterruptedEvent { at_step: number; mode: 'agent' | 'plan'; interrupt_reason: 'user_cancelled' | 'aborted' | 'max_steps' | 'error' | 'filtered' | 'blocked'; - /** Resolved model protocol; v2 has no separate provider type (v1 parity). */ provider_type?: string; - /** Resolved model protocol. */ protocol?: string; } @@ -65,9 +60,7 @@ export interface TurnEndedEvent { reason: 'completed' | 'cancelled' | 'failed'; duration_ms: number; mode: 'agent' | 'plan'; - /** Resolved model protocol; v2 has no separate provider type (v1 parity). */ provider_type?: string; - /** Resolved model protocol. */ protocol?: string; } @@ -79,11 +72,6 @@ export interface ToolCallEvent { tool_name: string; outcome: ToolCallOutcome; duration_ms: number; - /** - * Whether the call was a duplicate. v1's union is 'normal' | 'cross_step'; - * v2 adds 'same_step' because same-step duplicates reach execution telemetry - * through the placeholder-result path (v1 swallowed them beforehand). - */ dup_type: 'normal' | 'same_step' | 'cross_step'; error_type?: 'cancelled' | 'error'; } @@ -91,16 +79,12 @@ export interface ToolCallEvent { export interface ApiErrorEvent { error_type: string; model: string; - /** Model alias the request targeted, when one is bound. */ alias?: string; retryable: boolean; duration_ms: number; status_code?: number; - /** Resolved model protocol; v2 has no separate provider type (v1 parity). */ provider_type?: string; - /** Resolved model protocol. */ protocol?: string; - /** Current turn's accumulated total input tokens, when usage exists. */ input_tokens?: number; } @@ -140,7 +124,6 @@ export interface PermissionPolicyDecisionEvent { tool_name: string; permission_mode: TelemetryPermissionMode; decision: 'approve' | 'deny' | 'ask'; - /** Open property bag: policies attach their own reason keys. */ [key: string]: TelemetryPrimitive; } @@ -181,18 +164,13 @@ export interface CompactionFinishedEvent { tokens_after: number; duration_ms: number; compacted_count: number; - /** Always sent; undefined when no entries were dropped. */ dropped_count?: number; retry_count: number; round: number; thinking_effort: string; - /** Total input tokens (other + cache read + cache creation). */ input_tokens?: number; - /** Output tokens. */ output_tokens?: number; - /** Cache-read input tokens (v2 extra). */ input_cache_read?: number; - /** Cache-creation input tokens (v2 extra). */ input_cache_creation?: number; } @@ -207,21 +185,13 @@ export interface CompactionFailedEvent { } export interface ContextProjectionRepairedEvent { - /** Tool results moved back next to their call. */ reordered: number; - /** Placeholder results invented for lost ones. */ synthesized: number; - /** Results with no matching call dropped. */ dropped_orphan: number; - /** Tool calls with an already-seen id dropped. */ duplicate_calls_dropped: number; - /** Second results for an already-answered id dropped. */ duplicate_results_dropped: number; - /** Leading non-user messages dropped. */ leading_dropped: number; - /** Consecutive assistant messages merged. */ assistants_merged: number; - /** Whitespace-only text blocks dropped. */ whitespace_dropped: number; } @@ -372,7 +342,6 @@ export interface ImageCompressEvent { export interface ImageCropEvent { source: string; ok: boolean; - /** Always sent; undefined when the crop succeeded. */ error_kind?: | 'empty' | 'unsupported_format' @@ -381,25 +350,17 @@ export interface ImageCropEvent { | 'out_of_bounds' | 'budget' | 'decode_failed'; - /** Always sent; undefined when the crop failed before producing a result. */ resized?: boolean; - /** Always sent; undefined when the crop failed before producing a result. */ original_width?: number; - /** Always sent; undefined when the crop failed before producing a result. */ original_height?: number; - /** Always sent; undefined when there is no result or no original pixels. */ region_area_ratio?: number; - /** Always sent; undefined when the crop failed before producing a result. */ final_bytes?: number; duration_ms: number; } export interface VideoUploadEvent { - /** Always sent; undefined when no model alias is bound. */ model?: string; - /** Always sent; undefined when the model is unresolved. */ provider_type?: string; - /** Always sent; undefined when the model is unresolved. */ protocol?: string; mime_type: string; size_bytes: number; @@ -409,12 +370,10 @@ export interface VideoUploadEvent { } export interface SessionStartedEvent { - /** True when the session was resumed from disk; false for startup/fork. */ resumed: boolean; } export interface SessionLoadFailedEvent { - /** Error code (Error2), error name, or 'unknown'. */ reason: string; } diff --git a/packages/agent-core-v2/src/app/telemetry/telemetry.ts b/packages/agent-core-v2/src/app/telemetry/telemetry.ts index 6b42303e54..2b2aa09df2 100644 --- a/packages/agent-core-v2/src/app/telemetry/telemetry.ts +++ b/packages/agent-core-v2/src/app/telemetry/telemetry.ts @@ -44,17 +44,7 @@ export interface TelemetryServiceOptions { export interface ITelemetryService { readonly _serviceBrand: undefined; - /** - * Low-level untyped event sink — appender plumbing and tests only. - * Business events must go through `track2` so the event name and its - * properties are checked against the registry in `events.ts`. - */ track(event: string, properties?: TelemetryProperties): void; - /** - * Track a registered business event. The event name must exist in - * `telemetryEventDefinitions` and the properties must match the registered - * type exactly (checked at compile time, zero runtime cost). - */ track2<K extends TelemetryEventName, E extends TelemetryEventProperties<K> = never>( event: K, properties?: StrictPropertyCheck<TelemetryEventProperties<K>, E>, @@ -77,11 +67,6 @@ export const nullTelemetryAppender: ITelemetryAppender = { shutdown: () => {}, }; -/** - * No-op `ITelemetryService` for callers that want to accept an optional - * telemetry service (e.g. tools constructed outside DI in tests). Mirrors v1's - * `noopTelemetryClient`. - */ export const noopTelemetryService: ITelemetryService = { _serviceBrand: undefined, track: () => {}, diff --git a/packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts b/packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts index cbbd361c2c..30ae12b0c1 100644 --- a/packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts +++ b/packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts @@ -3,15 +3,8 @@ import { parseHTML as rawParseHTML } from 'linkedom'; import { HttpFetchError, type UrlFetcher, type UrlFetchResult } from '../tools/fetch-url-types'; -// Readability's .d.ts references the global `Document` type, but this -// package compiles with `lib: ES2023` (no DOM). Extracting the -// constructor parameter type keeps us off the global `Document` name -// while still accepting whatever Readability wants. type ReadabilityDocument = ConstructorParameters<typeof Readability>[0]; -// linkedom's published types depend on DOM libs we don't load. Declare -// the minimal surface we actually use so the rest of the file stays -// type-safe without pulling lib.dom.d.ts into the host build. interface DomElementLike { textContent: string | null; querySelector(selector: string): DomElementLike | null; @@ -31,15 +24,6 @@ export interface LocalFetchURLProviderOptions { userAgent?: string; fetchImpl?: typeof fetch; maxBytes?: number; - /** - * Allow fetching loopback / RFC 1918 / link-local / ULA addresses. - * Defaults to `false` — enabled only for tests and explicit opt-in. - * - * Note: the guard below is a static string check against the URL host; it - * does not resolve DNS, so a hostname that resolves to a private address - * (DNS rebinding) is not blocked. Do not rely on this as a security boundary - * against a determined attacker. - */ allowPrivateAddresses?: boolean; } @@ -70,7 +54,6 @@ export class LocalFetchURLProvider implements UrlFetcher { if (response.status >= 400) { await response.body?.cancel().catch(() => { - /* already closed */ }); throw new HttpFetchError( response.status, @@ -120,7 +103,6 @@ export class LocalFetchURLProvider implements UrlFetcher { } } } catch { - // Fall through to the container-based fallback. } const { document } = parseHTML(html); diff --git a/packages/agent-core-v2/src/app/web/providers/moonshot-fetch-url.ts b/packages/agent-core-v2/src/app/web/providers/moonshot-fetch-url.ts index 35e421d2af..5f69d2fc3e 100644 --- a/packages/agent-core-v2/src/app/web/providers/moonshot-fetch-url.ts +++ b/packages/agent-core-v2/src/app/web/providers/moonshot-fetch-url.ts @@ -39,14 +39,9 @@ export class MoonshotFetchURLProvider implements UrlFetcher { ): Promise<UrlFetchResult> { try { const content = await this.fetchViaMoonshot(url, options?.toolCallId, options?.signal); - // The service returns text it has already extracted from the page. return { content, kind: 'extracted' }; } catch (error) { - // If the caller cancelled, do not fall back to the local fetcher — - // propagate the abort instead of issuing a second request. if (options?.signal?.aborted === true) throw error; - // Forward an explicit options object even when the caller passed - // none, so downstream consumers always see a defined second arg. return this.localFallback.fetch(url, options ?? {}); } } @@ -64,7 +59,6 @@ export class MoonshotFetchURLProvider implements UrlFetcher { try { detail = await response.text(); } catch { - /* ignore */ } throw new HttpFetchError( response.status, diff --git a/packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts b/packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts index a6ba946544..de43ab052c 100644 --- a/packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts +++ b/packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts @@ -13,9 +13,7 @@ export type UrlFetchKind = 'passthrough' | 'extracted'; export interface UrlFetchResult { - /** The text handed to the LLM. */ readonly content: string; - /** Whether `content` is a verbatim passthrough or extracted main text. */ readonly kind: UrlFetchKind; } @@ -26,12 +24,6 @@ export interface UrlFetcher { ): Promise<UrlFetchResult>; } -/** - * Thrown by a `UrlFetcher` when the upstream HTTP request completed but - * returned a non-success status. The tool branches on this to surface - * `Status: N` in the error message; non-HTTP failures (DNS, timeout, - * connection reset, …) keep flowing through as plain `Error`. - */ export class HttpFetchError extends Error { override readonly name = 'HttpFetchError'; readonly status: number; diff --git a/packages/agent-core-v2/src/app/web/tools/fetch-url.ts b/packages/agent-core-v2/src/app/web/tools/fetch-url.ts index bd2a201440..6c847b0d04 100644 --- a/packages/agent-core-v2/src/app/web/tools/fetch-url.ts +++ b/packages/agent-core-v2/src/app/web/tools/fetch-url.ts @@ -26,7 +26,6 @@ import { IWebFetchService } from '../web'; import { HttpFetchError, type UrlFetcher } from './fetch-url-types'; import DESCRIPTION from './fetch-url.md?raw'; -// ── Input schema ───────────────────────────────────────────────────── export const FetchURLInputSchema = z.object({ url: z.string().describe('The URL to fetch content from.'), @@ -34,7 +33,6 @@ export const FetchURLInputSchema = z.object({ export type FetchURLInput = z.infer<typeof FetchURLInputSchema>; -// ── Implementation ─────────────────────────────────────────────────── export class FetchURLTool implements BuiltinTool<FetchURLInput> { readonly name = 'FetchURL' as const; @@ -70,12 +68,6 @@ export class FetchURLTool implements BuiltinTool<FetchURLInput> { } const builder = new ToolResultBuilder({ maxLineLength: null }); - // Tell the LLM whether it received the whole body or only the extracted - // article text, so it can judge how complete the content is, and remind it - // to cite this page when it uses the content. Both notes must ride in - // `output`: the result's `message` field is dropped from the transcript, so - // `output` is the only place the model can read them. Put them at the front - // so they survive any downstream truncation of the body. const note = kind === 'passthrough' ? 'The returned content is the full response body, returned verbatim.' @@ -85,10 +77,6 @@ export class FetchURLTool implements BuiltinTool<FetchURLInput> { builder.write(`${note} ${citeReminder}\n\n${content}`); return builder.ok(); } catch (error) { - // An in-flight abort rejects the signal-aware fetch promptly. Re-throw - // so the executor can classify it (including user cancellation) and - // produce the right message, rather than surfacing it as a generic - // network error that the model may retry. if (signal.aborted) throw error; const msg = error instanceof Error ? error.message : String(error); if (error instanceof HttpFetchError) { diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts b/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts index c0351a82cf..70bff87ade 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts @@ -19,8 +19,6 @@ import { } from './workspacePersistence'; const WORKSPACE_REGISTRY_VERSION = 1; -// Empty scope resolves to `<homeDir>/<key>` (join skips empty segments), -// preserving the historical `<homeDir>/workspaces.json` location. const WORKSPACE_REGISTRY_SCOPE = ''; const WORKSPACE_REGISTRY_KEY = 'workspaces.json'; @@ -41,8 +39,6 @@ export class FileWorkspacePersistence implements IWorkspacePersistence { typeof (file as { workspaces?: unknown }).workspaces !== 'object' || (file as { workspaces?: unknown }).workspaces === null ) { - // Structurally malformed catalog → treat as unusable so the registry - // rebuilds from the legacy session index instead of sticking on empty. return undefined; } const now = Date.now(); diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts index 9a85863a7f..8a06729a54 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts @@ -16,7 +16,6 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio import type { Workspace } from './workspaceRegistry'; -/** On-disk entry shape — v1 `workspaces.json` compatible (ISO timestamps). */ export interface PersistedWorkspaceEntry { readonly root: string; readonly name: string; @@ -24,7 +23,6 @@ export interface PersistedWorkspaceEntry { readonly last_opened_at: string; } -/** On-disk document shape — v1 `workspaces.json` compatible. */ export interface PersistedWorkspaceFile { readonly version: number; readonly workspaces: Record<string, PersistedWorkspaceEntry>; @@ -33,15 +31,7 @@ export interface PersistedWorkspaceFile { export interface IWorkspacePersistence { readonly _serviceBrand: undefined; - /** - * Load the persisted catalog. - * - * - `undefined` → no usable catalog exists (absent or malformed); the caller - * should rebuild. - * - `Workspace[]` (possibly empty) → a materialized catalog; do not rebuild. - */ load(): Promise<Workspace[] | undefined>; - /** Atomically replace the persisted catalog. */ save(workspaces: readonly Workspace[]): Promise<void>; } diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceQuery.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceQuery.ts index f8ebcbcf92..553d81c121 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceQuery.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceQuery.ts @@ -14,17 +14,11 @@ import type { SessionSummary } from '#/app/sessionIndex/sessionIndex'; export type { SessionSummary }; -/** Number of recent sessions returned by `listRecentSessions`. */ export const RECENT_SESSIONS_LIMIT = 20; export interface IWorkspaceQueryService { readonly _serviceBrand: undefined; - /** - * List the `RECENT_SESSIONS_LIMIT` (20) most recent sessions in - * `workspaceId`, newest first (by `updatedAt`). Returns an empty array when - * the workspace has no sessions or is unknown to the session index. - */ listRecentSessions(workspaceId: string): Promise<readonly SessionSummary[]>; } diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistry.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistry.ts index 2ff0db0271..2a0960d9d4 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistry.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistry.ts @@ -13,9 +13,7 @@ export interface Workspace { readonly id: string; readonly root: string; readonly name: string; - /** Epoch ms when the workspace was first registered in this process. */ readonly createdAt: number; - /** Epoch ms of the most recent `createOrTouch` (open) for this workspace. */ readonly lastOpenedAt: number; } @@ -28,11 +26,6 @@ export interface IWorkspaceRegistry { list(): Promise<readonly Workspace[]>; get(id: string): Promise<Workspace | undefined>; - /** - * Register (or refresh `lastOpenedAt` for) a workspace rooted at `root`. - * Throws `fs.path_not_found` when `root` is missing or not a directory — - * callers opening a session must ensure the directory exists first. - */ createOrTouch(root: string, name?: string): Promise<Workspace>; update(id: string, patch: WorkspaceUpdate): Promise<Workspace | undefined>; delete(id: string): Promise<void>; diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts index 53852f8d12..b91248de15 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts @@ -29,8 +29,6 @@ import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IWorkspaceRegistry, type Workspace, type WorkspaceUpdate } from './workspaceRegistry'; import { IWorkspacePersistence } from './workspacePersistence'; -// Legacy v1 session index, read only for the one-shot rebuild. Empty scope -// resolves to `<homeDir>/<key>` (join skips empty segments). const SESSION_INDEX_SCOPE = ''; const SESSION_INDEX_KEY = 'session_index.jsonl'; @@ -45,7 +43,6 @@ interface SessionIndexLine { export class WorkspaceRegistryService implements IWorkspaceRegistry { declare readonly _serviceBrand: undefined; - /** `undefined` until the first access loads/rebuilds the catalog. */ private cache: Map<string, Workspace> | undefined; private opQueue: Promise<unknown> = Promise.resolve(); @@ -72,14 +69,10 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { createOrTouch(root: string, name?: string): Promise<Workspace> { return this.runExclusive(async () => { const cache = await this.ensureLoaded(); - // Refuse to catalog a root that is not a live directory: every consumer - // of a workspace (session cwd, fs tools, Bash spawn) assumes it exists, - // and failing here beats a misleading spawn ENOENT at prompt time. let stat; try { stat = await this.hostFs.stat(root); } catch (error) { - // hostFs wraps raw errnos in `HostFsError`; classify the unwrapped cause. const code = (unwrapErrorCause(error) as NodeJS.ErrnoException | undefined)?.code; if (code === 'ENOENT' || code === 'ENOTDIR') { throw new Error2(ErrorCodes.FS_PATH_NOT_FOUND, `workspace root ${root} does not exist`); @@ -200,14 +193,6 @@ function parseSessionIndexLine(line: string): SessionIndexLine | undefined { } } -/** - * Collapse registered workspaces that share a `root`. The persisted catalog - * (v1-compatible `workspaces.json`) can hold legacy entries whose id was - * computed by an older `encodeWorkDirKey` (e.g. realpath-based on Windows) for - * the same folder, so one root may map to multiple ids. Prefer the entry whose - * id matches the current canonical key so current sessions' `workspace_id` - * still resolves and the same folder is not listed twice. - */ function dedupeByRoot(cache: ReadonlyMap<string, Workspace>): Workspace[] { const byRoot = new Map<string, Workspace>(); for (const ws of cache.values()) { diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 04caac4171..3bedc09b4e 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -266,7 +266,6 @@ export * from '#/app/workspaceRegistry/workspaceRegistry'; export * from '#/app/workspaceRegistry/workspaceRegistryService'; export * from '#/app/workspaceRegistry/workspacePersistence'; export * from '#/app/workspaceRegistry/fileWorkspacePersistence'; -// Register-only bindings not re-exported by their domain barrel — loaded for side effects. import '#/app/workspaceRegistry/workspaceQueryService'; import '#/app/git/gitService'; export * from '#/session/process/processRunner'; @@ -345,7 +344,6 @@ export * from '#/app/web/webService'; export * from '#/app/web/providers/local-fetch-url'; export * from '#/app/web/providers/moonshot-fetch-url'; -// Ported agent services. These keep the current service boundaries during the migration. export * from '#/agent/blob/agentBlobService'; export * from '#/agent/blob/agentBlobServiceImpl'; export * from '#/agent/contextMemory/contextMemory'; diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostEnvironmentService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostEnvironmentService.ts index cd62035382..7d3c1eb789 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostEnvironmentService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostEnvironmentService.ts @@ -29,12 +29,6 @@ export class HostEnvironmentService implements IHostEnvironment { readonly ready: Promise<void>; constructor() { - // Enrich process.env.PATH from the user's login shell so spawned commands - // find user-installed tools (e.g. Homebrew's gh) even when kimi-code itself - // was launched without the full profile PATH. Both probes are memoised, - // independent, and run concurrently: the login-shell probe is a no-op on - // win32 (where probeHostEnvironment reads PATH to locate Git Bash), and on - // POSIX probeHostEnvironment does not consult PATH. this.ready = Promise.all([ probeHostEnvironmentFromNode().then((info) => { this._info = info; diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts index a8e9c4d3fd..d3e525ebfc 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts @@ -172,8 +172,6 @@ export class HostFileSystem implements IHostFileSystem { } return true; } catch (error) { - // EEXIST keeps its boolean semantics: callers treat a collision as - // "the same bytes are already present", not as a failure. if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false; throw toHostFsError(error, { path, op: 'create' }); } @@ -181,10 +179,6 @@ export class HostFileSystem implements IHostFileSystem { async stat(path: string): Promise<HostFileStat> { try { - // Non-following `lstat` so a symbolic link is reported as itself - // (`isSymbolicLink: true`) rather than transparently resolved to its - // target. Callers that confine paths lexically rely on this to avoid - // escaping the workspace through a symlinked directory. const s = await lstat(path); return { isFile: s.isFile(), diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts index a0e047050b..ef57be07b7 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts @@ -22,7 +22,6 @@ import { IHostFsWatchService, } from '#/os/interface/hostFsWatch'; -/** Suppress `.git` directories by default — they are high-volume noise. */ const DEFAULT_IGNORED = (p: string): boolean => /(?:^|[/\\])\.git(?:$|[/\\])/.test(p); class HostFsWatchHandle implements IHostFsWatchHandle { @@ -47,8 +46,6 @@ class HostFsWatchHandle implements IHostFsWatchHandle { if (mapped !== undefined) this.emitter.fire(mapped); }); this.watcher.on('error', (error: unknown) => { - // Best-effort: a watcher error must not crash the host. Higher layers - // can always re-subscribe if events stop arriving. onUnexpectedError(error); }); this.watcher.add(path); diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostProcessService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostProcessService.ts index 0caf0a3207..a3bae6cd7b 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostProcessService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostProcessService.ts @@ -146,7 +146,6 @@ class HostProcess implements IHostProcess { try { this._child.kill(signal ?? 'SIGTERM'); } catch { - /* best effort */ } return; } diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostTerminalService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostTerminalService.ts index 450022cea3..680b1fddb3 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostTerminalService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostTerminalService.ts @@ -49,7 +49,6 @@ export class HostTerminalService extends Disposable implements IHostTerminalServ try { process.kill(); } catch { - // best-effort cleanup } } this.processes.clear(); diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts index a0ae3d9d7d..564d1f3a02 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts @@ -142,7 +142,6 @@ async function disposeProcess(proc: IProcess): Promise<void> { try { await proc.dispose(); } catch { - /* best-effort cleanup */ } } @@ -166,11 +165,6 @@ function withoutBackgroundDescription(description: string): string { ); } -/** - * Strip the auto-background-on-timeout promise when the config disabled it - * (`task.bash_auto_background_on_timeout = false`): timed-out foreground - * commands are killed in that configuration, and the description must say so. - */ function withoutAutoBackgroundOnTimeout(description: string): string { return description.replace( ' When a foreground command hits its timeout it is moved to the background instead of being killed, and you will be automatically notified when it completes.', @@ -249,16 +243,10 @@ export class BashTool implements BuiltinTool<BashInput> { const noninteractiveEnv: Record<string, string> = { NO_COLOR: '1', TERM: 'dumb', - // Default to '0' so git fails fast on private remotes if a TTY happens - // to be inherited; honour an explicit ambient value when the user has - // set one. GIT_TERMINAL_PROMPT: process.env['GIT_TERMINAL_PROMPT'] ?? '0', SHELL: this.env.shellPath, }; - // v2's ISessionProcessRunner.exec overlays this env on process.env, so we pass - // only the noninteractive knobs (the v1 spread of process.env is handled - // by the runner). return this.runner.exec(shellArgs, { env: noninteractiveEnv }); } @@ -316,14 +304,7 @@ export class BashTool implements BuiltinTool<BashInput> { { detached: startsInBackground, timeoutMs, - // Detaching (ctrl+b) moves a foreground command to the background; - // give it the background timeout so it is not still bounded by the - // shorter foreground deadline. detachTimeoutMs: DEFAULT_BACKGROUND_TIMEOUT_S * MS_PER_SECOND, - // A foreground command that hits its timeout is moved to the - // background (re-armed to detachTimeoutMs) instead of being killed — - // unless disabled via config, or background tooling is unavailable - // for this agent. autoBackgroundOnTimeout: this.allowBackground() && this.autoBackgroundOnTimeout(), signal: startsInBackground ? undefined : signal, }, @@ -338,8 +319,6 @@ export class BashTool implements BuiltinTool<BashInput> { }; } - // Foreground `!` shell commands surface their task id so the TUI can detach - // (ctrl+b) this exact task. Background runs are already detached. if (!startsInBackground) onForegroundTaskStart?.(taskId); if (startsInBackground) { @@ -496,9 +475,6 @@ export class BashTool implements BuiltinTool<BashInput> { scenario: 'background_started' | 'foreground_detached', ): string { if (scenario === 'foreground_detached') { - // The user explicitly moved a foreground call to the background to avoid - // blocking the current turn. Steer the model away from waiting on it. - // Only mention TaskOutput when the tool is actually available. const avoid = this.allowBackground() ? 'do NOT wait, poll, or call TaskOutput on it' : 'do NOT wait or poll'; @@ -507,9 +483,6 @@ export class BashTool implements BuiltinTool<BashInput> { `when it completes — ${avoid}; continue with your current work.\n` ); } - // background_started: the model chose to launch in the background. Same anti-wait - // stance — immediately waiting on a background task is just a blocked turn, so do - // not invite a TaskOutput peek here. if (!this.allowBackground()) { return 'next_step: You will be automatically notified when it completes.\n'; } @@ -544,7 +517,6 @@ function closeProcessStdin(proc: IProcess): void { try { proc.stdin.end(); } catch { - /* process already gone */ } } @@ -552,7 +524,6 @@ async function killSpawnedProcess(proc: IProcess): Promise<void> { try { await proc.kill('SIGTERM'); } catch { - /* process already gone */ } finally { await disposeProcess(proc); } diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/glob.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/glob.ts index e5557781d3..dfaff3d474 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/glob.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/glob.ts @@ -109,8 +109,6 @@ export const MAX_MATCHES = 100; const VCS_DIRECTORIES_TO_EXCLUDE = ['.git', '.svn', '.hg', '.bzr', '.jj', '.sl'] as const; -// Conservative rg-level prefilter. The authoritative sensitive-file check -// still happens on parsed rg records via `isSensitiveFile` after execution. const SENSITIVE_KEY_BASENAMES = ['id_rsa', 'id_ed25519', 'id_ecdsa'] as const; const SENSITIVE_GLOBS_TO_EXCLUDE: readonly string[] = [ '**/.env', @@ -125,27 +123,12 @@ const SENSITIVE_GLOBS_TO_EXCLUDE: readonly string[] = [ '**/.gcp/credentials/**', ]; -/** - * Path-shape hint appended to the tool description only on a Windows - * (`win32` path class) backend. The `path` argument accepts both native - * Windows paths and POSIX-style paths, but matched paths come back in - * Windows backslash form — a command run through Bash must convert them - * to forward slashes first. Injected conditionally so non-Windows - * sessions are not shown a hint that does not apply to them. - */ export const WINDOWS_PATH_HINT = '\n\nWindows note: the `path` argument accepts both Windows paths ' + '(e.g. `C:\\Users\\foo`) and POSIX-style paths (e.g. `/c/Users/foo`). Matched paths are ' + 'returned in Windows backslash form; convert them to forward slashes before ' + 'using them in a Bash command.'; -/** - * Tool-level description shown to the LLM at tool declaration time. - * Tells the model — before any round-trip — which patterns are accepted, - * how brace expansion is handled, and which directories are too large to - * recurse into. On a Windows backend the description also carries - * `WINDOWS_PATH_HINT` (path-shape guidance). - */ export class GlobTool implements BuiltinTool<GlobInput> { readonly name = 'Glob' as const; readonly description: string; @@ -156,8 +139,6 @@ export class GlobTool implements BuiltinTool<GlobInput> { @IHostProcessService private readonly processService: IHostProcessService, @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, @ITelemetryService private readonly telemetry: ITelemetryService, - // Optional so unit tests that construct the tool directly (bypassing DI) - // keep working; always registered in production scopes. @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, ) { this.description = @@ -165,8 +146,6 @@ export class GlobTool implements BuiltinTool<GlobInput> { } private get workspaceConfig(): WorkspaceConfig { - // Skill roots are merged per call (v1 merged once at tool construction): - // the catalog loads asynchronously and gains roots on plugin reloads. return extendWorkspaceWithSkillRoots( { workspaceDir: this.workspaceCtx.workDir, @@ -219,9 +198,6 @@ export class GlobTool implements BuiltinTool<GlobInput> { ): Promise<ExecutableToolResult> { const searchRoot = searchRoots[0] ?? this.workspaceConfig.workspaceDir; - // `rg --files <file>` exits 0 and lists the file itself, so without this - // check a file root would be returned as its own match instead of - // rejected, and a missing root would surface as "No matches found". try { const st = await this.fs.stat(searchRoot); if (!st.isDirectory) { @@ -238,10 +214,6 @@ export class GlobTool implements BuiltinTool<GlobInput> { return { isError: true, output: 'Glob aborted' }; } - // Resolve a working `rg` before running. Probes the execution environment - // (system PATH, then the cached bootstrap binary) so a missing `rg` gets a - // clear, actionable message — and so a non-PATH fallback is recorded in - // telemetry — instead of a confusing `spawn rg ENOENT`. let rgPath: string; try { const resolution = await ensureRgPath(createRgProbe(this.processService), { @@ -263,11 +235,6 @@ export class GlobTool implements BuiltinTool<GlobInput> { return { isError: true, output: rgUnavailableMessage(error) }; } - // Run rg with its cwd pinned to the search root and `.` as the search - // path. ripgrep matches `--glob` patterns against the path *as passed to - // rg*, so with an absolute search path a pattern containing a `/` (e.g. - // `src/**/*.ts`) is matched against the absolute path and never matches. - // Running from the search root makes glob matching relative to it. let run; try { run = await runRgOnce(this.processService, buildRgArgs(rgPath, args), signal, { cwd: searchRoot }); @@ -278,9 +245,6 @@ export class GlobTool implements BuiltinTool<GlobInput> { return { isError: true, output: 'Glob aborted' }; } - // ripgrep can fail with EAGAIN ("os error 11") when its thread pool cannot - // spawn a worker under load; a single single-threaded retry sidesteps the - // pool and usually succeeds. if (shouldRetryRipgrepEagain(run)) { try { run = await runRgOnce(this.processService, buildRgArgs(rgPath, args, true), signal, { cwd: searchRoot }); @@ -294,12 +258,6 @@ export class GlobTool implements BuiltinTool<GlobInput> { const { exitCode, stdoutText, stderrText, bufferTruncated, timedOut } = run; - // rg exit codes: 0 = matches, 1 = no matches, 2+ = error. Timeout kills - // usually surface as a signal exit code; keep any partial paths. If rg - // returned complete paths before failing on a traversal error such as an - // unreadable subdirectory, keep those paths and surface a warning instead - // of failing the whole search. If no complete path was produced, treat - // stderr as authoritative (invalid glob, spawn failure, etc.). let traversalWarning: string | undefined; if (exitCode !== 0 && exitCode !== 1 && !timedOut) { const rawPathsBeforeError = splitCompletePaths(stdoutText, true); @@ -312,18 +270,10 @@ export class GlobTool implements BuiltinTool<GlobInput> { return { isError: true, output: 'Glob aborted' }; } - // One path per line from `rg --files`. When stdout is capped or the run - // timed out, the final chunk can cut a path in half; drop any trailing - // line that lacks its terminating newline so a half-written path is never - // surfaced as a match. rg reports paths relative to its cwd (the search - // root), e.g. `./src/a.ts`; resolve them back to absolute paths so the - // sensitive-file check, workspace relativization, and display all keep - // working on absolute paths. const rawPaths = splitCompletePaths(stdoutText, bufferTruncated || timedOut).map((p) => resolve(searchRoot, p), ); - // Authoritative sensitive-file check (the rg prefilter is conservative). const kept: string[] = []; let filteredSensitive = 0; for (const p of rawPaths) { @@ -346,10 +296,6 @@ export class GlobTool implements BuiltinTool<GlobInput> { return { output: 'No matches found' }; } - // Content shown to the LLM uses paths relative to the search base to - // save tokens, but only for the primary workspace. Relative paths are - // later resolved against workspaceDir, so additionalDir matches stay - // absolute to keep follow-up Read/Edit calls on the same file. const pathClass = this.env.pathClass; const shouldRelativize = isWithinDirectory(searchRoot, this.workspaceConfig.workspaceDir, pathClass); const displayLines = limited.map((p) => @@ -387,13 +333,6 @@ export class GlobTool implements BuiltinTool<GlobInput> { registerTool(GlobTool); -/** - * Adapt an `IHostProcessService` to the locator's {@link RgProbe}. The probe - * runs `rg --version` (or the cached binary with `--version`) through the host - * process service and reports the exit code. stdout/stderr are drained - * (flowing mode) so a chatty probe can never block the pipe; the bytes are - * discarded. - */ function createRgProbe(processService: IHostProcessService): RgProbe { return { exec: async (args) => { @@ -403,7 +342,6 @@ function createRgProbe(processService: IHostProcessService): RgProbe { try { proc.stdin.end(); } catch { - /* already gone */ } proc.stdout.resume(); proc.stderr.resume(); @@ -411,7 +349,6 @@ function createRgProbe(processService: IHostProcessService): RgProbe { try { proc.dispose(); } catch { - /* best-effort cleanup */ } return { exitCode }; }, @@ -425,15 +362,11 @@ function buildRgArgs(rgPath: string, args: GlobInput, singleThreaded = false): s for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) { cmd.push('--glob', `!${dir}`); } - // Positive pattern first, then sensitive-file exclusions so a broad pattern - // cannot re-include a sensitive path. cmd.push('--glob', args.pattern); for (const glob of SENSITIVE_GLOBS_TO_EXCLUDE) { cmd.push('--glob', `!${glob}`); } if (args.include_ignored) cmd.push('--no-ignore'); - // Search path is `.` because the process cwd is pinned to the search root - // (see execution()); this keeps `--glob` matching relative to that root. cmd.push('.'); return cmd; } @@ -462,8 +395,6 @@ function formatSpawnError(error: unknown): string { } function errorCode(error: unknown): string | undefined { - // hostFs / hostProcess translate raw errnos into coded errors; classify the - // unwrapped cause so boundary translation stays invisible here. const unwrapped = unwrapErrorCause(error); if (unwrapped !== null && typeof unwrapped === 'object' && 'code' in unwrapped) { const code = (unwrapped as { code?: unknown }).code; @@ -472,12 +403,6 @@ function errorCode(error: unknown): string | undefined { return undefined; } -/** - * Split `rg --files` stdout into complete paths. When the run was capped or - * timed out (`truncatedOutput`), a path cut mid-write lacks its terminating - * newline; drop that trailing fragment so it is never surfaced as a match. - * Complete output always ends in `\n`, so the split is lossless in that case. - */ export function splitCompletePaths(stdoutText: string, truncatedOutput: boolean): string[] { let text = stdoutText; if (truncatedOutput && !text.endsWith('\n')) { @@ -487,11 +412,6 @@ export function splitCompletePaths(stdoutText: string, truncatedOutput: boolean) return text.split('\n').filter((p) => p.length > 0); } -/** - * If `candidate` is under `base`, return the portion after `base/`. - * Otherwise return `candidate` unchanged (absolute). Both arguments - * should be canonical absolute paths. - */ function relativizeIfUnder(candidate: string, base: string, pathClass: PathClass): string { const normCandidate = normalize(candidate); const normBase = normalize(base); diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/grep.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/grep.ts index 736800d565..12990abd56 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/grep.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/grep.ts @@ -160,8 +160,6 @@ export const GrepOutputSchema = z.object({ export type GrepInput = z.infer<typeof GrepInputSchema>; export type GrepOutput = z.infer<typeof GrepOutputSchema>; -// Column cap applied to non-content output modes only; `content` mode returns -// matching lines in full so the cap is intentionally skipped there. const RG_MAX_COLUMNS = 500; const DEFAULT_HEAD_LIMIT = 250; const MTIME_STAT_CONCURRENCY = 32; @@ -182,14 +180,6 @@ const SENSITIVE_GLOBS_TO_EXCLUDE = [ '**/.gcp/credentials/**', ] as const; -// Line formats produced by ripgrep: -// content match with --null: "file.py<NUL>10:matched text" -// context line with --null: "file.py<NUL>9-context text" -// count_matches with --null: "file.py<NUL>2" -// non-NUL content fallback: "file.py:10:matched text" -// context divider: "--" -// Runtime rg output uses NUL as the path boundary; the regex handles -// line-oriented output without NUL delimiters. const CONTENT_LINE_RE = /^(.*?)([:-])(\d+)\2/; export class GrepTool implements BuiltinTool<GrepInput> { @@ -202,14 +192,10 @@ export class GrepTool implements BuiltinTool<GrepInput> { @IHostEnvironment private readonly env: IHostEnvironment, @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, @ITelemetryService private readonly telemetry: ITelemetryService, - // Optional so unit tests that construct the tool directly (bypassing DI) - // keep working; always registered in production scopes. @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, ) {} private get workspace(): WorkspaceConfig { - // Skill roots are merged per call (v1 merged once at tool construction): - // the catalog loads asynchronously and gains roots on plugin reloads. return extendWorkspaceWithSkillRoots( { workspaceDir: this.workspaceCtx.workDir, @@ -303,8 +289,6 @@ export class GrepTool implements BuiltinTool<GrepInput> { const { exitCode, stderrText, bufferTruncated, stderrTruncated, timedOut } = runResult; let { stdoutText } = runResult; - // rg exit codes: 0 = matches, 1 = no matches, 2 = error. Timeout kills - // usually surface as a signal exit code; keep any complete partial records. if (exitCode !== 0 && exitCode !== 1 && !timedOut) { return { isError: true, @@ -350,12 +334,6 @@ export class GrepTool implements BuiltinTool<GrepInput> { const limited = limitActive ? afterOffset.slice(0, headLimit) : afterOffset; const paginationTruncated = limitActive && afterOffset.length > headLimit; - // Notices ride in `output` (not `result.message`, which is dropped before the - // result reaches the model). The count-mode aggregate — the total and the - // "use offset=N to see more" cue — leads the output as a HEADER, written before - // the rows, so ToolResultBuilder's char cap can only ever truncate the rows, not - // the total (count rows are unbounded with head_limit: 0). Incidental notices - // trail the body. const headerLines: string[] = []; const messages: string[] = []; if (filteredSensitive.size > 0) { @@ -427,7 +405,6 @@ export class GrepTool implements BuiltinTool<GrepInput> { try { proc.stdin.end(); } catch { - /* already gone */ } proc.stdout.resume(); proc.stderr.resume(); @@ -435,7 +412,6 @@ export class GrepTool implements BuiltinTool<GrepInput> { try { proc.dispose(); } catch { - /* best-effort cleanup */ } return { exitCode }; }, @@ -459,7 +435,6 @@ export class GrepTool implements BuiltinTool<GrepInput> { const mtimeMs = (await this.fs.stat(path)).mtimeMs ?? 0; mtime = Math.trunc(mtimeMs / 1000); } catch { - // Keep stat failures visible; use mtime=0 so they sort after known files. } } return { line, mtime, index }; @@ -481,8 +456,6 @@ function formatSpawnError(error: unknown): string { } function errorCode(error: unknown): string | undefined { - // hostFs / hostProcess translate raw errnos into coded errors; classify the - // unwrapped cause so boundary translation stays invisible here. const unwrapped = unwrapErrorCause(error); if (unwrapped !== null && typeof unwrapped === 'object' && 'code' in unwrapped) { const code = (unwrapped as { code?: unknown }).code; @@ -552,10 +525,6 @@ function buildRgArgs( if (singleThreaded) cmd.push('-j', '1'); cmd.push('--hidden'); const mode = args.output_mode ?? 'files_with_matches'; - // `content` mode returns matching lines verbatim. Capping columns here would - // make rg replace any line wider than the cap with a placeholder, silently - // dropping the actual match text. The cap is only useful outside `content` - // mode, where line text is never surfaced. if (mode !== 'content') { cmd.push('--max-columns', String(RG_MAX_COLUMNS)); } @@ -566,9 +535,6 @@ function buildRgArgs( if (mode === 'files_with_matches') cmd.push('-l'); else if (mode === 'count_matches') { - // rg omits the filename when only one file is searched, so pin it on. Without - // this, the per-file line collapses to a bare count and the summary parser - // disagrees with the displayed number. cmd.push('--count-matches', '--with-filename'); } @@ -592,14 +558,8 @@ function buildRgArgs( if (args.multiline) cmd.push('-U', '--multiline-dotall'); if (args.include_ignored) cmd.push('--no-ignore'); for (const glob of SENSITIVE_GLOBS_TO_EXCLUDE) { - // Appended after user globs so a broad include such as `**/.env` cannot - // undo this first-pass exclusion. Explicit file paths are still protected - // by the post-processing filter because rg intentionally searches them. cmd.push('--glob', `!${glob}`); } - // Do not forward `head_limit` to `rg --max-count`: omitted means "use the - // tool default", head_limit=0 means "unlimited", while `rg --max-count 0` - // means "zero matches per file". Pagination happens in post-processing. cmd.push('--', args.pattern, ...searchPaths); return cmd; @@ -608,7 +568,6 @@ function buildRgArgs( function splitRgLines(text: string): string[] { if (text === '') return []; const lines = text.split('\n'); - // Strip the trailing empty line left by a final newline. while (lines.length > 0 && lines.at(-1) === '') { lines.pop(); } @@ -699,11 +658,6 @@ function formatDisplayLine( return text; } -/** - * If `candidate` is under `base`, return the portion after `base/`. - * Otherwise return `candidate` unchanged. Both arguments should be - * canonical absolute paths in the active backend path class. - */ function relativizeIfUnder(candidate: string, base: string, pathClass: PathClass): string { const normCandidate = normalize(candidate); const normBase = normalize(base); diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/process-task.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/process-task.ts index 3f2559a8a4..3615a7f6f2 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/process-task.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/process-task.ts @@ -48,8 +48,6 @@ export class ProcessTask implements AgentTask { observeProcessStream(this.proc.stdout, 'stdout', sink, this.onOutput), observeProcessStream(this.proc.stderr, 'stderr', sink, this.onOutput), ]).then(() => undefined); - // Attach a rejection handler immediately; start() still awaits the same - // promise after proc.wait() so stream errors keep failing the task. void streamDrained.catch(() => {}); const requestStop = (): void => { @@ -107,7 +105,6 @@ export class ProcessTask implements AgentTask { try { await this.proc.dispose(); } catch { - /* best-effort cleanup */ } } } @@ -131,7 +128,6 @@ async function waitForStreamDrainSettled(streamDrained: Promise<void>): Promise< try { await waitForStreamDrain(streamDrained); } catch { - /* original process/stream error wins */ } } @@ -145,11 +141,6 @@ function observeProcessStream( const onData = (chunk: string): void => { if (chunk.length === 0) return; sink.appendOutput(chunk); - // Once the manager has begun terminating the task — an output-limit trip - // (see MAX_TASK_OUTPUT_BYTES), a user interrupt, or a timeout — - // `appendOutput` above may synchronously abort the signal. Stop forwarding - // live output from that point so the unbounded forward buffer cannot keep - // growing while the process is being killed. if (sink.signal.aborted) return; onOutput?.(kind, chunk); }; @@ -180,8 +171,6 @@ function observeProcessStream( fail(createPrematureCloseError()); }; const onError = (error: Error): void => { - // When the task is aborted we intentionally destroy the streams, which - // can emit errors. Swallow those expected errors; surface anything else. if (sink.signal.aborted) { done(); } else { @@ -204,11 +193,6 @@ export interface ProcessTaskResult { readonly exitCode: number | null; } -/** - * Create a `taskService.run()`-compatible executor that drives a spawned - * process to completion. Returns a resolved `ProcessTaskResult` on exit 0, - * throws on non-zero exit or abort. - */ export function createProcessExecutor( proc: IProcess, onOutput?: ProcessTaskOutputCallback, @@ -300,7 +284,7 @@ function observeProcessStreamRaw( } async function disposeProcess(proc: IProcess): Promise<void> { - try { await proc.dispose(); } catch { /* best-effort */ } + try { await proc.dispose(); } catch { } } function createPrematureCloseError(): Error { diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts index f1243c9c1a..95220a9e5e 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts @@ -200,8 +200,6 @@ function renderEntries( } function isFileNotFoundError(error: unknown): boolean { - // hostFs translates raw errnos into `HostFsError`; classify the unwrapped - // cause so boundary translation stays invisible to these predicates. const unwrapped = unwrapErrorCause(error); if (typeof unwrapped !== 'object' || unwrapped === null) return false; const code = (unwrapped as { code?: unknown })['code']; @@ -243,14 +241,10 @@ export class ReadTool implements BuiltinTool<ReadInput> { @IHostFileSystem private readonly fs: IHostFileSystem, @IHostEnvironment private readonly env: IHostEnvironment, @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, - // Optional so unit tests that construct the tool directly (bypassing DI) - // keep working; always registered in production scopes. @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, ) {} private get workspaceConfig(): WorkspaceConfig { - // Skill roots are merged per call (v1 merged once at tool construction): - // the catalog loads asynchronously and gains roots on plugin reloads. return extendWorkspaceWithSkillRoots( { workspaceDir: this.workspaceCtx.workDir, @@ -491,9 +485,6 @@ export class ReadTool implements BuiltinTool<ReadInput> { } private finishReadResult(input: FinishReadResultInput): ExecutableToolResult { - // The status line rides the `note` side channel (model-only); `output` is - // the rendered file content and nothing else. The `<system>` wrapping is - // this tool's wording choice. return { output: input.renderedLines.join('\n'), note: `<system>${this.finishMessage(input)}</system>`, diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts index b66797b0e6..5969455396 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts @@ -36,18 +36,9 @@ function disposeProcess(proc: IHostProcess): void { try { proc.dispose(); } catch { - /* best-effort cleanup */ } } -/** - * Spawn `rgArgs` (`[rgPath, ...args]`) through the host `IHostProcessService` - * and drain its stdout/stderr with a byte cap. Handles abort (via `signal`) - * and a hard timeout with a two-phase kill (SIGTERM, then SIGKILL after a - * grace period) and process disposal. Returns `{ kind: 'aborted' }` when the - * run is cancelled so the caller can surface a stable "aborted" message. Spawn - * failures (e.g. ENOENT) are thrown to the caller. - */ export async function runRgOnce( processService: IHostProcessService, rgArgs: readonly string[], @@ -67,7 +58,6 @@ export async function runRgOnce( try { proc.stdin.end(); } catch { - /* already gone */ } let timedOut = false; @@ -80,7 +70,6 @@ export async function runRgOnce( try { await proc.kill('SIGTERM'); } catch { - /* process already gone */ } const exited = proc .wait() @@ -98,7 +87,6 @@ export async function runRgOnce( try { await proc.kill('SIGKILL'); } catch { - /* ignore */ } } disposeProcess(proc); @@ -109,8 +97,6 @@ export async function runRgOnce( void killProc(); }; signal.addEventListener('abort', onAbort); - // AbortSignal does not replay past abort events; check once after registering - // the listener so already-aborted calls still run the cleanup path. if (signal.aborted) onAbort(); const timeoutHandle = setTimeout(() => { @@ -140,7 +126,6 @@ export async function runRgOnce( if (!(isPrematureCloseError(error) && (timedOut || aborted || killed))) { throw error; } - // The disposer intentionally closes streams after a terminating signal. } finally { clearTimeout(timeoutHandle); signal.removeEventListener('abort', onAbort); @@ -162,11 +147,6 @@ export async function runRgOnce( }; } -/** - * ripgrep can fail with `os error 11` (EAGAIN, "Resource temporarily - * unavailable") when its thread pool can't spawn a worker under load. A single - * single-threaded retry (`-j 1`) sidesteps the pool and usually succeeds. - */ export function shouldRetryRipgrepEagain(result: RunRgResult): boolean { return ( result.exitCode !== 0 && diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/write.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/write.ts index bcd669f1fc..821cfb80b4 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/write.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/write.ts @@ -61,7 +61,6 @@ export const WriteInputSchema = z.object({ }); export const WriteOutputSchema = z.object({ - /** Number of UTF-8 bytes written to disk by this call. */ bytesWritten: z.number().int().nonnegative(), }); @@ -77,14 +76,10 @@ export class WriteTool implements BuiltinTool<WriteInput> { @IHostFileSystem private readonly fs: IHostFileSystem, @IHostEnvironment private readonly env: IHostEnvironment, @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, - // Optional so unit tests that construct the tool directly (bypassing DI) - // keep working; always registered in production scopes. @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, ) {} private get workspaceConfig(): WorkspaceConfig { - // Skill roots are merged per call (v1 merged once at tool construction): - // the catalog loads asynchronously and gains roots on plugin reloads. return extendWorkspaceWithSkillRoots( { workspaceDir: this.workspaceCtx.workDir, @@ -129,15 +124,11 @@ export class WriteTool implements BuiltinTool<WriteInput> { } else { await this.fs.writeText(safePath, args.content); } - // Report the number of UTF-8 bytes this call wrote to disk. The string - // length would only equal the byte count for pure ASCII content, so it - // is not used here. const bytesWritten = Buffer.byteLength(args.content, 'utf8'); return { output: `${mode === 'append' ? 'Appended' : 'Wrote'} ${String(bytesWritten)} bytes to ${args.path}`, }; } catch (error) { - // hostFs wraps raw errnos in `HostFsError`; classify the unwrapped cause. const code = (unwrapErrorCause(error) as { code?: unknown } | null)?.code; if (code === 'ENOENT') { return { @@ -152,22 +143,6 @@ export class WriteTool implements BuiltinTool<WriteInput> { } } - /** - * Best-effort check that the parent directory is usable, creating it when - * it is missing. - * - * If the parent (or any ancestor) does not exist, it is created - * recursively — mirroring Python's `Path.mkdir(parents=True, - * exist_ok=True)` — so the agent does not need a separate `mkdir` round - * trip before writing into a fresh subfolder. An existing parent that is - * not a directory is still a hard error. Any other `stat` failure - * (permissions, an environment without `stat`) is treated as - * inconclusive: the check is skipped and the write proceeds, surfacing - * the real I/O error if any. - * - * Returns an error string when the precondition is definitively violated, - * or `undefined` otherwise. - */ private async ensureParentDirectory(safePath: string): Promise<string | undefined> { const parent = dirname(safePath); let stat: HostFileStat; diff --git a/packages/agent-core-v2/src/os/interface/hostEnvironment.ts b/packages/agent-core-v2/src/os/interface/hostEnvironment.ts index 8ddb5c8fb5..c9856592b5 100644 --- a/packages/agent-core-v2/src/os/interface/hostEnvironment.ts +++ b/packages/agent-core-v2/src/os/interface/hostEnvironment.ts @@ -31,28 +31,13 @@ export type { HostEnvironmentInfo, OsKind, PathClass, ShellName }; export interface IHostEnvironment { readonly _serviceBrand: undefined; - /** Family of the host OS (`macOS` / `Linux` / `Windows`, or the raw - * `process.platform` string for unknown platforms). */ readonly osKind: OsKind; - /** Host architecture (`process.arch`). */ readonly osArch: string; - /** Host kernel release (`os.release()`). */ readonly osVersion: string; - /** Name of the POSIX shell discovered on this host. */ readonly shellName: ShellName; - /** Absolute path to the POSIX shell (`/bin/bash`, `/bin/sh`, or a Git Bash - * installation on Windows). */ readonly shellPath: string; - /** Path style used by this host — `win32` on Windows, `posix` elsewhere. */ readonly pathClass: PathClass; - /** Absolute path of the current user's home directory (`os.homedir()`). */ readonly homeDir: string; - /** - * Resolves once the probe has completed. Every field above is populated by - * the time this promise settles. The composition root awaits this before - * creating a Session scope so all Session/Agent consumers can read the - * fields synchronously. - */ readonly ready: Promise<void>; } diff --git a/packages/agent-core-v2/src/os/interface/hostFileSystem.ts b/packages/agent-core-v2/src/os/interface/hostFileSystem.ts index cf38a822c9..a6ce33be05 100644 --- a/packages/agent-core-v2/src/os/interface/hostFileSystem.ts +++ b/packages/agent-core-v2/src/os/interface/hostFileSystem.ts @@ -13,16 +13,9 @@ import type { TextDecodeErrors } from '#/_base/execEnv/decodeText'; export interface HostFileStat { readonly isFile: boolean; readonly isDirectory: boolean; - /** - * `true` when the path itself is a symbolic link (reported via a - * non-following `lstat`). Lets callers surface `kind: 'symlink'` instead of - * silently following the link and reporting the target's type. - */ readonly isSymbolicLink?: boolean; readonly size: number; - /** Last-modified time in epoch milliseconds, when the backend exposes it. */ readonly mtimeMs?: number; - /** Inode number, when the backend exposes it (`0` on backends without inodes). */ readonly ino?: number; } @@ -30,11 +23,6 @@ export interface HostDirEntry { readonly name: string; readonly isFile: boolean; readonly isDirectory: boolean; - /** - * `true` when the directory entry is a symbolic link (from `readdir` - * `withFileTypes`). Does not follow the link — a symlink to a directory is - * reported with `isSymbolicLink: true` and `isDirectory: false`. - */ readonly isSymbolicLink?: boolean; } @@ -46,40 +34,13 @@ export interface IHostFileSystem { options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors }, ): Promise<string>; writeText(path: string, data: string): Promise<void>; - /** - * Append UTF-8 `data` to the end of `path`, creating the file if it does not - * exist. Maps to a native append (POSIX `O_APPEND` / `fs.appendFile`): it - * never reads or truncates existing content, so concurrent readers never see - * a partially-rewritten file and a crash mid-write can lose only the new - * bytes, never the prior contents. Prefer this over a read-then-rewrite for - * log-style appends. - */ appendText(path: string, data: string): Promise<void>; - /** - * Read bytes from `path`. When `n` is given, reads at most the first `n` - * bytes (a ranged/prefix read); otherwise reads the whole file. The ranged - * form is used by callers that only need a header (e.g. file-type sniffing) - * so they never load a large file just to inspect its first bytes. - */ readBytes(path: string, n?: number): Promise<Uint8Array>; writeBytes(path: string, data: Uint8Array): Promise<void>; - /** - * Stream the lines of a UTF-8 (or other `encoding`) text file, yielding each - * line including its trailing terminator. `errors` mirrors Python's text - * decode error handling (`strict` throws on invalid bytes, used by the Read - * tool to surface non-UTF-8 files). Streaming lets callers paginate and - * stop early without loading the whole file. - */ readLines( path: string, options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors }, ): AsyncGenerator<string>; - /** - * Create a file exclusively with `data`. Returns `true` when the file was - * created, `false` when it already existed (EEXIST) — the existing content is - * left untouched. Used by content-addressed stores where a collision means - * the same bytes are already present. - */ createExclusive(path: string, data: Uint8Array): Promise<boolean>; stat(path: string): Promise<HostFileStat>; readdir(path: string): Promise<readonly HostDirEntry[]>; diff --git a/packages/agent-core-v2/src/os/interface/hostFsErrors.ts b/packages/agent-core-v2/src/os/interface/hostFsErrors.ts index d8b1df4823..4735dbfd36 100644 --- a/packages/agent-core-v2/src/os/interface/hostFsErrors.ts +++ b/packages/agent-core-v2/src/os/interface/hostFsErrors.ts @@ -84,7 +84,6 @@ export class HostFsError extends Error2 { } } -/** Short human-readable reason per code; keeps `message` free of paths/errnos. */ const REASONS: Record<HostFsErrorCode, string> = { 'os.fs.not_found': 'path does not exist', 'os.fs.is_directory': 'path is a directory', @@ -129,12 +128,6 @@ function mapErrno(errno: string | undefined): HostFsErrorCode { } } -/** - * Translate a raw backend error into a `HostFsError`. Idempotent: a - * `HostFsError` (or any nested backend already translated) passes through - * unchanged. The original error is preserved as `cause`; path/op/errno live in - * `details` (always JSON-serializable), never in the message. - */ export function toHostFsError(error: unknown, ctx: { path: string; op: string }): HostFsError { if (error instanceof HostFsError) return error; const errno = readErrno(error); diff --git a/packages/agent-core-v2/src/os/interface/hostFsWatch.ts b/packages/agent-core-v2/src/os/interface/hostFsWatch.ts index 6577d68706..890ce98c61 100644 --- a/packages/agent-core-v2/src/os/interface/hostFsWatch.ts +++ b/packages/agent-core-v2/src/os/interface/hostFsWatch.ts @@ -17,24 +17,16 @@ export type HostFsChangeKind = 'file' | 'directory'; export type HostFsChangeAction = 'created' | 'modified' | 'deleted'; export interface HostFsChange { - /** Absolute path that changed. */ readonly path: string; readonly action: HostFsChangeAction; readonly kind: HostFsChangeKind; } export interface HostFsWatchOptions { - /** Watch recursively into subdirectories. Defaults to `true`. */ readonly recursive?: boolean; - /** - * Predicate returning `true` for paths the watcher should ignore. Defaults - * to a filter that suppresses `.git` directories. Replaces the default when - * provided. - */ readonly ignored?: (path: string) => boolean; } -/** A live watch subscription. Dispose to stop receiving events. */ export interface IHostFsWatchHandle extends IDisposable { readonly onDidChange: Event<HostFsChange>; } @@ -42,11 +34,6 @@ export interface IHostFsWatchHandle extends IDisposable { export interface IHostFsWatchService { readonly _serviceBrand: undefined; - /** - * Watch `path` (absolute, file or directory) and return a handle that fires - * for changes beneath it. Synchronous — the underlying watcher is armed - * immediately; dispose the handle to stop. - */ watch(path: string, options?: HostFsWatchOptions): IHostFsWatchHandle; } diff --git a/packages/agent-core-v2/src/os/interface/hostProcess.ts b/packages/agent-core-v2/src/os/interface/hostProcess.ts index cb95319d01..01b7d732bf 100644 --- a/packages/agent-core-v2/src/os/interface/hostProcess.ts +++ b/packages/agent-core-v2/src/os/interface/hostProcess.ts @@ -16,25 +16,12 @@ import { Error2, type Error2Options } from '#/_base/errors/errors'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; export interface HostProcessOptions { - /** Working directory for the child. Defaults to `process.cwd()`. */ readonly cwd?: string; - /** Complete env bag for the child. When omitted the child inherits `process.env`. */ readonly env?: Record<string, string>; - /** - * If `true`, the command is run through the system shell. If a string, it is - * used as the shell path. Mirrors Python `subprocess.run(..., shell=True)`. - */ readonly shell?: boolean | string; - /** - * Whether the child becomes a process-group leader. Default is `true` on - * POSIX and `false` on Windows so that `kill()` can signal the whole tree. - */ readonly detached?: boolean; - /** Hide the child window on Windows. Default `true`. */ readonly windowsHide?: boolean; - /** Redirect stderr into stdout (the child still gets a merged stream). */ readonly mergeStderr?: boolean; - /** Optional timeout in milliseconds for `wait()`. */ readonly timeout?: number; } @@ -46,22 +33,14 @@ export interface IHostProcess { readonly stdin: Writable; readonly stdout: Readable; readonly stderr: Readable; - /** Wait for the process to exit and return its exit code. */ wait(): Promise<number>; - /** Kill the process tree (not just the direct child) with the given signal. */ kill(signal?: NodeJS.Signals): Promise<void>; - /** Release stdio streams. Does not kill the process. */ dispose(): void; } export interface IHostProcessService { readonly _serviceBrand: undefined; - /** - * Spawn a child process on the host. Resolves once the child has successfully - * started (or rejects with a coded error if spawn fails with ENOENT / EACCES - * / etc.). - */ spawn( command: string, args?: readonly string[], diff --git a/packages/agent-core-v2/src/os/interface/terminal.ts b/packages/agent-core-v2/src/os/interface/terminal.ts index 7b12320e69..c17de551fe 100644 --- a/packages/agent-core-v2/src/os/interface/terminal.ts +++ b/packages/agent-core-v2/src/os/interface/terminal.ts @@ -50,13 +50,6 @@ export interface TerminalProcess { kill(): void; } -/** - * App-scoped OS terminal process service. - * - * Owns the actual PTY process layer for the whole process. It does not know - * about sessions, workspace paths, or output buffering; it only spawns and - * exposes `TerminalProcess` handles directly via `node-pty`. - */ export interface IHostTerminalService { readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts b/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts index 62d7ac323c..89882e219f 100644 --- a/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts +++ b/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts @@ -101,13 +101,6 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { }, }, ).catch((error) => { - // The query store is a rebuildable derived read model; authoritative data - // lives in the append-log / atomic-document stores. `openOrRebuild` - // already turns on-disk corruption into a clean rebuild, so an open - // failure here is almost always another kimi process holding the - // single-writer lock on `<cacheDir>/query-store`. Surface it as - // `storage.locked` (memoized via the cached rejected promise) and let - // each consumer decide how to fall back — no silent no-op degradation. throw new StorageError( StorageErrors.codes.STORAGE_LOCKED, 'minidb query-store is locked by another process', @@ -162,10 +155,6 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { await db.createCompoundIndex(name, { groupBy: def.groupBy, orderBy: def.orderBy }); } } else { - // A text index that already exists (rebuilt from persisted definitions on - // reopen) makes `createTextIndex` throw; treat that as already-ensured. - // TODO: minidb throws a bare `Error` here — switch to a structured error - // type if minidb ever exports one (do not parse messages long-term). try { await db.createTextIndex(name, { fields: def.fields }); } catch (error) { @@ -185,7 +174,6 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { async close(): Promise<void> { if (this.dbPromise === undefined) return; - // A failed (locked) open must not make disposal throw. const db = await this.dbPromise.catch(() => undefined); await db?.close(); } @@ -233,7 +221,6 @@ class MiniDbQuery<T> implements IQuery<T> { q.sort = { [this.sortField]: this.sortDir === 'desc' ? -1 : 1 }; } q.skip = this.skip; - // Fetch one extra row to know whether a next page exists. if (this.lim !== undefined) q.limit = this.lim + 1; const rows = db.query(q) as ReadonlyArray<{ key: string; value: T }>; let items = rows.map((r) => r.value); diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts index 4c8d295500..27e0011a3e 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts @@ -58,12 +58,6 @@ export class AppendLogStore implements IAppendLogStore { async *read<R>(scope: string, key: string): AsyncIterable<R> { await this.flushLog(scope, key); - // A fresh `TextDecoder` per read: `TextDecoder` is stateful in `stream` - // mode (it buffers an incomplete trailing multi-byte sequence until the - // next `decode`). Sharing one instance across reads would let leftover - // state from an earlier read — e.g. one that returns early before flushing, - // like `ensureWireMetadata` bailing on the leading `metadata` record — - // leak into the next read and prepend a spurious U+FFFD to its first line. const textDecoder = new TextDecoder(); let pending = ''; let lineNumber = 0; @@ -82,8 +76,6 @@ export class AppendLogStore implements IAppendLogStore { pending += textDecoder.decode(); if (pending.length > 0) { lineNumber++; - // A crash can leave a half-written last line (no trailing newline); drop - // it. Corruption anywhere before the end is real and must surface. const record = this.parseLine<R>(pending, scope, key, lineNumber, true); if (record !== undefined) yield record; } diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts index 1668375994..e5dbf0d8a2 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts @@ -39,8 +39,6 @@ import type { } from '#/persistence/interface/storage'; import { toStorageIoError } from '#/persistence/interface/storage'; -// `fs.watch` often emits a burst per save (plus the temp file of an atomic -// replace); collapse it into one reload signal. const WATCH_DEBOUNCE_MS = 150; function isEnoent(error: unknown): boolean { @@ -168,11 +166,6 @@ export class FileStorageService implements IFileSystemStorageService { timer = setTimeout(() => emitter.fire(), WATCH_DEBOUNCE_MS); }; - // Watch the parent directory and filter by exact path: the directory survives - // atomic-replace renames (which would detach a single-file watcher) and it - // lets us observe a file that does not exist yet at subscription time. Events - // are debounced to collapse the burst a single save (plus its atomic-replace - // temp file) emits. const arm = (): void => { try { mkdirSync(dir, { recursive: true, mode: this.dirMode }); @@ -187,7 +180,6 @@ export class FileStorageService implements IFileSystemStorageService { watcher.on('error', (error: unknown) => onUnexpectedError(error)); watcher.add(dir); } catch (error) { - // Best effort: callers can still reload explicitly when watching fails. onUnexpectedError(error); } }; @@ -224,7 +216,6 @@ export class FileStorageService implements IFileSystemStorageService { } async flush(): Promise<void> { - // Writes resolve only after the bytes are durable; nothing is buffered. } async close(): Promise<void> {} diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/workspaceLocalConfigService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/workspaceLocalConfigService.ts index 9f7248da98..550510c770 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/workspaceLocalConfigService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/workspaceLocalConfigService.ts @@ -299,7 +299,6 @@ function isPlainObject(value: unknown): value is Record<string, unknown> { } function isPathMissing(error: unknown): boolean { - // hostFs wraps raw errnos in `HostFsError`; classify the unwrapped cause. const code = getErrorCode(unwrapErrorCause(error)); return code === 'ENOENT' || code === 'ENOTDIR'; } diff --git a/packages/agent-core-v2/src/persistence/interface/appendLogStore.ts b/packages/agent-core-v2/src/persistence/interface/appendLogStore.ts index 31ad33bbc6..c241836493 100644 --- a/packages/agent-core-v2/src/persistence/interface/appendLogStore.ts +++ b/packages/agent-core-v2/src/persistence/interface/appendLogStore.ts @@ -28,12 +28,6 @@ import { type IDisposable } from '#/_base/di/lifecycle'; import { StorageError, StorageErrors } from '#/persistence/interface/storage'; -/** - * A non-final line of an append log failed to parse — real corruption (a torn - * final line is dropped silently instead). Carries `storage.corrupted`; the - * scope/key/lineNumber coordinates live in `details` and the underlying parse - * error is preserved as `cause`. - */ export class AppendLogCorruptedError extends StorageError { constructor(scope: string, key: string, lineNumber: number, cause: unknown) { super( diff --git a/packages/agent-core-v2/src/persistence/interface/atomicDocumentStore.ts b/packages/agent-core-v2/src/persistence/interface/atomicDocumentStore.ts index 99513c1224..2d109d49b3 100644 --- a/packages/agent-core-v2/src/persistence/interface/atomicDocumentStore.ts +++ b/packages/agent-core-v2/src/persistence/interface/atomicDocumentStore.ts @@ -14,7 +14,6 @@ import { type IDisposable } from '#/_base/di/lifecycle'; import { type Event } from '#/_base/event'; export interface DocumentCodec { - /** Wire format name (`'json'` / `'toml'`), surfaced in decode-error details. */ readonly format: string; encode(value: unknown): Uint8Array; decode(bytes: Uint8Array): unknown; diff --git a/packages/agent-core-v2/src/persistence/interface/storage.ts b/packages/agent-core-v2/src/persistence/interface/storage.ts index d407111f62..83c57ca17d 100644 --- a/packages/agent-core-v2/src/persistence/interface/storage.ts +++ b/packages/agent-core-v2/src/persistence/interface/storage.ts @@ -93,11 +93,6 @@ function readErrno(error: unknown): string | undefined { return typeof code === 'string' ? code : undefined; } -/** - * Translate a raw backend I/O failure into `StorageError(storage.io_failed)`. - * Idempotent: an existing `StorageError` passes through unchanged. The original - * error is preserved as `cause`; path/op/errno live in `details`. - */ export function toStorageIoError(error: unknown, ctx: { path: string; op: string }): StorageError { if (error instanceof StorageError) return error; return new StorageError( diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts index fbb7535e44..d2116e58ed 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts @@ -41,36 +41,14 @@ import type { Hooks } from '#/hooks'; export interface CreateAgentOptions { readonly agentId?: string; - /** - * Profile + Model to bind at creation so the agent is born runnable - * (`Profile + Model ⇒ Agent`). May be omitted by exactly two callers: - * session resume/fork (the binding is restored from the wire log) and the - * edge-bootstrapped main agent (the edge binds a model right after). Every - * other creation path must pass a full binding. - */ readonly binding?: BindAgentInput; - /** - * Initial permission mode for the new agent. Used by subagent dispatch - * (`Agent` / `AgentSwarm`) so a child inherits its caller's mode instead of - * falling back to the model default (`manual`). Applied right after binding, - * before the handle is returned — i.e. before any turn runs. - */ readonly permissionMode?: PermissionMode; - /** Agent this one is derived from (provenance only; not used by business logic). */ readonly forkedFrom?: string; - /** - * Business-defined recorded values (e.g. the swarm's `swarmItem`). Persisted - * verbatim into the session's agent registry; never interpreted here. - */ readonly labels?: Readonly<Record<string, string>>; } export interface ForkAgentOptions { readonly agentId?: string; - /** - * Overrides merged over the source agent's binding (e.g. a title generator - * forking `main` onto a cheaper model). - */ readonly binding?: Partial<BindAgentInput>; } @@ -79,14 +57,8 @@ export type AgentRunRequest = | { readonly kind: 'retry'; readonly trigger?: string }; export interface RunAgentOptions { - /** Cancellation signal. Aborting it cancels the agent's turn. */ readonly signal: AbortSignal; - /** - * Summary distillation policy. Defaults to the `summaryPolicy` of the - * profile the target agent is bound to; pass explicitly to override. - */ readonly summaryPolicy?: AgentProfileSummaryPolicy; - /** Fires once the turn's first request is committed (used by swarm to fan out). */ readonly onReady?: () => void; } @@ -100,14 +72,12 @@ export interface AgentListFilter { readonly prefix?: string; } -/** Facts announced when an agent run this session is hosting is about to start. */ export interface AgentTaskStartHookContext { readonly agentName: string; readonly prompt: string; readonly signal: AbortSignal; } -/** Facts announced when an agent run this session is hosting has stopped. */ export interface AgentTaskStopHookContext { readonly agentName: string; readonly response: string; @@ -120,68 +90,19 @@ export type AgentTaskHooks = { export interface IAgentLifecycleService { readonly _serviceBrand: undefined; - /** - * Requester-side agent-run hook slot (`onWillStartAgentTask`) run by - * `mirrorAgentRun` when one agent drives another. Observers — e.g. the - * Session-scope `externalHooks` adapter — register here to translate a run - * into the `SubagentStart` external hook command; a rejecting handler - * cancels the run. The slot host lives on the service that owns the run; - * callers never invoke the external hook commands directly. - */ readonly hooks: Hooks<AgentTaskHooks>; - /** - * Fires after a mirrored agent run has stopped, with the run's distilled - * summary. Announced by `mirrorAgentRun` via {@link notifyAgentTaskStopped}; - * observers such as the Session-scope `externalHooks` adapter translate it - * into the `SubagentStop` external hook command. - */ readonly onDidStopAgentTask: Event<AgentTaskStopHookContext>; - /** Fires after an agent is created and registered, with its scope handle. */ readonly onDidCreate: Event<IAgentScopeHandle>; - /** - * Fires once after the main agent is created and its main-only wirings are - * attached, with its scope handle. Use this instead of `onDidCreate` when a - * capability belongs exclusively to the main agent, so subscribers do not - * need to filter every agent creation by `id === 'main'`. - */ readonly onDidCreateMain: Event<IAgentScopeHandle>; - /** Fires after an agent is removed, with its agent id. */ readonly onDidDispose: Event<string>; - /** Create an agent from zero (empty context). */ create(opts?: CreateAgentOptions): Promise<IAgentScopeHandle>; whenReady(agentId: string): Promise<IAgentScopeHandle | undefined>; - /** - * Resolve the session MCP config (file config + caller-supplied servers, with - * plugin servers on top) and wait for the initial connection attempt to - * finish. Per-server failures are reflected in MCP status entries rather than - * rejecting this promise. `callerServers` is honored only by the call that - * starts the initial load — `sessionLifecycle.materializeSession` passes the - * session's caller-supplied servers there; later callers (e.g. agent - * creation) just await the in-flight load. - */ ensureMcpReady(callerServers?: Readonly<Record<string, McpServerConfig>>): Promise<void>; notifyMainCreated(handle: IAgentScopeHandle): void; - /** - * Fire {@link onDidStopAgentTask} for a mirrored run that has stopped. - * Called by `mirrorAgentRun` once per mirrored run completion; no other - * caller should invoke it. - */ notifyAgentTaskStopped(context: AgentTaskStopHookContext): void; - /** - * Fork an agent: copy its profile binding and context history into a new - * agent, recording `forkedFrom = sourceAgentId`. Throws when the source does - * not exist. - */ fork(sourceAgentId: string, opts?: ForkAgentOptions): Promise<IAgentScopeHandle>; - /** - * Submit one prompt (or retry) turn to an existing agent and return a handle - * whose `completion` resolves with the distilled summary and token usage. - * Emits nothing on anyone else's record stream — a caller that wants to - * surface this run (the `Agent` tool, the swarm) mirrors it itself. Throws - * when the agent does not exist or a turn cannot be started (busy / no head). - */ run(agentId: string, request: AgentRunRequest, opts: RunAgentOptions): Promise<AgentRunHandle>; getHandle(agentId: string): IAgentScopeHandle | undefined; list(filter?: AgentListFilter): readonly IAgentScopeHandle[]; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index b6d30b1fe5..febd1c63bb 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -148,11 +148,6 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle @IAppendLogStore private readonly appendLog?: IAppendLogStore, ) { super(); - // Bridge the per-agent `IEventBus` `turn.ended` into the Session-scope - // interaction kernel: the bus is Agent-scoped and cannot be injected into - // `SessionInteractionService` directly. Every agent (main + sub/forked) is - // created through `create()`, which fires `onDidCreate`, so subscribing here - // covers all of them; `onDidDispose` releases the per-agent subscription. this._register(this.onDidCreate((handle) => this.subscribeInteractionBus(handle))); this._register( this.onDidDispose((agentId) => { @@ -186,9 +181,6 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle this.assertCanCreate(); const mcpManager = this.getMcpManager(); const mcpReady = this.ensureMcpReady(); - // Per-agent homedir → the wire-record persistence key (`hashKey(homedir)`). - // Bootstrap computes it under the session dir, mirroring v1's - // `<sessionDir>/agents/<id>`; business code never assembles the path itself. const agentHomedir = this.bootstrap.agentHomedir( this.ctx.workspaceId, this.ctx.sessionId, @@ -240,8 +232,6 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle }, ): Promise<IAgentScopeHandle> { try { - // Record the agent in the session registry so a closed-session fork can - // enumerate every agent and relocate its wire log. await this.sessionMetadata.registerAgent(agentId, { homedir: bootstrap.agentHomedir, type: agentId === 'main' ? 'main' : 'sub', @@ -254,16 +244,13 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle await bootstrap.mcpReady; await this.ensureWireMetadata(handle, bootstrap.agentScope); await this.bindBootstrap(handle, opts); - // Bootstrap (eager tool / hook / MCP setup, wire metadata, profile binding) - // is complete: drive the activity kernel `initializing → idle` so the agent - // can admit turns. Until this point `begin` rejects with `activity.initializing`. handle.accessor.get(IAgentActivityService).markReady(); return handle; } catch (error) { if (this.handles.get(agentId) === handle) this.handles.delete(agentId); try { handle.dispose(); - } catch {} + } catch { } this.onDidDisposeEmitter.fire(agentId); throw error; } @@ -311,54 +298,16 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle ]; } - // Force-instantiate the agent-scope eager registrars before the first turn, - // in dependency order: each consumes scope contributions or observes domain - // hooks and must exist before `bindBootstrap` publishes the first status. private igniteEagerServices(handle: IAgentScopeHandle): void { - // Builtin-tools registrar: consumes every module-level `registerTool(...)` - // contribution and registers each built-in tool (with `@IX` deps resolved - // against this scope) into the per-agent `IAgentToolRegistryService`. Must - // happen before the first turn — otherwise the LLM sees an empty tool list. - // Separate from the registry itself to avoid a construction cycle where - // tool ctors transitively depend on the registry. handle.accessor.get(IAgentBuiltinToolsRegistrar); - // Media-tools registrar: media tools cannot use the contribution table - // (capabilities are unknown until a model binds), so this service - // re-registers ReadMediaFile on every `agent.status.updated`. handle.accessor.get(IAgentMediaToolsRegistrar); - // Image-config bridge: pushes the env-resolved `[image]` section into the - // compression support module's resolver seam before the first turn, so - // ReadMediaFile / MCP / prompt ingestion honor `[image] max_edge_px` and - // `read_byte_budget` (and their env overrides) through the implicit default. handle.accessor.get(IImageConfigBridge); - // Tool-call dedupe plugin: self-wiring — its constructor registers the - // loop step hooks and the executor's onBefore/onDidExecuteTool handlers, - // and no other service injects it. Must be ignited BEFORE the external - // hooks service below: that get transitively constructs the permission - // gate, and `toolDedupe` has to sit ahead of `permission` on - // `onBeforeExecuteTool` so same-step duplicates are suppressed before - // authorization runs (v1 ran dedup in prepare, before authorize). handle.accessor.get(IAgentToolDedupeService); - // External hook adapter: registers listeners on the agent's domain hooks - // before the first turn. No business service injects it directly; it - // observes their hooks instead. handle.accessor.get(IAgentExternalHooksService); - // Agent MCP service: attaches the (shared) manager's tools and registers - // the `wait-for-initial-load` hook before the first turn — otherwise - // plugin/session MCP servers would connect but their tools would never - // register until something explicitly requests the service. handle.accessor.get(IAgentMcpService); - // Tool-select services: precompute tool selection and the announcements - // derived from it before the first turn. handle.accessor.get(IAgentToolSelectService); handle.accessor.get(IAgentToolSelectAnnouncementsService); - // Step-retry plugin: registers the loop error handler that retries - // retryable provider failures. Nothing injects it directly — it observes - // the loop — so it must be ignited before the first turn. handle.accessor.get(IAgentStepRetryService); - // Loop-continuation aspect: enqueues the next step whenever a step ran - // tools. It only observes the loop's onDidFinishStep hook, so without ignition - // every tool-using turn would stop after a single step. handle.accessor.get(IAgentLoopContinuationService); } @@ -465,13 +414,6 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle return this.catalog.get(profileName)?.summaryPolicy; } - /** - * One shared `McpConnectionManager` per session (built lazily, cached). All - * agents in the session share it, matching v1's session-scoped MCP and - * avoiding a reconnect storm per agent. The initial connect is driven - * through `ensureMcpReady`, so session creation and first agent creation can - * await config resolution before tool execution starts. - */ private getMcpManager(): McpConnectionManager { if (this.mcpManager !== undefined) return this.mcpManager; const oauthService = new McpOAuthService({ @@ -495,8 +437,6 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle resolveSessionMcpConfig({ cwd: this.workspace.workDir, homeDir: this.bootstrap.homeDir }), this.plugins.enabledMcpServers(), ]); - // Precedence mirrors v1's `mergeCallerMcpServers` + `mergePluginMcpConfig` - // (`rpc/core-impl.ts`): file config < caller-supplied < plugin. const withCaller = mergeCallerMcpServers(base, callerServers); const servers = { ...withCaller?.servers, ...pluginServers }; if (Object.keys(servers).length === 0) return; @@ -542,10 +482,6 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle if (handle === undefined) return; this.handles.delete(agentId); await handle.accessor.get(IAgentTaskService).stopAllOnExit('Session closed'); - // Drive the agent activity kernel through disposal: reject new begins and - // abort any in-flight turn / background activity, then wait for it to drain - // (including the tool-execution grace window) before releasing the scope. - // This guarantees no async work keeps running on a disposed agent. const activity = handle.accessor.get(IAgentActivityService); activity.beginDisposal(); await activity.settled(); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts index 8be16f182c..c90c33aff3 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts @@ -28,19 +28,10 @@ import { IAgentLifecycleService } from './agentLifecycle'; export const MAIN_AGENT_ID = 'main'; export interface EnsureMainAgentOptions { - /** Profile + Model to bind at creation. Omit for an edge-bound main agent. */ readonly binding?: BindAgentInput; - /** - * Permission posture for the main agent. Falls back to the persisted - * `defaultPermissionMode` config section when omitted. - */ readonly permissionMode?: PermissionMode; } -/** - * Return the session's main agent, creating it (with its session-start - * bootstrap wiring) when it does not exist yet. - */ export async function ensureMainAgent( session: ISessionScopeHandle, opts?: EnsureMainAgentOptions, @@ -57,11 +48,7 @@ export async function ensureMainAgent( binding: opts?.binding, permissionMode, }); - // Force-instantiate the agent plugin service so main-agent-only plugin - // guidance is registered before the first turn. main.accessor.get(IAgentPluginService); - // Notify main-only capabilities (e.g. the cron tool registrar) that the main - // agent is ready, so they bind to it without filtering every `onDidCreate`. agents.notifyMainCreated(main); return main; } diff --git a/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts index 1043f59261..678eea9f43 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts @@ -57,28 +57,13 @@ export interface AgentRunSpawnedMeta { } export interface MirrorAgentRunOptions { - /** Profile the target runs under; only used for hooks / record labels. */ readonly profileName: string; - /** - * Prompt text submitted to the target. When present the requester-side - * `SubagentStart` external hook runs (via `IAgentLifecycleService`); omit for - * retry turns, which skip the hook. - */ readonly prompt?: string; - /** Skip the requester-side `subagent.failed` record for provider-rate-limit / aborted failures. */ readonly suppressRateLimitFailureEvent?: boolean; - /** The requester's cancellation signal (passed through to the start hook slot). */ readonly signal: AbortSignal; - /** Called to abort the underlying run when the start hook slot aborts/rejects it. */ readonly cancel?: (reason?: unknown) => void; } -/** - * Emit the requester-side "an agent run was launched" record + telemetry. - * Called once per launch (spawn or resume), before or right after the run is - * submitted, because it carries tool-call provenance (`parentToolCallId`, - * `swarmIndex`, `runInBackground`) only the requester knows. - */ export function emitAgentRunSpawned( requester: IAgentScopeHandle, targetAgentId: string, @@ -102,11 +87,6 @@ export function emitAgentRunSpawned( }); } -/** - * Mirror a running agent turn onto the requester's record stream + external - * hooks and await its completion. Returns the distilled summary/usage; - * rethrows the run's failure after emitting the requester-side failure record. - */ export async function mirrorAgentRun( requester: IAgentScopeHandle, run: AgentRunHandle, diff --git a/packages/agent-core-v2/src/session/agentLifecycle/runAgentTurn.ts b/packages/agent-core-v2/src/session/agentLifecycle/runAgentTurn.ts index 662d938682..26a501d82a 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/runAgentTurn.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/runAgentTurn.ts @@ -31,13 +31,6 @@ import type { AgentProfileSummaryPolicy } from '#/app/agentProfileCatalog/agentP import type { AgentRunHandle, AgentRunRequest } from './agentLifecycle'; -/** - * Legacy `PromptOrigin` tag emitted when one agent submits a prompt to another - * (the `Agent` tool, swarm scheduler, …). Wire shape kept unchanged - * (`kind: 'system_trigger', name: 'subagent'`) so existing session recordings - * replay against v2 without a protocol schema bump. Rename lives on a separate - * wire-cleanup PR. - */ export const AGENT_RUN_PROMPT_ORIGIN: PromptOrigin = { kind: 'system_trigger', name: 'subagent', @@ -47,19 +40,11 @@ const SUBAGENT_MAX_TOKENS_ERROR = 'Subagent turn failed before completing its final summary: reason=max_tokens'; export interface RunAgentTurnOptions { - /** When set, drives a continuation-prompt loop when the agent's summary is too short. */ readonly summaryPolicy?: AgentProfileSummaryPolicy; - /** Cancellation signal. Aborting it cancels the agent's turn. */ readonly signal: AbortSignal; - /** Fires once the turn's first request is committed (used by swarm to fan out). */ readonly onReady?: () => void; } -/** - * Submit a prompt (or a retry) to `target` and resolve to the running `Turn` - * plus a promise of the distilled summary/usage. Throws when the underlying - * `IAgentPromptService.prompt/retry` refuses to launch a turn (busy / no head). - */ export async function runAgentTurn( target: IAgentScopeHandle, request: AgentRunRequest, diff --git a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts index d53f1575d5..7907b98998 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts @@ -58,11 +58,6 @@ const RESUMED_LABEL = 'subagent'; export const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000; export const DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION = '30 minutes'; -// ── Input schema ──────────────────────────────────────────────────── -// -// Wire arg name `subagent_type` is kept for compatibility (a rename would -// invalidate the tool_call args in existing session recordings). Internally -// the value is treated as a profile name from `IAgentProfileCatalogService`. export const AgentToolInputSchema = z.preprocess( (input) => { if (typeof input !== 'object' || input === null || Array.isArray(input)) { @@ -107,7 +102,6 @@ export const AgentToolInputSchema = z.preprocess( export type AgentToolInput = z.infer<typeof AgentToolInputSchema>; -// ── Output schema (drift-guard only) ───────────────────────────────── export const AgentToolOutputSchema = z.object({ result: z.string().describe('Aggregated text output from the subagent'), @@ -130,7 +124,6 @@ const RESUME_WITH_TYPE_UNAVAILABLE = const USER_INTERRUPTED_SUBAGENT_MESSAGE = "The user manually interrupted this subagent (and any sibling agents launched alongside it). This was a deliberate user action, not a system error, a timeout, or a capacity/concurrency limit. Do not retry automatically or speculate about why it failed — wait for the user's next instruction."; -// ── AgentTool class ────────────────────────────────────────────────── export class AgentTool implements BuiltinTool<AgentToolInput> { readonly name: string = 'Agent'; @@ -207,13 +200,6 @@ export class AgentTool implements BuiltinTool<AgentToolInput> { return target.accessor.get(IAgentProfileService).data().profileName; } - /** - * Launch (or resume) the target agent and start its turn: create from an - * explicit Profile + Model binding inherited from this agent's own config, - * submit the prompt via `lifecycle.run`, and mirror the run onto this - * agent's record stream. Returns a handle whose completion carries the - * distilled result text. - */ private async launch( args: AgentToolInput, toolCallId: string, @@ -252,9 +238,6 @@ export class AgentTool implements BuiltinTool<AgentToolInput> { if (own.modelAlias === undefined) { throw new Error('Caller agent has no model bound'); } - // Explicit inheritance: the new agent runs the requested profile on this - // agent's own model / thinking level / cwd, and inherits this agent's - // permission mode so it does not fall back to `manual`. const created = await this.lifecycle.create({ binding: { profile: profile.name, @@ -373,8 +356,6 @@ export class AgentTool implements BuiltinTool<AgentToolInput> { throw error; } - // Wrap the run handle in a background task so the LLM can interact - // with it via TaskList / TaskOutput / TaskStop. let taskId: string; try { const registerOptions: RegisterAgentTaskOptions = { @@ -452,7 +433,6 @@ export class AgentTool implements BuiltinTool<AgentToolInput> { registerTool(AgentTool); -// ── formatting helpers ─────────────────────────────────────────────── function buildProfileDescriptions( profiles: readonly AgentProfile[], diff --git a/packages/agent-core-v2/src/session/agentLifecycle/tools/subagent-task.ts b/packages/agent-core-v2/src/session/agentLifecycle/tools/subagent-task.ts index 9cb9b402e4..b11edc4886 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/tools/subagent-task.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/tools/subagent-task.ts @@ -12,7 +12,6 @@ type SubagentCompletion = { readonly usage?: TokenUsage; }; -/** Handle to an agent run launched by the `Agent` tool (or swarm). */ export type SubagentHandle = { readonly agentId: string; readonly profileName: string; @@ -21,9 +20,7 @@ export type SubagentHandle = { export interface SubagentTaskInfo extends AgentTaskInfoBase { readonly kind: 'agent'; - /** Agent identifier accepted by Agent(resume=...). */ readonly agentId?: string; - /** Profile name of the agent. Wire DTO field name kept for compatibility. */ readonly subagentType?: string; } @@ -37,11 +34,6 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } -/** - * Create a `taskService.run()`-compatible executor that waits for an - * agent-run completion promise. Resolves with the agent's result on - * success, throws on abort or failure. - */ export function createSubagentExecutor( handle: SubagentHandle, abortController: AbortController, diff --git a/packages/agent-core-v2/src/session/approval/approval.ts b/packages/agent-core-v2/src/session/approval/approval.ts index 4e3b611ab3..6bd2ee2f0d 100644 --- a/packages/agent-core-v2/src/session/approval/approval.ts +++ b/packages/agent-core-v2/src/session/approval/approval.ts @@ -34,11 +34,6 @@ export interface ISessionApprovalService { readonly _serviceBrand: undefined; request(req: ApprovalRequest): Promise<ApprovalResponse>; - /** - * Submit an approval request without blocking on the decision. Returns the - * request with its resolved `id`; the decision is delivered through the - * interaction `onDidResolve` stream. - */ enqueue(req: ApprovalRequest): ApprovalRequest & { readonly id: string }; decide(id: string, response: ApprovalResponse): void; listPending(): readonly ApprovalRequest[]; diff --git a/packages/agent-core-v2/src/session/btw/btw.ts b/packages/agent-core-v2/src/session/btw/btw.ts index 8123b2142d..85620d3472 100644 --- a/packages/agent-core-v2/src/session/btw/btw.ts +++ b/packages/agent-core-v2/src/session/btw/btw.ts @@ -9,14 +9,9 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -/** Rejection message returned by the deny-all permission policy for tool calls. */ export const TOOL_CALL_DISABLED_MESSAGE = 'Tool calls are disabled for side questions. Answer with text only.'; -/** - * System reminder appended to a `btw` child agent. Tool definitions remain - * visible only for prompt-cache reasons; the model must not call them. - */ export const SIDE_QUESTION_SYSTEM_REMINDER = ` This is a side-channel conversation with the user. You should answer user questions directly based on what you already know. @@ -35,10 +30,6 @@ IMPORTANT: export interface ISessionBtwService { readonly _serviceBrand: undefined; - /** - * Fork the main agent into a side-question child agent (tools disabled, - * side-channel reminder appended) and return the child's id. - */ start(): Promise<string>; } diff --git a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts index 630e472756..0a42d4d868 100644 --- a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts +++ b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts @@ -87,9 +87,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe @IConfigService private readonly config: IConfigService, ) { super(); - // `clocks` starts as `SYSTEM_CLOCKS` and is re-resolved from the real cron - // config in `bindMainAgent` after `config.ready` (see `resolveClocks`), so - // construction never reads config before it is ready. this._register( this.agentLifecycle.onDidCreateMain((handle) => { @@ -110,12 +107,7 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe } private async bindMainAgent(handle: IAgentScopeHandle): Promise<void> { - // Wait for the config document to load before reading any cron config, so - // `getCronConfig()` observes the real value (config.toml + env overlay) - // rather than the pre-ready default. await this.config.ready; - // Re-resolve clocks from the real cron config now that it is loaded (they - // defaulted to `SYSTEM_CLOCKS` at construction). this.resolveClocks(); const wire = handle.accessor.get(IAgentWireService); this._register( @@ -157,12 +149,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe } private getCronConfig(): CronConfig { - // Read through `IConfigService.get()` so the env overlay is re-applied - // on every call — this is what keeps `KIMI_DISABLE_CRON` (and the other - // `KIMI_CRON_*` toggles) live after process start. Callers ensure - // `this.config.ready` (see `bindMainAgent` / `start` / `tick`); after - // ready the `cron` section is registered and `effective` is populated, - // so this is always defined. return this.config.get<CronConfig>(CRON_SECTION); } @@ -170,7 +156,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe return this.getCronConfig().disabled; } - // —— task CRUD —— addTask(init: CronTaskInit): CronTask { const task: CronTask = { @@ -208,7 +193,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe return Array.from(this.tasks.values()); } - // —— scheduling queries —— isStale(task: CronTask): boolean { return this.isStaleAt(task, this.clocks.wallNow()); @@ -231,7 +215,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe return this.nextFireFor(task); } - // —— lifecycle —— async loadFromStore(options: CronLoadOptions = {}): Promise<void> { if (options.replace !== false) { @@ -242,11 +225,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe const owner = task.tags?.[CRON_SESSION_TAG]; if (owner !== undefined && owner !== this.ctx.sessionId) continue; if (owner === undefined) { - // Legacy / hand-edited task whose shape is valid but which carries no - // `sessionId` tag. Adopt it into this session and stamp the tag back - // to disk so a concurrent resume by another session can't also claim - // it (atomic write — last stamper wins, and the record is now owned, - // so future resumes filter by tag as usual). const claimed: CronTask = { ...task, tags: { ...task.tags, [CRON_SESSION_TAG]: this.ctx.sessionId }, @@ -265,8 +243,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe if (this.started) return; this.started = true; - // Defensive: a direct `start()` call outside `bindMainAgent` still waits - // for ready so `getCronConfig()` is readable. await this.config.ready; const cfg = this.getCronConfig(); const poll = cfg.manualTick ? null : cfg.pollIntervalMs; @@ -301,10 +277,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe const now = this.clocks.wallNow(); - // Fan out one async delivery per due task and wait for all to settle. - // Each task owns its own `inFlight` entry (cleared in `processDue`'s - // finally), so a slow `.launched` on one task neither blocks the others - // from starting this tick nor lets the same task be re-picked next tick. const work: Promise<void>[] = []; for (const task of this.list()) { work.push(this.processDue(task, now)); @@ -368,8 +340,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe } finally { this.inFlight.delete(task.id); } - // Not delivered → leave `lastSeenAt` / store untouched so the next tick - // re-detects this task as due and retries (loud retry, not silent loss). if (!delivered) return; if (task.recurring === false) { @@ -424,7 +394,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe this.telemetry.track2(CRON_DELETED, { task_id: taskId }); } - // —— fire delivery —— private async deliverDue(task: CronTask, coalescedCount: number): Promise<boolean> { const firedAt = this.clocks.wallNow(); @@ -479,10 +448,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe return Promise.resolve(false); } - // Resolve to `true` only once the agent has actually accepted the prompt - // (`.launched` settled). A synchronous throw or an async rejection both - // resolve to `false`, so the caller keeps the task and retries next tick - // instead of deleting a one-shot whose prompt never reached the context. return launched.then( () => { this.signalCron({ type: 'cron.fired', origin, prompt: task.prompt }); @@ -515,7 +480,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe ); } - // —— wire borrow helpers —— private dispatchCron(op: Op): void { const mainHandle = this.agentLifecycle.getHandle('main'); @@ -529,7 +493,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe mainHandle.accessor.get(IEventBus).publish(event); } - // —— scheduler helpers —— private getParsed(expr: string): ParsedCronExpression { const cached = this.parsedCache.get(expr); @@ -557,10 +520,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe parsed: ParsedCronExpression, idealMs: number, ): number | null { - // Apply the same jitter the scheduler will use — including the - // `KIMI_CRON_NO_JITTER` bypass — to an already-computed ideal fire time, - // so the `nextFireAt` reported by `CronCreate` matches the actual - // delivery (and what `CronList` shows via `getNextFireForTask`). const noJitter = this.getCronConfig().noJitter; if (task.recurring === false) { return oneShotJitteredNextCronRunMs(task, idealMs, undefined, noJitter); @@ -628,7 +587,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe } } - // —— task-set primitives —— private adopt(task: CronTask): void { this.tasks.set(task.id, task); @@ -654,10 +612,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe private generateUniqueId(): string { for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt++) { - // ULID: 128-bit (48-bit ms timestamp + 80-bit random), Crockford - // base32, 26 chars. The 80-bit random tail makes cross-session id - // collisions a practical impossibility, so two sessions sharing a - // workspace no longer risk overwriting each other's `<id>.json`. const candidate = ulid(); if (!CRON_ID_REGEX.test(candidate)) continue; if (!this.tasks.has(candidate)) return candidate; @@ -674,7 +628,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe return Number.isFinite(age) && age >= STALE_THRESHOLD_MS; } - // —— persistence write serialization —— private persistEnqueue(id: string, work: () => Promise<void>): void { const prev = this.persistQueues.get(id) ?? Promise.resolve(); @@ -690,7 +643,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe this.persistQueues.set(id, next); } - // —— SIGUSR1 manual-tick hook —— private bindSigusr1(): void { if (process.platform === 'win32') return; diff --git a/packages/agent-core-v2/src/session/cron/tools/cron-create.ts b/packages/agent-core-v2/src/session/cron/tools/cron-create.ts index 8e89eb329f..b4fb6b9985 100644 --- a/packages/agent-core-v2/src/session/cron/tools/cron-create.ts +++ b/packages/agent-core-v2/src/session/cron/tools/cron-create.ts @@ -33,40 +33,13 @@ import { computeNextCronRun, cronToHuman, hasFireWithinYears, parseCronExpressio import { formatLocalIsoWithOffset } from '#/app/cron/format'; import CRON_CREATE_DESCRIPTION from './cron-create.md?raw'; -// ── Constants ──────────────────────────────────────────────────────── -/** - * Session-level cap on the number of live cron tasks. Exported so tests - * can pre-fill the store without re-deriving the magic number. - */ export const MAX_CRON_JOBS_PER_SESSION = 50; -/** - * Hard ceiling on `prompt` byte length (UTF-8). The zod `.max(...)` - * upstream is in code units, which underflows multi-byte input - * (`'汉'.length === 1` even though it is 3 bytes); we re-check using - * `Buffer.byteLength` so the budget reflects the actual on-the-wire - * size the model will eventually see. - */ const MAX_PROMPT_BYTES = 8 * 1024; -/** - * Maximum forward distance allowed for a one-shot (`recurring: false`) - * cron's first fire. The canonical footgun is following the tool docs - * and pinning today's day/month for a "remind me at X today" - * reminder — if submission lands seconds past the target minute, - * `computeNextCronRun` rolls the match to next year (~365 days), - * which is still inside the 5-year `hasFireWithinYears` window, and - * the user gets a year-late notification instead of an error. 350 - * days is tight enough to catch the rollover (365 ± epsilon) while - * still leaving room for legitimate "schedule for late this year" - * pinning from early-year submissions. A user who genuinely wants a - * one-shot 11+ months out is better served by a natural-language - * date in the prompt body than by stretching the cron field semantics. - */ const ONE_SHOT_MAX_FUTURE_MS = 350 * 24 * 60 * 60 * 1000; -// ── Input schema ───────────────────────────────────────────────────── export const CronCreateInputSchema = z.object({ cron: z @@ -90,7 +63,6 @@ export const CronCreateInputSchema = z.object({ export type CronCreateInput = z.Infer<typeof CronCreateInputSchema>; -// ── Output shape (internal) ───────────────────────────────────────── interface CronCreateOutput { readonly id: string; @@ -100,7 +72,6 @@ interface CronCreateOutput { readonly nextFireAt: number | null; } -// ── Implementation ─────────────────────────────────────────────────── export class CronCreateTool implements BuiltinTool<CronCreateInput> { readonly name = 'CronCreate' as const; @@ -112,12 +83,6 @@ export class CronCreateTool implements BuiltinTool<CronCreateInput> { constructor(@ISessionCronService private readonly cron: ISessionCronService) {} resolveExecution(args: CronCreateInput): ToolExecution { - // 1. Global killswitch — checked first so a flipped env stops all - // further work, including the cron parse which can throw on - // legitimately-malformed input. Read live from the service (which - // reads through `ConfigService.get()`'s env overlay) rather than a - // value frozen at registration time, so `KIMI_DISABLE_CRON=1` takes - // effect even after the tool is registered. if (this.cron.isDisabled()) { return { isError: true, @@ -125,19 +90,8 @@ export class CronCreateTool implements BuiltinTool<CronCreateInput> { }; } - // 2. Normalize whitespace BEFORE parsing so `parsed.raw` (which - // `cronToHuman` falls back to for non-template shapes) is the - // single-line form. Otherwise tabs/newlines from the raw input - // leak into the rendered `humanSchedule:` row and break the - // one-key-per-line tool output format. Parse errors still report - // against canonical field positions; only whitespace is - // degraded, not semantics. const normalizedCron = args.cron.trim().split(/\s+/).join(' '); - // 3. Parse the cron expression. Any parse failure is a user error - // rather than an internal one, so we surface the message - // verbatim — the parser is already careful to name the - // offending field. let parsed: ParsedCronExpression; try { parsed = parseCronExpression(normalizedCron); @@ -150,12 +104,6 @@ export class CronCreateTool implements BuiltinTool<CronCreateInput> { }; } - // 4. Reject "legal but never fires within 5 years" — the same - // bound the scheduler uses internally to refuse to spin. - // `0 0 31 2 *` is the canonical example. The exact `nowMs` does - // not matter for this judgment (it only changes the search - // window by < 5 years), so we read it here at prepare time and - // re-read inside `execute()` for the actual schedule anchor. const nowAtPrepare = this.cron.now(); if (!hasFireWithinYears(parsed, 5, nowAtPrepare)) { return { @@ -166,10 +114,6 @@ export class CronCreateTool implements BuiltinTool<CronCreateInput> { }; } - // 5. Session-level cap — preliminary check. We re-check inside - // `execute()` because manual-approval mode can delay execution - // long enough for parallel CronCreate calls to all pass this - // gate and then collectively breach the cap on insert. if (this.cron.list().length >= MAX_CRON_JOBS_PER_SESSION) { return { isError: true, @@ -179,9 +123,6 @@ export class CronCreateTool implements BuiltinTool<CronCreateInput> { }; } - // 6. Byte-length cap. zod's `.max()` counts code units, which is - // not the budget we actually want for a multi-byte prompt; the - // Buffer.byteLength check makes the 8 KiB intent literal. const byteLen = Buffer.byteLength(args.prompt, 'utf8'); if (byteLen > MAX_PROMPT_BYTES) { return { @@ -192,21 +133,8 @@ export class CronCreateTool implements BuiltinTool<CronCreateInput> { }; } - // `recurring` is defaulted to true upstream; we re-derive the - // boolean (rather than trusting the post-default arg) to match the - // canonical "recurring iff not explicitly false" convention used - // everywhere else in the cron stack. const recurring = args.recurring !== false; - // 7. One-shot "rolled to next year" guard. The tool docs recommend - // pinning today's dom/month for "remind me at X today"; if - // submission lands seconds past the target minute, - // `computeNextCronRun` returns next year's match, the 5-year - // window above accepts it, and the user's reminder fires a - // year late. Reject when the first ideal fire is more than - // ~one year out — for a 5-field cron this can only mean the - // pinned date already passed this year. Recurring tasks are - // unaffected; they re-fire as expected. if (!recurring) { const firstFire = computeNextCronRun(parsed, nowAtPrepare); if ( @@ -228,12 +156,6 @@ export class CronCreateTool implements BuiltinTool<CronCreateInput> { description: recurring ? `Scheduling cron ${normalizedCron}` : `Scheduling one-shot ${normalizedCron}`, - // Scope `session` approval to this exact payload. Without the - // payload in the rule, a single approved CronCreate would - // authorize any future scheduled prompt for the rest of the - // session — including ones the user never saw before approving. - // Matches the Bash / Write / Edit convention of including the - // command / path in the literal rule pattern. approvalRule: literalRulePattern( this.name, JSON.stringify({ @@ -243,17 +165,8 @@ export class CronCreateTool implements BuiltinTool<CronCreateInput> { }), ), execute: async () => { - // Anchor the schedule to the moment of execution, not the - // moment of preparation. Manual-approval mode can leave - // resolveExecution() and execute() minutes apart; inserting - // with a stale `nowMs` would let the scheduler treat a fresh - // one-shot as already overdue and fire it on the next tick - // with a phantom `coalescedCount > 1`. const nowMs = this.cron.now(); - // Re-check the session cap against the live store size so two - // concurrently-prepared CronCreate calls cannot collectively - // breach it after both passed the prepare-time check. if (this.cron.list().length >= MAX_CRON_JOBS_PER_SESSION) { return { isError: true, @@ -269,21 +182,12 @@ export class CronCreateTool implements BuiltinTool<CronCreateInput> { recurring, }); - // Post-jitter next-fire for the response. `computeNextCronRun` - // returns `null` if there's no fire in the 5-year window (we - // already rejected that above, but be defensive — the jitter - // helper would then have nothing to shift). Delegate to the - // service so the reported `nextFireAt` uses the same jitter the - // scheduler will — including the `KIMI_CRON_NO_JITTER` bypass — - // and matches what `CronList` shows for the same task. const ideal = computeNextCronRun(parsed, nowMs); const nextFireAt = ideal === null ? null : this.cron.computeDisplayNextFire(task, parsed, ideal); const humanSchedule = cronToHuman(parsed); - // Telemetry goes through the service so the tool stays out of - // `service.telemetry`. this.cron.emitScheduled(task); const output: CronCreateOutput = { diff --git a/packages/agent-core-v2/src/session/cron/tools/cron-delete.ts b/packages/agent-core-v2/src/session/cron/tools/cron-delete.ts index 145202599f..7bc2e81271 100644 --- a/packages/agent-core-v2/src/session/cron/tools/cron-delete.ts +++ b/packages/agent-core-v2/src/session/cron/tools/cron-delete.ts @@ -42,18 +42,9 @@ import { toInputJsonSchema } from '#/tool/input-schema'; import { ISessionCronService } from '#/session/cron/sessionCronService'; import CRON_DELETE_DESCRIPTION from './cron-delete.md?raw'; -// ── Constants ──────────────────────────────────────────────────────── -/** - * Same id shape used by the service and the on-disk persistence - * layer. We re-check here so a malformed id never reaches the service — - * the regex is the single source of truth for the on-the-wire id - * format and an early reject keeps the error message close to the - * user's input. - */ const ID_PATTERN = /^(?:[0-9a-f]{8}|[0-9A-HJKMNP-TV-Z]{26})$/i; -// ── Input schema ───────────────────────────────────────────────────── export const CronDeleteInputSchema = z.object({ id: z @@ -62,7 +53,6 @@ export const CronDeleteInputSchema = z.object({ }); export type CronDeleteInput = z.infer<typeof CronDeleteInputSchema>; -// ── Implementation ─────────────────────────────────────────────────── export class CronDeleteTool implements BuiltinTool<CronDeleteInput> { readonly name = 'CronDelete' as const; @@ -74,9 +64,6 @@ export class CronDeleteTool implements BuiltinTool<CronDeleteInput> { constructor(@ISessionCronService private readonly cron: ISessionCronService) {} resolveExecution(args: CronDeleteInput): ToolExecution { - // Format check up front. The store would reject the lookup anyway, - // but the message is more actionable when it names the constraint - // ("ULID") rather than a generic "not found". if (!ID_PATTERN.test(args.id)) { return { isError: true, @@ -92,19 +79,12 @@ export class CronDeleteTool implements BuiltinTool<CronDeleteInput> { execute: async () => { const removed = this.cron.removeTasks([args.id]); if (removed.length === 0) { - // Not found is reported as an error so the model can correct - // itself — see the module header for the rationale. We - // deliberately do NOT emit `cron_deleted` here; the metric - // tracks real state changes. return { isError: true, output: `No cron job with id ${args.id}.`, }; } - // Telemetry goes through the service so the tool stays out of - // `ITelemetryService` — symmetric with `CronCreate`'s use of - // `emitScheduled`. this.cron.emitDeleted(args.id); return { diff --git a/packages/agent-core-v2/src/session/cron/tools/cron-list.ts b/packages/agent-core-v2/src/session/cron/tools/cron-list.ts index 15b279e3fb..05189c51ea 100644 --- a/packages/agent-core-v2/src/session/cron/tools/cron-list.ts +++ b/packages/agent-core-v2/src/session/cron/tools/cron-list.ts @@ -50,36 +50,23 @@ import { type CronTask } from '#/app/cron/cronTask'; import { formatLocalIsoWithOffset } from '#/app/cron/format'; import CRON_LIST_DESCRIPTION from './cron-list.md?raw'; -// ── Input schema ───────────────────────────────────────────────────── -/** - * No arguments. Strict so the loop's AJV validator rejects accidental - * extras (e.g. an `active_only` borrowed from `TaskList`) instead of - * silently ignoring them. - */ export const CronListInputSchema = z.object({}).strict(); export type CronListInput = z.infer<typeof CronListInputSchema>; -// ── Constants ──────────────────────────────────────────────────────── const MS_PER_DAY = 24 * 60 * 60 * 1000; -// Cap each rendered prompt at 200 UTF-8 bytes so a 50-task list with -// kilobyte-scale prompts can't blow up the context window. const PROMPT_PREVIEW_BYTES = 200; function previewPrompt(prompt: string): string { const buf = Buffer.from(prompt, 'utf8'); if (buf.byteLength <= PROMPT_PREVIEW_BYTES) return prompt; - // Slice to PROMPT_PREVIEW_BYTES. If that lands inside a multi-byte - // sequence, walk back to the nearest UTF-8 char boundary (continuation - // bytes start with 10xxxxxx). let end = PROMPT_PREVIEW_BYTES; while (end > 0 && (buf[end]! & 0b1100_0000) === 0b1000_0000) end--; return `${buf.subarray(0, end).toString('utf8')}…(truncated)`; } -// ── Implementation ─────────────────────────────────────────────────── export class CronListTool implements BuiltinTool<CronListInput> { readonly name = 'CronList' as const; @@ -95,11 +82,6 @@ export class CronListTool implements BuiltinTool<CronListInput> { description: 'Listing scheduled cron jobs', approvalRule: this.name, execute: async () => { - // Snapshot the task set once and pin "now" from the service's - // clock — keeping both reads inside the same execute() call - // guarantees the `ageDays` and `nextFireAt` columns are - // computed against the same instant even if the bench-injected - // clock advances between the two. const tasks = this.cron.list(); const nowMs = this.cron.now(); const records = tasks.map((t) => this.renderRecord(t, nowMs)); @@ -119,14 +101,8 @@ export class CronListTool implements BuiltinTool<CronListInput> { } private renderRecord(task: CronTask, nowMs: number): string { - // `recurring: undefined` is the canonical "repeat by default" - // shape across the cron stack; only an explicit `false` opts out. const recurring = task.recurring !== false; - // `ageDays` is purely informational — a non-finite age (e.g. - // wallNow returned NaN from a misconfigured bench clock) is - // reported as 0.00 so the column stays parseable rather than - // emitting the string "NaN". const ageMs = nowMs - task.createdAt; const ageDays = Number.isFinite(ageMs) ? ageMs / MS_PER_DAY : 0; @@ -137,27 +113,17 @@ export class CronListTool implements BuiltinTool<CronListInput> { try { const parsed = parseCronExpression(task.cron); humanSchedule = cronToHuman(parsed); - // Delegate to the service so the rendered ISO matches what the - // scheduler will actually deliver — including a pending jittered - // slot in the current period. const nextFireMs = this.cron.getNextFireForTask(task.id); if (nextFireMs !== null) { nextFireAtIso = formatLocalIsoWithOffset(nextFireMs); } } catch { - // Malformed cron string — leave humanSchedule as the raw - // expression and nextFireAt as `null`. Should never happen for - // tasks that went through CronCreate (which validates), but - // defends against direct store inserts (tests). } return [ `id: ${task.id}`, `cron: ${task.cron}`, `humanSchedule: ${humanSchedule}`, - // JSON-stringify so embedded newlines become `\n` escapes and - // the record stays one `key: value` per line — otherwise a - // multi-line prompt would corrupt the per-record parser. `prompt: ${JSON.stringify(previewPrompt(task.prompt))}`, `nextFireAt: ${nextFireAtIso}`, `recurring: ${String(recurring)}`, diff --git a/packages/agent-core-v2/src/session/interaction/interaction.ts b/packages/agent-core-v2/src/session/interaction/interaction.ts index a06ef0a02b..e58b112468 100644 --- a/packages/agent-core-v2/src/session/interaction/interaction.ts +++ b/packages/agent-core-v2/src/session/interaction/interaction.ts @@ -33,19 +33,15 @@ export interface Interaction<TPayload = unknown> { readonly kind: InteractionKind; readonly payload: TPayload; readonly origin: InteractionOrigin; - /** Epoch ms when the interaction was parked. */ readonly createdAt: number; } -/** Emitted by {@link ISessionInteractionService.onDidResolve} when a request is responded to. */ export interface InteractionResolution { readonly id: string; readonly response: unknown; } -/** Emitted by {@link ISessionInteractionService.onDidChangePending} when the pending set changes. */ export interface InteractionPendingChangedEvent { - /** Ids of the currently pending interactions, in insertion order. */ readonly pending: readonly string[]; } @@ -53,32 +49,12 @@ export interface ISessionInteractionService { readonly _serviceBrand: undefined; request<TPayload, TResponse>(req: InteractionRequest<TPayload>): Promise<TResponse>; - /** - * Park a request without blocking on its response. Returns the created - * `Interaction` (with its resolved `id`) immediately; the outcome is - * delivered through {@link onDidResolve}. Used by edge callers that stream - * the response rather than awaiting a Promise. - */ enqueue<TPayload>(req: InteractionRequest<TPayload>): Interaction; respond(id: string, response: unknown): void; listPending(kind?: InteractionKind): readonly Interaction[]; - /** - * Whether `id` was responded to within the recent-resolution window. Lets - * edge callers distinguish a duplicate resolve (idempotent conflict) from an - * unknown id. The window is bounded (see {@link SessionInteractionService}) and - * exists purely for idempotency signaling. - */ isRecentlyResolved(id: string): boolean; - /** - * Cancel every pending interaction whose {@link InteractionOrigin.turnId} - * matches `turnId`, resolving each as `{ cancelled: true, reason: 'turn_ended' }`. - * Driven from the per-agent `IEventBus` `turn.ended` via the Session-scope - * `IAgentLifecycleService` bridge — the bus is Agent-scoped and cannot be - * injected here directly. No-op when no pending interaction matches. - */ cancelPendingForTurn(turnId: number): void; readonly onDidChangePending: Event<InteractionPendingChangedEvent>; - /** Fires when a pending request is responded to, carrying its id and response. */ readonly onDidResolve: Event<InteractionResolution>; } diff --git a/packages/agent-core-v2/src/session/interaction/interactionService.ts b/packages/agent-core-v2/src/session/interaction/interactionService.ts index 4b53f1d108..984fe019c8 100644 --- a/packages/agent-core-v2/src/session/interaction/interactionService.ts +++ b/packages/agent-core-v2/src/session/interaction/interactionService.ts @@ -26,16 +26,13 @@ interface Pending { readonly resolve: (response: unknown) => void; } -/** How long a resolved id is remembered for idempotent-conflict signaling. */ const RECENTLY_RESOLVED_TTL_MS = 60_000; -/** Upper bound on the resolved-ledger size; oldest entries are swept first. */ const RECENTLY_RESOLVED_MAX = 256; export class SessionInteractionService extends Disposable implements ISessionInteractionService { declare readonly _serviceBrand: undefined; private readonly pending = new Map<string, Pending>(); - /** id → epoch ms when it was resolved. */ private readonly recentlyResolved = new Map<string, number>(); private readonly _onDidChangePending = this._register(new Emitter<InteractionPendingChangedEvent>()); readonly onDidChangePending: Event<InteractionPendingChangedEvent> = this._onDidChangePending.event; @@ -47,14 +44,6 @@ export class SessionInteractionService extends Disposable implements ISessionInt super(); } - // When a turn ends (cancelled or otherwise), any pending interaction that - // originated from it must not strand in the pending set — otherwise - // `sessionActivity` keeps reporting `awaiting_approval` forever (矛盾 c). - // The pending origin carries `{ agentId, turnId }`; match by turnId (the - // field carried by `turn.ended`), which is unambiguous in practice because - // a parent turn waits for its sub-agents before ending. Wired from the - // per-agent `IEventBus` by `AgentLifecycleService` (the bus is Agent-scoped, - // so it cannot be injected into this Session-scope service directly). cancelPendingForTurn(turnId: number): void { let changed = false; for (const [id, entry] of this.pending) { @@ -125,7 +114,6 @@ export class SessionInteractionService extends Disposable implements ISessionInt } private rememberResolved(id: string): void { - // Lazy sweep: drop expired entries, then cap by size (oldest first). const now = Date.now(); for (const [key, resolvedAt] of this.recentlyResolved) { if (now - resolvedAt > RECENTLY_RESOLVED_TTL_MS) this.recentlyResolved.delete(key); diff --git a/packages/agent-core-v2/src/session/process/processRunnerService.ts b/packages/agent-core-v2/src/session/process/processRunnerService.ts index 9b11265190..6eb730a2f6 100644 --- a/packages/agent-core-v2/src/session/process/processRunnerService.ts +++ b/packages/agent-core-v2/src/session/process/processRunnerService.ts @@ -43,14 +43,9 @@ export class SessionProcessRunner implements ISessionProcessRunner { private _buildExecEnv( invocationEnv: Record<string, string> | undefined, ): Record<string, string> | undefined { - // No per-call override — inherit process.env verbatim by passing - // `undefined` to the host process service. if (invocationEnv === undefined) { return undefined; } - // The host replaces the child's env with what we pass, so layer the - // per-call override on top of the current process env to form a complete - // bag. return { ...(process.env as Record<string, string>), ...invocationEnv, diff --git a/packages/agent-core-v2/src/session/question/question.ts b/packages/agent-core-v2/src/session/question/question.ts index 90ebe5f8eb..aa29ec13ab 100644 --- a/packages/agent-core-v2/src/session/question/question.ts +++ b/packages/agent-core-v2/src/session/question/question.ts @@ -32,11 +32,6 @@ export interface QuestionItem { export type QuestionAnswerMethod = 'enter' | 'space' | 'number_key'; -/** - * Flattened answers keyed by question text; values are the chosen option - * label(s) (comma-joined for multi-select) or free-form "Other" text. - * `true` marks a question as answered without echoing a concrete value. - */ export type QuestionAnswers = Record<string, string | true>; export interface QuestionResponse { @@ -44,11 +39,9 @@ export interface QuestionResponse { readonly method?: QuestionAnswerMethod; } -/** `null` = the whole question group was dismissed without answering. */ export type QuestionResult = null | QuestionAnswers | QuestionResponse; export interface QuestionRequest { - /** Caller-supplied correlation id; synthesized from `toolCallId` / a fallback when absent. */ readonly id?: string; readonly turnId?: number; readonly toolCallId?: string; @@ -58,22 +51,9 @@ export interface QuestionRequest { export interface ISessionQuestionService { readonly _serviceBrand: undefined; - /** - * Post a question and block on the answer. When `options.signal` aborts - * while the question is parked (or was already aborted), the pending entry - * is dismissed and the promise resolves with `null` — the same dismissed - * result as an explicit dismiss (v1 broker semantics). - */ request(req: QuestionRequest, options?: { signal?: AbortSignal }): Promise<QuestionResult>; - /** - * Post a question without blocking on the answer. Returns the request with - * its resolved `id`; the answer is delivered through the interaction - * `onDidResolve` stream. - */ enqueue(req: QuestionRequest): QuestionRequest & { readonly id: string }; - /** Settle a pending question with the user's answers (or `null`). */ answer(id: string, result: QuestionResult): void; - /** Dismiss a pending question without answering — resolves it with `null`. */ dismiss(id: string): void; listPending(): readonly QuestionRequest[]; } diff --git a/packages/agent-core-v2/src/session/question/questionService.ts b/packages/agent-core-v2/src/session/question/questionService.ts index 6e677b65b6..444f1acd8b 100644 --- a/packages/agent-core-v2/src/session/question/questionService.ts +++ b/packages/agent-core-v2/src/session/question/questionService.ts @@ -29,11 +29,6 @@ export class SessionQuestionService implements ISessionQuestionService { origin: { turnId: req.turnId }, }); - // Mirrors the v1 broker: when the caller aborts (turn interrupted, - // background task killed) — or was aborted before parking — the entry is - // dismissed so listPending()/session status don't stay stuck in - // awaiting_question, and the caller receives the same `null` (dismissed) - // result as an explicit dismiss. const signal = options?.signal; if (signal !== undefined) { if (signal.aborted) { diff --git a/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts b/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts index b111271184..316e60c287 100644 --- a/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts +++ b/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts @@ -25,15 +25,7 @@ export interface ISessionContext { readonly workspaceId: string; readonly sessionDir: string; readonly metaScope: string; - /** Absolute working directory frozen at session creation. */ readonly cwd: string; - /** - * Persistence scope rooted at this session. `scope()` returns the session - * scope itself; `scope(subKey)` returns `${sessionScope}/${subKey}`. The - * returned string is what business code passes to `IFileSystemStorageService` / - * `IAtomicDocumentStore` / `IAppendLogStore` — it is bootstrap-resolved and - * business code should not perform further path arithmetic on it. - */ scope(subKey?: string): string; } @@ -44,12 +36,6 @@ export function sessionContextSeed(ctx: ISessionContext): ScopeSeed { return [[ISessionContext as ServiceIdentifier<unknown>, ctx]]; } -/** - * Build an `ISessionContext` from its scope-and-directory facts, wiring the - * `scope(subKey?)` helper automatically. `sessionScope` is the session's - * persistence root (typically `sessions/<workspaceId>/<sessionId>`); `subKey` - * concatenation happens inside the returned function. - */ export function makeSessionContext(input: { readonly sessionId: string; readonly workspaceId: string; diff --git a/packages/agent-core-v2/src/session/sessionFs/fs.ts b/packages/agent-core-v2/src/session/sessionFs/fs.ts index 840218b4c5..fb68224612 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fs.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fs.ts @@ -33,14 +33,12 @@ import type { FsStatResponse, } from '@moonshot-ai/protocol'; -/** Absolute + workspace-relative path resolution for a session file. */ export interface FsPathResolved { readonly absolute: string; readonly relative: string; readonly isDirectory: boolean; } -/** Metadata needed by the download route to stream a session file. */ export interface FsDownloadResolved { readonly absolute: string; readonly relative: string; diff --git a/packages/agent-core-v2/src/session/sessionFs/fsProcess.ts b/packages/agent-core-v2/src/session/sessionFs/fsProcess.ts index 6ca3cef476..2eabe9bb43 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsProcess.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsProcess.ts @@ -21,7 +21,6 @@ export interface RunResult { export interface RunCommandOptions { readonly cwd?: string; readonly env?: Record<string, string>; - /** When aborted, the child is killed with `SIGKILL`. */ readonly signal?: AbortSignal; } diff --git a/packages/agent-core-v2/src/session/sessionFs/fsService.ts b/packages/agent-core-v2/src/session/sessionFs/fsService.ts index b783a2783f..1a3562b7af 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsService.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsService.ts @@ -71,11 +71,8 @@ const SEARCH_HARD_CAP = 500; const GREP_TIMEOUT_MS = 30_000; const WALK_MAX_DEPTH = 64; -/** Hard cap for `fs:read` payloads (10 MiB). */ const FS_READ_MAX_BYTES = 10 * 1024 * 1024; -/** Sample size used to sniff binary content. */ const FS_BINARY_SAMPLE_BYTES = 4096; -/** Fraction of non-printable bytes above which a sample is treated as binary. */ const FS_BINARY_NONPRINTABLE_FRACTION = 0.3; const HIDDEN_NAME_RE = /^\./; @@ -85,11 +82,6 @@ export class SessionFsService implements ISessionFsService { declare readonly _serviceBrand: undefined; private readonly gitignoreCache = new Map<string, Ignore>(); - /** - * Cached ripgrep resolution. `undefined` = not probed yet; `null` = probed - * and unavailable (use the node fallback). Mirrors the old `rgAvailable` - * boolean cache so we probe at most once per session. - */ private rgResolution: RgResolution | null | undefined = undefined; constructor( @@ -100,7 +92,6 @@ export class SessionFsService implements ISessionFsService { @IGitService private readonly git: IGitService, ) {} - /** Resolve a workspace-relative path (or `.`) back to an absolute path for `IHostFileSystem`. */ private absOf(rel: string): string { return rel === '' || rel === '.' ? this.workspace.workDir : join(this.workspace.workDir, rel); } @@ -493,10 +484,6 @@ export class SessionFsService implements ISessionFsService { const proc = await this.runner.exec([rgPath, ...args], { cwd: this.workspace.workDir }); - // Stream `--json` records as they arrive so we can stop `rg` the moment a - // cap (`max_total_matches` / `max_files`) is hit, instead of buffering the - // whole output and letting rg scan the entire tree. The accumulator drops - // any records that were already buffered before the kill landed. const acc = new RgJsonAccumulator(req); let killed = false; const kill = (): void => { @@ -527,8 +514,6 @@ export class SessionFsService implements ISessionFsService { } if (stdoutBuf.length > 0) acc.feed(stdoutBuf); } catch (error) { - // Once we kill rg (cap reached / abort / timeout) the pipe can close - // mid-read; that is the intended early stop, not a search failure. if (!(killed && isPrematureCloseError(error))) throw error; } }; @@ -540,7 +525,6 @@ export class SessionFsService implements ISessionFsService { try { void proc.dispose(); } catch { - /* best-effort cleanup */ } } @@ -640,9 +624,6 @@ export class SessionFsService implements ISessionFsService { const { name } = entry; if (name === '.git') continue; const childRel = rootRel === '' ? name : `${rootRel}/${name}`; - // Symlinks are reported as themselves and never descended into — a - // symlinked directory must not be treated as a traversable directory, - // otherwise search could escape the workspace through the link target. const isDir = entry.isDirectory && entry.isSymbolicLink !== true; if (matcher) { const probe = isDir ? `${childRel}/` : childRel; @@ -670,20 +651,11 @@ export class SessionFsService implements ISessionFsService { const contents = await this.hostFs.readText(join(this.workspace.workDir, '.gitignore')); ig.add(contents); } catch { - // No .gitignore — keep the `.git/` default only. } this.gitignoreCache.set(cwd, ig); return ig; } - /** - * Resolve a usable `rg` once per session via the shared locator. Probes - * `rg --version` through the session runner (so it respects the execution - * environment). Returns `null` when `rg` is unavailable so the caller can - * fall back to the pure-node walker. The cached-binary fallback is disabled - * here — Grep's node fallback already covers the missing-`rg` case and - * keeping it off makes the fallback deterministic. - */ private async resolveRg(): Promise<RgResolution | null> { if (this.rgResolution !== undefined) return this.rgResolution; const probe: RgProbe = { @@ -732,13 +704,6 @@ export class SessionFsService implements ISessionFsService { } } -/** - * Incremental accumulator for ripgrep `--json` output. Fed one record per line - * by `grepWithRg` so the caller can kill `rg` as soon as `max_total_matches` - * or `max_files` is reached (see {@link RgJsonAccumulator.capped}). Records - * buffered in the pipe after the cap are dropped by the same `>=` guards that - * bound the live counts. - */ class RgJsonAccumulator { private readonly fileBuf = new Map< string, @@ -751,7 +716,6 @@ class RgJsonAccumulator { constructor(private readonly req: FsGrepRequest) {} - /** `true` once either output cap has been reached and `rg` should be stopped. */ get capped(): boolean { return ( this.totalMatches >= this.req.max_total_matches || this.filesScanned >= this.req.max_files @@ -843,10 +807,6 @@ class RgJsonAccumulator { } } -// --------------------------------------------------------------------------- -// Helpers shared by the list/read/stat/mkdir methods. Ported from the v1 -// `SessionFsService` so the `/api/v1` mirror stays byte-compatible. -// --------------------------------------------------------------------------- function isHidden(name: string): boolean { return HIDDEN_NAME_RE.test(name) || MACOS_NOISE.has(name); @@ -872,7 +832,6 @@ function sortChildren( }, name_asc: (a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name), name_desc: (a: { name: string }, b: { name: string }) => b.name.localeCompare(a.name), - // v1 does not implement mtime/size ordering; keep the same name fallback. mtime_desc: (a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name), size_desc: (a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name), }[sort]; @@ -938,7 +897,6 @@ function countLines(text: string): number { } function errnoCode(err: unknown): string | undefined { - // hostFs wraps raw errnos in `HostFsError`; classify the unwrapped cause. const unwrapped = unwrapErrorCause(err); if (typeof unwrapped === 'object' && unwrapped !== null && 'code' in unwrapped) { const c = (unwrapped as { code: unknown }).code; diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts b/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts index 30d600454b..4b3c123c66 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts @@ -16,22 +16,10 @@ import type { FsChangeEvent } from '@moonshot-ai/protocol'; export interface ISessionFsWatchService { readonly _serviceBrand: undefined; - /** - * Replace the set of workspace-relative paths to observe. `'.'` watches the - * whole workspace. Passing an empty array stops the underlying watcher. - * Paths are confined to the workspace; absolute / `..` / escaping inputs - * throw `FS_PATH_ESCAPES`. - */ setWatchedPaths(paths: readonly string[]): void; - /** Currently observed workspace-relative paths (posix). */ readonly watchedPaths: readonly string[]; - /** - * Coalesced change feed. Each event carries the changes for one debounce - * window; when the window overflows, `changes` is emptied and `truncated` - * (with `count`) is set so consumers can fall back to a full refresh. - */ readonly onDidChangeFiles: Event<FsChangeEvent>; } diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts b/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts index f3265817e6..9434217177 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts @@ -33,7 +33,6 @@ import { ISessionFsWatchService } from './fsWatch'; const DEFAULT_DEBOUNCE_MS = 200; const DEFAULT_MAX_CHANGES_PER_WINDOW = 500; -/** Positive-int env read for the test-only window overrides below. */ function readPositiveIntEnv(name: string, fallback: number): number { const raw = process.env[name]; if (raw === undefined || raw === '') return fallback; @@ -56,9 +55,6 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch private rawCount = 0; private truncated = false; - // Env-overridable for tests: the burst-truncation e2e cannot rely on - // chokidar delivering >500 events inside one 200ms window under CPU - // contention. Production leaves both unset and gets the defaults. private readonly debounceMs = readPositiveIntEnv( 'KIMI_CODE_FS_WATCH_DEBOUNCE_MS', DEFAULT_DEBOUNCE_MS, @@ -68,7 +64,6 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch DEFAULT_MAX_CHANGES_PER_WINDOW, ); - /** Always present; starts with `.git/` and is augmented with `.gitignore` once loaded. */ private readonly matcher: Ignore = ignore().add('.git/'); private gitignoreLoaded = false; diff --git a/packages/agent-core-v2/src/session/sessionFs/gitContext.ts b/packages/agent-core-v2/src/session/sessionFs/gitContext.ts index 5f04c17d7d..15daafc9ae 100644 --- a/packages/agent-core-v2/src/session/sessionFs/gitContext.ts +++ b/packages/agent-core-v2/src/session/sessionFs/gitContext.ts @@ -195,7 +195,6 @@ async function runGit( try { proc.stdin.end(); } catch { - /* stdin already closed */ } const work = Promise.all([collectStream(proc.stdout), collectStream(proc.stderr), proc.wait()]); @@ -218,7 +217,6 @@ async function runGit( try { await proc.kill('SIGKILL'); } catch { - /* process already gone */ } await work.catch(() => {}); if (timedOut) return { ok: false, kind: 'timeout' }; @@ -241,6 +239,5 @@ async function disposeProcess(proc: IProcess): Promise<void> { try { await proc.dispose(); } catch { - /* best-effort cleanup */ } } diff --git a/packages/agent-core-v2/src/session/sessionFs/rgLocator.ts b/packages/agent-core-v2/src/session/sessionFs/rgLocator.ts index 389cd08e15..178514afc1 100644 --- a/packages/agent-core-v2/src/session/sessionFs/rgLocator.ts +++ b/packages/agent-core-v2/src/session/sessionFs/rgLocator.ts @@ -23,36 +23,19 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; -/** Where the resolved `rg` came from. Used for fallback telemetry. */ export type RgResolutionSource = 'system-path' | 'share-bin-cached'; export interface RgResolution { - /** Command or absolute path to pass as argv[0] when spawning `rg`. */ readonly path: string; readonly source: RgResolutionSource; } -/** - * Minimal probe surface the locator runs against. Lets the same locator run - * over Glob's and Grep's `ISessionProcessRunner` without depending on either - * directly. - */ export interface RgProbe { - /** Run `argv` and resolve with the process exit code. */ exec(args: readonly string[]): Promise<{ readonly exitCode: number }>; } export interface EnsureRgPathOptions { - /** - * Cancels this caller's wait. Checked between probe steps; an aborted signal - * makes {@link ensureRgPath} throw an `AbortError`. - */ readonly signal?: AbortSignal; - /** - * When true, fall back to the cached binary at `<share>/bin/rg` if `rg` is - * not on PATH. Defaults to false so callers with their own fallback (Grep's - * node walker) keep deterministic behavior. - */ readonly allowCachedFallback?: boolean; } @@ -66,7 +49,6 @@ function getShareDir(): string { return join(homedir(), '.kimi-code'); } -/** Absolute path of the cached `rg` binary, if one has been installed. */ export function getShareBinRgPath(): string { return join(getShareDir(), 'bin', rgBinaryName()); } @@ -77,11 +59,6 @@ function throwIfAborted(signal: AbortSignal | undefined): void { } } -/** - * Resolve a usable `rg`. Probes `rg --version` through `probe`; on a non-zero - * exit (and only when `allowCachedFallback` is set) tries the cached binary - * before giving up. Throws when no working `rg` can be found. - */ export async function ensureRgPath( probe: RgProbe, options: EnsureRgPathOptions = {}, @@ -105,10 +82,6 @@ export async function ensureRgPath( throw new Error('ripgrep (rg) is not available on PATH'); } -/** - * User-facing message when {@link ensureRgPath} throws. Kept in one place so - * the Glob / Grep plumbing surfaces the same actionable hint. - */ export function rgUnavailableMessage(cause: unknown): string { const detail = cause instanceof Error ? cause.message : typeof cause === 'string' ? cause : 'unknown error'; diff --git a/packages/agent-core-v2/src/session/sessionFs/runRg.ts b/packages/agent-core-v2/src/session/sessionFs/runRg.ts index aa3767dcc5..46ab223428 100644 --- a/packages/agent-core-v2/src/session/sessionFs/runRg.ts +++ b/packages/agent-core-v2/src/session/sessionFs/runRg.ts @@ -37,18 +37,9 @@ async function disposeProcess(proc: IProcess): Promise<void> { try { await proc.dispose(); } catch { - /* best-effort cleanup */ } } -/** - * Spawn `rgArgs` through the session `ISessionProcessRunner` and drain its - * stdout/stderr with a byte cap. Handles abort (via `signal`) and a hard - * timeout with a two-phase kill (SIGTERM, then SIGKILL after a grace period) - * and process disposal. Returns `{ kind: 'aborted' }` when the run is - * cancelled so the caller can surface a stable "aborted" message. Spawn - * failures (e.g. ENOENT) are thrown to the caller. - */ export async function runRgOnce( runner: ISessionProcessRunner, rgArgs: readonly string[], @@ -64,7 +55,6 @@ export async function runRgOnce( try { proc.stdin.end(); } catch { - /* already gone */ } let timedOut = false; @@ -77,7 +67,6 @@ export async function runRgOnce( try { await proc.kill('SIGTERM'); } catch { - /* process already gone */ } const exited = proc .wait() @@ -95,7 +84,6 @@ export async function runRgOnce( try { await proc.kill('SIGKILL'); } catch { - /* ignore */ } } await disposeProcess(proc); @@ -106,8 +94,6 @@ export async function runRgOnce( void killProc(); }; signal.addEventListener('abort', onAbort); - // AbortSignal does not replay past abort events; check once after registering - // the listener so already-aborted calls still run the cleanup path. if (signal.aborted) onAbort(); const timeoutHandle = setTimeout(() => { @@ -135,7 +121,6 @@ export async function runRgOnce( if (!(isPrematureCloseError(error) && (timedOut || aborted || killed))) { throw error; } - // The disposer intentionally closes streams after a terminating signal. } finally { clearTimeout(timeoutHandle); signal.removeEventListener('abort', onAbort); @@ -149,11 +134,6 @@ export async function runRgOnce( return { kind: 'result', exitCode, stdoutText, stderrText, bufferTruncated, timedOut }; } -/** - * ripgrep can fail with `os error 11` (EAGAIN, "Resource temporarily - * unavailable") when its thread pool can't spawn a worker under load. A single - * single-threaded retry (`-j 1`) sidesteps the pool and usually succeeds. - */ export function shouldRetryRipgrepEagain(result: RunRgResult): boolean { return ( result.exitCode !== 0 && diff --git a/packages/agent-core-v2/src/session/sessionInit/sessionInit.ts b/packages/agent-core-v2/src/session/sessionInit/sessionInit.ts index fb5fbe2f25..c1f3b3db75 100644 --- a/packages/agent-core-v2/src/session/sessionInit/sessionInit.ts +++ b/packages/agent-core-v2/src/session/sessionInit/sessionInit.ts @@ -20,12 +20,6 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio export interface ISessionInitService { readonly _serviceBrand: undefined; - /** - * Run `/init`: launch the `coder` subagent with the init brief, await its - * completion, reload `AGENTS.md`, and append an `init` system reminder to the - * main agent. Throws `SESSION_INIT_FAILED` wrapping the underlying error when - * the subagent run or the AGENTS.md reload fails. - */ generateAgentsMd(): Promise<void>; } diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index be5fadf508..fbaed0f29c 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -13,63 +13,34 @@ import type { Event } from '#/_base/event'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; export interface AgentMeta { - /** Per-agent directory used as the wire-record `homedir` (persistence key). */ readonly homedir: string; readonly type?: 'main' | 'sub' | 'independent'; - /** Legacy v1 documents may carry `null`; read-compat. */ readonly parentAgentId?: string | null; - /** Agent this one was forked / derived from (provenance only; not used by business logic). */ readonly forkedFrom?: string; - /** - * Business-defined recorded values (e.g. the swarm's `swarmItem`), persisted - * verbatim. Never interpreted by the lifecycle. - */ readonly labels?: Readonly<Record<string, string>>; - /** @deprecated Legacy on-disk field predating `labels`; read-compat only. */ readonly swarmItem?: string; } -/** - * Metadata document schema version written by this build. Stored on each - * session's `state.json` so readers can tell which layout a document follows: - * `2` = written by v2 (epoch-ms timestamps); absent = legacy v1 (ISO-string - * timestamps). Both v1 and v2 write the document to `<sessionDir>/state.json`; - * the version field is what distinguishes them. - */ export const SESSION_META_VERSION = 2; export interface SessionMeta { readonly id: string; - /** Metadata schema version — `2` for documents written by v2. */ readonly version?: number; readonly title?: string; - /** True when the title was explicitly set by the user (rename), false/undefined for auto titles. */ readonly isCustomTitle?: boolean; - /** Last user prompt text, surfaced on the wire `Session.last_prompt`. */ readonly lastPrompt?: string; readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; - /** - * Absolute working directory frozen at session creation (`metadata.cwd` on - * the wire). Persisted so the session read model (`sessionIndex`) can surface - * it without reverse-resolving the workspace registry — a session whose - * workspace was unregistered keeps its original cwd (closes gap G3). Mirrors - * v1, which stores `workDir` on the session. Optional only for documents - * predating this field; `load()` always writes it for new sessions. - */ readonly cwd?: string; readonly forkedFrom?: string; - /** Registry of agents belonging to this session, keyed by agent id. */ readonly agents?: Readonly<Record<string, AgentMeta>>; - /** Free-form custom metadata (wire `Session.metadata` minus reserved keys like `goal`). */ readonly custom?: Record<string, unknown>; } export type SessionMetaPatch = Partial<Omit<SessionMeta, 'id' | 'createdAt'>>; export interface SessionMetadataChangedEvent { - /** Metadata fields touched by the update (the `SessionMetaPatch` keys). */ readonly changed: readonly (keyof SessionMeta)[]; } @@ -82,7 +53,6 @@ export interface ISessionMetadata { update(patch: SessionMetaPatch): Promise<void>; setTitle(title: string): Promise<void>; setArchived(archived: boolean): Promise<void>; - /** Register (or replace) an agent entry in the session's agent registry. */ registerAgent(agentId: string, meta: AgentMeta): Promise<void>; } diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index c4aaef27d5..aac71fb63b 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -147,26 +147,12 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { } } -/** - * Normalize a persisted `state.json` document into the v2 `SessionMeta` shape. - * - * Documents tagged `version: 2` are already v2-shaped and returned as-is. - * Legacy v1 documents (no `version`) store `createdAt`/`updatedAt` as ISO - * strings and omit the `id` field; we coerce the timestamps to epoch ms and - * backfill `id` from the session identity so every reader sees a consistent - * v2-shaped object. Normalization is in-memory only — the on-disk document is - * left untouched until an explicit write, so a read-only snapshot of a v1 - * session does not migrate it. - */ export function normalizeSessionMeta(raw: SessionMeta, sessionId: string): SessionMeta { const legacy = raw as unknown as { createdAt?: unknown; updatedAt?: unknown; workDir?: unknown; }; - // Backfill `cwd` for legacy v1 documents, which store the working directory - // as `workDir` (older v1 sessions used `custom.cwd`). New v2 documents already - // carry `cwd` and pass through unchanged. const cwd = raw.cwd ?? (typeof legacy.workDir === 'string' && legacy.workDir.length > 0 ? legacy.workDir @@ -184,7 +170,6 @@ export function normalizeSessionMeta(raw: SessionMeta, sessionId: string): Sessi }; } -/** Coerce a persisted timestamp (v2 epoch-ms number or v1 ISO string) to epoch ms. */ export function toEpochMs(value: unknown): number { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string') { diff --git a/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts b/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts index de06f56646..b3382aeb6c 100644 --- a/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts +++ b/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts @@ -15,13 +15,6 @@ import * as retry from 'retry'; import { isUserCancellation } from '#/_base/utils/abort'; import type { SessionSwarmRunResult, SessionSwarmTask } from './sessionSwarm'; -// ── Launcher contract ──────────────────────────────────────────────── -// -// The scheduler drives agent-run attempts through a small launcher -// interface. Consumers (currently only `SessionSwarmService`) implement it -// on top of `IAgentLifecycleService.create({ binding })` + `run` + -// `mirrorAgentRun`; the option shapes are defined here so the scheduler has -// a stable contract regardless of how launches are wired. export interface AgentRunAttemptOptions { readonly parentToolCallId: string; @@ -49,26 +42,6 @@ export type AgentRunAttemptHandle = { }>; }; -/* -Agent-run batch scheduling contract: -Normal phase: -- Return results in input order; empty input returns an empty list. -- Start up to 5 tasks immediately, then 1 more every 700 ms while queued work remains. By default active tasks do not cap this ramp; when KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY is set to a positive integer, the ramp additionally stops while active tasks reach that cap, and resumes as tasks complete. -- Launch priority: previous agent id saved after a rate limit, explicit resume, then new spawn. -- Readiness can be reported while the attempt is active. Ready normal launches seed the first rate-limit capacity. -- The first provider rate limit stops the ramp and enters rate-limit phase. - -Rate-limit phase: -- A provider rate limit requeues while there is other unfinished work. Save the agent id for same-agent retry, emit suspended, and requeue the task at the front; its own eligibility delays are 3000 ms, 6000 ms, 12000 ms, then doubling. -- If the rate-limited attempt is the only unfinished task, fail that task instead of suspending the whole batch forever. -- Enter with capacity equal to ready normal launches, minimum 1; set the next global launch no earlier than 3000 ms later; then shrink capacity by 1, minimum 1. Later rate limits shrink by 1, minimum 1, at most once per 2000 ms. -- Each pass starts at most 1 task: active attempts must be below capacity, global launch time reached, and task eligibility reached. Choose the first eligible queued task, then set next global launch to now plus the current interval. If blocked by time or queued work remains after a launch, wake at the earlier of next launch/eligibility and next capacity recovery. -- Core recovery rule: in rate-limit phase, if work is queued and no provider rate limit happened for 3 minutes, capacity increases by 1, which can launch one more task immediately. This can happen once per quiet window; a new rate limit restarts the window. If active attempts still fill capacity, wake at the next recovery time. - -Results and cancellation: -- Completed, failed, aborted, and timed-out attempts occupy their input slots; when all slots have results, return the ordered list. A task timeout fails only that task and does not enter rate-limit phase or stop others. -- The first task signal is the batch signal. User cancellation preserves existing results, marks ready or agent-known unfinished tasks aborted/started, and marks never-started tasks aborted/not_started. Non-user cancellation rejects. -*/ const INITIAL_LAUNCH_LIMIT = 5; const INITIAL_LAUNCH_INTERVAL_MS = 700; @@ -126,11 +99,6 @@ type ActiveAttempt<T> = { }; export type AgentRunBatchOptions = { - /** - * Optional cap on how many agent runs may execute concurrently during the normal - * phase. `undefined` means no cap (legacy ramp behavior). The rate-limit - * phase is governed by its own capacity logic and is not affected. - */ readonly maxConcurrency?: number; }; @@ -667,13 +635,6 @@ export class AgentRunBatch<T> { } } -/** - * Resolve the optional AgentSwarm normal-phase concurrency cap from the environment. - * - * Returns `undefined` when the variable is unset/empty. A present value must be a - * positive integer; invalid input fails fast so a misconfigured cap never silently - * reverts to the uncapped ramp. - */ export function resolveSwarmMaxConcurrency( env: Readonly<Record<string, string | undefined>> = process.env, ): number | undefined { diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts index e7d3e30534..25024ade01 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -61,10 +61,6 @@ declare module '#/app/event/eventBus' { } } -/** - * Requester-facing label for a resumed agent whose profile binding is unknown. - * Kept as the legacy wire display value. - */ const RESUMED_PROFILE_FALLBACK = 'subagent'; export class SessionSwarmService implements ISessionSwarmService { @@ -140,9 +136,6 @@ export class SessionSwarmService implements ISessionSwarmService { if (callerData.modelAlias === undefined) { throw new Error('Caller agent has no model bound'); } - // Explicit inheritance: the child runs the requested profile on the - // caller's own model / thinking level / cwd, and inherits the caller's - // permission mode so it does not fall back to `manual`. const child = await this.lifecycle.create({ binding: { profile: profile.name, @@ -265,7 +258,6 @@ export class SessionSwarmService implements ISessionSwarmService { } } -// Kept as a type-anchor so future maintenance imports the usage shape from here. export type _AgentRunUsage = TokenUsage; registerScopedService( diff --git a/packages/agent-core-v2/src/session/terminal/terminalService.ts b/packages/agent-core-v2/src/session/terminal/terminalService.ts index ac7d46588b..8bbfb4f926 100644 --- a/packages/agent-core-v2/src/session/terminal/terminalService.ts +++ b/packages/agent-core-v2/src/session/terminal/terminalService.ts @@ -174,7 +174,6 @@ export class SessionTerminalService extends Disposable implements ISessionTermin try { record.process.kill(); } catch { - // best-effort cleanup } } this.records.clear(); @@ -251,9 +250,6 @@ function frameSeq(frame: TerminalFrame): number { } function defaultShell(): string { - // Use `||` (not `??`): an EMPTY $SHELL (set but blank, as some daemon/launchd - // envs leave it) must still fall back, or a PTY spawn fails with - // "posix_spawnp failed". return process.env['SHELL'] || '/bin/sh'; } diff --git a/packages/agent-core-v2/src/session/todo/sessionTodo.ts b/packages/agent-core-v2/src/session/todo/sessionTodo.ts index f159d449a7..8465986a98 100644 --- a/packages/agent-core-v2/src/session/todo/sessionTodo.ts +++ b/packages/agent-core-v2/src/session/todo/sessionTodo.ts @@ -15,13 +15,9 @@ import type { TodoItem } from './todoItem'; export interface ISessionTodoService { readonly _serviceBrand: undefined; - /** Current in-memory todo list (the materialized main-agent wire state). */ getTodos(): readonly TodoItem[]; - /** Replace the whole list: appends a `tools.update_store` (`key: 'todo'`) to the main agent's wire. */ setTodos(todos: readonly TodoItem[]): void; - /** Clear the list (equivalent to `setTodos([])`). */ clear(): void; - /** Fires when the materialized list changes (after a `tools.update_store` is applied); carries the sanitized list. */ readonly onDidChange: Event<readonly TodoItem[]>; } diff --git a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts index db81f8b696..8e9304af6f 100644 --- a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts +++ b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts @@ -45,7 +45,6 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic private readonly onDidChangeEmitter = this._register(new Emitter<readonly TodoItem[]>()); readonly onDidChange = this.onDidChangeEmitter.event; - /** Per-agent bindings (reminder per agent, plus the model subscription for main). */ private readonly agentBindings = new Map<string, IDisposable[]>(); constructor( @@ -103,10 +102,6 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic private bindMainWire(handle: IAgentScopeHandle): void { const wire = handle.accessor.get(IAgentWireService); - // Registered on the main agent's wire by `onDidCreateMain`, which fires in - // `ensureMainAgent` strictly before that wire's `replay`. Bridge model - // changes to `onDidChange`: replay applies silently (no notification), so - // this fires only for live `tools.update_store` (`key: 'todo'`) writes, carrying the sanitized model. const disposable = wire.subscribe(TodoModel, (state) => { this.onDidChangeEmitter.fire(state); }); diff --git a/packages/agent-core-v2/src/tool/args-validator.ts b/packages/agent-core-v2/src/tool/args-validator.ts index a21894301d..3d8b33b3b0 100644 --- a/packages/agent-core-v2/src/tool/args-validator.ts +++ b/packages/agent-core-v2/src/tool/args-validator.ts @@ -35,8 +35,6 @@ const DRAFT_2019_KEYWORDS = new Set([ const DRAFT_2020_KEYWORDS = new Set(['prefixItems', '$dynamicAnchor', '$dynamicRef']); -// Mixing JSON Schema dialects in a single Ajv instance is unsafe because -// keyword semantics differ, e.g. draft-07 tuple `items` vs 2020-12 `prefixItems`. function ajvFor(schema: Record<string, unknown>): Ajv | Ajv2019 | Ajv2020 { const $schema = schema['$schema']; if (typeof $schema === 'string') { @@ -63,10 +61,8 @@ function containsSchemaKeyword(value: unknown, keywords: ReadonlySet<string>): b export type JsonType = null | number | string | boolean | JsonArray | JsonObject; -/** @internal */ export interface JsonArray extends Array<JsonType> {} -/** @internal */ export interface JsonObject extends Record<string, JsonType> {} export type ToolArgsValidator = ValidateFunction<JsonType>; diff --git a/packages/agent-core-v2/src/tool/input-schema.ts b/packages/agent-core-v2/src/tool/input-schema.ts index b1927115de..6a9fa12e15 100644 --- a/packages/agent-core-v2/src/tool/input-schema.ts +++ b/packages/agent-core-v2/src/tool/input-schema.ts @@ -19,12 +19,6 @@ import { z } from 'zod'; -/** - * Convert a zod schema into the input JSON Schema exposed to the model. - * - * @param schema - The zod schema describing the tool's parameters. - * @returns A draft-07 JSON Schema rendered with the input view. - */ export function toInputJsonSchema(schema: z.ZodType): Record<string, unknown> { const jsonSchema = z.toJSONSchema(schema, { target: 'draft-7', @@ -34,20 +28,6 @@ export function toInputJsonSchema(schema: z.ZodType): Record<string, unknown> { return jsonSchema; } -/** - * Re-assert `additionalProperties: false` on every object node. - * - * The input view drops `additionalProperties: false` from `z.object` nodes - * because, before unknown-key stripping, an *input* object may legally carry - * extra keys. But a tool's parameter schema is a model-facing contract that - * the runtime validates with AJV only — there is no zod parse/strip step - * before dispatch — so without the closed-object guard a misspelled argument - * passes validation and is silently ignored. Restoring it keeps unknown - * arguments rejected, matching the output view's pre-input-view behavior. - * - * Nodes that already declare `additionalProperties` (e.g. `z.record`) are - * left untouched. - */ function closeObjectNodes(value: unknown): void { if (Array.isArray(value)) { for (const item of value) closeObjectNodes(item); diff --git a/packages/agent-core-v2/src/tool/path-access.ts b/packages/agent-core-v2/src/tool/path-access.ts index 88ef57cb84..aaaab3140d 100644 --- a/packages/agent-core-v2/src/tool/path-access.ts +++ b/packages/agent-core-v2/src/tool/path-access.ts @@ -23,9 +23,7 @@ import * as pathe from 'pathe'; import type { IHostEnvironment } from '#/os/interface/hostEnvironment'; export interface WorkspaceConfig { - /** Primary workspace directory (absolute, canonicalized). */ readonly workspaceDir: string; - /** Extra allowed roots (e.g. `--add-dir` CLI flag). */ readonly additionalDirs: readonly string[]; } @@ -77,8 +75,6 @@ export function isSensitiveFile(path: string): boolean { for (const prefix of SENSITIVE_BASENAME_PREFIXES) { if (comparableName === prefix) return true; - // Catch rename-shielded variants without flagging unrelated filenames - // like `id_rsafoo` or ordinary JSON files like `credentials.json`. if (comparableName.length > prefix.length && comparableName.startsWith(prefix)) { const suffix = comparableName.slice(prefix.length); const next = suffix[0]; @@ -144,8 +140,6 @@ function isWin32DriveRelative(path: string): boolean { export function normalizeUserPath(path: string, pathClass: PathClass = DEFAULT_PATH_CLASS): string { if (pathClass !== 'win32') return path; - // A bare root slash stays forward so downstream pathe operations - // treat it consistently. Matches the py helper's behavior. if (path === '/') return '/'; if (path.startsWith('//')) { @@ -178,10 +172,6 @@ function expandUserPath(path: string, homeDir: string | undefined, pathClass: Pa return path; } -/** - * Lexical canonicalization: resolve relative → absolute against `cwd`, - * then normalize `..` / `.` segments. No filesystem I/O. - */ export function canonicalizePath( path: string, cwd: string, @@ -211,10 +201,6 @@ export function canonicalizePath( return pathe.normalize(abs); } -/** - * True iff `candidate` is `base` itself or a descendant of it, compared - * on path-component boundaries. Both arguments must already be canonical. - */ export function isWithinDirectory( candidate: string, base: string, @@ -229,10 +215,6 @@ export function isWithinDirectory( return comparableCandidate.startsWith(prefix); } -/** - * True iff `candidate` (already canonical) sits inside any of the workspace - * roots listed in `config` (primary `workspaceDir` or any `additionalDirs`). - */ export function isWithinWorkspace( candidate: string, config: WorkspaceConfig, @@ -245,16 +227,6 @@ export function isWithinWorkspace( return false; } -/** - * Merge skill-catalog roots into a tool workspace's `additionalDirs` so the - * file tools (Read/Write/Edit/Grep/Glob/ReadMediaFile) can reach skill - * directories outside the cwd (e.g. `~/.kimi-code/skills`). Roots already - * inside the workspace or an existing additional dir are skipped. Returns - * `workspace` unchanged when nothing was added. Port of v1's - * `skill/scanner.ts` helper — v1 applied it once at builtin-tool construction; - * v2 tools call it per execution so roots from late-loading skill sources - * (plugin reloads, ad-hoc contributions) are picked up. - */ export function extendWorkspaceWithSkillRoots<T extends WorkspaceConfig>( workspace: T, skillRoots: readonly string[], @@ -272,7 +244,6 @@ export function extendWorkspaceWithSkillRoots<T extends WorkspaceConfig>( export interface AssertPathOptions { readonly mode: PathAccessOperation; - /** When true (default), also reject paths matching a sensitive-file pattern. */ readonly checkSensitive?: boolean | undefined; readonly pathClass?: PathClass | undefined; } @@ -362,15 +333,6 @@ export function resolvePathAccessPath( }).path; } -/** - * Throw `PathSecurityError` if `path` escapes the workspace through a relative - * path, matches a known sensitive file, or is empty. Returns the canonical - * absolute path when the check passes. - * - * Note: this is purely lexical. It does NOT protect against symlink - * targets that point outside the workspace — that would require kaos-layer - * realpath support, which is not currently available. - */ export function assertPathAllowed( path: string, cwd: string, diff --git a/packages/agent-core-v2/src/tool/rule-match.ts b/packages/agent-core-v2/src/tool/rule-match.ts index 0d27133677..d34a2f324b 100644 --- a/packages/agent-core-v2/src/tool/rule-match.ts +++ b/packages/agent-core-v2/src/tool/rule-match.ts @@ -27,10 +27,6 @@ interface PathMatchSemantics { readonly pathClass: PathClass; } -/** - * Match ordinary string fields, like command text or search patterns. - * `*` and `**` work as wildcards, but the value is not treated as a file path. - */ export function globMatch(value: string, pattern: string, options?: { nocase?: boolean }): boolean { if (picomatch.isMatch(value, pattern, options)) return true; @@ -44,11 +40,6 @@ function stripLeadingDotSlash(value: string): string { return value.startsWith('./') ? value.slice(2) : value; } -/** - * Match file path fields, like Read/Write/Edit `path`. - * Also compares normalized forms, so `./a`, `dir/../a`, and Windows - * separator or case variants can match the same rule. - */ export function pathGlobMatch( value: string, pattern: string, @@ -67,15 +58,6 @@ export function pathGlobMatch( return false; } -/** - * Build equivalent spellings for one path string before glob matching: - * the original text, a leading `./` or `.\` form without that prefix, - * the canonical absolute path when possible, and slash-form Windows paths. - * - * Example: with cwd `/repo`, `./src/../secret.txt` adds both - * `src/../secret.txt` and `/repo/secret.txt`. On Windows, - * `C:\repo\secret.txt` also adds `C:/repo/secret.txt`. - */ function pathVariants( value: string, semantics: PathMatchSemantics, @@ -128,8 +110,6 @@ function pathMatchSemantics( pattern: string, pathOptions: PermissionPathMatchOptions | undefined, ): PathMatchSemantics { - // Production callers pass the active Kaos path class. The fallback keeps - // the pure matcher useful for tests and direct helper calls. const pathClass = pathOptions?.pathClass ?? ([value, pattern].some((candidate) => { @@ -146,8 +126,6 @@ function pathMatchSemantics( function addPathVariant(variants: Set<string>, value: string, pathClass: PathClass): void { variants.add(value); - // Picomatch treats backslashes as escape syntax in some cases; add a - // slash-separated Win32 variant so nocase and globs behave predictably. if (pathClass === 'win32') variants.add(value.replaceAll('\\', '/')); } diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index 8e64e7b3c3..07774be0fc 100644 --- a/packages/agent-core-v2/src/tool/toolContract.ts +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -21,18 +21,6 @@ import type { ToolInputDisplay } from '@moonshot-ai/protocol'; export type ExecutableToolOutput = string | ContentPart[]; -/** - * Declared side channel for delivering an extra user message into context - * memory, separate from the tool result returned to the model. The tool result - * always pairs with its `tool_call`; `delivery` asks the agent layer to inject - * an additional message (e.g. a steered user message) so tools do not reach - * into `IAgentPromptService` themselves. - * - * The L3 contract only carries an L3-legal payload: `origin` is intentionally - * `unknown` so the tool contract stays free of the L4 `ContextMessage` type; - * the L4 consumer forwards it verbatim onto the steered `ContextMessage`. - * Kinds grow with later phases. - */ export type ToolDeliveryKind = 'steer'; export interface ToolDeliveryMessage { diff --git a/packages/agent-core-v2/src/wire/model.ts b/packages/agent-core-v2/src/wire/model.ts index d387b273da..83e3031346 100644 --- a/packages/agent-core-v2/src/wire/model.ts +++ b/packages/agent-core-v2/src/wire/model.ts @@ -55,10 +55,6 @@ export interface ModelDef<S> { readonly name: string; readonly initial: () => S; readonly blobs?: ModelBlobCodec<S>; - /** - * Declare an Op on this model — `defineOp(model, ...)` with the model - * bound. Preferred call style: `MyModel.defineOp('my.op', { apply })`. - */ readonly defineOp: DefineOpFn<S>; } diff --git a/packages/agent-core-v2/src/wire/op.ts b/packages/agent-core-v2/src/wire/op.ts index 20dbcf002a..ea2b0db124 100644 --- a/packages/agent-core-v2/src/wire/op.ts +++ b/packages/agent-core-v2/src/wire/op.ts @@ -46,25 +46,8 @@ export class DuplicateOpError extends WireError { export interface OpDescriptor<K extends string, S, P> { readonly type: K; readonly model: ModelDef<S>; - /** - * Zod schema for the payload — the payload type's single source of truth - * (`P` is inferred from it). Stored on the descriptor so wire boundaries - * (replay of `wire.jsonl`, record export) can validate payloads against the - * Op's declared shape. Not consulted by `dispatch` / `replay` themselves. - */ readonly schema: z.ZodType<P>; readonly apply: (state: S, payload: P) => S; - /** - * Optional fact derivation: when present, `WireService` publishes the - * returned event to `IEventBus` after the op is applied + persisted - * (`dispatch` only — `replay` is silent and never derives events). `state` - * is the post-apply model state, for ops whose event payload is read from - * state (e.g. a snapshot). Returns `unknown` so generic `op.ts` stays - * decoupled from `IEventBus`; the producer-side type safety comes from each - * domain's `DomainEventMap` augmentation at the `defineOp` call site and the - * `eventBus.publish` cast in `WireService`. Return `undefined` (or omit) to - * derive no event. - */ readonly toEvent?: (payload: P, state: S) => unknown; readonly persist?: boolean; readonly stamp?: boolean; @@ -87,13 +70,6 @@ interface OpBehaviorOptions<S, P> { readonly stamp?: boolean; } -/** - * Registry-derived constraint on a defined Op's options. A type registered in - * both maps is rejected outright; a registered type must honor its map's - * persistence policy (persisted Ops may not opt out, transient Ops must pass - * `persist: false`). Key-level only — never resolves the registry's member - * types, so Op definitions stay free of registry cycles. - */ type RegisteredOpConstraint<K extends string> = K extends ConflictingOpType ? never : K extends OpType @@ -107,11 +83,6 @@ type DefineOpOptions<K extends string, S, P> = OpBehaviorOptions<S, P> & { type DefinedOp<K extends string, S, P> = OpDescriptor<K, S, P> & ((payload: P) => Op<K, P>); -/** - * Call signature of `ModelDef.defineOp` — `defineOp` with the model bound. - * Lives here so `model.ts` can type the method without duplicating the - * registry-aware generics. - */ export interface DefineOpFn<S> { <const K extends string, P>( type: K & SingleStringLiteral<K>, @@ -127,13 +98,6 @@ type SingleStringLiteral<K extends string, Whole extends string = K> = {} extend : never : never; -/** - * Build `ModelDef.defineOp` for a model under construction. The getter defers - * the model read so `defineModel` can bind while the literal is initializing. - * The casts bypass TS's inability to re-prove the literal guard - * (`SingleStringLiteral`) on an already-validated abstract `K`; callers still - * get the full guard through `DefineOpFn`'s signature. - */ export function bindDefineOp<S>(getModel: () => ModelDef<S>): DefineOpFn<S> { const bound = (type: string, opts: unknown): unknown => defineOp(getModel(), type as never, opts as never); diff --git a/packages/agent-core-v2/src/wire/types.ts b/packages/agent-core-v2/src/wire/types.ts index 8704b4b687..f084c312b0 100644 --- a/packages/agent-core-v2/src/wire/types.ts +++ b/packages/agent-core-v2/src/wire/types.ts @@ -20,16 +20,11 @@ type StringKey<T> = Extract<keyof T, string>; type PersistedOpKey = StringKey<PersistedOpMap>; type TransientOpKey = StringKey<TransientOpMap>; -// Everything here is key-level: the maps' member types (`typeof` an Op) are -// resolved only by `OpPayload`, never by the classification aliases — an -// intersection of the maps would normalize members and re-enter Op -// definitions, forming a type cycle. export type ConflictingOpType = Extract<PersistedOpKey, TransientOpKey>; export type PersistedOpType = Exclude<PersistedOpKey, ConflictingOpType>; export type TransientOpType = Exclude<TransientOpKey, ConflictingOpType>; export type OpType = PersistedOpType | TransientOpType; -/** Payload carried by a defined Op (the result of `Model.defineOp(...)`). */ export type PayloadOf<T> = T extends (payload: infer P) => unknown ? P : never; export type OpPayload<K extends OpType> = K extends PersistedOpType diff --git a/packages/agent-core-v2/src/wire/wireService.ts b/packages/agent-core-v2/src/wire/wireService.ts index 71b3cb1bdd..c6f54c9a2d 100644 --- a/packages/agent-core-v2/src/wire/wireService.ts +++ b/packages/agent-core-v2/src/wire/wireService.ts @@ -40,23 +40,10 @@ export interface ModelChange<S> { readonly prev: S; } -/** - * Outcome of a `replay`: how many records were skipped because their Op type - * is absent from `OP_REGISTRY`. Skips are also reported individually through - * `onUnexpectedError` (`wire.unknown_record`); the count lets callers detect a - * lossy restore without subscribing to the global error hook. - */ export interface ReplayResult { readonly unknownRecords: number; } -/** - * Live append-log observation: `dispatch` emits each persisted record here so - * observers (the test harness's `[wire]` capture, audit tooling) see the record - * stream as it happens. Op-derived *facts* (`toEvent`) go to `IEventBus` - * instead — they are not records and are not emitted here. The `signal` - * variant that used to share this channel was retired in favor of `IEventBus`. - */ export interface WireEmission { readonly type: 'record'; readonly record: PersistedRecord; diff --git a/packages/agent-core-v2/src/wire/wireServiceImpl.ts b/packages/agent-core-v2/src/wire/wireServiceImpl.ts index 9f908de139..fe13b1e8df 100644 --- a/packages/agent-core-v2/src/wire/wireServiceImpl.ts +++ b/packages/agent-core-v2/src/wire/wireServiceImpl.ts @@ -82,7 +82,6 @@ export class CycleError extends WireError { WireErrors.codes.WIRE_CYCLE, `Wire dispatch cascade exceeded MAX_DRAIN (${depth}); possible op cycle`, { - // Cap the sample so `details` stays small and JSON-serializable. details: { depth, opTypes: opTypes.slice(0, 20) }, }, ); @@ -228,9 +227,6 @@ export class WireService extends Disposable implements IWireService { const record = records[index]!; const descriptor = OP_REGISTRY.get(record.type); if (descriptor === undefined) { - // Unknown record types (written by a newer version, or by a retired op) - // are skipped for compatibility, but never silently: report each skip - // and return the count so the caller knows the replay was lossy. unknownRecords++; onUnexpectedError( new WireError( diff --git a/packages/agent-core-v2/test/_base/di/child.test.ts b/packages/agent-core-v2/test/_base/di/child.test.ts index 085d09c580..99d3da62e7 100644 --- a/packages/agent-core-v2/test/_base/di/child.test.ts +++ b/packages/agent-core-v2/test/_base/di/child.test.ts @@ -293,7 +293,6 @@ describe('InstantiationService.createChild', () => { } const ISvc = createDecorator<ISvc>('child-ctor-counter-svc'); - // case 1: parent consumes BEFORE the child is created let count = 0; class CtorCounter1 implements ISvc { tag = 'svc'; @@ -310,7 +309,6 @@ describe('InstantiationService.createChild', () => { expect(count).toBe(1); parent.dispose(); - // case 2: child is created BEFORE the parent consumes count = 0; class CtorCounter2 implements ISvc { tag = 'svc'; @@ -345,7 +343,6 @@ describe('InstantiationService.createChild', () => { child.dispose(); - // parent still works; child is dead expect(parent.invokeFunction((a) => a.get(IB).value)).toBe(1); expect(() => child.invokeFunction((a) => a.get(IB))).toThrow(/disposed/); diff --git a/packages/agent-core-v2/test/_base/di/cyclic.test.ts b/packages/agent-core-v2/test/_base/di/cyclic.test.ts index bd63ff33ba..5ee05aac00 100644 --- a/packages/agent-core-v2/test/_base/di/cyclic.test.ts +++ b/packages/agent-core-v2/test/_base/di/cyclic.test.ts @@ -10,11 +10,6 @@ import { import { InstantiationService } from '#/_base/di/instantiationService'; import { ServiceCollection } from '#/_base/di/serviceCollection'; -/** - * Cycle-detection tests declare the loop with real constructor dependencies, - * the same way production services do. The container detects the cycle while - * resolving the constructor graph. - */ describe('Cyclic dependency detection', () => { it('direct self-cycle A → A throws CyclicDependencyError', () => { @@ -171,7 +166,6 @@ describe('Recursive instantiation regression (#105562)', () => { class Service1Impl implements IService1 { tag = 's1' as const; constructor(@IInstantiationService insta: IInstantiationServiceType) { - // Re-entrancy: while Service1 is being constructed, resolve Service2. const c = insta.invokeFunction((accessor) => accessor.get(IService2)); expect(c).toBeTruthy(); } @@ -201,7 +195,6 @@ describe('Recursive instantiation regression (#105562)', () => { expect(obj).toBeInstanceOf(Service21Impl); expect(obj.service1).toBeInstanceOf(Service1Impl); expect(obj.service2).toBeInstanceOf(Service2Impl); - // Regression guard: Service2 must be constructed exactly once. expect(service2CtorCount).toBe(1); }); }); diff --git a/packages/agent-core-v2/test/_base/di/delayed.test.ts b/packages/agent-core-v2/test/_base/di/delayed.test.ts index 494b4077e0..b1fe2a503b 100644 --- a/packages/agent-core-v2/test/_base/di/delayed.test.ts +++ b/packages/agent-core-v2/test/_base/di/delayed.test.ts @@ -7,15 +7,6 @@ import { InstantiationService } from '#/_base/di/instantiationService'; import { dispose } from '#/_base/di/lifecycle'; import { ServiceCollection } from '#/_base/di/serviceCollection'; -/** - * Delayed-instantiation tests for the `SyncDescriptor(Ctor, [], true)` Proxy - * mechanism ("Delayed and events" family). - * - * A service registered this way is handed to consumers as a Proxy: subscribing - * to its `onDid…`/`onWill…` events does NOT construct it, and the first real - * property/method access does. Listeners that subscribed before construction - * are replayed onto the real instance once it materializes. - */ describe('Delayed instantiation', () => { it('subscribing to an event does not instantiate; first method call does', () => { @@ -60,14 +51,12 @@ describe('Delayed instantiation', () => { eventCount++; }; - // subscribing to the event does NOT trigger instantiation const d1 = c.a.onDidDoIt(listener); const d2 = c.a.onDidDoIt(listener); expect(created).toBe(false); expect(eventCount).toBe(0); d2.dispose(); - // instantiation happens on the first real method call c.a.doIt(); expect(created).toBe(true); expect(eventCount).toBe(1); @@ -124,15 +113,12 @@ describe('Delayed instantiation', () => { eventCount++; }; - // capture the event function reference BEFORE instantiation const event = c.a.onDidDoIt; expect(created).toBe(false); - // trigger instantiation through an unrelated method c.a.noop(); expect(created).toBe(true); - // the reference captured earlier is still usable const d1 = event(listener); c.a.doIt(); expect(eventCount).toBe(1); diff --git a/packages/agent-core-v2/test/_base/di/graph.test.ts b/packages/agent-core-v2/test/_base/di/graph.test.ts index 7d86dca7d4..0a7cd43f35 100644 --- a/packages/agent-core-v2/test/_base/di/graph.test.ts +++ b/packages/agent-core-v2/test/_base/di/graph.test.ts @@ -2,11 +2,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { Graph } from '#/_base/di/graph'; -/** - * Direct unit tests for the DI dependency graph. Covers the API exposed by - * `_base/di/graph.ts` (no `lookup()`; nodes are created via - * `lookupOrInsertNode`). - */ describe('Graph', () => { let graph: Graph<string>; @@ -24,7 +19,6 @@ describe('Graph', () => { const node = graph.lookupOrInsertNode('ddd'); expect(node.data).toBe('ddd'); expect(graph.isEmpty()).toBe(false); - // calling again returns the same node, not a duplicate expect(graph.lookupOrInsertNode('ddd')).toBe(node); }); @@ -41,7 +35,6 @@ describe('Graph', () => { expect(roots).toHaveLength(1); expect(roots[0]!.data).toBe('2'); - // adding the back-edge creates a cycle: no roots remain graph.insertEdge('2', '1'); roots = graph.roots(); expect(roots).toHaveLength(0); diff --git a/packages/agent-core-v2/test/_base/di/invocation.test.ts b/packages/agent-core-v2/test/_base/di/invocation.test.ts index f420a6af73..2a4d8a1a24 100644 --- a/packages/agent-core-v2/test/_base/di/invocation.test.ts +++ b/packages/agent-core-v2/test/_base/di/invocation.test.ts @@ -72,7 +72,6 @@ describe('ServiceCollection', () => { expect(consumer.service1).toBeInstanceOf(Service1); expect(consumer.service1.c).toBe(1); - // add IService2 AFTER the InstantiationService was built collection.set(IService2, new Service2()); const target2 = service.createInstance(Target2Dep); diff --git a/packages/agent-core-v2/test/_base/errors/serialize.test.ts b/packages/agent-core-v2/test/_base/errors/serialize.test.ts index 572098c1eb..9ccc14c420 100644 --- a/packages/agent-core-v2/test/_base/errors/serialize.test.ts +++ b/packages/agent-core-v2/test/_base/errors/serialize.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from 'vitest'; -// Side effect: populate the error-code registry through the facade, the way -// the package entrypoint does. import '#/errors'; import { Error2 } from '#/_base/errors/errors'; diff --git a/packages/agent-core-v2/test/_base/execEnv/environmentProbe.test.ts b/packages/agent-core-v2/test/_base/execEnv/environmentProbe.test.ts index 497f5792d9..9e084a0ebc 100644 --- a/packages/agent-core-v2/test/_base/execEnv/environmentProbe.test.ts +++ b/packages/agent-core-v2/test/_base/execEnv/environmentProbe.test.ts @@ -30,7 +30,6 @@ interface StubOpts { readonly execFileResults?: Readonly<Record<string, string>>; } -/** Build a stub deps bag mimicking Node's `os` + `process` surface. */ function stubDeps(opts: StubOpts): HostEnvironmentProbeDeps { const existing = new Set(opts.existingPaths ?? []); return { diff --git a/packages/agent-core-v2/test/_base/execEnv/loginShellPath.test.ts b/packages/agent-core-v2/test/_base/execEnv/loginShellPath.test.ts index 0a19ff9f2d..bf3d4a4185 100644 --- a/packages/agent-core-v2/test/_base/execEnv/loginShellPath.test.ts +++ b/packages/agent-core-v2/test/_base/execEnv/loginShellPath.test.ts @@ -43,7 +43,6 @@ interface StubOpts { readonly userShell?: string | undefined; } -/** Build a stub deps bag; records `execFileText` invocations in `calls`. */ function stubDeps(opts: StubOpts): { deps: LoginShellPathDeps; calls: unknown[][] } { const calls: unknown[][] = []; return { @@ -68,9 +67,6 @@ describe('probeLoginShellPath', () => { execFileResult: 'HOME=/Users/u\nPATH=/opt/homebrew/bin:/usr/bin:/bin\nTERM=dumb\n', }); await expect(probeLoginShellPath(deps)).resolves.toBe('/opt/homebrew/bin:/usr/bin:/bin'); - // env must be invoked by absolute path: a bare `env` resolves through the - // inherited (possibly cwd-dependent) PATH from the workspace cwd, so a - // repo-planted `env` binary could run at session startup. expect(calls).toEqual([['/bin/zsh', ['-l', '-c', '/usr/bin/env'], 5_000]]); }); @@ -88,9 +84,6 @@ describe('probeLoginShellPath', () => { }); it('falls back to the account login shell when SHELL is unset or blank', async () => { - // launchd/daemon launches can leave $SHELL unset or blank (the very - // contexts whose PATH is impoverished); the probe must then use the OS - // account's login shell instead of giving up. for (const env of [{}, { SHELL: '' }, { SHELL: ' ' }]) { const { deps, calls } = stubDeps({ env, @@ -129,18 +122,13 @@ describe('mergeLoginShellPath', () => { }); it('returns the current PATH string verbatim when nothing is missing', () => { - // Strict identity, including empty components and duplicates the user - // already has — a no-op merge must not normalize anything. expect(mergeLoginShellPath('/a::/b:/a:', '/b:/a')).toBe('/a::/b:/a:'); }); it('preserves empty components (cwd lookup) in the current PATH while appending', () => { - // POSIX treats a leading colon, trailing colon, or double colon as "search - // the current directory"; merging must not strip that. expect(mergeLoginShellPath(':/usr/bin', '/new')).toBe(':/usr/bin:/new'); expect(mergeLoginShellPath('/usr/bin:', '/new')).toBe('/usr/bin::/new'); expect(mergeLoginShellPath('/a::/b', '/c')).toBe('/a::/b:/c'); - // A set-but-empty PATH is cwd-only lookup; the empty component stays first. expect(mergeLoginShellPath('', '/a')).toBe(':/a'); }); @@ -149,16 +137,10 @@ describe('mergeLoginShellPath', () => { }); it('skips empty and duplicate login-shell entries', () => { - // Empty login-shell components are never imported: appending a cwd lookup - // the user did not already have would widen their search path. expect(mergeLoginShellPath('/a', ':/b::/a:')).toBe('/a:/b'); }); it('skips relative login-shell entries', () => { - // `.` and relative components are cwd-dependent lookup with another - // spelling — the host runs commands from arbitrary workspace directories, - // so importing one would let a command name resolve from an untrusted - // project cwd. Only absolute entries may be appended. expect(mergeLoginShellPath('/a', '.:bin:../x:/b')).toBe('/a:/b'); }); }); @@ -179,9 +161,6 @@ describe('applyLoginShellPath', () => { }); it('does not set an unset PATH when the login shell contributes nothing', async () => { - // Pathological but possible: the login-shell PATH holds only empty - // components. Writing '' back would turn "unset" (implementation default - // search path) into "cwd-only lookup". const env: Record<string, string | undefined> = { SHELL: '/bin/zsh' }; const { deps } = stubDeps({ env, execFileResult: 'PATH=:::\n' }); await applyLoginShellPath(deps); @@ -209,23 +188,16 @@ describe.skipIf(process.platform === 'win32')('applyLoginShellPathFromNode', () it('appends login-shell PATH entries missing from process.env.PATH', async () => { const extraDir = join(tempDir, 'login-only-bin'); const stubShell = join(tempDir, 'stub-shell.sh'); - // Stands in for the user's login shell: its shebang runs under /bin/sh, so - // the trailing `-l -c /usr/bin/env` land as positional args and the script - // just reports an environment whose PATH carries an entry the kimi-code - // process does not have. await writeFile(stubShell, `#!/bin/sh\necho "HOME=$HOME"\necho "PATH=${extraDir}:/usr/bin:/bin"\n`); await chmod(stubShell, 0o755); process.env['SHELL'] = stubShell; - // Drop any memoised probe from prior tests so this call probes the stub - // shell instead of returning a cached result. vi.resetModules(); const { applyLoginShellPathFromNode } = await import('#/_base/execEnv/loginShellPath'); await applyLoginShellPathFromNode(); const entries = (process.env['PATH'] ?? '').split(':'); expect(entries).toContain(extraDir); - // Existing entries keep priority: the login-shell extras are appended. expect(process.env['PATH']?.startsWith(originalPath ?? '')).toBe(true); }); }); diff --git a/packages/agent-core-v2/test/_base/log/fileLog.test.ts b/packages/agent-core-v2/test/_base/log/fileLog.test.ts index cf25da754e..6b5a4be619 100644 --- a/packages/agent-core-v2/test/_base/log/fileLog.test.ts +++ b/packages/agent-core-v2/test/_base/log/fileLog.test.ts @@ -53,12 +53,10 @@ describe('RotatingFileWriter', () => { sink.enqueue(`${i.toString().padStart(3, '0')} ${'x'.repeat(30)}\n`); await sink.flush(); } - // Final write to ensure active file exists post-rotation sink.enqueue('final\n'); await sink.flush(); const files = await listLogs(workDir); expect(files).toEqual(expect.arrayContaining(['app.log'])); - // files = 2 → active + at most 1 archive; no app.log.2 or higher expect(files.some((f) => /^app\.log\.[2-9]$/.test(f))).toBe(false); }); @@ -89,15 +87,12 @@ describe('RotatingFileWriter', () => { await sink.flush(); const text = await readFile(path, 'utf-8'); expect(text).toMatch(/\.\.\. dropped \d+ entries \.\.\./); - // First lines (oldest) should be gone expect(text).not.toContain('line0\n'); - // Latest should be present expect(text).toContain(`line${over - 1}\n`); }); it('does not throw when fs write fails; emits stderr notice', async () => { const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - // Force failure by passing an invalid path char on POSIX const badWriter = new RotatingFileWriter({ path: '\0/invalid/path', maxBytes: 1024, diff --git a/packages/agent-core-v2/test/_base/log/stubs.ts b/packages/agent-core-v2/test/_base/log/stubs.ts index 10f3e8e1b3..b7b8a3c466 100644 --- a/packages/agent-core-v2/test/_base/log/stubs.ts +++ b/packages/agent-core-v2/test/_base/log/stubs.ts @@ -9,7 +9,6 @@ import type { ServiceRegistration } from '#/_base/di/test'; import { ILogService } from '#/_base/log/log'; import type { ILogger } from '#/_base/log/log'; -/** A no-op `ILogger`: every method is a no-op, `child()` returns itself. */ export function stubLogger(): ILogger { const logger: ILogger = { error: () => {}, @@ -21,7 +20,6 @@ export function stubLogger(): ILogger { return logger; } -/** A no-op `ILogService` fixed at `info` level. */ export function stubLog(): ILogService { return { ...stubLogger(), @@ -32,7 +30,6 @@ export function stubLog(): ILogService { }; } -/** Register the default no-op `ILogService`. */ export function registerLogServices(reg: ServiceRegistration): void { reg.defineInstance(ILogService, stubLog()); } diff --git a/packages/agent-core-v2/test/activity/activity.test.ts b/packages/agent-core-v2/test/activity/activity.test.ts index 585f95c97a..7b42328bc3 100644 --- a/packages/agent-core-v2/test/activity/activity.test.ts +++ b/packages/agent-core-v2/test/activity/activity.test.ts @@ -56,7 +56,6 @@ describe('AgentActivityService (turn lane)', () => { it('starts initializing and admits a turn only after markReady', () => { expect(activity.lane()).toBe('initializing'); - // Admission is rejected while the bootstrap has not finished. expect(() => activity.begin('turn')).toThrowError( expect.objectContaining({ code: ErrorCodes.ACTIVITY_INITIALIZING }), ); @@ -92,7 +91,6 @@ describe('AgentActivityService (turn lane)', () => { expect(activity.cancel('stop')).toBe(true); expect(lease.signal.aborted).toBe(true); expect(lease.ending).toBe(true); - // Lane stays `turn` (ending) until the lease is returned. expect(activity.lane()).toBe('turn'); lease.end('cancelled'); expect(activity.lane()).toBe('idle'); diff --git a/packages/agent-core-v2/test/agent/blob/agentBlobService.test.ts b/packages/agent-core-v2/test/agent/blob/agentBlobService.test.ts index 315441ae92..bb7cc649e3 100644 --- a/packages/agent-core-v2/test/agent/blob/agentBlobService.test.ts +++ b/packages/agent-core-v2/test/agent/blob/agentBlobService.test.ts @@ -35,7 +35,6 @@ import { InMemoryStorageService } from '#/persistence/backends/memory/inMemorySt import { IBlobStore } from '#/persistence/interface/blobStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; -// The default offload threshold is 4096 base64 chars; LARGE straddles it. const LARGE = 'A'.repeat(5000); const SMALL = 'AQID'; diff --git a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts index 757aa8ed75..fbe4745520 100644 --- a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts +++ b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts @@ -83,11 +83,6 @@ describe('AgentContextInjectorService', () => { disposables.dispose(); }); - /** - * Splice the stub's backing history directly and publish `context.spliced`, - * standing in for the removed `IAgentContextMemoryService.splice` so the - * injector still observes non-append splices (compaction, deletions). - */ function spliceContext( start: number, deleteCount: number, diff --git a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts index 19cb5b6b06..13b352d2d9 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -200,8 +200,6 @@ describe('Agent context', () => { }, ]; - // Empty tool output never reaches the model as a blank block (and no - // longer throws): the projection renders the empty-output status text. expect(ctx.project(history)).toEqual([ { role: 'assistant', @@ -399,11 +397,7 @@ describe('Agent context', () => { }, ); - // Raw history records the reminder in insertion order, behind the open - // exchange. expect(context.get().map((message) => message.role)).toEqual(['user', 'assistant', 'user']); - // The projector keeps the reminder behind the exchange — closing the open - // calls (synthetic results) and placing the reminder after them. expect(ctx.project().map((message) => message.role)).toEqual([ 'user', 'assistant', @@ -420,7 +414,6 @@ describe('Agent context', () => { toolCallId: 'call_write', }, ); - // The real result is pulled up; the still-open call is synthesized. expect(ctx.project().map((message) => message.role)).toEqual([ 'user', 'assistant', @@ -526,7 +519,6 @@ describe('Agent context', () => { it('get(start, end) returns the size of a context-message range', () => { ctx.appendAssistantTextWithUsage(1, 'previous answer', 1_000); - // The measured prefix covers the user + assistant pair (2 messages, 1_000 tokens). expect(contextSize.get()).toEqual({ size: 1_000, measured: 1_000, estimated: 0 }); ctx.appendUserMessage([{ type: 'text', text: 'pending one'.repeat(20) }]); @@ -535,14 +527,12 @@ describe('Agent context', () => { const messages = context.get(); const tailEstimate = estimateTokensForMessages(messages.slice(2)); - // Whole context: measured prefix + estimated tail. expect(contextSize.get()).toEqual({ size: 1_000 + tailEstimate, measured: 1_000, estimated: tailEstimate, }); - // A range fully inside the pending tail is purely estimated. const firstPending = estimateTokensForMessages(messages.slice(2, 3)); expect(contextSize.get(2, 3)).toEqual({ size: firstPending, @@ -550,10 +540,8 @@ describe('Agent context', () => { estimated: firstPending, }); - // The full measured prefix uses the deterministic aggregate. expect(contextSize.get(0, 2)).toEqual({ size: 1_000, measured: 1_000, estimated: 0 }); - // A sub-range of the prefix falls back to a per-message estimate. const prefixHead = estimateTokensForMessages(messages.slice(0, 1)); expect(contextSize.get(0, 1)).toEqual({ size: prefixHead, @@ -561,7 +549,6 @@ describe('Agent context', () => { estimated: 0, }); - // A range spanning the measured/tail boundary splits both sides. const assistant = estimateTokensForMessages(messages.slice(1, 2)); expect(contextSize.get(1, 3)).toEqual({ size: assistant + firstPending, @@ -569,7 +556,6 @@ describe('Agent context', () => { estimated: firstPending, }); - // Negative indices resolve like `Array.prototype.slice`. expect(contextSize.get(-2)).toEqual({ size: tailEstimate, measured: 0, @@ -582,7 +568,6 @@ describe('Agent context', () => { estimated: firstPending, }); - // An inverted range is empty. expect(contextSize.get(-1, -3)).toEqual({ size: 0, measured: 0, estimated: 0 }); }); @@ -598,7 +583,6 @@ describe('Agent context', () => { it('rebases the measured prefix to an estimate when undo truncates it', () => { ctx.appendAssistantTextWithUsage(1, 'a1', 1_000); ctx.appendAssistantTextWithUsage(2, 'a2', 2_000); - // The measured prefix covers the full four-message context. expect(contextSize.get().measured).toBe(2_000); ctx.undoHistory(1); @@ -606,7 +590,6 @@ describe('Agent context', () => { const surviving = context.get(); expect(surviving.map((m) => m.role)).toEqual(['user', 'assistant']); const estimate = estimateTokensForMessages(surviving); - // The truncated prefix is rebased to an estimate of the surviving context. expect(contextSize.get()).toEqual({ size: estimate, measured: estimate, estimated: 0 }); }); @@ -625,7 +608,6 @@ describe('Agent context', () => { ctx.appendAssistantText(1, 'first response'); ctx.appendAssistantText(2, 'second response'); - // Append a task notification (role: 'user' but not a real prompt) context.append( { role: 'user', @@ -650,7 +632,6 @@ describe('Agent context', () => { ctx.undoHistory(1); - // Should remove the background notification, the second assistant, and the second user prompt expect(context.get().map((m) => m.role)).toEqual(['user', 'assistant']); }); @@ -674,10 +655,6 @@ describe('Agent context', () => { ctx.undoHistory(1); - // v2 undo cuts at the oldest undone real-user prompt regardless of origin: - // injections inside the removed range go with the turn (unlike v1, which - // kept them); dynamic context such as plan-mode notices and tool schemas - // self-heals via re-injection on the next turn boundary. expect(context.get()).toEqual([ expect.objectContaining({ role: 'user', diff --git a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts index 0591ad1959..4cc538b2c5 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -96,7 +96,6 @@ describe('reduceContextTranscript', () => { expect(texts(result)).toEqual(['u1', 'a1', 'u2', 'a2', 'SUM', 'u3']); expect(result.entries[4]!.origin).toEqual({ kind: 'compaction_summary' }); expect(result.entries[4]!.role).toBe('user'); - // live folded view would be [u1, u2, SUM, u3] expect(result.foldedLength).toBe(4); }); @@ -108,7 +107,6 @@ describe('reduceContextTranscript', () => { compaction('SUM', 3, 1), appendMessage(userMessage('u4')), ]); - // 1 kept user message + summary + u4 appended after compaction. expect(result.foldedLength).toBe(3); }); @@ -119,7 +117,6 @@ describe('reduceContextTranscript', () => { ...assistantStep('s1', 'a1'), compaction('SUM', 3, 2, 1), ]); - // Live context: head user + elision marker + tail user + summary. expect(result.foldedLength).toBe(4); }); @@ -151,9 +148,6 @@ describe('reduceContextTranscript', () => { }); it('preserves the pre-compaction assistant reply after a later undo', () => { - // The reported regression: send A, /compact, send B, undo. The snapshot - // must still show A's assistant reply (compaction only folds the live - // context; the transcript keeps the full history). const result = reduceContextTranscript([ appendMessage(userMessage('message A')), appendMessage(assistantMessage('reply A')), @@ -186,7 +180,6 @@ describe('reduceContextTranscript', () => { appendMessage(assistantMessage('answer')), undo(2), ]); - // Only the post-compaction exchange is removed; the summary blocks further undo. expect(texts(result)).toEqual(['old', 'SUM']); }); @@ -209,8 +202,6 @@ describe('reduceContextTranscript', () => { appendMessage(assistantMessage('a2')), undo(1), ]); - // The post-clear exchange (u2 + a2) is removed; pre-clear u1 stays in the - // transcript and the clear floor blocks undo from reaching it. expect(texts(result)).toEqual(['u1']); expect(result.foldedLength).toBe(0); }); diff --git a/packages/agent-core-v2/test/agent/contextMemory/loopEventFold.test.ts b/packages/agent-core-v2/test/agent/contextMemory/loopEventFold.test.ts index c6148c5198..50267db277 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/loopEventFold.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/loopEventFold.test.ts @@ -121,7 +121,6 @@ describe('loop-event fold parity', () => { it('drops an empty partial assistant left by a failed attempt when the retry begins', () => { context.appendLoopEvent({ type: 'step.begin', uuid: 's1' }); - // The attempt failed before any output; the loop re-runs the step. context.appendLoopEvent({ type: 'step.begin', uuid: 's2' }); context.appendLoopEvent({ type: 'content.part', @@ -156,7 +155,6 @@ describe('loop-event fold parity', () => { name: 'Bash', args: {}, }); - // The attempt failed before the tool result arrived; the retry begins. context.appendLoopEvent({ type: 'step.begin', uuid: 's2' }); expect(shapes(context.get())).toEqual([ diff --git a/packages/agent-core-v2/test/agent/contextMemory/message-history.test.ts b/packages/agent-core-v2/test/agent/contextMemory/message-history.test.ts index bdd6eac07c..7add9daffa 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/message-history.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/message-history.test.ts @@ -27,11 +27,6 @@ function textOf(message: ContextMessage): string { .join(''); } -// NOTE: the legacy `IMessageService` (which projected context history into -// `ProtocolMessage`s with derived `msg-N` ids) was removed -// (see commit `chore: remove IMessageService`). Message history now lives on -// `IAgentContextMemoryService`, so these cases exercise that history directly instead of -// the deleted derived-id projection. describe('message history (IAgentContextMemoryService)', () => { let disposables: DisposableStore; @@ -62,8 +57,6 @@ describe('message history (IAgentContextMemoryService)', () => { ctx.append(textMessage('user', 'keep')); const view = ctx.get(); - // Wire-backed state is frozen, so the returned view cannot be mutated in place — - // stronger than a defensive copy: the internal history is unaffected either way. expect(() => (view as ContextMessage[]).splice(0, view.length)).toThrow(); expect(ctx.get().map(textOf)).toEqual(['keep']); diff --git a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts index 63c0516659..178b2f24e5 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts @@ -21,7 +21,6 @@ import { IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord'; -/** A no-op `IAgentWireRecordService`. */ export function stubWireRecord(): IAgentWireRecordService { return { _serviceBrand: undefined, @@ -33,15 +32,9 @@ export function stubWireRecord(): IAgentWireRecordService { } export interface StubContextMemory extends IAgentContextMemoryService { - /** The live backing history, exposed so tests can inspect splices. */ readonly messages: readonly ContextMessage[]; } -/** - * An in-memory `IAgentContextMemoryService`. Each mutation updates the backing - * history and publishes `context.spliced`, mirroring `AgentContextMemoryService` - * enough for collaborators (e.g. `AgentContextInjectorService`) to react. - */ function publishSplice( eventBus: IEventBus | undefined, input: { @@ -100,12 +93,6 @@ export function stubContextMemory(eventBus?: IEventBus): StubContextMemory { }; } -/** - * DI-constructible variant of {@link stubContextMemory}: publishes - * `context.spliced` to the Agent-scope {@link IEventBus} so collaborators - * (e.g. `AgentContextInjectorService`) react to splices exactly as they do - * against the real `AgentContextMemoryService`. - */ class StubContextMemoryService implements IAgentContextMemoryService { declare readonly _serviceBrand: undefined; private readonly impl: StubContextMemory; @@ -135,12 +122,6 @@ class StubContextMemoryService implements IAgentContextMemoryService { } } -/** - * Register the default collaborators consumed by `AgentContextMemoryService` - * (`IAgentWireRecordService`) and an in-memory `IAgentContextMemoryService`. - * Tests that exercise the real `AgentContextMemoryService` should override - * `IAgentContextMemoryService` via `additionalServices`. - */ export function registerContextMemoryServices(reg: ServiceRegistration): void { reg.defineInstance(IAgentWireRecordService, stubWireRecord()); reg.define(IEventBus, EventBusService); diff --git a/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts b/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts index 7b0a981f22..61b29e4b01 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts @@ -91,8 +91,6 @@ describe('precheckUndo', () => { }); it('reports compaction_boundary over insufficient when the boundary stops the scan', () => { - // One real user prompt sits after the summary, but count=2 needs more and the - // scan is stopped by the summary before reaching the older prompts. const history = [user(USER_ORIGIN), compaction(), user(USER_ORIGIN), assistant()]; expect(precheckUndo(history, 2)).toEqual({ ok: false, diff --git a/packages/agent-core-v2/test/agent/contextProjector/contextProjector.bench.ts b/packages/agent-core-v2/test/agent/contextProjector/contextProjector.bench.ts index 3d9ab3a763..5fbbd108af 100644 --- a/packages/agent-core-v2/test/agent/contextProjector/contextProjector.bench.ts +++ b/packages/agent-core-v2/test/agent/contextProjector/contextProjector.bench.ts @@ -38,9 +38,6 @@ const noopLogService: ILogService = { flush: () => Promise.resolve(), }; -// --------------------------------------------------------------------------- -// Legacy implementation (verbatim copy of the pre-rewrite `project`) -// --------------------------------------------------------------------------- function projectLegacy(history: readonly ContextMessage[]): Message[] { const openCalls = new Map<string, ToolCall>(); @@ -149,9 +146,6 @@ function stripContextMetadata(message: ContextMessage): Message { }; } -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- function makeExchangeHistory(exchanges: number, callsPerStep: number): ContextMessage[] { const history: ContextMessage[] = []; @@ -206,19 +200,14 @@ function createProjector(disposables: DisposableStore): IAgentContextProjectorSe return ix.get(IAgentContextProjectorService); } -// --------------------------------------------------------------------------- -// Benchmarks -// --------------------------------------------------------------------------- const disposables = new DisposableStore(); const projector = createProjector(disposables); -const TYPICAL = makeMixedHistory(4); // ~76 messages, a normal mid-session turn -const EXCHANGE_HEAVY = makeExchangeHistory(1000, 4); // 6000 messages of tool exchanges -const MERGE_HEAVY = makeMergeHistory(2000, 500); // 2000 adjacent user prompts +const TYPICAL = makeMixedHistory(4); +const EXCHANGE_HEAVY = makeExchangeHistory(1000, 4); +const MERGE_HEAVY = makeMergeHistory(2000, 500); -// Long warmup and sample windows: the large fixtures allocate multi-thousand -// element outputs per iteration, so short runs are dominated by GC noise. const OPTIONS = { warmupTime: 500, time: 3000 }; describe(`typical mid-session history (${TYPICAL.length} messages)`, () => { diff --git a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts index cd0c1bd976..d09744167e 100644 --- a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts +++ b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts @@ -44,11 +44,6 @@ function repairPayloads(warnings: WarningCall[]): Record<string, unknown>[] { .map((call) => call.payload as Record<string, unknown>); } -// Tests for how the projector normalizes tool exchanges: results are pulled up -// right after their call, messages that landed between a call and its results -// are deferred to after the exchange, unanswered calls are closed with a -// synthetic error result, stale duplicate results are dropped, and orphan -// results are dropped in a real projection (but kept in a bare slice). const INTERRUPTED = 'Tool result is not available in the current context'; @@ -210,8 +205,6 @@ describe('projector tool-exchange normalization', () => { }); it('drops a stale duplicate result for an already-answered call', () => { - // The call is closed (synthetically) when the next assistant turn starts; - // the trailing duplicate result for the same call is dropped. const history = [ user('go'), assistant('', ['c1']), @@ -246,9 +239,6 @@ describe('projector tool-exchange normalization', () => { }); it('drops a partial assistant exchange without stranding its results', () => { - // A partial assistant (stream interrupted) is removed before the exchange - // normalization, so its recorded results become orphans and are dropped, - // and no synthetic result is invented for its open calls. const history: ContextMessage[] = [ user('go'), { ...assistant('', ['c1', 'c2']), partial: true }, @@ -259,7 +249,6 @@ describe('projector tool-exchange normalization', () => { }); it('keeps a bare result slice with no preceding assistant (used for sizing)', () => { - // A leading result is kept rather than treated as an orphan. expect(shape([toolResult('c1', 'partial result')])).toEqual(['tool:c1']); }); @@ -328,9 +317,6 @@ describe('projector tool-exchange normalization', () => { }); it('passes raw media parts through as the tool_result output', () => { - // ReadMediaFile-style result: the live tool.result event carries the raw - // kosong content-part array, so the protocol projection must emit the same - // shape — otherwise media rendering is lost after reload/resume. const result: ContextMessage = { role: 'tool', content: [ @@ -607,9 +593,6 @@ describe('projector tool-exchange normalization', () => { const allParts = projected.flatMap((message) => message.content); expect(allParts.some((part) => part.type === 'image_url')).toBe(false); expect(allParts.some((part) => part.type === 'video_url')).toBe(false); - // The strip is full — the poisoned image could be anywhere — but the - // text around it survives, including the ReadMediaFile wrapper the - // model needs to re-read the file for conversion guidance. const texts = allParts.filter((part) => part.type === 'text').map((part) => part.text); expect(texts).toContain('look at these'); expect(texts).toContain('<image path="/tmp/shot.png">'); diff --git a/packages/agent-core-v2/test/agent/externalHooks/runner.test.ts b/packages/agent-core-v2/test/agent/externalHooks/runner.test.ts index d1b09e8959..853ff1076f 100644 --- a/packages/agent-core-v2/test/agent/externalHooks/runner.test.ts +++ b/packages/agent-core-v2/test/agent/externalHooks/runner.test.ts @@ -125,12 +125,6 @@ describe('runHook process runner', () => { }); }); -// Regression coverage for the "every hook flashes an empty console window on -// Windows" bug. With `shell:true` and no `windowsHide`, Node allocates a -// visible console for each hook child process on Windows. The fix is to pass -// `windowsHide:true` (mirrors the node-local host's `buildSpawnOptions` and -// the runner's own taskkill spawn). The flag is only observable on Windows, -// so we assert the spawn options builder directly. describe('buildHookSpawnOptions (Windows console-window regression)', () => { it('sets windowsHide:true so hooks do not flash a console on Windows', () => { expect(buildHookSpawnOptions({}).windowsHide).toBe(true); diff --git a/packages/agent-core-v2/test/agent/fullCompaction/compactionOps.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/compactionOps.test.ts index 9d380d9431..f36d05d5b6 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/compactionOps.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/compactionOps.test.ts @@ -74,7 +74,6 @@ describe('fullCompaction ops (wire-backed)', () => { 'full_compaction.begin', 'full_compaction.cancel', ]); - // Flat record shape: payload fields sit next to `type`, never under `payload`. expect(records.every((record) => 'payload' in record === false)).toBe(true); expect(records[0]).toEqual( expect.objectContaining({ @@ -114,12 +113,10 @@ describe('fullCompaction ops (wire-backed)', () => { }); await host.wire.replay(...records); - // Model rebuilt (begin then complete → idle), but replay is silent. expect(host.wire.getModel(CompactionModel).phase).toBe('idle'); expect(emissions).toEqual([]); expect(modelChanges).toBe(0); - // A log stranded mid-compaction replays to `running`. const stranded = buildHost('full-compaction-stranded'); await stranded.wire.replay({ type: 'full_compaction.begin', source: 'auto' }); expect(stranded.wire.getModel(CompactionModel).phase).toBe('running'); 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 7d802958df..9f630c5ab1 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -178,8 +178,6 @@ describe('FullCompaction', () => { textMessage('user', 'next prompt'), ]; - // The only valid split is before the parallel exchange (after 'old assistant'), - // never between tool_a and tool_b — that would leave tool_b as an orphan. expect(strategy.computeCompactCount(messages, 'auto')).toBe(2); }); @@ -271,7 +269,6 @@ describe('FullCompaction', () => { const candidate = event as { type?: unknown; event?: unknown }; return candidate.type === '[wire]' && candidate.event === 'full_compaction.complete'; }); - // The engine stamps `time` on every persisted record; the payload itself is empty. expect(completeEvent?.args).toEqual({ time: '<time>' }); expect(ctx.lastLlmInput()).toMatchInlineSnapshot(` system: <system-prompt> @@ -637,12 +634,6 @@ describe('FullCompaction', () => { }); it('recovers from an image-format rejection with a media-stripped resend', async () => { - // A poisoned image in the history makes the provider reject every request - // with a deterministic image-format 400 — the summarizer request included. - // The compaction must not fail: the requester resends once with every - // media part replaced by a text marker (read-side only — the stored - // history keeps its media), the only projection guaranteed to carry no - // poison. let attempts = 0; let sawMedia = false; let sawStrippedResend = false; @@ -664,8 +655,6 @@ describe('FullCompaction', () => { modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, }); ctx.appendExchange(1, 'old user one', 'old assistant one', 20); - // Seeds an image_url and a video_url part — the poison the provider - // rejects on every request until the media-stripped resend. ctx.appendRichToolExchange(); ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80); const compacted = ctx.once('full_compaction.complete'); @@ -682,11 +671,6 @@ describe('FullCompaction', () => { }); it('recovers from a request-body 413 with a media-degraded resend', async () => { - // A history bloated by accumulated base64 media gets rejected with HTTP - // 413 — a body-size rejection, not a token overflow, so token-driven - // recovery never fires. The requester resends once with the - // media-degraded projection (all but the most recent media replaced by - // text markers), which is enough to fit under the provider's byte limit. let attempts = 0; let sawFullMedia = false; let sawDegradedResend = false; @@ -712,8 +696,6 @@ describe('FullCompaction', () => { modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, }); ctx.appendExchange(1, 'old user one', 'old assistant one', 20); - // Two rich exchanges seed four media parts; the degraded projection keeps - // only the two most recent. ctx.appendRichToolExchange(); ctx.appendRichToolExchange(); ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80); @@ -759,9 +741,6 @@ describe('FullCompaction', () => { await completed; expect(attempts).toBe(3); - // Empty summaries are retried without shrinking the history; the recovered - // summary replaces the whole history with the real user messages plus the - // prefixed summary. expect(ctx.compactHistory()).toEqual([ { role: 'user', text: 'old user one' }, { role: 'user', text: 'recent user two' }, @@ -783,11 +762,6 @@ describe('FullCompaction', () => { }); it('reduces the compacted prefix and retries when the model returns only thinking content', async () => { - // End-to-end through the real kosong generate(): a think-only stream (think - // parts, no text, no tool calls) makes generate() itself throw - // APIEmptyResponseError. Compaction must treat that like a truncated summary - // — shrink the compacted prefix and retry — rather than resend the identical - // request that produced no summary. vi.useFakeTimers(); const firstThinkOnly = deferred<void>(); const inputs: string[][] = []; @@ -818,7 +792,6 @@ describe('FullCompaction', () => { await completed; expect(inputs).toHaveLength(2); - // The retry sends a strictly smaller input than the first attempt. expect(inputs[1]!.length).toBeLessThan(inputs[0]!.length); expect(ctx.compactHistory()).toEqual([ { role: 'user', text: 'old user one' }, @@ -872,10 +845,6 @@ describe('FullCompaction', () => { }); it('fails after exhausting retries when the model only ever returns thinking content', async () => { - // End-to-end through the real kosong generate(): every attempt is think-only, - // so generate() keeps throwing APIEmptyResponseError. Compaction shrinks the - // prefix on each retry but eventually exhausts MAX_COMPACTION_RETRY_ATTEMPTS - // and fails without ever applying a summary. vi.useFakeTimers(); const records: TelemetryRecord[] = []; const inputs: string[][] = []; @@ -898,9 +867,6 @@ describe('FullCompaction', () => { await vi.advanceTimersByTimeAsync(60_000); await failed; - // Each empty/think-only response drops the oldest item and resets the retry - // counter; once only one item remains, MAX_COMPACTION_RETRY_ATTEMPTS more - // retries run before failing. 3 drops + 5 retries = 8 generate calls. expect(inputs).toHaveLength(8); expect(inputs[1]!.length).toBeLessThan(inputs[0]!.length); expect(records).toContainEqual({ @@ -911,7 +877,6 @@ describe('FullCompaction', () => { error_type: 'APIEmptyResponseError', }), }); - // No summary was ever applied; the original history is left intact. expect(ctx.compactHistory()).toEqual([ { role: 'user', text: 'old user one' }, { role: 'assistant', text: 'old assistant one' }, @@ -1108,10 +1073,6 @@ describe('FullCompaction', () => { await vi.advanceTimersByTimeAsync(60_000); await failed; - // The four-message compacted prefix shrinks on each truncated response. - // Once only one message remains, it cannot shrink further, so the - // CompactionTruncatedError fails immediately instead of falling through to - // generic retry attempts. expect(attempts).toBe(4); expect(ctx.newEvents()).toContainEqual( expect.objectContaining({ @@ -1548,8 +1509,6 @@ describe('FullCompaction', () => { variant: 'host', }); - // ContextMemory records raw insertion order — the reminder sits where it - // was added, right after the still-open tool exchange. expect(ctx.context.get().map((m) => m.role)).toEqual([ 'user', 'assistant', @@ -1557,9 +1516,6 @@ describe('FullCompaction', () => { 'assistant', 'user', ]); - // The projector guarantees ordering for the model: the open calls are - // closed (synthetic results) and the reminder is placed after them, never - // between a tool call and its results. expect(ctx.project().map((m) => m.role)).toEqual([ 'user', 'assistant', @@ -1575,8 +1531,6 @@ describe('FullCompaction', () => { await ctx.rpc.beginCompaction({}); await compacted; - // Compaction drops the in-flight tool exchange and the deferred reminder; - // only real user messages and the compaction summary remain. expect(ctx.context.get().map((m) => m.role)).toEqual([ 'user', 'user', @@ -1584,8 +1538,6 @@ describe('FullCompaction', () => { ]); expect(ctx.context.get().at(-1)?.origin).toEqual({ kind: 'compaction_summary' }); - // The dropped tool calls no longer exist, so late tool results are orphans - // and do not change history. await ctx.dispatch({ type: 'context.append_loop_event', event: { @@ -1624,10 +1576,6 @@ describe('FullCompaction', () => { variant: 'host', }); - // One tool result has landed but the second is still pending. Raw history - // keeps insertion order (reminder after the partial exchange); the - // projector keeps the real result, synthesizes the open one, and places the - // reminder after the closed exchange. expect(ctx.context.get().map((m) => m.role)).toEqual([ 'user', 'assistant', @@ -1651,8 +1599,6 @@ describe('FullCompaction', () => { await ctx.rpc.beginCompaction({}); await compacted; - // Compaction drops the partially-resolved tool exchange and the deferred - // reminder; only real user messages and the compaction summary remain. expect(ctx.context.get().map((m) => m.role)).toEqual([ 'user', 'user', @@ -1660,8 +1606,6 @@ describe('FullCompaction', () => { ]); expect(ctx.context.get().at(-1)?.origin).toEqual({ kind: 'compaction_summary' }); - // The dropped tool calls no longer exist, so a late tool result is an - // orphan and does not change history. await ctx.dispatch({ type: 'context.append_loop_event', event: { @@ -1887,8 +1831,6 @@ describe('FullCompaction', () => { expect(ctx.llmCalls).toHaveLength(2); const [compactionCall, answerCall] = ctx.llmCalls; const compactionTexts = compactionCall?.history.map(messageText) ?? []; - // The whole history is compacted, so the pending prompt is included in the - // compaction input and kept verbatim in the post-compaction replacement. expect(compactionTexts.some((text) => text.includes('keep-this-pending-verbatim'))).toBe(true); expect(compactionCall?.history.map((message) => message.role)).toEqual([ 'user', @@ -1925,8 +1867,6 @@ describe('FullCompaction', () => { expect(ctx.llmCalls).toHaveLength(2); const [compactionCall, answerCall] = ctx.llmCalls; const compactionTexts = compactionCall?.history.map(messageText) ?? []; - // The whole history is compacted, so the pending prompt is included in the - // compaction input and kept verbatim in the post-compaction replacement. expect(compactionTexts.some((text) => text.includes('ratio-pending-verbatim'))).toBe(true); expect(compactionCall?.history.map((message) => message.role)).toEqual([ 'user', @@ -2269,10 +2209,6 @@ describe('FullCompaction', () => { await ctx.untilTurnEnd(); expect(callCount).toBe(3); - // The catalogued model declares no supportEfforts, so the Kimi provider - // normalizes to boolean thinking and reports 'on' rather than the - // requested 'high'. The stored thinkingLevel still carries 'high' across - // compaction, which is asserted through telemetry below. expect(providerThinkingEfforts).toEqual(['on', 'on', 'on']); expect(records).toContainEqual({ event: 'compaction_finished', @@ -2445,9 +2381,6 @@ describe('FullCompaction', () => { provider: CATALOGUED_PROVIDER, modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, }); - // Set maxOutputSize on the harness's internal kimiConfig. Keep it below - // the Kimi model context window so provider-side context clipping does not - // hide whether compaction passed this configured value through. const models = (ctx as unknown as MutableKimiConfig).kimiConfig.models; models![CATALOGUED_PROVIDER.model] = { ...models![CATALOGUED_PROVIDER.model]!, @@ -2765,9 +2698,6 @@ function mockStreamedMessage(parts: readonly StreamedMessagePart[]): StreamedMes }; } -// Runs the REAL kosong generate() over a scripted provider stream so think-only -// and empty responses exercise kosong's actual APIEmptyResponseError path rather -// than a mocked generate function that throws directly. function realKosongGenerate( script: (attempt: number, history: readonly Message[]) => StreamedMessage, ): GenerateFn { @@ -2855,9 +2785,6 @@ function messageText(message: Message | undefined): string { } function hookPayloadLoggerCommand(logPath: string): string { - // Write the hook script to a file and run it with node, instead of - // `node -e <json>`; cmd.exe on Windows mangles the escaped quotes in the - // inline form and corrupts the script before it can run. const scriptPath = `${logPath}.cjs`; const script = [ "const fs = require('node:fs');", @@ -3058,8 +2985,6 @@ describe('goal reminder re-injection after full compaction', () => { expect(floor).toBe(ctx.get(IAgentContextSizeService).get().size); expect(floor!).toBeGreaterThan(tokensAfter as number); - // V1-parity quirk (reproduced deliberately): after an idle manual compact, - // the next turn's per-turn injection adds a second copy of the reminder. ctx.mockNextResponse({ type: 'text', text: 'Reply after compaction.' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'next prompt' }] }); await ctx.untilTurnEnd(); diff --git a/packages/agent-core-v2/test/agent/fullCompaction/strategy.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/strategy.test.ts index c2d779b5aa..3a94845818 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/strategy.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/strategy.test.ts @@ -89,8 +89,6 @@ describe('DefaultCompactionStrategy', () => { textMessage('user', 'next prompt'), ]; - // The only valid split is before the parallel exchange (after 'old assistant'), - // never between tool_a and tool_b — that would leave tool_b as an orphan. expect(strategy.computeCompactCount(messages, 'auto')).toBe(2); }); diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index a1047afcec..dbb30a54f8 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -85,8 +85,6 @@ async function runGoalStep(loopService: StubLoop, turn: Turn): Promise<boolean> }; await loopService.hooks.onWillBeginStep.run(step); await loopService.hooks.onDidFinishStep.run(afterStep); - // Hooks ask for another step by enqueueing a continuation request (the old - // `afterStep.continue` flag); the loop pops it as the next step's driver. return loopService.queue.takeNextBatch() !== undefined; } @@ -476,95 +474,7 @@ describe('AgentGoalService', () => { expect(goals.getGoal().goal?.budget.turnBudget).toBe(2); }); - // TODO(phase-4.6): rewrite against wire resume — buildReplay() facade deleted - // it('projects restored goal status changes into replay records', async () => { - // await restoreGoalRecords(ctx, goals, [ - // { - // type: 'goal.create', - // goalId: 'g1', - // objective: 'work', - // completionCriterion: 'tests pass', - // time: Date.parse('2026-01-01T00:00:00.000Z'), - // }, - // { type: 'goal.update', tokensUsed: 5 }, - // { type: 'goal.update', turnsUsed: 1 }, - // { - // type: 'goal.update', - // status: 'paused', - // reason: 'break', - // actor: 'runtime', - // }, - // { type: 'goal.update', status: 'active', actor: 'user' }, - // { - // type: 'goal.update', - // status: 'complete', - // reason: 'done', - // actor: 'model', - // }, - // ]); - // - // expect(replayBuilder.buildReplay()).toEqual([ - // expect.objectContaining({ - // type: 'goal_updated', - // snapshot: expect.objectContaining({ objective: 'work', status: 'active' }), - // change: { kind: 'created' }, - // }), - // expect.objectContaining({ - // type: 'goal_updated', - // snapshot: expect.objectContaining({ status: 'paused', terminalReason: 'break' }), - // change: { kind: 'lifecycle', status: 'paused', reason: 'break', actor: 'runtime' }, - // }), - // expect.objectContaining({ - // type: 'goal_updated', - // snapshot: expect.objectContaining({ status: 'active' }), - // change: { kind: 'lifecycle', status: 'active', reason: undefined, actor: 'user' }, - // }), - // expect.objectContaining({ - // type: 'goal_updated', - // snapshot: expect.objectContaining({ - // status: 'complete', - // terminalReason: 'done', - // turnsUsed: 1, - // tokensUsed: 5, - // }), - // change: { - // kind: 'completion', - // status: 'complete', - // reason: 'done', - // stats: { turnsUsed: 1, tokensUsed: 5, wallClockMs: 0 }, - // actor: 'model', - // }, - // }), - // ]); - // }); - - // TODO(phase-4.6): rewrite against wire resume — buildReplay() facade deleted - // it('keeps resume-normalization pauses in core replay records', async () => { - // await restoreGoalRecords(ctx, goals, [ - // { - // type: 'goal.create', - // goalId: 'g1', - // objective: 'work', - // time: Date.parse('2026-01-01T00:00:00.000Z'), - // }, - // { - // type: 'goal.update', - // status: 'paused', - // reason: 'Paused after agent resume', - // }, - // ]); - // - // expect(replayBuilder.buildReplay().at(-1)).toMatchObject({ - // type: 'goal_updated', - // snapshot: { status: 'paused', terminalReason: 'Paused after agent resume' }, - // change: { - // kind: 'lifecycle', - // status: 'paused', - // reason: 'Paused after agent resume', - // actor: undefined, - // }, - // }); - // }); + it('normalizes active replayed goals to paused', async () => { records.length = 0; @@ -629,8 +539,6 @@ describe('AgentGoalService core workflow hooks', () => { turnsUsed: 1, }); expect(loopService.launches).toHaveLength(1); - // The continuation message is carried by a queued step request and only - // lands in context when the loop pops it. expect(loopService.drainNextBatch(context)).toBeDefined(); expect(context.get().at(-1)?.origin).toEqual({ kind: 'system_trigger', @@ -783,14 +691,12 @@ describe('AgentGoalService core workflow hooks', () => { await runTerminalUpdateGoalResult(toolExecutor, turn, 'complete', 'outcome prompt'); await loopService.hooks.onDidFinishStep.run(afterStep); - // The outcome continuation is a queued step request now, not a ctx flag. expect(loopService.hasPendingRequests()).toBe(true); expect(goals.getGoal().goal).toBeNull(); expect(loopService.launches).toEqual([]); expect(JSON.stringify(context.get())).not.toContain('goal_completion_summary'); expect(JSON.stringify(context.get())).not.toContain('goal_blocked_reason'); - // The loop pops the continuation to drive step 2. expect(loopService.drainNextBatch(context)).toBeDefined(); const secondAfterStep: AfterStepContext = { turnId: turn.id, @@ -1102,7 +1008,6 @@ describe('AgentGoalService mid-turn budget stop', () => { const goal = (await ctx.rpc.getGoal({})).goal; expect(goal?.status).toBe('blocked'); - // The rejected SetGoalBudget never executed: the turn budget is unchanged. expect(goal?.budget.turnBudget).toBeNull(); } finally { await ctx.dispose(); @@ -1120,7 +1025,6 @@ describe('AgentGoalService mid-turn budget stop', () => { await goals.incrementTurn(); expect(goals.getGoal().goal?.status).toBe('blocked'); - // Resume does not re-check the budget: the goal comes back active. const resumed = await goals.resumeGoal(); expect(resumed.status).toBe('active'); const telemetryAfterResume = telemetry.length; @@ -1128,8 +1032,6 @@ describe('AgentGoalService mid-turn budget stop', () => { ctx.mockNextResponse({ type: 'text', text: 'Answering the prompt normally.' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] }); const events = await ctx.untilTurnEnd(); - // Let the turn.ended subscriber settle so a (wrongly) launched goal - // continuation would be observable below. await new Promise((resolve) => setTimeout(resolve, 0)); expect(ctx.llmCalls).toHaveLength(1); diff --git a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts index 24ccbab7f7..62f70affb9 100644 --- a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts @@ -212,7 +212,6 @@ describe('AgentGoalService (wire-backed)', () => { }); await host.wire.replay(...records); - // Model rebuilt, but no live signal and no subscriber notification (silent). expect(modelOf(host.wire)?.status).toBe('paused'); expect(replaySignals).toEqual([]); expect(replayModelChanges).toBe(0); @@ -223,7 +222,6 @@ describe('AgentGoalService (wire-backed)', () => { const records = await readRecords(); const host = buildHost('goal-restore'); - // Realize the service so its ctor registers wire.onRestored BEFORE replay. void host.svc; await host.wire.replay(...records); diff --git a/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts b/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts index 48b6b26e07..a631229f18 100644 --- a/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts +++ b/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts @@ -166,7 +166,7 @@ describe('GoalInjection content', () => { await goals.setBudgetLimits({ budgetLimits: { turnBudget: 4 } }, 'model'); await goals.incrementTurn(); await goals.incrementTurn(); - await goals.incrementTurn(); // 3/4 = 75% + await goals.incrementTurn(); }))!; expect(text).toContain('nearing a budget'); expect(text).toContain('avoid starting new discretionary work'); @@ -177,7 +177,7 @@ describe('GoalInjection content', () => { await goals.createGoal({ objective: 'work' }); await goals.setBudgetLimits({ budgetLimits: { turnBudget: 2 } }, 'model'); await goals.incrementTurn(); - await goals.incrementTurn(); // 2/2 = 100% + await goals.incrementTurn(); }))!; expect(text).toContain('currently blocked'); expect(text).toContain('Blocked after goal budget reached: turn budget 2'); @@ -307,8 +307,6 @@ describe('GoalInjection integration', () => { profile.update({ activeToolNames: ['Lookup', 'UpdateGoal'] }); await goals.createGoal({ objective: 'Ship feature X' }); - // Turn 1 (user prompt) spans two steps: a Lookup tool call, then a - // final text step. ctx.mockNextResponse({ type: 'text', text: 'I will look it up.' }, lookupCall()); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Look up moon' }] }); await ctx.untilApproval(true); @@ -317,12 +315,6 @@ describe('GoalInjection integration', () => { output: 'lookup-result', }); ctx.mockNextResponse({ type: 'text', text: 'The lookup result is lookup-result.' }); - // The goal is still active when turn 1 ends, so the goal driver holds - // the turn lane and immediately launches a continuation turn — that - // continuation IS the second turn boundary (a second explicit prompt - // would throw ACTIVITY_AGENT_BUSY). Script its two steps up front: a - // terminal UpdateGoal, then the forced outcome step, which ends the - // continuation loop. ctx.mockNextResponse( { type: 'text', text: 'Wrapping up.' }, { @@ -336,16 +328,10 @@ describe('GoalInjection integration', () => { await toolCallEvents; await ctx.untilTurnEnd(); - // Two turn boundaries have injected a reminder by now — turn 1's plus - // the already-launched continuation turn's — even though turn 1 alone - // ran two steps. await expect(flushedGoalReminderRecords(ctx, persistence)).resolves.toHaveLength(2); await ctx.untilTurnEnd(); - // The continuation turn also ran two steps (UpdateGoal + outcome - // message) but added no further reminders: one per turn boundary, - // never per step. await expect(flushedGoalReminderRecords(ctx, persistence)).resolves.toHaveLength(2); }); 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 c6da46fa29..348e57ca98 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts @@ -73,7 +73,6 @@ describe('LLMRequester service migration coverage', () => { ]; beforeEach(() => { - // Stubbed before createTestAgent snapshots the env into bootstrap. vi.stubEnv(TOOL_SELECT_FLAG_ENV, '1'); ctx = createTestAgent(); llmRequester = ctx.get(IAgentLLMRequesterService); @@ -89,9 +88,6 @@ describe('LLMRequester service migration coverage', () => { }); it('records one tools snapshot per unique provider-visible tool table and one request per outbound call', async () => { - // Gate the scenario on like v1's recorder contract requires: `toolSelect` - // in the record is the disclosure gate (flag × capability), not the - // presence of deferred entries in this request's tool table. ctx.configure({ modelCapabilities: { image_in: false, @@ -516,12 +512,8 @@ describe('LLMRequester service migration coverage', () => { const timing = finish.timing; expect(timing?.firstTokenLatencyMs).toBeGreaterThanOrEqual(0); - // kosong accounts the decode window (server wait vs. client consume) and - // the requester surfaces it on the timing event. expect(timing?.serverDecodeMs).toBeGreaterThanOrEqual(0); expect(timing?.clientConsumeMs).toBeGreaterThanOrEqual(0); - // The scripted provider does not fire onRequestSent, so the TTFT split is - // not reported through the requester event. expect(timing?.requestBuildMs).toBeUndefined(); expect(timing?.serverFirstTokenMs).toBeUndefined(); }); diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index bcb18664b0..8a21a7f4f7 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -271,15 +271,11 @@ describe('AgentLLMRequesterService media-stripped resend', () => { }, }); - // Step 1: normal projection rejected, media-stripped resend recovers. await service.request({ source: { type: 'turn', turnId: 1, step: 1 } }); expect(calls.value).toBe(2); expect(projectCalls).toBe(1); expect(strippedCalls).toBe(1); - // Step 2 of the same turn: the poison is still in the full history, so - // the request builds from the stripped projection directly — no fresh - // rejection, no normal projection. await service.request({ source: { type: 'turn', turnId: 1, step: 2 } }); expect(calls.value).toBe(3); expect(projectCalls).toBe(1); @@ -356,14 +352,11 @@ describe('AgentLLMRequesterService media-degraded resend', () => { }, }); - // Step 1: normal projection rejected with 413, degraded resend recovers. await service.request({ source: { type: 'turn', turnId: 1, step: 1 } }); expect(calls.value).toBe(2); expect(projectCalls).toBe(1); expect(degradedCalls).toBe(1); - // Step 2 of the same turn: the accumulated media is still in the full - // history, so the request builds from the degraded projection directly. await service.request({ source: { type: 'turn', turnId: 1, step: 2 } }); expect(calls.value).toBe(3); expect(projectCalls).toBe(1); @@ -415,8 +408,6 @@ describe('AgentLLMRequesterService fault injection (experimental)', () => { const result = await service.request({ source: { type: 'turn', turnId: 1, step: 1 } }); - // The fault fired before the provider was contacted, so the model only - // ever saw the degraded resend. expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]); expect(calls.value).toBe(1); expect(projectCalls).toBe(1); @@ -444,7 +435,6 @@ describe('AgentLLMRequesterService fault injection (experimental)', () => { expect(strippedCalls).toBe(1); expect(faultInjection.status().fired).toEqual(['image-format']); - // One-shot: the next request fires nothing and builds normally. const result = await service.request({ source: { type: 'turn', turnId: 2, step: 1 } }); expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]); expect(faultInjection.status().fired).toEqual(['image-format']); 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 bd68cb380d..08a427c76e 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -151,9 +151,6 @@ describe('Agent loop', () => { }); it('stops the turn when provider reports tool_calls without any tool call structure', async () => { - // Mirrors v1 turn-lifecycle "treats provider tool_calls without tool call - // structure as unknown": a bare 'tool_calls' signal with no tool calls must - // end the turn instead of looping on the bare signal until maxSteps. profile.update({ activeToolNames: [] }); ctx.mockNextProviderResponse({ parts: [{ type: 'text', text: 'done' }], @@ -703,8 +700,6 @@ describe('step timing split propagation', () => { const stepCompleted = ctx.allEvents.find( (event) => event.type === '[rpc]' && event.event === 'turn.step.completed', ); - // The protocol event is copied field-by-field from the step.end event, so - // these exact values also prove the split survived on step.end. expect(stepCompleted?.args).toMatchObject({ llmFirstTokenLatencyMs: 100, llmStreamDurationMs: 200, diff --git a/packages/agent-core-v2/test/agent/mcp/mcp.test.ts b/packages/agent-core-v2/test/agent/mcp/mcp.test.ts index 5a7df66c0f..03d3d80a0f 100644 --- a/packages/agent-core-v2/test/agent/mcp/mcp.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/mcp.test.ts @@ -591,7 +591,7 @@ describe('AgentMcpService', () => { const { records, off } = collectDiscoveries(); try { manager.connect('grafana'); - expect(records).toHaveLength(0); // parked until restore + expect(records).toHaveLength(0); await wire.replay(); expect(records).toHaveLength(1); expect(records[0]).toMatchObject({ @@ -602,11 +602,9 @@ describe('AgentMcpService', () => { }); expect(records[0]!['collisions']).toBeUndefined(); - // identical content -> no second record manager.connect('grafana'); expect(records).toHaveLength(1); - // allow-list change is a different gating decision -> record again manager.setResolved('grafana', client, await discoverTools(client), new Set(), rawTools); manager.connect('grafana'); expect(records).toHaveLength(2); @@ -631,7 +629,7 @@ describe('AgentMcpService', () => { const { records, off } = collectDiscoveries(); try { manager.connect('grafana'); - expect(records).toHaveLength(0); // parked, not yet durable + expect(records).toHaveLength(0); await wire.replay(); expect(records).toHaveLength(1); } finally { @@ -720,7 +718,7 @@ describe('AgentMcpService', () => { ); createService(manager); manager.connect('graf.ana'); - await wire.replay(); // restore; occupant discovery recorded (before we subscribe) + await wire.replay(); const { records, off } = collectDiscoveries(); try { @@ -733,12 +731,12 @@ describe('AgentMcpService', () => { new Set(['query_range']), rawTools, ); - manager.connect('graf_ana'); // collides with the occupant's qualified name + manager.connect('graf_ana'); expect(records).toHaveLength(1); expect(records[0]!['collisions']).toHaveLength(1); - manager.disconnect('graf.ana'); // occupant gone - manager.connect('graf_ana'); // same rawTools/allow-list, collision flipped + manager.disconnect('graf.ana'); + manager.connect('graf_ana'); expect(records).toHaveLength(2); expect(records[1]!['collisions']).toBeUndefined(); } finally { diff --git a/packages/agent-core-v2/test/agent/mcp/output.test.ts b/packages/agent-core-v2/test/agent/mcp/output.test.ts index 5885282b5c..cf5ca3fb75 100644 --- a/packages/agent-core-v2/test/agent/mcp/output.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/output.test.ts @@ -179,10 +179,6 @@ describe('convertMCPContentBlock', () => { }); test('replaces a resource_link whose declared image format is unsupported with a notice', () => { - // The declared MIME is the only format signal for a remote image: when - // the server honestly declares a format providers reject (e.g. an image - // search tool returning AVIF links), the image is dropped for a notice - // that keeps the URL so the model can fetch and convert it. const block = assertValidMcpBlock({ type: 'resource_link', name: 'img.avif', diff --git a/packages/agent-core-v2/test/agent/media/file-type.test.ts b/packages/agent-core-v2/test/agent/media/file-type.test.ts index 0c5c7b8a0e..b919cc131b 100644 --- a/packages/agent-core-v2/test/agent/media/file-type.test.ts +++ b/packages/agent-core-v2/test/agent/media/file-type.test.ts @@ -124,7 +124,6 @@ describe('sniffMediaFromMagic', () => { }); it('uses MEDIA_SNIFF_BYTES as the header slice size ceiling', () => { - // Typed constant guard. expect(MEDIA_SNIFF_BYTES).toBe(512); }); }); @@ -173,7 +172,6 @@ describe('detectFileType', () => { }); it('treats .svg (text) as text, not image, even though the MIME is image/*', () => { - // SVG is XML text even though its MIME says `image/svg+xml`. const result = detectFileType('pic.svg'); expect(result.kind).toBe('text'); expect(result.mimeType).toBe('image/svg+xml'); @@ -186,8 +184,6 @@ describe('detectFileType', () => { }); it('extension + sniff disagree → unknown', () => { - // `.mp4` extension but JPEG magic bytes — when the mime types - // disagree we refuse to guess and return `unknown`. const jpegHeader = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); const result = detectFileType('mismatch.mp4', jpegHeader); expect(result.kind).toBe('unknown'); @@ -213,19 +209,12 @@ describe('detectFileType', () => { }); it('returns unknown for an image extension whose bytes fail to sniff', () => { - // A `.png` file with no recognisable image magic and no NUL byte must not - // be reported as `image/png` in either mode. In media mode it would build - // a mismatched data URL the model API rejects as - // `application/octet-stream`; in text mode it would redirect the user to - // ReadMediaFile for a file that is not an image. const garbage = Buffer.from('plain ascii, definitely not a png'); expect(detectFileType('fake.png', garbage, 'media').kind).toBe('unknown'); expect(detectFileType('fake.png', garbage).kind).toBe('unknown'); }); it('extension in NON_TEXT_SUFFIXES → unknown', () => { - // A `.zip` file with no header and no image/video hint must not - // be treated as text. const result = detectFileType('archive.zip'); expect(result.kind).toBe('unknown'); }); @@ -248,9 +237,7 @@ describe('detectFileType', () => { expect(detectFileType('image.PNG').kind).toBe('image'); expect(detectFileType('clip.mp4').kind).toBe('video'); expect(detectFileType('notes.txt').kind).toBe('text'); - // No suffix at all → falls through to text/plain. expect(detectFileType('Makefile').kind).toBe('text'); - // Leading dot-only names have no suffix → text/plain fallback. expect(detectFileType('.env').kind).toBe('text'); expect(detectFileType('icon.svg').kind).toBe('text'); expect(detectFileType('archive.tar.gz').kind).toBe('unknown'); @@ -258,9 +245,6 @@ describe('detectFileType', () => { }); it('keeps TypeScript suffixes as text rather than MPEG-TS video', () => { - // Regression lockdown: the `.ts` suffix maps to video/mp2t in some MIME - // tables. We must NOT classify .ts/.tsx/.mts/.cts as video — they are - // source files. expect(detectFileType('app.ts').kind).toBe('text'); expect(detectFileType('component.tsx').kind).toBe('text'); expect(detectFileType('module.mts').kind).toBe('text'); @@ -278,24 +262,16 @@ describe('detectFileType', () => { expect(detectFileType('sample', iso5Header).kind).toBe('video'); const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0]); - // .bin is in NON_TEXT_SUFFIXES; a sniffed PNG header refines it to image/png. expect(detectFileType('sample.bin', pngHeader).mimeType).toBe('image/png'); - // NUL byte in header overrides the .txt text hint. const binaryHeader = Buffer.concat([Buffer.from('partial'), Buffer.from([0x00, 0x00])]); expect(detectFileType('notes.txt', binaryHeader).kind).toBe('unknown'); }); }); -// ── sniffImageDimensions ────────────────────────────────────────────── -// -// Minimal valid header builders for each supported raster format. Each -// produces just enough bytes for `sniffImageDimensions` to locate the -// dimension fields. const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; -/** PNG IHDR: width/height are big-endian uint32 at offsets 16 and 20. */ function buildPng(width: number, height: number): Buffer { const buf = Buffer.alloc(24); Buffer.from(PNG_SIGNATURE).copy(buf, 0); @@ -305,7 +281,6 @@ function buildPng(width: number, height: number): Buffer { return buf; } -/** GIF logical-screen: width/height are little-endian uint16 at 6 and 8. */ function buildGif(signature: 'GIF87a' | 'GIF89a', width: number, height: number): Buffer { const buf = Buffer.alloc(10); Buffer.from(signature, 'latin1').copy(buf, 0); @@ -314,7 +289,6 @@ function buildGif(signature: 'GIF87a' | 'GIF89a', width: number, height: number) return buf; } -/** BMP DIB header: width/height are little-endian int32 at 18 and 22. */ function buildBmp(width: number, height: number): Buffer { const buf = Buffer.alloc(26); Buffer.from('BM', 'latin1').copy(buf, 0); @@ -323,7 +297,6 @@ function buildBmp(width: number, height: number): Buffer { return buf; } -/** WebP VP8 (lossy): 14-bit width/height masked from uint16 at 26 and 28. */ function buildWebpVp8(width: number, height: number): Buffer { const buf = Buffer.alloc(30); Buffer.from('RIFF', 'latin1').copy(buf, 0); @@ -334,7 +307,6 @@ function buildWebpVp8(width: number, height: number): Buffer { return buf; } -/** WebP VP8L (lossless): width-1 / height-1 bit-packed into uint32 at 21. */ function buildWebpVp8l(width: number, height: number): Buffer { const buf = Buffer.alloc(30); Buffer.from('RIFF', 'latin1').copy(buf, 0); @@ -345,7 +317,6 @@ function buildWebpVp8l(width: number, height: number): Buffer { return buf; } -/** WebP VP8X (extended): width-1 / height-1 as 24-bit LE at 24 and 27. */ function buildWebpVp8x(width: number, height: number): Buffer { const buf = Buffer.alloc(30); Buffer.from('RIFF', 'latin1').copy(buf, 0); @@ -362,30 +333,19 @@ function buildWebpVp8x(width: number, height: number): Buffer { return buf; } -/** - * JPEG with one SOF0 frame: SOI marker, an APP0 segment to exercise the - * segment-skipping loop, then the SOF0 segment carrying height/width as - * big-endian uint16. - */ function buildJpeg(width: number, height: number): Buffer { const soi = Buffer.from([0xff, 0xd8]); - // APP0 segment: marker + length(2) + 4 bytes of payload. const app0 = Buffer.from([0xff, 0xe0, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00]); - // SOF0: marker, length(0x0011=17), precision, height(BE), width(BE), … const sof0 = Buffer.alloc(19); sof0[0] = 0xff; sof0[1] = 0xc0; sof0.writeUInt16BE(17, 2); - sof0[4] = 8; // sample precision + sof0[4] = 8; sof0.writeUInt16BE(height, 5); sof0.writeUInt16BE(width, 7); return Buffer.concat([soi, app0, sof0]); } -/** - * A minimal EXIF APP1 segment: 'Exif\0\0' + TIFF header + IFD0 holding a - * single Orientation (0x0112) SHORT entry, in the requested byte order. - */ function exifApp1(orientation: number, byteOrder: 'II' | 'MM'): Buffer { const le = byteOrder === 'II'; const tiff = Buffer.alloc(26); @@ -399,13 +359,13 @@ function exifApp1(orientation: number, byteOrder: 'II' | 'MM'): Buffer { else tiff.writeUInt32BE(value, offset); }; u16(42, 2); - u32(8, 4); // offset of IFD0 - u16(1, 8); // one directory entry - u16(0x0112, 10); // tag: Orientation - u16(3, 12); // type: SHORT - u32(1, 14); // count - u16(orientation, 18); // value, left-aligned in the 4-byte field - u32(0, 22); // no next IFD + u32(8, 4); + u16(1, 8); + u16(0x0112, 10); + u16(3, 12); + u32(1, 14); + u16(orientation, 18); + u32(0, 22); const body = Buffer.concat([Buffer.from('Exif\0\0', 'latin1'), tiff]); const header = Buffer.alloc(4); header.writeUInt16BE(0xff_e1, 0); @@ -413,7 +373,6 @@ function exifApp1(orientation: number, byteOrder: 'II' | 'MM'): Buffer { return Buffer.concat([header, body]); } -/** A JPEG whose EXIF APP1 sits between SOI and the remaining segments. */ function buildJpegWithOrientation( width: number, height: number, @@ -474,22 +433,17 @@ describe('sniffImageDimensions', () => { }); it('reads VP8 14-bit masking — values above 0x3fff wrap to the low bits', () => { - // 14-bit field tops out at 16383; the mask discards higher bits. const data = buildWebpVp8(16383, 1); expect(sniffImageDimensions(data)).toEqual({ width: 16383, height: 1 }); }); it('keeps JPEG height/width order distinct (non-square frame)', () => { - // A non-square frame proves the SOF0 reader does not transpose axes. const data = buildJpeg(100, 700); expect(sniffImageDimensions(data)).toEqual({ width: 100, height: 700 }); }); describe('JPEG EXIF orientation (dimensions are display-space)', () => { it.each([5, 6, 7, 8])('swaps width/height for transposing orientation %i', (orientation) => { - // Orientations 5-8 rotate/transpose at decode time: a 120x80 sensor - // frame displays as 80x120. The sniff must report the display space — - // the space decoded images, crop regions, and captions live in. const data = buildJpegWithOrientation(120, 80, orientation); expect(sniffImageDimensions(data)).toEqual({ width: 80, height: 120, transposed: true }); }); @@ -516,8 +470,6 @@ describe('sniffImageDimensions', () => { }); it('survives a truncated APP1 payload without throwing', () => { - // Declared APP1 length points past the actual TIFF bytes. Whatever - // the sniff returns (unswapped dims or null), it must not throw. const jpeg = buildJpeg(120, 80); const app1 = exifApp1(6, 'II'); const truncated = Buffer.concat([ @@ -568,16 +520,12 @@ describe('sniffImageDimensions', () => { }, { name: 'JPEG with an illegal segment length (< 2) before any SOF', - // SOI then an APP0 marker whose declared length is 0; the - // `segmentLength < 2` guard must break instead of looping forever. data: Buffer.from([ 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]), }, { name: 'JPEG SOF marker whose payload runs past the buffer end', - // SOI + SOF0 marker but the segment body is cut short, so the - // `offset + 9 < buf.length` guard stops the loop before reading. data: Buffer.from([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x00]), }, { diff --git a/packages/agent-core-v2/test/agent/media/image-compress.test.ts b/packages/agent-core-v2/test/agent/media/image-compress.test.ts index ed2f64088a..eec3074636 100644 --- a/packages/agent-core-v2/test/agent/media/image-compress.test.ts +++ b/packages/agent-core-v2/test/agent/media/image-compress.test.ts @@ -60,7 +60,6 @@ import { unsupportedImageMimeFromUrl, } from '#/agent/media/image-format-policy'; -// ── fixtures ───────────────────────────────────────────────────────── async function solidPng(width: number, height: number, color = 0x3366ccff): Promise<Uint8Array> { const image = new Jimp({ width, height, color }); @@ -73,18 +72,14 @@ async function solidJpeg(width: number, height: number, color = 0x3366ccff): Pro } async function translucentPng(width: number, height: number): Promise<Uint8Array> { - // Alpha 0x80 on every pixel → hasAlpha() is true. const image = new Jimp({ width, height, color: 0x33_66_cc_80 }); return new Uint8Array(await image.getBuffer('image/png')); } -/** High-entropy image whose PNG barely compresses — used to force the ladder. */ async function noisePng(width: number, height: number, alpha = false): Promise<Uint8Array> { const image = new Jimp({ width, height, color: 0x000000ff }); const data = image.bitmap.data; for (let i = 0; i < data.length; i += 4) { - // Deterministic pseudo-random bytes (no Math.random for stable fixtures). - // Distinct multipliers per channel keep entropy high so PNG barely shrinks. data[i] = (i * 2_654_435_761) & 0xff; data[i + 1] = (i * 40_503) & 0xff; data[i + 2] = (i * 12_289) & 0xff; @@ -93,19 +88,12 @@ async function noisePng(width: number, height: number, alpha = false): Promise<U return new Uint8Array(await image.getBuffer('image/png')); } -/** - * Statistically random (deterministic xorshift) noise. Unlike noisePng's - * periodic pattern — whose post-resize deflate size is unpredictable — this - * stays roughly proportionally incompressible after a resize smooths it, - * so byte sizes can be compared across scales. - */ async function randomNoisePng(width: number, height: number): Promise<Uint8Array> { const image = new Jimp({ width, height, color: 0x000000ff }); fillXorshiftNoise(image.bitmap.data); return new Uint8Array(await image.getBuffer('image/png')); } -/** JPEG twin of {@link randomNoisePng}, for exercising the JPEG source path. */ async function randomNoiseJpeg(width: number, height: number): Promise<Uint8Array> { const image = new Jimp({ width, height, color: 0x000000ff }); fillXorshiftNoise(image.bitmap.data); @@ -143,8 +131,6 @@ async function encodeWebp( requireLocal.resolve('@jsquash/webp/encode.js') )) as typeof import('@jsquash/webp/encode.js'); const { readFileSync } = await import('node:fs'); - // The repo tsconfig has no DOM lib, so the global WebAssembly name is - // reached structurally (same approach as the production decoder). const wasmNamespace = ( globalThis as unknown as { WebAssembly: { compile(bytes: Uint8Array): Promise<object> } } ).WebAssembly; @@ -168,7 +154,6 @@ async function encodeWebp( return new Uint8Array(encoded); } -/** Minimal VP8X container header with the ANIM flag set. */ function animatedWebpHeader(): Uint8Array { const bytes = new Uint8Array(30); const ascii = (s: string, at: number) => { @@ -179,33 +164,28 @@ function animatedWebpHeader(): Uint8Array { ascii('WEBP', 8); ascii('VP8X', 12); new DataView(bytes.buffer).setUint32(16, 10, true); - bytes[20] = 0x02; // ANIM flag + bytes[20] = 0x02; return bytes; } -/** - * Insert a minimal EXIF APP1 segment carrying only an Orientation tag right - * after the JPEG SOI marker (jimp itself never writes EXIF). - */ function withExifOrientation(jpeg: Uint8Array, orientation: number): Uint8Array { - // TIFF body, little-endian: 8-byte header + IFD0 with a single entry. const tiff = Buffer.alloc(26); tiff.write('II', 0, 'latin1'); tiff.writeUInt16LE(42, 2); - tiff.writeUInt32LE(8, 4); // offset of IFD0 - tiff.writeUInt16LE(1, 8); // one directory entry - tiff.writeUInt16LE(0x0112, 10); // tag: Orientation - tiff.writeUInt16LE(3, 12); // type: SHORT - tiff.writeUInt32LE(1, 14); // count - tiff.writeUInt16LE(orientation, 18); // value, left-aligned in the 4-byte field - tiff.writeUInt32LE(0, 22); // no next IFD + tiff.writeUInt32LE(8, 4); + tiff.writeUInt16LE(1, 8); + tiff.writeUInt16LE(0x0112, 10); + tiff.writeUInt16LE(3, 12); + tiff.writeUInt32LE(1, 14); + tiff.writeUInt16LE(orientation, 18); + tiff.writeUInt32LE(0, 22); const exifBody = Buffer.concat([Buffer.from('Exif\0\0', 'latin1'), tiff]); const app1Header = Buffer.alloc(4); app1Header.writeUInt16BE(0xff_e1, 0); app1Header.writeUInt16BE(exifBody.length + 2, 2); return new Uint8Array( Buffer.concat([ - Buffer.from(jpeg.subarray(0, 2)), // SOI + Buffer.from(jpeg.subarray(0, 2)), app1Header, exifBody, Buffer.from(jpeg.subarray(2)), @@ -213,14 +193,13 @@ function withExifOrientation(jpeg: Uint8Array, orientation: number): Uint8Array ); } -// ── fast path ──────────────────────────────────────────────────────── describe('compressImageForModel — fast path', () => { it('passes a within-budget image through untouched (same reference)', async () => { const png = await solidPng(64, 64); const result = await compressImageForModel(png, 'image/png'); expect(result.changed).toBe(false); - expect(result.data).toBe(png); // identity: no copy, no re-encode + expect(result.data).toBe(png); expect(result.mimeType).toBe('image/png'); expect(result.width).toBe(64); expect(result.height).toBe(64); @@ -234,7 +213,6 @@ describe('compressImageForModel — fast path', () => { }); }); -// ── dimension cap ──────────────────────────────────────────────────── describe('compressImageForModel — dimension cap', () => { it('scales the longest edge down to MAX_IMAGE_EDGE_PX, preserving aspect', async () => { @@ -242,7 +220,6 @@ describe('compressImageForModel — dimension cap', () => { const result = await compressImageForModel(png, 'image/png'); expect(result.changed).toBe(true); expect(Math.max(result.width, result.height)).toBe(MAX_IMAGE_EDGE_PX); - // 2100x1050 → 2000x1000 (aspect 2:1 preserved). expect(result.width).toBe(2000); expect(result.height).toBe(1000); const dims = sniffImageDimensions(result.data); @@ -258,8 +235,6 @@ describe('compressImageForModel — dimension cap', () => { }); it('keeps a downscaled opaque PNG lossless (no needless JPEG conversion)', async () => { - // A screenshot-like opaque PNG that only needs downscaling must stay PNG so - // sharp text is not degraded by JPEG artifacts. const png = await solidPng(2100, 1050); const result = await compressImageForModel(png, 'image/png'); expect(result.changed).toBe(true); @@ -268,7 +243,6 @@ describe('compressImageForModel — dimension cap', () => { }); }); -// ── byte budget ────────────────────────────────────────────────────── describe('compressImageForModel — byte budget', () => { it('walks the JPEG ladder for an over-budget non-alpha image', async () => { @@ -289,7 +263,7 @@ describe('compressImageForModel — byte budget', () => { }); it('drops alpha to JPEG only as a last resort under a tiny budget', async () => { - const png = await noisePng(400, 400, /* alpha */ true); + const png = await noisePng(400, 400, true); const result = await compressImageForModel(png, 'image/png', { byteBudget: 4 * 1024 }); expect(result.changed).toBe(true); expect(result.mimeType).toBe('image/jpeg'); @@ -297,12 +271,6 @@ describe('compressImageForModel — byte budget', () => { }); it('steps down through the 2000px edge before the 1000px fallback', async () => { - // Regression guard for the 3000px cap raise: a PNG whose fitted encode - // is over budget but whose 2000px encode fits must come back at 2000px - // (as it did under the old cap), not skip straight to 1000px. - // The budget is anchored to the actual 2000px encode size (probed with - // an unlimited budget) so the test does not depend on exact deflate - // output sizes. const png = await randomNoisePng(2400, 600); const probe = await compressImageForModel(png, 'image/png', { maxEdge: 2000, @@ -311,8 +279,6 @@ describe('compressImageForModel — byte budget', () => { expect(probe.changed).toBe(true); expect(probe.mimeType).toBe('image/png'); expect(Math.max(probe.width, probe.height)).toBe(2000); - // Sanity: the anchor budget must sit below the input size, or the run - // below would pass through on the fast path instead of re-encoding. expect(probe.finalByteLength + 1024).toBeLessThan(png.length); const result = await compressImageForModel(png, 'image/png', { @@ -326,23 +292,13 @@ describe('compressImageForModel — byte budget', () => { it( 're-runs the JPEG quality ladder at fallback sizes instead of jumping to q20', async () => { - // A JPEG whose quality ladder fails at every size above 1000px, with the - // budget tuned so that at 1000px a mid-quality (q60) encode fits. The - // fallback must walk the ladder again and return that q60 encode — not - // collapse straight to q20 and needlessly destroy detail. - // The probe replays the implementation's exact resize chain - // (2400 → 2000 → 1000): box-resizing twice does not yield the same - // bitmap as resizing once, and JPEG encoding is deterministic, so the - // probed q60 size matches the implementation's encode byte-for-byte. - // The width must exceed 2000px to exercise the full fallback chain; the - // height is kept small so the ~11 JPEG encodes stay fast on slow CI. const jpeg = await randomNoiseJpeg(2400, 300); const probe = await Jimp.fromBuffer(Buffer.from(jpeg)); probe.resize({ w: 2000, h: 250 }); probe.resize({ w: 1000, h: 125 }); const q60Size = (await probe.getBuffer('image/jpeg', { quality: 60 })).length; const q20Size = (await probe.getBuffer('image/jpeg', { quality: 20 })).length; - expect(q60Size).toBeGreaterThan(q20Size); // sanity: the anchor separates the rungs + expect(q60Size).toBeGreaterThan(q20Size); const result = await compressImageForModel(jpeg, 'image/jpeg', { byteBudget: q60Size + 256, @@ -350,18 +306,15 @@ describe('compressImageForModel — byte budget', () => { expect(result.changed).toBe(true); expect(result.mimeType).toBe('image/jpeg'); expect(Math.max(result.width, result.height)).toBe(1000); - // The highest quality that fits the budget at 1000px is q60. expect(result.finalByteLength).toBe(q60Size); }, 15_000, ); }); -// ── fallback / robustness ──────────────────────────────────────────── describe('compressImageForModel — fallback', () => { it('returns the original on corrupt bytes (never throws)', async () => { - // Valid PNG signature followed by garbage — decode will fail. const corrupt = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4, 5]); const result = await compressImageForModel(corrupt, 'image/png'); expect(result.changed).toBe(false); @@ -376,7 +329,6 @@ describe('compressImageForModel — fallback', () => { }); it('passes GIF through (preserves animation)', async () => { - // Minimal GIF89a header — enough for the MIME guard to skip it. const gif = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 1, 0, 1, 0]); const result = await compressImageForModel(gif, 'image/gif'); expect(result.changed).toBe(false); @@ -384,8 +336,6 @@ describe('compressImageForModel — fallback', () => { }); it('passes a tiny within-budget WebP through untouched (fast path)', async () => { - // Too small to be worth re-encoding: already within both budgets, so the - // fast path returns it before any codec is loaded. const webp = new Uint8Array([ 0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, ]); @@ -395,12 +345,9 @@ describe('compressImageForModel — fallback', () => { }); it('skips compression for absurd pixel counts without decoding (bomb guard)', async () => { - // A PNG header advertising 30000×30000 (900 MP) with no pixel data. The - // dimension sniff reads the IHDR; the guard must pass through before Jimp - // is ever invoked, so this completes instantly with no multi-GB bitmap. const header = Buffer.alloc(24); Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(header, 0); - header.writeUInt32BE(13, 8); // IHDR chunk length + header.writeUInt32BE(13, 8); header.write('IHDR', 12, 'latin1'); header.writeUInt32BE(30000, 16); header.writeUInt32BE(30000, 20); @@ -408,19 +355,17 @@ describe('compressImageForModel — fallback', () => { const result = await compressImageForModel(bomb, 'image/png'); expect(result.changed).toBe(false); - expect(result.data).toBe(bomb); // identity → Jimp was never called + expect(result.data).toBe(bomb); }); it('skips compression for payloads over the byte cap without decoding', async () => { - // Over the edge (so not the fast path), but capped by maxDecodeBytes. const png = await solidPng(2100, 100); const result = await compressImageForModel(png, 'image/png', { maxDecodeBytes: 64 }); expect(result.changed).toBe(false); - expect(result.data).toBe(png); // passthrough → Jimp was never called + expect(result.data).toBe(png); }); }); -// ── webp ───────────────────────────────────────────────────────────── describe('compressImageForModel — webp', () => { it( @@ -507,7 +452,6 @@ describe('compressImageForModel — webp', () => { }); }); -// ── invariants ─────────────────────────────────────────────────────── describe('compressImageForModel — invariants', () => { it('changed always yields a within-cap, decodable payload', async () => { @@ -520,21 +464,17 @@ describe('compressImageForModel — invariants', () => { const result = await compressImageForModel(bytes, 'image/png'); expect(result.finalByteLength).toBe(result.data.length); if (result.changed) { - // A change is only kept when it helped: fewer bytes or fewer pixels. const original = sniffImageDimensions(bytes)!; const shrankBytes = result.finalByteLength < result.originalByteLength; const shrankPixels = result.width * result.height < original.width * original.height; expect(shrankBytes || shrankPixels).toBe(true); - // Dimensions never exceed the cap after a change. expect(Math.max(result.width, result.height)).toBeLessThanOrEqual(MAX_IMAGE_EDGE_PX); - // The result must decode. expect(sniffImageDimensions(result.data)).not.toBeNull(); } } }, 30000); }); -// ── base64 wrapper ─────────────────────────────────────────────────── describe('compressBase64ForModel', () => { it('round-trips an over-sized image', async () => { @@ -543,7 +483,6 @@ describe('compressBase64ForModel', () => { const result = await compressBase64ForModel(base64, 'image/png', { byteBudget: 8 * 1024 }); expect(result.changed).toBe(true); expect(result.finalByteLength).toBeLessThan(result.originalByteLength); - // The re-encoded base64 still decodes to a valid image. const dims = sniffImageDimensions(Buffer.from(result.base64, 'base64')); expect(dims).not.toBeNull(); }); @@ -557,15 +496,14 @@ describe('compressBase64ForModel', () => { }); it('skips a base64 payload over the byte cap without decoding', async () => { - const png = await solidPng(2100, 100); // over edge, would otherwise compress + const png = await solidPng(2100, 100); const base64 = Buffer.from(png).toString('base64'); const result = await compressBase64ForModel(base64, 'image/png', { maxDecodeBytes: 64 }); expect(result.changed).toBe(false); - expect(result.base64).toBe(base64); // unchanged → not decoded + expect(result.base64).toBe(base64); }); }); -// ── performance ────────────────────────────────────────────────────── describe('compressImageForModel — performance', () => { it('fast path is codec-free and quick across many calls', async () => { @@ -573,10 +511,9 @@ describe('compressImageForModel — performance', () => { const start = performance.now(); for (let i = 0; i < 100; i += 1) { const result = await compressImageForModel(png, 'image/png'); - expect(result.data).toBe(png); // proves no decode/encode happened + expect(result.data).toBe(png); } const elapsed = performance.now() - start; - // 100 metadata-only checks should be well under 100ms. expect(elapsed).toBeLessThan(100); }); @@ -595,7 +532,6 @@ describe('compressImageForModel — performance', () => { }); }); -// ── content-part helper ────────────────────────────────────────────── describe('compressImageContentParts', () => { function dataUrl(mime: string, bytes: Uint8Array): string { @@ -648,9 +584,6 @@ describe('compressImageContentParts', () => { }); it('drops image parts the provider cannot accept, replacing each with a notice', async () => { - // MCP servers can return any image/* MIME (e.g. an AVIF from an image - // search tool). Forwarding it would poison the session history, so the - // part is dropped and a text notice stands in. const parts = [ { type: 'text' as const, text: 'search results' }, { type: 'image_url' as const, imageUrl: { url: dataUrl('image/avif', new Uint8Array([1, 2, 3])) } }, @@ -663,7 +596,6 @@ describe('compressImageContentParts', () => { const notices = out.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text); expect(notices.some((t) => t.includes('image/avif'))).toBe(true); expect(notices.some((t) => t.includes('image/heic'))).toBe(true); - // Dropping is not compression: no captions are produced. expect(captions).toEqual([]); }); @@ -677,9 +609,6 @@ describe('compressImageContentParts', () => { }); it('forwards accepted MIME aliases in canonical form', async () => { - // `image/jpg` (and case/whitespace variants) pass the gate, but the raw - // alias must not land in the session: strict provider whitelists (e.g. - // Anthropic's) reject it and every later request would fail. const bytes = new Uint8Array([1, 2, 3]); const base64 = Buffer.from(bytes).toString('base64'); for (const alias of ['image/jpg', 'Image/JPEG', ' image/jpeg ']) { @@ -691,7 +620,6 @@ describe('compressImageContentParts', () => { type: 'image_url', imageUrl: { url: `data:image/jpeg;base64,${base64}` }, }); - // Rewriting the MIME is not compression: no caption. expect(captions).toEqual([]); } }); @@ -710,7 +638,6 @@ describe('compressImageContentParts', () => { }); }); -// ── format gate (shared by every ingestion point) ──────────────────── describe('gateImageFormatParts', () => { function dataUrl(mime: string, bytes: Uint8Array): string { @@ -728,11 +655,9 @@ describe('gateImageFormatParts', () => { const out = gateImageFormatParts(parts); expect(out[0]).toEqual({ type: 'text', text: 'results' }); - // Both unsupported images became notices naming their MIME. const notices = out.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text); expect(notices.some((t) => t.includes('image/avif'))).toBe(true); expect(notices.some((t) => t.includes('image/bmp'))).toBe(true); - // Video parts and the accepted image pass through untouched. expect(out).toContainEqual(parts[3]); expect(out).toContainEqual(parts[4]); expect( @@ -754,8 +679,6 @@ describe('gateImageFormatParts', () => { }); it('rewrites an accepted MIME carrying parameters to the bare canonical form', () => { - // Strict provider whitelists exact-match the full data-URL header, so - // `image/jpeg;charset=utf-8` would be rejected just like an alias. const base64 = Buffer.from([1, 2, 3]).toString('base64'); const out = gateImageFormatParts([ { type: 'image_url', imageUrl: { url: `data:image/jpeg;charset=utf-8;base64,${base64}` } }, @@ -776,22 +699,18 @@ describe('gateImageFormatParts', () => { return buf; }; - // AVIF bytes labeled image/png (a mislabeling MCP image search tool): - // dropped as the AVIF it is — the provider decodes bytes, not labels. const mislabeled = gateImageFormatParts([ { type: 'image_url', imageUrl: { url: `data:image/png;base64,${ftyp('avif').toString('base64')}` } }, ]); expect(mislabeled.some((p) => p.type === 'image_url')).toBe(false); expect((mislabeled[0] as { text: string }).text).toContain('image/avif'); - // A video container hiding in an image part is refused too. const video = gateImageFormatParts([ { type: 'image_url', imageUrl: { url: `data:image/png;base64,${ftyp('isom').toString('base64')}` } }, ]); expect(video.some((p) => p.type === 'image_url')).toBe(false); expect((video[0] as { text: string }).text).toContain('video/mp4'); - // PNG bytes labeled image/avif: rescued — forwarded as the PNG it is. const rescued = gateImageFormatParts([ { type: 'image_url', imageUrl: { url: `data:image/avif;base64,${pngBytes.toString('base64')}` } }, ]); @@ -800,8 +719,6 @@ describe('gateImageFormatParts', () => { imageUrl: { url: `data:image/png;base64,${pngBytes.toString('base64')}` }, }); - // Unrecognized bytes (corrupt image): the declared MIME stands; the - // 400-recovery path is the backstop for this case. const garbage = gateImageFormatParts([ { type: 'image_url', imageUrl: { url: `data:image/png;base64,${Buffer.from([1, 2, 3]).toString('base64')}` } }, ]); @@ -809,9 +726,6 @@ describe('gateImageFormatParts', () => { }); it('parses the base64 marker case-insensitively', () => { - // `;BASE64,` is a legal data URL (RFC 2045 encoding names are - // case-insensitive): an uppercase marker must not slip past the gate as - // if it were a remote URL, and the canonical rebuild lowercases it. const base64 = Buffer.from([1, 2, 3]).toString('base64'); const accepted = gateImageFormatParts([ @@ -830,9 +744,6 @@ describe('gateImageFormatParts', () => { }); it('drops remote image URLs whose extension is unsupported, passes others through', () => { - // No bytes to inspect, so the gate uses the path extension: a known-bad - // extension becomes a notice; extensionless / unknown / accepted - // extensions pass through to the provider (and the 400 recovery). for (const bad of [ 'https://example.com/pic.avif', 'https://example.com/pic.AVIF', @@ -843,7 +754,6 @@ describe('gateImageFormatParts', () => { ]) { const out = gateImageFormatParts([{ type: 'image_url', imageUrl: { url: bad } }]); expect(out[0]).toMatchObject({ type: 'text' }); - // The notice keeps the URL so the model can fetch and convert the image. expect((out[0] as { text: string }).text).toContain(bad); } for (const ok of [ @@ -858,9 +768,6 @@ describe('gateImageFormatParts', () => { }); it('drops a malformed data URL instead of letting it poison the session', () => { - // A `data:` URL parseImageDataUrl cannot parse is guaranteed to fail at - // the provider (Anthropic throws, OpenAI-compat 400s): dropping it at - // ingestion beats paying a rejected request + media strip every turn. const cases = [ 'data:image/avif', 'data:image/png;notbase64,QUJD', @@ -891,8 +798,6 @@ describe('normalizeImageMime', () => { expect(normalizeImageMime('Image/JPEG')).toBe('image/jpeg'); expect(normalizeImageMime('image/jpg')).toBe('image/jpeg'); expect(normalizeImageMime(' image/webp ')).toBe('image/webp'); - // Parameters (e.g. charset) are dropped so a declared media type stays - // consistent with a data-URL MIME token. expect(normalizeImageMime('image/jpeg; charset=utf-8')).toBe('image/jpeg'); expect(normalizeImageMime('IMAGE/PNG;foo=bar')).toBe('image/png'); }); @@ -905,8 +810,6 @@ describe('unsupportedImageMimeFromUrl', () => { expect(unsupportedImageMimeFromUrl('https://example.com/photo.HEIC#frame')).toBe('image/heic'); expect(unsupportedImageMimeFromUrl('https://example.com/scan.tiff')).toBe('image/tiff'); expect(unsupportedImageMimeFromUrl('https://example.com/icon.ico')).toBe('image/x-icon'); - // .svg is not in the shared suffix map (SVG is text for the file tools), - // but remote SVG images are accepted by no provider. expect(unsupportedImageMimeFromUrl('https://example.com/logo.svg')).toBe('image/svg+xml'); expect(unsupportedImageMimeFromUrl('https://example.com/logo.svgz')).toBe('image/svg+xml'); }); @@ -920,31 +823,22 @@ describe('unsupportedImageMimeFromUrl', () => { }); }); -// ── original-dimension metadata ────────────────────────────────────── describe('compressImageForModel — EXIF orientation', () => { it('reports original dimensions in the decoded (EXIF-rotated) space', async () => { - // Orientation 6 (rotate 90° CW): the file header says 120x80, but jimp - // decodes to 80x120 — the space the sent image and any later crop region - // actually live in. The reported original dimensions must match it, not - // the pre-rotation header sniff. const jpeg = withExifOrientation(await solidJpeg(120, 80), 6); const result = await compressImageForModel(jpeg, 'image/jpeg', { maxEdge: 64 }); expect(result.changed).toBe(true); expect(result.originalWidth).toBe(80); expect(result.originalHeight).toBe(120); - // The sent image keeps the rotated (portrait) aspect. expect(result.width).toBeLessThan(result.height); }); it('reports display-space dimensions for an EXIF-rotated passthrough', async () => { - // Within both budgets → no decode ever happens. The header sniff itself - // must account for EXIF orientation so passthrough metadata agrees with - // the space a later region readback (which decodes) will use. const jpeg = withExifOrientation(await solidJpeg(120, 80), 6); const result = await compressImageForModel(jpeg, 'image/jpeg'); expect(result.changed).toBe(false); - expect(result.data).toBe(jpeg); // fast path — not decoded + expect(result.data).toBe(jpeg); expect(result.originalWidth).toBe(80); expect(result.originalHeight).toBe(120); expect(result.width).toBe(80); @@ -980,7 +874,6 @@ describe('compressImageForModel — original dimensions metadata', () => { }); }); -// ── crop ───────────────────────────────────────────────────────────── describe('cropImageForModel', () => { it('crops a region out of a PNG at native resolution', async () => { @@ -1114,8 +1007,6 @@ describe('cropImageForModel', () => { }); it('rejects non-finite region coordinates with a clean error', async () => { - // NaN slips past every `<`/`>=` comparison, so without an explicit guard - // it reaches jimp and surfaces as a misleading internal validation dump. const png = await solidPng(300, 200); for (const region of [ { x: Number.NaN, y: 0, width: 10, height: 10 }, @@ -1148,7 +1039,6 @@ describe('cropImageForModel', () => { }); }); -// ── compression caption ────────────────────────────────────────────── describe('buildImageCompressionCaption', () => { it('describes the original and sent variants with a readback path', () => { @@ -1224,7 +1114,6 @@ describe('extractImageCompressionCaptions', () => { }); }); -// ── content-part annotation ────────────────────────────────────────── describe('compressImageContentParts — annotate', () => { function dataUrl(mime: string, bytes: Uint8Array): string { @@ -1244,7 +1133,6 @@ describe('compressImageContentParts — annotate', () => { }, }); - // The caption comes back as data, never inserted into the parts. expect(out.parts).toHaveLength(1); expect(out.parts[0]?.type).toBe('image_url'); expect(out.captions).toHaveLength(1); @@ -1278,20 +1166,7 @@ describe('compressImageContentParts — annotate', () => { }); }); -// ── downscale quality guards ───────────────────────────────────────── -// -// Downscaling is a resampling operation: input frequencies above the output -// Nyquist limit must be filtered out (averaged), or they fold back as -// low-frequency moiré — spectral aliasing. A 1px checkerboard is the -// worst-case probe: ALL of its energy sits at the input Nyquist frequency, -// so a resampler that skips source pixels turns it into high-contrast -// artifacts, while a correct full-coverage average yields flat ~50% gray. -// These tests pin the compressor to the correct behavior, keep the aliasing -// counter-example executable, and cover the other classic downscale bugs -// (transparent-pixel bleed, brightness drift, iterative degradation, -// degenerate aspect ratios). - -/** 1px checkerboard: every pixel alternates black/white in both axes. */ + async function checkerboardPng(size: number): Promise<Uint8Array> { const image = new Jimp({ width: size, height: size, color: 0x000000ff }); const data = image.bitmap.data; @@ -1314,7 +1189,6 @@ interface GrayStats { readonly mean: number; } -/** Min/max/mean over the red channel (all probes here are grayscale). */ function grayStats(image: { bitmap: { data: Buffer | Uint8Array } }): GrayStats { const data = image.bitmap.data; let min = 255; @@ -1331,9 +1205,6 @@ function grayStats(image: { bitmap: { data: Buffer | Uint8Array } }): GrayStats describe('compressImageForModel — downscale quality guards', () => { it('averages a 1px checkerboard to flat gray at an integer ratio (no aliasing)', async () => { - // 1000 → 250 (4:1). Every output pixel covers a 4×4 block holding 8 - // black and 8 white pixels, so a full-coverage average lands on ~127. - // Aliasing would instead show up as black/white patches or moiré bands. const png = await checkerboardPng(1000); const result = await compressImageForModel(png, 'image/png', { maxEdge: 250 }); expect(result.changed).toBe(true); @@ -1346,9 +1217,6 @@ describe('compressImageForModel — downscale quality guards', () => { }); it('stays alias-free at a non-integer ratio (fractional pixel coverage)', async () => { - // 1000 → 390 (≈2.56:1). Non-integer ratios are where phase-dependent - // point sampling degrades worst. Fractional window coverage leaves the - // average some mild texture, but nothing may approach black or white. const png = await checkerboardPng(1000); const result = await compressImageForModel(png, 'image/png', { maxEdge: 390 }); expect(result.changed).toBe(true); @@ -1360,32 +1228,16 @@ describe('compressImageForModel — downscale quality guards', () => { }); it('control: jimp point-sampled BILINEAR aliases the same input (keeps the probe honest)', async () => { - // Executable counter-example for the constraint documented on - // fitWithinEdge: the named ResizeStrategy modes sample a fixed 2×2 - // neighborhood around the mapped point and skip the rest. At 4:1 the - // sample grid lands on a single checkerboard phase and the 50%-gray - // pattern collapses to solid black — the pattern's energy is entirely - // misrepresented. This proves the two tests above can fail (the probe - // distinguishes resamplers) and pins the library behavior the - // mode-less default call relies on — if jimp ever changes either - // side, revisit the fitWithinEdge comment. const image = await Jimp.fromBuffer(Buffer.from(await checkerboardPng(1000))); image.resize({ w: 250, h: 250, mode: ResizeStrategy.BILINEAR }); const { min, max, mean } = grayStats(image); - // The correct answer is flat ~127 gray (mean ≈ 127, max-min ≈ 0). - // Aliasing shows up as a solid black/white collapse or full-contrast - // banding — far from that answer regardless of sampling phase. const aliased = mean < 60 || mean > 195 || max - min > 200; expect(aliased).toBe(true); }); it('never bleeds color from fully transparent pixels into visible ones', async () => { - // Fully transparent pixels still carry RGB values. A resizer that - // blends them into the average tints every transparency edge (halo). - // Probe: a fully transparent BRIGHT RED field around an opaque blue - // square — after a 4:1 downscale no visible pixel may pick up red. const size = 800; - const image = new Jimp({ width: size, height: size, color: 0xff000000 }); // red, alpha 0 + const image = new Jimp({ width: size, height: size, color: 0xff000000 }); const data = image.bitmap.data; for (let y = 200; y < 600; y += 1) { for (let x = 200; x < 600; x += 1) { @@ -1400,7 +1252,7 @@ describe('compressImageForModel — downscale quality guards', () => { const result = await compressImageForModel(png, 'image/png', { maxEdge: 200 }); expect(result.changed).toBe(true); - expect(result.mimeType).toBe('image/png'); // alpha survives + expect(result.mimeType).toBe('image/png'); const decoded = await Jimp.fromBuffer(Buffer.from(result.data)); const out = decoded.bitmap.data; @@ -1408,15 +1260,13 @@ describe('compressImageForModel — downscale quality guards', () => { for (let i = 0; i < out.length; i += 4) { if (out[i + 3]! >= 8) { visible += 1; - expect(out[i]!).toBeLessThanOrEqual(16); // red channel stays ~0 + expect(out[i]!).toBeLessThanOrEqual(16); } } - expect(visible).toBeGreaterThan(0); // the blue square is still there + expect(visible).toBeGreaterThan(0); }); it('preserves mean brightness through the downscale (no energy drift)', async () => { - // A normalized filter keeps the image mean; drift here would indicate - // non-normalized weights (or a broken gamma pipeline stage). const png = await noisePng(400, 400); const input = await Jimp.fromBuffer(Buffer.from(png)); const inputMean = grayStats(input).mean; @@ -1428,20 +1278,15 @@ describe('compressImageForModel — downscale quality guards', () => { }); it('recompressing a compressed result is a no-op (no iterative degradation)', async () => { - // Model-bound bytes can re-enter the pipeline (session replay, MCP - // round-trips). Once within budget they must pass through untouched - // instead of being shaved a little smaller on every pass. const first = await compressImageForModel(await solidPng(2100, 1050), 'image/png'); expect(first.changed).toBe(true); const second = await compressImageForModel(first.data, first.mimeType); expect(second.changed).toBe(false); - expect(second.data).toBe(first.data); // identity — not even re-decoded + expect(second.data).toBe(first.data); }); it('keeps a degenerate aspect ratio at least 1px tall (no zero-size collapse)', async () => { - // 9000×2 scaled to a 2000px edge would round the short side to 0.44px; - // the resizer must clamp to 1, not produce an undecodable 2000×0 image. const png = await solidPng(9000, 2); const result = await compressImageForModel(png, 'image/png'); expect(result.changed).toBe(true); @@ -1451,7 +1296,6 @@ describe('compressImageForModel — downscale quality guards', () => { }); }); -// ── telemetry ──────────────────────────────────────────────────────── interface CapturedEvent { readonly event: string; @@ -1503,7 +1347,6 @@ describe('compressImageForModel — telemetry', () => { }); it('reports decode guards as passthrough_guard', async () => { - // Decompression-bomb header: 30000×30000 with no pixel data. const header = Buffer.alloc(24); Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(header, 0); header.writeUInt32BE(13, 8); @@ -1542,9 +1385,6 @@ describe('compressImageForModel — telemetry', () => { }); it('reports undecodable bytes as passthrough_error', async () => { - // A tiny corrupt blob would pass through on the fast path (unknown dims, - // small bytes) without ever decoding; to reach the decoder the header - // must claim an over-cap size. 4000×4000 forces a decode of garbage. const { client, events } = captureTelemetry(); const corrupt = Buffer.alloc(32); Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(corrupt, 0); @@ -1604,7 +1444,7 @@ describe('compressImageForModel — telemetry', () => { const result = await compressImageForModel(png, 'image/png', { telemetry: { client: throwing, source: 'read_media' }, }); - expect(result.changed).toBe(true); // compression outcome unaffected + expect(result.changed).toBe(true); }); }); @@ -1628,7 +1468,6 @@ describe('cropImageForModel — telemetry', () => { expect(props['resized']).toBe(false); expect(props['original_width']).toBe(1000); expect(props['original_height']).toBe(500); - // 500×250 of 1000×500 → a quarter of the pixels. expect(props['region_area_ratio']).toBeCloseTo(0.25, 5); expect(typeof props['duration_ms']).toBe('number'); expect(typeof props['final_bytes']).toBe('number'); @@ -1666,9 +1505,6 @@ describe('cropImageForModel — telemetry', () => { }); describe('image-compress config resolver seam', () => { - // The resolvers read process-global "configured" overrides pushed by the - // media-domain image-config bridge. Clear them after every test so this - // module-global state never leaks into the compression cases above. afterEach(() => { setConfiguredMaxImageEdgePx(undefined); setConfiguredReadImageByteBudget(undefined); diff --git a/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts b/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts index 566f50d721..583cdc524b 100644 --- a/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts +++ b/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts @@ -42,9 +42,7 @@ const PNG_HEIGHT = 1080; function pngBuffer(): Buffer { const buf = Buffer.alloc(24); - // PNG signature buf.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); - // IHDR length (13) + 'IHDR' buf.writeUInt32BE(13, 8); buf.write('IHDR', 12, 'latin1'); buf.writeUInt32BE(PNG_WIDTH, 16); @@ -62,28 +60,23 @@ function mp4Buffer(): Buffer { ]); } -/** - * Wrap a baseline JPEG in an EXIF APP1 segment carrying the given Orientation - * tag, so decoders (and the header sniff) see a rotated image. - */ function withExifOrientation(jpeg: Uint8Array, orientation: number): Buffer { - // TIFF body, little-endian: 8-byte header + IFD0 with a single entry. const tiff = Buffer.alloc(26); tiff.write('II', 0, 'latin1'); tiff.writeUInt16LE(42, 2); - tiff.writeUInt32LE(8, 4); // offset of IFD0 - tiff.writeUInt16LE(1, 8); // one directory entry - tiff.writeUInt16LE(0x0112, 10); // tag: Orientation - tiff.writeUInt16LE(3, 12); // type: SHORT - tiff.writeUInt32LE(1, 14); // count - tiff.writeUInt16LE(orientation, 18); // value, left-aligned in the 4-byte field - tiff.writeUInt32LE(0, 22); // no next IFD + tiff.writeUInt32LE(8, 4); + tiff.writeUInt16LE(1, 8); + tiff.writeUInt16LE(0x0112, 10); + tiff.writeUInt16LE(3, 12); + tiff.writeUInt32LE(1, 14); + tiff.writeUInt16LE(orientation, 18); + tiff.writeUInt32LE(0, 22); const exifBody = Buffer.concat([Buffer.from('Exif\0\0', 'latin1'), tiff]); const app1Header = Buffer.alloc(4); app1Header.writeUInt16BE(0xff_e1, 0); app1Header.writeUInt16BE(exifBody.length + 2, 2); return Buffer.concat([ - Buffer.from(jpeg.subarray(0, 2)), // SOI + Buffer.from(jpeg.subarray(0, 2)), app1Header, exifBody, Buffer.from(jpeg.subarray(2)), @@ -181,8 +174,6 @@ async function execute( args: ReadMediaFileInput, ): Promise<ExecutableToolResult> { const execution = tool.resolveExecution(args); - // `resolveExecution` may return a validation error result directly (e.g. an - // empty path) instead of a runnable execution. if (!('execute' in execution)) { return execution; } @@ -200,9 +191,6 @@ function outputParts(result: ExecutableToolResult): ContentPart[] { return result.output as ContentPart[]; } -// The media summary rides the result's `note` side channel (rendered to the -// model at projection time, never to UIs); the tool keeps its own `<system>` -// wrapping as a wording choice. function noteText(result: ExecutableToolResult): string { expect(typeof result.note).toBe('string'); return result.note as string; @@ -299,9 +287,7 @@ describe('ReadMediaFileTool', () => { expect(systemText).toMatch(/^<system>.*<\/system>$/s); expect(systemText).toContain('Mime type: image/png'); expect(systemText).toContain(`Original dimensions: ${PNG_WIDTH}x${PNG_HEIGHT}`); - // With the original size known, the coordinate guidance is included. expect(systemText).toMatch(/relative coordinates first/i); - // The re-read reminder is included regardless of dimensions. expect(systemText).toMatch(/read the result back/i); const parts = outputParts(result); @@ -324,18 +310,13 @@ describe('ReadMediaFileTool', () => { path: '/workspace/big.png', }); - // The <system> note keeps the ORIGINAL size so coordinate mapping holds. const systemText = noteText(result); expect(systemText).toContain('2200x2200'); expect(systemText).toContain(`${String(big.length)} bytes`); - // Wording must not depend on serialization order: some providers keep - // the note inline after the media, others flatten tool text and - // re-attach the image after it — so no "above"/"below". expect(systemText).toMatch(/The attached image was downsampled to 2000x2000/); expect(systemText).toMatch(/fine detail/i); expect(systemText).toContain('region'); - // The image actually sent to the model is downsampled to the edge cap. const parts = outputParts(result); const url = (parts[1] as { imageUrl: { url: string } }).imageUrl.url; const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(url); @@ -345,8 +326,6 @@ describe('ReadMediaFileTool', () => { }); it('does not claim downsampling for an image sent untouched', async () => { - // A real 3x4 PNG passes through unchanged — the <system> note must not - // carry a downsample note (that would be its own kind of misreporting). const png = Buffer.from( '89504e470d0a1a0a0000000d49484452000000030000000408020000003a' + '63dc1c0000001949444154789c63606060f8cf80019aa0a8a020' + @@ -361,9 +340,6 @@ describe('ReadMediaFileTool', () => { it('reads image regions at native resolution', async () => { const big = Buffer.from( - // Over the 2000px edge cap on purpose: region reads must crop from the - // original coordinate space, which a sub-cap fixture cannot distinguish - // from cropping the downsampled delivery. await new Jimp({ width: 2100, height: 2100, color: 0x3366ccff }).getBuffer('image/png'), ); const result = await execute(makeTool({ '/workspace/big.png': { data: big } }), { @@ -387,8 +363,6 @@ describe('ReadMediaFileTool', () => { it('rejects a region outside the image with the original size in the error', async () => { const big = Buffer.from( - // Over the edge cap so "original size" is distinguishable from any - // downsampled delivery size. await new Jimp({ width: 2100, height: 2100, color: 0x3366ccff }).getBuffer('image/png'), ); const result = await execute(makeTool({ '/workspace/big.png': { data: big } }), { @@ -401,7 +375,6 @@ describe('ReadMediaFileTool', () => { it('serves full_resolution when the bytes fit the per-image budget', async () => { const big = Buffer.from( - // Over the edge cap, tiny in bytes. await new Jimp({ width: 2100, height: 1050, color: 0x3366ccff }).getBuffer('image/png'), ); const result = await execute(makeTool({ '/workspace/big.png': { data: big } }), { @@ -417,8 +390,6 @@ describe('ReadMediaFileTool', () => { }); it('fails full_resolution explicitly when the file exceeds the per-image budget', async () => { - // PNG magic followed by 4MB of filler: recognizably an image, over the - // 3.75MB byte budget — full_resolution must refuse, not silently shrink. const data = Buffer.concat([pngBuffer(), Buffer.alloc(4 * 1024 * 1024, 1)]); const result = await execute(makeTool({ '/workspace/huge.png': { data } }), { path: '/workspace/huge.png', @@ -427,17 +398,11 @@ describe('ReadMediaFileTool', () => { expect(result.isError).toBe(true); expect(result.output).toMatch(/full_resolution/); expect(result.output).toMatch(/region/); - // Exact byte counts accompany the rounded sizes: a file a hair over - // budget would otherwise read "is 3.8 MB, over the 3.8 MB limit". expect(result.output).toContain(`${String(data.length)} bytes`); expect(result.output).toContain('3932160-byte'); }); it('reports an EXIF-rotated original in the decoded coordinate space', async () => { - // Orientation 6 (rotate 90° CW): the header says 2200x1100, but jimp - // decodes to 1100x2200 — the space the sent image and any region - // readback live in. The note's original size must match that space, - // not the pre-rotation header sniff. const portrait = withExifOrientation( new Uint8Array( await new Jimp({ width: 2200, height: 1100, color: 0x3366ccff }).getBuffer('image/jpeg', { @@ -456,8 +421,6 @@ describe('ReadMediaFileTool', () => { }, 15000); it('reports the decoded size for a region read of an EXIF-rotated image', async () => { - // Region coordinates live in the decoded (rotated) space; the note's - // original size must agree with it even when the header sniff succeeds. const portrait = withExifOrientation( new Uint8Array( await new Jimp({ width: 120, height: 80, color: 0x3366ccff }).getBuffer('image/jpeg', { @@ -475,9 +438,6 @@ describe('ReadMediaFileTool', () => { }); it('reports display-space dimensions for an EXIF-rotated image sent untouched', async () => { - // Within both budgets the original bytes are sent without decoding; the - // note must still report the display-space size so coordinates derived - // from it agree with a later region readback (which decodes). const portrait = withExifOrientation( new Uint8Array( await new Jimp({ width: 120, height: 80, color: 0x3366ccff }).getBuffer('image/jpeg', { @@ -646,7 +606,6 @@ describe('registerMediaTools', () => { capabilities: capabilities({ image_in: false, video_in: false }), }); expect(registry.resolve('ReadMediaFile')).toBeUndefined(); - // Disposing the no-op registration is safe. expect(() => disposable.dispose()).not.toThrow(); }); }); @@ -731,7 +690,6 @@ describe('AgentMediaToolsRegistrar', () => { bindModel('vision-model', capabilities({ image_in: true, video_in: true })); const first = registry.resolve('ReadMediaFile'); - // Same alias, same media capabilities — e.g. a thinking-level update. bindModel('vision-model', capabilities({ image_in: true, video_in: true })); expect(registry.resolve('ReadMediaFile')).toBe(first); }); @@ -743,7 +701,6 @@ describe('AgentMediaToolsRegistrar', () => { registrar.dispose(); expect(registry.resolve('ReadMediaFile')).toBeUndefined(); - // A status update after dispose must not resurrect the tool. bindModel('vision-model-2', capabilities({ image_in: true, video_in: true })); expect(registry.resolve('ReadMediaFile')).toBeUndefined(); }); @@ -822,7 +779,6 @@ describe('createVideoUploader', () => { }); function heicBytes(): Buffer { - // Minimal ftyp box: size(4) + 'ftyp' + major_brand 'heic' + minor(4) + compat(8). return Buffer.from([ 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63, 0x00, 0x00, 0x00, 0x00, 0x68, 0x65, 0x69, 0x63, 0x00, 0x00, 0x00, 0x00, @@ -838,12 +794,10 @@ describe('createVideoUploader', () => { expect(result.output).toContain('image/heic'); expect(result.output).toContain('Convert it to JPEG first'); expect(result.output).toContain('/workspace/photo.jpg'); - // The exact command depends on the host osKind; accept any of the named tools. expect(result.output).toMatch(/sips -s format jpeg|heif-convert|magick/); }); function ftypBytes(brand: string): Buffer { - // Minimal ftyp box with the given major_brand (avif, bmp, …). const buf = Buffer.alloc(24); buf.writeUInt32BE(24, 0); buf.write('ftyp', 4, 'latin1'); @@ -853,9 +807,6 @@ describe('createVideoUploader', () => { } it('refuses every format outside the provider-accepted set, not just HEIC', async () => { - // AVIF/BMP/TIFF/ICO are no more accepted than HEIC: once the image_url - // lands in the history every later request fails, so the tool refuses - // with conversion guidance instead of passing the bytes through. const result = await execute(makeTool({ '/workspace/photo.avif': { data: ftypBytes('avif') } }), { path: '/workspace/photo.avif', }); @@ -864,8 +815,6 @@ describe('createVideoUploader', () => { expect(result.output).toContain('image/avif'); expect(result.output).toContain('Convert it to JPEG first'); expect(result.output).toContain('/workspace/photo.jpg'); - // AVIF has no dedicated Linux decoder in the policy, so the guidance is - // sips (macOS) or ImageMagick — never heif-convert. expect(result.output).toMatch(/sips -s format jpeg|magick/); expect(result.output).not.toContain('heif-convert'); }); diff --git a/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts b/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts index 268cdccc30..8fbb36b6fc 100644 --- a/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts +++ b/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts @@ -47,7 +47,6 @@ let disposables: DisposableStore; let ix: TestInstantiationService; let log: IAppendLogStore; let svc: IAgentPermissionModeService; -/** Whether the last returned reminder is still live in (simulated) history. */ let reminderLive = false; beforeEach(() => { @@ -85,12 +84,10 @@ async function runRegisteredInjection(): Promise<string | undefined> { if (typeof content !== 'string' && content !== undefined) { throw new Error('expected permission mode injection provider to return text'); } - // The injector appends returned content to history, so it is live afterwards. if (content !== undefined) reminderLive = true; return content; } -/** Simulate compaction / undo splicing the live reminder out of history. */ function spliceReminderOut(): void { reminderLive = false; } @@ -110,8 +107,6 @@ describe('AgentPermissionModeService (wire-backed)', () => { expect(svc.mode).toBe('auto'); expect(changes).toEqual([{ mode: 'auto', previousMode: 'manual' }]); - // Re-dispatching the current mode is a no-op: apply returns the same - // reference, so the wire emits no change and onDidChangeMode does not fire again. svc.setMode('auto'); expect(changes).toEqual([{ mode: 'auto', previousMode: 'manual' }]); }); @@ -159,11 +154,6 @@ describe('AgentPermissionModeService (wire-backed)', () => { it('re-announces auto mode on a fresh instance even with a live reminder in history (restore)', async () => { svc.setMode('auto'); - // Simulate a fresh engine instance after session restore: no in-memory - // lastMode, but history still carries a live reminder from before the - // restart (the injector re-syncs positions on restore). Positions are - // content-agnostic, so the survivor may even be a stale EXIT reminder — - // auto mode must still be announced, exactly as v1 does. let restoredProvider: ContextInjectionProvider | undefined; const ix2 = disposables.add(new TestInstantiationService()); ix2.stub(IAgentContextInjectorService, { @@ -205,7 +195,6 @@ describe('AgentPermissionModeService (wire-backed)', () => { expect(fresh.getModel(PermissionModeModel)).toBe('auto'); - // Replay is silent: nothing is written back to the wire log. const written: PersistedRecord[] = []; for await (const record of log2.read<PersistedRecord>(SCOPE, 'permission-mode-replay')) { written.push(record); diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts index 714103d57d..c5743a3a30 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts @@ -101,8 +101,6 @@ describe('AgentPermissionPolicyService chain', () => { }); registration.dispose(); - // After disposal the built-in chain no longer sees the deny-all policy, so - // a benign builtin tool is no longer rejected by it. await expect(evaluate({ toolName: 'Read', args: { path: 'src/a.ts' } })).resolves.not.toMatchObject({ policyName: 'deny-all', }); diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/policies/goal-start-review-ask.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/policies/goal-start-review-ask.test.ts index 8d16301231..8a4b8da26a 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/policies/goal-start-review-ask.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/policies/goal-start-review-ask.test.ts @@ -84,7 +84,6 @@ describe('GoalStartReviewAskPermissionPolicyService', () => { const policy = new GoalStartReviewAskPermissionPolicyService(mode); const result = policy.evaluate(policyContext('CreateGoal', GOAL_DISPLAY)); if (result?.kind !== 'ask') throw new Error('expected ask'); - // Returning undefined lets CreateGoal.execute run and create the goal. expect(result.resolveApproval?.({ decision: 'approved', selectedLabel: 'auto' })).toBeUndefined(); expect(mode.mode).toBe('auto'); }); @@ -103,7 +102,6 @@ describe('GoalStartReviewAskPermissionPolicyService', () => { const policy = new GoalStartReviewAskPermissionPolicyService(mode); const result = policy.evaluate(policyContext('CreateGoal', GOAL_DISPLAY)); if (result?.kind !== 'ask') throw new Error('expected ask'); - // A cancel resolves to undefined; the manager then blocks the tool call. expect(result.resolveApproval?.({ decision: 'cancelled', selectedLabel: 'cancel' })).toBeUndefined(); expect(mode.mode).toBe('manual'); }); diff --git a/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts b/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts index 48d87b9428..12e32b56f3 100644 --- a/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts +++ b/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts @@ -66,7 +66,6 @@ describe('AgentPermissionRulesService (wire-backed)', () => { svc.addRules([denyRule]); expect(svc.rules).toEqual([allowRule, denyRule]); - // Empty add is a no-op: it does not dispatch. svc.addRules([]); expect(svc.rules).toEqual([allowRule, denyRule]); }); @@ -77,7 +76,6 @@ describe('AgentPermissionRulesService (wire-backed)', () => { expect(svc.sessionApprovalRulePatterns).toEqual(['Bash(rm *)']); - // Duplicate session approval is deduped by the model. svc.recordApprovalResult(approval); expect(svc.sessionApprovalRulePatterns).toEqual(['Bash(rm *)']); }); @@ -138,8 +136,6 @@ describe('AgentPermissionRulesService (wire-backed)', () => { rules: [], sessionApprovalRulePatterns: ['Bash(rm *)'], }); - // Replay is silent: no subscriber notification and nothing written back to - // the wire log. expect(changes).toBe(0); const written: PersistedRecord[] = []; for await (const record of log2.read<PersistedRecord>(SCOPE, 'permission-rules-replay')) { diff --git a/packages/agent-core-v2/test/agent/plan/plan.test.ts b/packages/agent-core-v2/test/agent/plan/plan.test.ts index ca05341b4b..0c70808f0a 100644 --- a/packages/agent-core-v2/test/agent/plan/plan.test.ts +++ b/packages/agent-core-v2/test/agent/plan/plan.test.ts @@ -27,11 +27,6 @@ interface PlanFakes { readonly runner: ISessionProcessRunner; } -/** - * Minimal fs + runner pair with sensible plan-service defaults (mkdir / - * readText no-op, runner throws). Individual tests override the specific - * methods they need. - */ function createPlanFakes(overrides: Partial<IHostFileSystem> = {}): PlanFakes { const fs = createFakeHostFs({ mkdir: vi.fn().mockResolvedValue(undefined), @@ -113,10 +108,6 @@ describe('Plan service', () => { } }); - /** - * A fs whose methods delegate to whichever `activeFakes.fs` is set at call - * time. Lets a test swap fakes mid-flight by reassigning `activeFakes`. - */ function delegatingFs(): IHostFileSystem { return new Proxy(createPlanFakes().fs, { get(_target, prop, receiver) { @@ -452,8 +443,6 @@ describe('Plan service', () => { type: 'function', id: 'call_exit_options', name: 'ExitPlanMode', - // The second option omits `description` - valid input after the - // schema relaxation. The approval policy must still surface both. arguments: JSON.stringify({ options: [ { label: 'Approach A', description: 'Smaller refactor.' }, diff --git a/packages/agent-core-v2/test/agent/plan/planOps.test.ts b/packages/agent-core-v2/test/agent/plan/planOps.test.ts index a8bf228dc3..1cac99ca64 100644 --- a/packages/agent-core-v2/test/agent/plan/planOps.test.ts +++ b/packages/agent-core-v2/test/agent/plan/planOps.test.ts @@ -76,7 +76,6 @@ describe('plan ops (wire-backed)', () => { 'plan_mode.enter', 'plan_mode.exit', ]); - // Flat record shape: payload fields sit next to `type`, never under `payload`. expect(records.every((record) => 'payload' in record === false)).toBe(true); expect(records[0]).toEqual( expect.objectContaining({ @@ -139,7 +138,6 @@ describe('plan ops (wire-backed)', () => { expect(emissions).toEqual([]); expect(modelChanges).toBe(0); - // A cancelled plan replays back to inactive. const cancelled = buildHost('plan-replay-cancel'); await cancelled.wire.replay( { type: 'plan_mode.enter', id: 'p1', planFilePath: '/w/plan/p1.md' }, diff --git a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts index a42bb4d3d6..e13a86c399 100644 --- a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts +++ b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts @@ -122,7 +122,6 @@ describe('AgentPluginService plugin session-start wiring', () => { ), ); - // Force-instantiate the real service (production does this from createMain). ctx.get(IAgentPluginService); await injectRegistered(ctx); diff --git a/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts b/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts index adaded3a92..dc0a2256dd 100644 --- a/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts +++ b/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts @@ -47,9 +47,6 @@ describe('AgentProfileService.applyProfile', () => { }); function buildContext(): { ctx: TestAgentContext; profile: IAgentProfileService } { - // Real session-scoped fs anchored at workDir, plus a hermetic home dir - // (empty temp dir) so a developer's real ~/.kimi-code / ~/.agents files - // never leak into the assertions. const fs = new HostFileSystem(); ctx = createTestAgent( execEnvServices({ hostFs: fs }), diff --git a/packages/agent-core-v2/test/agent/profile/binding.test.ts b/packages/agent-core-v2/test/agent/profile/binding.test.ts index d5747b8a23..3661e28a1b 100644 --- a/packages/agent-core-v2/test/agent/profile/binding.test.ts +++ b/packages/agent-core-v2/test/agent/profile/binding.test.ts @@ -31,8 +31,6 @@ describe('AgentProfileService.bind', () => { }); function buildContext(): { ctx: TestAgentContext; profile: IAgentProfileService } { - // Hermetic home dir so a developer's real ~/.kimi-code / ~/.agents files - // never leak into the rendered system prompt. ctx = createTestAgent(hostEnvironmentServices(homeDir)); return { ctx, profile: ctx.get(IAgentProfileService) }; } @@ -40,12 +38,9 @@ describe('AgentProfileService.bind', () => { it('binds a profile + model atomically and becomes runnable', async () => { const { ctx: context, profile: svc } = buildContext(); - // Sanity: the builtin default profile is registered in the catalog. const catalog = context.get(IAgentProfileCatalogService); expect(catalog.get(DEFAULT_AGENT_PROFILE_NAME)).toBeDefined(); - // Auto-configure sets a model alias but no profile, so the agent is not - // runnable until a profile is bound (no default agent). expect(svc.isRunnable()).toBe(false); await svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL }); @@ -54,7 +49,6 @@ describe('AgentProfileService.bind', () => { expect(svc.data().modelAlias).toBe(MOCK_MODEL); expect(svc.isRunnable()).toBe(true); expect(svc.getActiveToolNames()?.length).toBeGreaterThan(0); - // The rendered system prompt is the full base template (not an overlay). expect(svc.getSystemPrompt()).toContain('Kimi Code CLI'); }); diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index 5c1717cb2f..08b79944fe 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -224,10 +224,6 @@ describe('ConfigState prompt cache hint', () => { it('uses session id as a provider prompt cache hint without storing it on Agent', () => { profile.update({ modelAlias: 'kimi-code' }); - // The session id is now applied to the resolved `Model`'s generation kwargs - // (`prompt_cache_key`) by `AgentProfileService.resolveModel` for kimi - // models; the `Model` god-object no longer exposes the raw provider config, - // so we assert the resolved protocol and the "not stored on Agent" invariant. expect(profile.resolveModel()?.protocol).toBe('kimi'); expect('sessionId' in ctx).toBe(false); }); @@ -302,9 +298,6 @@ describe('ConfigState thinking clamp for always-thinking models', () => { it('builds the provider with thinking enabled even after thinking was set off', async () => { profile.update({ modelAlias: 'kimi-code/deep', thinkingLevel: 'off' }); - // The Model god-object carries no raw kwargs; the thinking state is - // materialized into the kimi ChatProvider's `_generationKwargs` at request - // time, so inspect the provider the request actually ran with. await requester.request({}, undefined, new AbortController().signal); const gen = Reflect.get(capturedProvider as object, '_generationKwargs') as { @@ -379,10 +372,6 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = requester = ctx.get(IAgentLLMRequesterService); } - // The env-derived request overrides ride on the resolved Model as lazy - // transforms; they materialize into the kimi ChatProvider's - // `_generationKwargs` only when a request runs, so drive one and inspect the - // provider it ran with (the provider compaction requests use the same path). function generationKwargs(): Record<string, unknown> { return Reflect.get(capturedProvider as object, '_generationKwargs') as Record<string, unknown>; } diff --git a/packages/agent-core-v2/test/agent/profile/context.test.ts b/packages/agent-core-v2/test/agent/profile/context.test.ts index e6ff4368b1..4a5d28d608 100644 --- a/packages/agent-core-v2/test/agent/profile/context.test.ts +++ b/packages/agent-core-v2/test/agent/profile/context.test.ts @@ -8,11 +8,6 @@ import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { loadAgentsMd, prepareSystemPromptContext } from '#/agent/profile/context'; -/** - * Build an os-backed `IHostFileSystem`. The v2 profile context loaders take - * `{ fs, homeDir }` and read every AGENTS.md through the fs's `readText` / - * `readdir` / `stat` using absolute paths, so no cwd rooting is needed. - */ function createFs(): IHostFileSystem { return new HostFileSystem(); } @@ -129,7 +124,6 @@ describe('loadAgentsMd nested project hierarchy', () => { extraDirs.push(projectRoot); const leaf = join(projectRoot, 'packages', 'app'); await mkdir(leaf, { recursive: true }); - // Mark the project root so findProjectRoot stops here. await mkdir(join(projectRoot, '.git')); await writeFile(join(projectRoot, 'AGENTS.md'), 'root instructions', 'utf-8'); await writeFile(join(projectRoot, 'packages', 'AGENTS.md'), 'packages instructions', 'utf-8'); diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index 5094482dd0..d35f14eb01 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -198,8 +198,6 @@ describe('AgentProfileService (wire-backed config.update)', () => { const model = modelOf(wire); expect(model.profileName).toBe(DEFAULT_AGENT_PROFILE_NAME); expect(model.systemPrompt).toBe('You are helpful.'); - // Explicit 'on' persists verbatim — normalizing it to a concrete effort - // is the UI boundary's job, not the resolver's. expect(model.thinkingLevel).toBe('on'); expect(svc.getSystemPrompt()).toBe('You are helpful.'); @@ -241,8 +239,6 @@ describe('AgentProfileService (wire-backed config.update)', () => { const records = await readRecords(); - // Fresh host + wire: replay the persisted records. The Model rebuilds but - // neither chdir nor emitStatusUpdated re-fires — replay is silent. const host = buildHost('profile-replay'); let replayChdir = 0; let replayEmits = 0; @@ -272,8 +268,6 @@ describe('AgentProfileService (wire-backed config.update)', () => { svc.update({ thinkingLevel: 'on' }); const records = await readRecords(); - // Fresh host whose config section would resolve differently is irrelevant: - // the persisted resolved value ('on') is restored verbatim. const host = buildHost('profile-replay-thinking'); await host.wire.replay(...records); expect(modelOf(host.wire).thinkingLevel).toBe('on'); diff --git a/packages/agent-core-v2/test/agent/profile/thinking.test.ts b/packages/agent-core-v2/test/agent/profile/thinking.test.ts index 3aa0ef9829..aa7b8a2745 100644 --- a/packages/agent-core-v2/test/agent/profile/thinking.test.ts +++ b/packages/agent-core-v2/test/agent/profile/thinking.test.ts @@ -37,9 +37,7 @@ describe('defaultThinkingEffortForModel', () => { }); it('falls back to the middle supportEfforts entry when defaultEffort is absent', () => { - // odd length -> exact middle expect(defaultThinkingEffortForModel(effortModel)).toBe('medium'); - // even length -> upper-middle index expect( defaultThinkingEffortForModel({ capabilities: ['thinking'], @@ -63,8 +61,6 @@ describe('resolveThinkingEffort', () => { expect(resolveThinkingEffort('low', undefined, effortModel)).toBe('low'); expect(resolveThinkingEffort('on', { enabled: false }, booleanModel)).toBe('on'); expect(resolveThinkingEffort('off', undefined, booleanModel)).toBe('off'); - // 'on' is a valid wire value, not a request for the configured effort — - // normalizing it to a concrete effort is the UI boundary's job (v1 parity). expect(resolveThinkingEffort('on', { effort: 'medium' }, effortModel)).toBe('on'); }); @@ -94,9 +90,6 @@ describe('resolveThinkingEffort', () => { }); it('honors a configured effort when clamping always-thinking models back on', () => { - // enabled=false resolves to 'off', then always_thinking clamps back on; - // an explicitly configured effort is preserved instead of falling back to - // the model default. expect( resolveThinkingEffort( undefined, @@ -104,7 +97,6 @@ describe('resolveThinkingEffort', () => { alwaysThinkingEffortModel, ), ).toBe('max'); - // without an explicit effort, fall back to the model's default effort. expect(resolveThinkingEffort(undefined, { enabled: false }, alwaysThinkingEffortModel)).toBe( 'high', ); diff --git a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts index 9233946062..d546626b07 100644 --- a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts @@ -131,10 +131,6 @@ describe('AgentPromptService', () => { }); it('replaces an unsupported prompt image with a text notice at the history funnel', async () => { - // The format gate is the last funnel before prompt content lands in the - // session history: an AVIF data-URL image (accepted by no provider) - // must never be appended as an image_url — one poisoned part makes every - // later request in the session fail. const { prompt, context, loop } = harness(); const avifUrl = `data:image/avif;base64,${Buffer.from([1, 2, 3]).toString('base64')}`; const handle = await prompt.enqueue({ diff --git a/packages/agent-core-v2/test/agent/questionTools/tools/ask-user.test.ts b/packages/agent-core-v2/test/agent/questionTools/tools/ask-user.test.ts index 370c27b19b..89ad3ab3d9 100644 --- a/packages/agent-core-v2/test/agent/questionTools/tools/ask-user.test.ts +++ b/packages/agent-core-v2/test/agent/questionTools/tools/ask-user.test.ts @@ -446,7 +446,6 @@ describe('AskUserQuestionTool', () => { expect(registerTask).toHaveBeenCalledOnce(); expect(registerTask.mock.calls[0]![1]).toMatchObject({ detached: true }); expect(getTask).toHaveBeenCalledWith('q_test_task_id'); - // Non-blocking: the question service is not awaited inside the tool call. expect(request).not.toHaveBeenCalled(); }); diff --git a/packages/agent-core-v2/test/agent/shellCommand/shellCommand.test.ts b/packages/agent-core-v2/test/agent/shellCommand/shellCommand.test.ts index 08550157cb..71701675f1 100644 --- a/packages/agent-core-v2/test/agent/shellCommand/shellCommand.test.ts +++ b/packages/agent-core-v2/test/agent/shellCommand/shellCommand.test.ts @@ -50,7 +50,6 @@ describe('AgentShellCommandService', () => { ]); expect(textOf(context.get()[0]!)).toBe('<bash-input>\necho hello\n</bash-input>'); expect(textOf(context.get()[1]!)).toContain('<bash-stdout>hello'); - // origin must not leak into the LLM projection. expect(ctx.project().some((message) => 'origin' in message)).toBe(false); }); @@ -60,9 +59,7 @@ describe('AgentShellCommandService', () => { await shell.run({ command: 'printf x' }); const out = textOf(context.get().at(-1)!); - // The embedded delimiter is escaped so the wrapper stays well-formed. expect(out).toContain('pre</bash-stdout>post'); - // Exactly one real closing tag. expect(out.match(/<\/bash-stdout>/g)).toHaveLength(1); }); diff --git a/packages/agent-core-v2/test/agent/skill/prompt.test.ts b/packages/agent-core-v2/test/agent/skill/prompt.test.ts index 465ffa38b3..e263d70af2 100644 --- a/packages/agent-core-v2/test/agent/skill/prompt.test.ts +++ b/packages/agent-core-v2/test/agent/skill/prompt.test.ts @@ -5,12 +5,6 @@ import { renderUserSlashSkillPrompt, } from '#/agent/skill/prompt'; -/** - * Regression coverage for the skill directory being surfaced on the - * `<kimi-skill-loaded>` block. Without `dir`, an agent that loads a skill - * cannot locate the skill's bundled resources (scripts, templates) by - * relative path — the bug this guards against. - */ describe('renderSkillLoadedBlock skill directory', () => { const base = { skillName: 'review', @@ -48,7 +42,6 @@ describe('renderSkillLoadedBlock skill directory', () => { const { skillDir: _omit, ...withoutDir } = base; const text = renderUserSlashSkillPrompt(withoutDir); expect(text).not.toContain('dir='); - // Other attributes still render so the block is well-formed. expect(text).toContain('name="review"'); expect(text).toContain('source="user"'); }); diff --git a/packages/agent-core-v2/test/agent/skill/skill.test.ts b/packages/agent-core-v2/test/agent/skill/skill.test.ts index dac31a9558..18742ecd97 100644 --- a/packages/agent-core-v2/test/agent/skill/skill.test.ts +++ b/packages/agent-core-v2/test/agent/skill/skill.test.ts @@ -295,7 +295,6 @@ describe('SkillTool', () => { output: 'Skill "commit" loaded inline. Follow its instructions.', }); expect(result.output).not.toContain('# Commit'); - // The tool only declares a `delivery`; the agent (L4) layer performs the steer. expect(prompted).toHaveLength(0); expect(result.delivery?.kind).toBe('steer'); expect(result.delivery?.message.origin).toMatchObject({ diff --git a/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts b/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts index 2a7706c84f..5fa0c6a204 100644 --- a/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts +++ b/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts @@ -13,13 +13,6 @@ import { ContinuationStepRequest } from '#/agent/loop/stepRequest'; import { createTestAgent, llmGenerateServices, type TestAgentContext } from '../../harness'; -/** - * The `stepRetry` plugin drives loop-level retries of retryable provider - * failures: it claims the error from the loop's handler registry, backs off, - * and re-runs the failed driver as the same step. Backoff sleeps use - * `setTimeout`, so the suite runs on fake timers and flushes them between the - * loop's `run()` promise and its resolution. - */ describe('stepRetry plugin', () => { let ctx: TestAgentContext; @@ -87,7 +80,6 @@ describe('stepRetry plugin', () => { expect( rpcEvents('turn.step.started').map((event) => (event.args as { step: number }).step), ).toEqual([1, 2]); - // A recovered error never surfaces as an interruption. expect(rpcEvents('turn.step.interrupted')).toEqual([]); expect(ctx.contextData().history).toEqual([ expect.objectContaining({ diff --git a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts index 8d5a8637ee..d4476de534 100644 --- a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts @@ -106,8 +106,6 @@ describe('AgentSwarmService', () => { expect(events).toEqual([ { type: 'agent.status.updated', swarmMode: true }, { type: 'agent.status.updated', swarmMode: false }, - // Exit pops the swarm-mode enter reminder via the ContextModel - // cross-reducer; the service mirrors the pop as a live context.spliced. { type: 'context.spliced', start: 0, deleteCount: 1, messages: [] }, ]); }); @@ -296,8 +294,6 @@ describe('AgentSwarmTool', () => { it('description states the enforced input requirements', () => { const host = mockSwarmHost(); const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode()); - // Mirrors the throws in createAgentSwarmSpecs (agent-swarm.ts): min-2-unless-resume, - // prompt_template required + must contain {{item}}, distinct resulting prompts. expect(tool.description).toContain('at least 2'); expect(tool.description).toContain('{{item}}'); expect(tool.description.toLowerCase()).toContain('distinct'); diff --git a/packages/agent-core-v2/test/agent/task/foreground-persistence.test.ts b/packages/agent-core-v2/test/agent/task/foreground-persistence.test.ts index a777041c24..f0b5e6d137 100644 --- a/packages/agent-core-v2/test/agent/task/foreground-persistence.test.ts +++ b/packages/agent-core-v2/test/agent/task/foreground-persistence.test.ts @@ -46,7 +46,6 @@ function immediateProcess(exitCode: number, stdoutText = ''): IProcess { }; } -/** A process whose stdout and exit are driven by the test, for timing control. */ function controllableProcess(): { proc: IProcess; pushStdout: (text: string) => void; @@ -89,15 +88,6 @@ function registerForeground( }); } -/** - * Detached tasks that reached a terminal state enqueue a notification onto - * the loop, which auto-launches its own turn when idle (`activeOrNewTurn`). - * Resume-compare requires the notification materialized in the live context - * (the replayed side re-derives it from the persisted record) and the loop - * settled, so queue one response in case the turn's LLM request has not - * fired yet and wait for the notification turn to drain before - * `expectResumeMatches`. - */ async function drainPendingNotifications( ctx: TestAgentContext, background: IAgentTaskService, @@ -157,7 +147,6 @@ describe('AgentTaskService — foreground persistence', () => { expect(existsSync(taskJsonPath(taskId))).toBe(false); expect(existsSync(persistence.taskOutputFile(taskId))).toBe(false); - // Output is still readable from the in-memory ring buffer. const snapshot = await background.getOutputSnapshot(taskId, 1_000); expect(snapshot.fullOutputAvailable).toBe(false); expect(snapshot.preview).toContain('hello'); @@ -168,7 +157,7 @@ describe('AgentTaskService — foreground persistence', () => { const taskId = registerForeground(background, proc, 'stream', 'demo'); pushStdout('before-detach\n'); - await tick(); // buffered in memory, not yet on disk + await tick(); expect(existsSync(persistence.taskOutputFile(taskId))).toBe(false); expect(background.detach(taskId)?.detached).toBe(true); @@ -178,7 +167,6 @@ describe('AgentTaskService — foreground persistence', () => { finish(0); await background.wait(taskId); - // output.log is the complete, in-order record across the detach boundary. expect(await background.readOutput(taskId)).toBe('before-detach\nafter-detach\n'); expect(existsSync(taskJsonPath(taskId))).toBe(true); }); @@ -189,10 +177,8 @@ describe('AgentTaskService — foreground persistence', () => { await background.wait(taskId); - // getOutputSnapshot drains the output write queue before reporting size. const snapshot = await background.getOutputSnapshot(taskId, 1_000); - // Spilled artifacts are persisted complete and NOT deleted on completion. expect(existsSync(persistence.taskOutputFile(taskId))).toBe(true); expect(existsSync(taskJsonPath(taskId))).toBe(true); expect(snapshot.fullOutputAvailable).toBe(true); diff --git a/packages/agent-core-v2/test/agent/task/idle-notification-repro.test.ts b/packages/agent-core-v2/test/agent/task/idle-notification-repro.test.ts index 5daf94b3ba..4100d14dd8 100644 --- a/packages/agent-core-v2/test/agent/task/idle-notification-repro.test.ts +++ b/packages/agent-core-v2/test/agent/task/idle-notification-repro.test.ts @@ -50,7 +50,6 @@ function agentTask( ); } -/** `task.notified` fires once per enqueued notification (after the enqueue). */ function notifiedCount(ctx: TestAgentContext): number { return ctx.allEvents.filter((e) => e.event === 'task.notified').length; } @@ -82,8 +81,6 @@ describe('task notification → main agent (real Agent instance)', () => { expect(loop.status().activeTurnId).toBeUndefined(); expect(ctx.llmCalls.length).toBe(0); - // `activeOrNewTurn` admission: with no active turn the notification - // launches a fresh turn on its own — no user prompt needed. ctx.mockNextResponse({ type: 'text', text: 'ack from main agent' }); const turnEnd = ctx.untilTurnEnd(); const taskId = background.registerTask(agentTask( @@ -112,11 +109,6 @@ describe('task notification → main agent (real Agent instance)', () => { }); it('BUSY: completed bg agent during an active turn is flushed into an LLM call', async () => { - // The notification is enqueued (mergeable, agent-scoped) while the - // user-prompted turn runs. Depending on delivery timing it either - // folds into that turn's next step or launches its own follow-up turn - // once the first one ends — in every case it must reach an LLM call. - // Three scripted responses cover both branches plus the drain prompt. ctx.mockNextResponse({ type: 'text', text: 'first turn ack' }); ctx.mockNextResponse({ type: 'text', text: 'notification ack' }); ctx.mockNextResponse({ type: 'text', text: 'drain turn ack' }); @@ -125,8 +117,6 @@ describe('task notification → main agent (real Agent instance)', () => { input: [{ type: 'text', text: 'kick off a turn' }], }); - // Right after kicking off, register a background task that completes - // immediately, so the notification is enqueued mid-turn. const taskId = background.registerTask(agentTask( Promise.resolve({ result: 'busy-state bg result' }), 'busy-state repro', @@ -141,8 +131,6 @@ describe('task notification → main agent (real Agent instance)', () => { { timeout: 2000 }, ); - // Drain whatever the first turn left queued, then assert the - // notification reached an LLM call whichever branch delivered it. await ctx.rpc.prompt({ input: [{ type: 'text', text: 'drain the queue' }], }); @@ -154,7 +142,6 @@ describe('task notification → main agent (real Agent instance)', () => { }); expect(delivered).toBe(true); - // …and it must be materialized in the agent's context history. const data = ctx.contextData(); const flatContext = JSON.stringify(data); expect(flatContext).toContain('<notification'); @@ -166,10 +153,6 @@ describe('task notification → main agent (real Agent instance)', () => { }); it('IDLE × N: a GROUP of bg agents completes — the first notification launches one turn, the rest fold in', async () => { - // The first idle delivery launches a turn; later notifications fold - // into it as mergeable requests (or launch a follow-up if they land - // after it ends). Three scripted responses cover the worst case of - // one LLM call per notification. ctx.mockNextResponse({ type: 'text', text: 'ack group 1' }); ctx.mockNextResponse({ type: 'text', text: 'ack group 2' }); ctx.mockNextResponse({ type: 'text', text: 'ack group 3' }); @@ -208,8 +191,6 @@ describe('task notification → main agent (real Agent instance)', () => { { timeout: 2000 }, ); - // Every notification reached an LLM call — either merged into the - // auto-launched turn's first batch or carried by a follow-up step. const flatHistoryText = JSON.stringify(ctx.llmCalls.map((call) => call.history)); for (const id of taskIds) { expect(flatHistoryText).toContain(id); @@ -224,7 +205,6 @@ describe('task notification → main agent (real Agent instance)', () => { }); it('RACE: bg completion right after turn end launches its own turn', async () => { - // 1st turn: prompted by user — produces text and ends. ctx.mockNextResponse({ type: 'text', text: 'first user-prompted ack' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello main agent' }], @@ -232,8 +212,6 @@ describe('task notification → main agent (real Agent instance)', () => { await ctx.untilTurnEnd(); expect(ctx.llmCalls.length).toBe(1); - // Fire the bg completion while the agent is idle: `activeOrNewTurn` - // admission launches a fresh turn for the notification. ctx.mockNextResponse({ type: 'text', text: 'ack from bg notification' }); const turnEnd = ctx.untilTurnEnd(); const taskId = background.registerTask(agentTask( @@ -268,8 +246,6 @@ describe('task notification → main agent (real Agent instance)', () => { beforeEach(async () => { sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-resume-repro-')); - // Simulate a previous session's bash bg task that completed - // before exit and an agent bg task that didn't (will be lost). const backgroundPersistence = createAgentTaskPersistence(sessionDir); await backgroundPersistence.writeTask({ taskId: 'bash-prev0000', @@ -310,48 +286,24 @@ describe('task notification → main agent (real Agent instance)', () => { }); it('RESUME: terminal bg tasks discovered on reconcile are SILENTLY injected (no auto-turn)', async () => { - // Scenario the user described: kimi exits while bg tasks are - // running; on next start, resume() loads them from disk and - // reconcile() classifies them as terminal (lost for in-process - // agent tasks; possibly completed for bash tasks if the process - // wrote a terminal state). The restore path appends the - // notifications to context directly, NOT via the loop queue, so: - // - Notification XML lands in context history ✓ - // - No new turn is launched ✗ - // - User sees nothing happen until they type - // - // This test pins that current behavior so any change shows up. - - // We do NOT mock any LLM response. If the resume path - // mistakenly launches a turn, scripted-generate throws - // "Unexpected generate call" and the test fails loudly. + const launchSpy = vi.spyOn(loop as unknown as { startTurn: () => unknown }, 'startTurn'); - // Reproduce Agent.resume()'s post-replay sequence. await background.loadFromDisk(); await background.reconcile(); - // The agent-* running task should now be lost. expect(background.getTask('agent-prev0000')?.status).toBe('lost'); - // Give the silent append a beat. await vi.waitFor(() => { const flatContext = JSON.stringify(ctx.contextData()); expect(flatContext).toContain('bash-prev0000'); expect(flatContext).toContain('agent-prev0000'); }); - // Hard assertion: no turn was launched for either restored task. - // The notifications were silently appended (never enqueued onto the - // loop), so no new turn ran. expect(launchSpy).not.toHaveBeenCalled(); expect(ctx.llmCalls.length).toBe(0); expect(loop.status().activeTurnId).toBeUndefined(); - // Both notifications are in context, waiting for the user. The - // completed bash task references its persisted output file rather - // than inlining the content (parity with the restored-notification - // behavior pinned in background/rpc-events.test.ts). const flatContext = JSON.stringify(ctx.contextData()); expect(flatContext).toContain('<output-file'); expect(flatContext).not.toContain('previous bash output'); diff --git a/packages/agent-core-v2/test/agent/task/output-access.test.ts b/packages/agent-core-v2/test/agent/task/output-access.test.ts index 990ed5254b..fd13ca62de 100644 --- a/packages/agent-core-v2/test/agent/task/output-access.test.ts +++ b/packages/agent-core-v2/test/agent/task/output-access.test.ts @@ -81,10 +81,6 @@ async function waitForTaskNotifications( ); if (tasks.length === 0) return; - // Live notifications auto-launch their own turn when the loop is idle - // (`activeOrNewTurn` admission) and materialize when that turn pops them. - // Queue one response in case the turn's LLM request has not fired yet, - // then wait for every enqueue and for the notification turns to drain. ctx.mockNextResponse({ type: 'text', text: 'notification drain ack' }); await vi.waitFor(() => { const delivered = ctx.allEvents.filter((e) => e.event === 'task.notified').length; diff --git a/packages/agent-core-v2/test/agent/task/persist.test.ts b/packages/agent-core-v2/test/agent/task/persist.test.ts index 17ecbdff57..8054fe6876 100644 --- a/packages/agent-core-v2/test/agent/task/persist.test.ts +++ b/packages/agent-core-v2/test/agent/task/persist.test.ts @@ -57,10 +57,6 @@ beforeEach(async () => { ); await mkdir(sessionDir, { recursive: true }); - // `AgentTaskPersistence` is a plain (non-DI) helper constructed by - // `AgentTaskService`, so the test builds it directly. Its `docs` - // collaborator (`IAtomicDocumentStore`) carries an `@IService` dependency, so - // it is resolved by interface through the container rather than `new`ed. disposables = new DisposableStore(); const ix = disposables.add(new TestInstantiationService()); const fs = new FileStorageService(sessionDir, 0o700); diff --git a/packages/agent-core-v2/test/agent/task/rpc-events.test.ts b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts index 08110e7bee..ac6f0f1586 100644 --- a/packages/agent-core-v2/test/agent/task/rpc-events.test.ts +++ b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts @@ -254,17 +254,10 @@ function firstAppendedContextMessage(agent: FakeTaskAgent): TestContextMessage { return message; } -/** `task.notified` fires once per enqueued notification (after the enqueue). */ function notifiedCount(ctx: TestAgentContext): number { return ctx.allEvents.filter((e) => e.event === 'task.notified').length; } -/** - * Live terminal notifications auto-launch their own turn when the loop is - * idle (`activeOrNewTurn` admission) and materialize into context when that - * turn pops them. Queue one response in case the turn's LLM request has not - * fired yet, then wait for every notification turn to drain. - */ async function drainNotifications(ctx: TestAgentContext): Promise<void> { ctx.mockNextResponse({ type: 'text', text: 'notification drain ack' }); await vi.waitFor(() => { @@ -274,7 +267,6 @@ async function drainNotifications(ctx: TestAgentContext): Promise<void> { }); } -/** The notification message materialized into context for `taskId` (post-drain). */ function notificationMessageFor(agent: FakeTaskAgent, taskId: string): TestContextMessage { for (const call of agent.context.appendUserMessage.mock.calls as unknown as TestContextMessage[][]) { for (const message of call) { @@ -405,9 +397,6 @@ describe('AgentTaskService — event emission', () => { await manager.stop(taskId, 'user'); - // The terminal notification auto-launches its own turn (`activeOrNewTurn`), - // which publishes turn / context events in the same window; the lifecycle - // assertion is about `task.terminated` alone. expect(agent.emittedEvents.filter((e) => e.type === 'task.terminated')).toEqual([ { type: 'task.terminated', @@ -467,8 +456,6 @@ describe('AgentTaskService — notification delivery', () => { await manager.wait(taskId); - // Idle completion launches a fresh turn (`activeOrNewTurn`) — the - // notification materializes when that turn pops it, no prompt needed. await vi.waitFor(() => { expect(notifiedCount(ctx)).toBe(1); }); @@ -817,8 +804,6 @@ describe('AgentTaskService — notification delivery', () => { expect(fireAndForgetTrigger).toHaveBeenCalled(); }); - // Delivery itself completed despite the hook failure: the notification - // materializes through its auto-launched turn. await drainNotifications(ctx); expect(notificationMessageFor(agent, taskId).content[0]!.text).toContain( 'inspect repository completed.', diff --git a/packages/agent-core-v2/test/agent/task/subagent-timeout.test.ts b/packages/agent-core-v2/test/agent/task/subagent-timeout.test.ts index 3c012a7f36..19f7b01424 100644 --- a/packages/agent-core-v2/test/agent/task/subagent-timeout.test.ts +++ b/packages/agent-core-v2/test/agent/task/subagent-timeout.test.ts @@ -46,13 +46,11 @@ describe('SubagentTask — timeoutMs', () => { it('external deadline marks task timed_out', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - // A never-resolving completion — only the deadline will fire. const hangForever = new Promise<{ result: string }>(() => {}); const taskId = background.registerTask(agentTask(hangForever, 'hang'), { timeoutMs: 2_000, }); - // Advance past the deadline and manager-owned stop grace. const terminalPromise = background.wait(taskId); await vi.advanceTimersByTimeAsync(7_100); const info = await terminalPromise; @@ -75,9 +73,6 @@ describe('SubagentTask — timeoutMs', () => { }); it('internal TimeoutError rejection = generic failure with error reason', async () => { - // Even with a deadline set, an internal TimeoutError that fires - // BEFORE the deadline must land as a plain `failed` (not as a - // deadline-driven timeout). const internalErr = new Error('aiohttp sock_read timeout'); internalErr.name = 'TimeoutError'; const rejecting = Promise.reject(internalErr); @@ -87,20 +82,9 @@ describe('SubagentTask — timeoutMs', () => { const info = await background.wait(taskId); expect(info?.status).toBe('failed'); - // Deadline never fired: this is a normal task failure, so the original - // error is preserved as the stop reason rather than being reported as a - // caller-driven timeout. expect(info?.stopReason).toBe('aiohttp sock_read timeout'); }); - // Explicit per-task timeoutMs must be surfaced on the task info so - // downstream wait-cap consumers can honour the agent-supplied value - // instead of falling back to a hard-coded default. (gap #6 family.) - // - // Uses fake timers so the deadline armed by registerTask - // does not leak across the test boundary into the Vitest worker — - // the `completion` promise here never resolves, so the lifecycle - // promise's `.finally(clearTimeout)` would not run under real time. it('explicit timeoutMs is persisted on the task info', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); let resolveFn!: (r: { result: string }) => void; @@ -117,18 +101,6 @@ describe('SubagentTask — timeoutMs', () => { await expect(background.wait(taskId)).resolves.toMatchObject({ status: 'completed' }); }); - // Decision (confirmed with team, 2026-05-19): background tasks in - // kimi-code do NOT carry an implicit default timeout. The Python - // kimi-cli enforced a 30-min default because its agents were - // expected to be short-lived; kimi-code's agents may legitimately - // run a dev server, a long compile, or a watch loop, and an - // auto-kill would be a footgun. The shutdown wait-cap that reads - // timeoutMs falls back to its own policy when the field is - // undefined; the BPM does not invent a default. - // - // This test is kept (rather than deleted) to act as a regression - // guard: if someone later adds a hard-coded default in - // registerTask, the assertion below catches it. it('omitted timeoutMs leaves the task info field undefined', async () => { let resolveFn!: (r: { result: string }) => void; const completion = new Promise<{ result: string }>((res) => { @@ -141,13 +113,6 @@ describe('SubagentTask — timeoutMs', () => { await expect(background.wait(taskId)).resolves.toMatchObject({ status: 'completed' }); }); - // Contract decision (2026-05-21): kimi-code treats `timeoutMs: 0` - // as "record the value but do NOT arm a deadline" rather than - // Python's "fire immediately" semantics. The field is preserved on - // the task info so shutdown wait-caps / UI can read it; the - // deadline-arming check (`timeoutMs > 0`) deliberately skips - // zero so a caller writing `0` does not lose its task to an - // immediate kill. it('timeoutMs=0 is preserved on the task info and does not arm a deadline', async () => { let resolveFn!: (r: { result: string }) => void; const completion = new Promise<{ result: string }>((res) => { @@ -156,14 +121,9 @@ describe('SubagentTask — timeoutMs', () => { const taskId = background.registerTask(agentTask(completion, 'zero timeout'), { timeoutMs: 0, }); - // The literal zero is preserved on the task info. const initial = background.getTask(taskId); expect((initial as unknown as { timeoutMs?: number }).timeoutMs).toBe(0); - // No deadline armed: the task stays running. We bound the wait - // with a short race so the test does not hang on the never- - // settling completion promise; the racing branch winning is the - // expected outcome. const info = await background.wait(taskId, 5); const raced = info === undefined ? undefined : { status: info.status, diff --git a/packages/agent-core-v2/test/agent/task/taskManager.test.ts b/packages/agent-core-v2/test/agent/task/taskManager.test.ts index 7ec4c5203c..61e727e805 100644 --- a/packages/agent-core-v2/test/agent/task/taskManager.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskManager.test.ts @@ -142,7 +142,6 @@ async function waitForOutput( throw new Error(`Timed out waiting for output: ${expected}`); } -// ---- test helpers ---- function immediateProcess(exitCode: number, stdoutText = ''): IProcess { return { @@ -938,12 +937,6 @@ describe('AgentTaskService', () => { expect(killSpy).not.toHaveBeenCalledWith('SIGKILL'); }); - /** - * Build a process that only reaps on SIGKILL and whose stdout never ends on - * its own, so the task lifecycle cannot settle before the manager's deadline - * and grace window drive teardown. Exercises the v1-aligned timeout path: - * deadline -> SIGTERM -> SIGTERM_GRACE_MS -> forceStop (SIGKILL). - */ function sigtermOnlyKillProcess(pid: number): { proc: IProcess; killSpy: ReturnType<typeof vi.fn>; @@ -985,11 +978,11 @@ describe('AgentTaskService', () => { }); const terminal = manager.wait(taskId); - await vi.advanceTimersByTimeAsync(1); // deadline -> abort -> SIGTERM (ignored) + await vi.advanceTimersByTimeAsync(1); expect(killSpy).toHaveBeenCalledWith('SIGTERM'); expect(killSpy).not.toHaveBeenCalledWith('SIGKILL'); - await vi.advanceTimersByTimeAsync(5_000); // grace elapses -> forceStop SIGKILL + await vi.advanceTimersByTimeAsync(5_000); const info = await terminal; expect(info?.status).toBe('timed_out'); @@ -999,13 +992,13 @@ describe('AgentTaskService', () => { it('reports timed_out when a timed-out process exits to SIGTERM within the grace window', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); const { manager } = createAgentTaskService(); - const { proc, killSpy } = pendingProcess(); // SIGTERM reaps with 143 + const { proc, killSpy } = pendingProcess(); const taskId = manager.registerTask(new ProcessTask(proc, 'sleep 60', 'timeout graceful'), { timeoutMs: 1, }); const terminal = manager.wait(taskId); - await vi.advanceTimersByTimeAsync(1); // deadline -> SIGTERM reaps within grace + await vi.advanceTimersByTimeAsync(1); const info = await terminal; expect(info?.status).toBe('timed_out'); @@ -1024,8 +1017,8 @@ describe('AgentTaskService', () => { manager.detach(taskId); const terminal = manager.wait(taskId); - await vi.advanceTimersByTimeAsync(1); // detach deadline -> SIGTERM (ignored) - await vi.advanceTimersByTimeAsync(5_000); // grace -> SIGKILL + await vi.advanceTimersByTimeAsync(1); + await vi.advanceTimersByTimeAsync(5_000); const info = await terminal; expect(info?.status).toBe('timed_out'); @@ -1045,14 +1038,11 @@ describe('AgentTaskService', () => { }); const waiting = manager.waitForForegroundRelease(taskId); - // The 1s foreground deadline detaches the task instead of killing it. await vi.advanceTimersByTimeAsync(1_000); await expect(waiting).resolves.toBe('timeout_detached'); expect(killSpy).not.toHaveBeenCalled(); expect(manager.getTask(taskId)).toMatchObject({ status: 'running', detached: true }); - // The task keeps running past the original deadline; the re-armed 5s - // detach deadline still applies (1000 + 5000 = 6000ms). await vi.advanceTimersByTimeAsync(1_000); expect(manager.getTask(taskId)?.status).toBe('running'); await vi.advanceTimersByTimeAsync(4_000); diff --git a/packages/agent-core-v2/test/agent/task/taskOps.test.ts b/packages/agent-core-v2/test/agent/task/taskOps.test.ts index 2fe20224a4..375077a3f1 100644 --- a/packages/agent-core-v2/test/agent/task/taskOps.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskOps.test.ts @@ -67,16 +67,12 @@ describe('task ops (wire-backed)', () => { wire.dispatch(taskStarted({ info: info('t1', 'running') })); expect(wire.getModel(TaskModel).get('t1')?.status).toBe('running'); - // A later terminated overwrites the earlier started for the same id. wire.dispatch(taskTerminated({ info: info('t1', 'completed') })); expect(wire.getModel(TaskModel).get('t1')?.status).toBe('completed'); wire.dispatch(taskStarted({ info: info('t2', 'running') })); expect(wire.getModel(TaskModel).size).toBe(2); - // `task.started` / `task.terminated` are persist: false — the model folds - // live, but nothing lands on the wire log (tasks restore from their own - // persistence, not the session log). expect(await readRecords()).toEqual([]); }); @@ -89,9 +85,6 @@ describe('task ops (wire-backed)', () => { }); it('replay rebuilds the task map from legacy task.* records silently (no emissions, no subscriber notifications)', async () => { - // Live dispatch no longer persists task.* records; the ops stay registered - // so legacy logs that contain them still replay. Feed hand-written records - // directly. const records: PersistedRecord[] = [ { type: 'task.started', info: info('t1', 'running') }, { type: 'task.terminated', info: info('t1', 'completed') }, diff --git a/packages/agent-core-v2/test/agent/task/taskService.test.ts b/packages/agent-core-v2/test/agent/task/taskService.test.ts index 4772732dea..7c2c374687 100644 --- a/packages/agent-core-v2/test/agent/task/taskService.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskService.test.ts @@ -616,23 +616,10 @@ describe('AgentTaskService', () => { await svc.stop(taskId); }); - // ── Output ceiling for shell (process) tasks ───────────────────────── - // - // A single shell command that streams more output than the per-command - // limit must be force-terminated instead of growing the unbounded - // live-forward buffer or the on-disk `output.log` write chain until the - // process runs out of memory or fills the disk. Foreground and detached - // (background) process tasks are both capped; non-process task results - // (subagent completions, user-question answers) are not. const MiB = 1024 * 1024; const LIMIT_BYTES = 16 * MiB; - /** - * A process that streams `chunks` of stdout, then exits 0 on its own — unless - * it is killed first, in which case `wait()` resolves with the signal's exit - * code and the stream is destroyed (simulating the child dying on SIGTERM). - */ function streamingProcess(chunks: string[]): { proc: IProcess; kill: ReturnType<typeof vi.fn>; @@ -663,11 +650,6 @@ describe('AgentTaskService', () => { return { proc, kill }; } - /** - * A process that keeps streaming all of `chunks` regardless of SIGTERM (only - * SIGKILL stops it) — simulating a producer that ignores the graceful stop - * and keeps writing through the SIGTERM grace window. - */ function sigtermIgnoringProcess(chunks: string[]): { proc: IProcess; kill: ReturnType<typeof vi.fn>; @@ -686,7 +668,6 @@ describe('AgentTaskService', () => { stdout.destroy(); resolveWait(137); } - // SIGTERM is intentionally ignored. }); const proc = { stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, @@ -701,7 +682,6 @@ describe('AgentTaskService', () => { return { proc, kill }; } - /** One-shot non-process task appending its full result at once, like a subagent. */ function agentLikeTask(result: string, description: string): AgentTask { return { idPrefix: 'agent', @@ -737,7 +717,6 @@ describe('AgentTaskService', () => { return svc.getTask(taskId); } - /** Re-stub the byte store so `output.log` appends are counted, then build the service. */ function serviceWithAppendCounter(): { svc: IAgentTaskService; persistedChars: () => number; @@ -760,7 +739,6 @@ describe('AgentTaskService', () => { it('terminates a foreground command that exceeds the output limit and stops forwarding', async () => { const svc = ix.get(IAgentTaskService); - // 20 MiB total, well past the 16 MiB ceiling. const chunks = Array.from({ length: 20 }, () => 'x'.repeat(MiB)); const { proc, kill } = streamingProcess(chunks); @@ -779,8 +757,6 @@ describe('AgentTaskService', () => { expect(info?.status).toBe('killed'); expect(info?.stopReason ?? '').toMatch(/output limit/i); expect(kill).toHaveBeenCalledWith('SIGTERM'); - // The live-forward path is capped at the ceiling rather than draining the - // full 20 MiB into the (unbounded) transcript/stderr buffer. expect(forwardedChars).toBeLessThanOrEqual(LIMIT_BYTES); }); @@ -804,8 +780,6 @@ describe('AgentTaskService', () => { it('stops enqueuing output to disk once the foreground cap trips', async () => { const { svc, persistedChars } = serviceWithAppendCounter(); - // 20 MiB, and the producer ignores SIGTERM so it keeps writing through - // the whole grace window. const chunks = Array.from({ length: 20 }, () => 'x'.repeat(MiB)); const { proc } = sigtermIgnoringProcess(chunks); @@ -818,18 +792,12 @@ describe('AgentTaskService', () => { const info = await waitForTerminal(svc, taskId); expect(info?.status).toBe('killed'); - // Before the fix every chunk of the 20 MiB is enqueued into the disk - // write chain (retaining each string until its write drains); afterwards - // enqueuing stops at the ceiling so the chain cannot grow unbounded. expect(persistedChars()).toBeLessThanOrEqual(17 * MiB); }); it('stops appending persisted output once the output limit trips for a detached process task', async () => { const { svc, persistedChars } = serviceWithAppendCounter(); - // 20 MiB, and the producer ignores SIGTERM so it keeps writing through - // the whole grace window. The detached task is still capped: once the - // ceiling trips the disk write chain stops growing. const chunks = Array.from({ length: 20 }, () => 'x'.repeat(MiB)); const { proc } = sigtermIgnoringProcess(chunks); @@ -848,8 +816,6 @@ describe('AgentTaskService', () => { it('does not cap or drop a detached subagent result larger than the limit', async () => { const { svc, persistedChars } = serviceWithAppendCounter(); - // 20 MiB result — well past the 16 MiB ceiling — delivered in one shot, - // exactly how a subagent appends its completed result. const bigResult = 'y'.repeat(20 * MiB); const taskId = svc.registerTask(agentLikeTask(bigResult, 'big subagent result'), { detached: true, @@ -858,8 +824,6 @@ describe('AgentTaskService', () => { const info = await waitForTerminal(svc, taskId); - // Non-process tasks must complete normally and have their full result - // persisted; the shell-output ceiling must not drop it. expect(info?.status).toBe('completed'); expect(persistedChars()).toBeGreaterThanOrEqual(bigResult.length); }); diff --git a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts index 7b4e3f56e5..a6bc7dab28 100644 --- a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts +++ b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts @@ -526,8 +526,6 @@ describe('TaskOutputTool', () => { expect(result.isError ?? false).toBe(false); expect(output).toContain('retrieval_status: timeout'); expect(output).toContain('status: running'); - // A blocking wait that timed out must steer the caller away from blocking - // again — the completion notification arrives on its own. expect(output).toContain('next_step:'); expect(output).toContain('Do not block on it again'); expect(tasks.waitCalls).toEqual([{ taskId, timeoutMs: 1_000 }]); diff --git a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts index 7fde5e3040..ed7c394b19 100644 --- a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts +++ b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts @@ -53,22 +53,12 @@ interface Harness { readonly registry: IAgentToolRegistryService; } -/** - * Builds a container wired the same way the agent is: real executor + registry, - * the dedupe plugin registered (and realized so its constructor installs the - * loop / tool-executor hooks), recording telemetry, and a stub loop with real - * hook slots. `ix.get(IAgentToolDedupeService)` is what forces the eager plugin - * to construct and register its hooks. - */ function createHarness(telemetry: ITelemetryService = recordingTelemetry(telemetryEvents)): Harness { const loop = stubLoopWithHooks(); const ix = createServices(disposables, { additionalServices: (reg) => { reg.defineInstance(ITelemetryService, telemetry); reg.defineInstance(IEventBus, noopEventBus); - // Seeds the real executor needs to derive its per-agent homedir (used by - // the tool-result budgeter). Dedupe outputs are small, so the budgeter - // never writes to disk; a fixed path is sufficient. const homedir = '/tmp/tool-dedupe-homedir'; reg.defineInstance(ISessionContext, { _serviceBrand: undefined, @@ -205,7 +195,6 @@ function dummyExecution(): ToolBeforeExecuteContext['execution'] { return { approvalRule: 'x', execute: async () => ({ output: '' }) }; } -/** Minimal `onBeforeExecuteTool` context — the dedupe handler reads only id/name/args. */ function willCtx( id: string, name: string, @@ -224,7 +213,6 @@ function willCtx( }; } -/** Minimal `onDidExecuteTool` context — the dedupe handler reads only id/name/args/result. */ function didCtx( id: string, name: string, @@ -252,20 +240,16 @@ describe('AgentToolDedupeService', () => { const w1 = willCtx('c1', 'Read', { path: '/a' }); await h.executor.hooks.onBeforeExecuteTool.run(w1); - // First occurrence is the original — no synthetic decision. expect(w1.decision).toBeUndefined(); const w2 = willCtx('c2', 'Read', { path: '/a' }); await h.executor.hooks.onBeforeExecuteTool.run(w2); - // Same-step dup gets a synthetic placeholder (non-error, empty string). expect(w2.decision?.syntheticResult).toEqual({ output: '' }); const d1 = didCtx('c1', 'Read', { path: '/a' }, okResult('FILE_A')); await h.executor.hooks.onDidExecuteTool.run(d1); expect(d1.result).toEqual(okResult('FILE_A')); - // Finalize the dup with the placeholder it was handed — it resolves to the - // original's real result. const d2 = didCtx('c2', 'Read', { path: '/a' }, w2.decision!.syntheticResult!); await h.executor.hooks.onDidExecuteTool.run(d2); expect(d2.result).toEqual(okResult('FILE_A')); @@ -289,9 +273,6 @@ describe('AgentToolDedupeService', () => { }); it('finalizes original before dup (provider order)', async () => { - // The loop guarantees finalize runs in provider order, so by the time a - // dup's finalize runs, the original's deferred is already resolved and - // both calls surface the original's real result. const h = createHarness(); const tool = new EchoTool('Echo'); h.registry.register(tool); @@ -408,13 +389,10 @@ describe('AgentToolDedupeService', () => { it('resets streak when a different call is interleaved', async () => { const h = createHarness(); registerRead(h); - // 2× Read({p:1}) — should NOT trigger yet for (let i = 0; i < 2; i += 1) { await runStep(h, 1, i + 1, [toolCall(`a${String(i)}`, 'Read', { p: 1 })]); } - // 1× Read({p:2}) interrupts the streak await runStep(h, 1, 3, [toolCall('b1', 'Read', { p: 2 })]); - // Back to Read({p:1}); streak restarts → 1 occurrence, no reminder const [last] = await runStep(h, 1, 4, [toolCall('c1', 'Read', { p: 1 })]); expect(last!.result.output as string).not.toContain('<system-reminder>'); }); @@ -422,19 +400,15 @@ describe('AgentToolDedupeService', () => { it('same-step dups inherit reminder1 when streak triggers on original', async () => { const h = createHarness(); const tool = registerRead(h); - // Build streak up to 2 across previous steps. for (let i = 0; i < 2; i += 1) { await runStep(h, 1, i + 1, [toolCall(`p${String(i)}`, 'Read', { p: 1 })]); } - // Next step: same call appears twice. First is the original (triggers reminder1 at streak=3), - // second is a same-step dup that should inherit it without re-executing the tool. const callsBefore = tool.calls.length; const results = await runStep(h, 1, 3, [ toolCall('orig', 'Read', { p: 1 }), toolCall('dup', 'Read', { p: 1 }), ]); - // Only the original executed in this step; the dup was short-circuited. expect(tool.calls.length).toBe(callsBefore + 1); const byId = new Map(results.map((result) => [result.toolCallId, result.result])); expect(byId.get('orig')!.output as string).toContain('<system-reminder>'); @@ -446,9 +420,6 @@ describe('AgentToolDedupeService', () => { it('same-step spam alone does not trigger reminder', async () => { const h = createHarness(); registerRead(h); - // 8 occurrences of the same call within a single step, but no prior - // streak — the trigger is about sustained behaviour across steps, not - // intra-step spam. Same-step dedupe already short-circuits execution. const calls = Array.from({ length: 8 }, (_, i) => toolCall(i === 0 ? 'orig' : `dup${String(i)}`, 'Read', { p: 1 }), ); @@ -463,13 +434,10 @@ describe('AgentToolDedupeService', () => { const h = createHarness(); const tool = new EchoTool('X', () => ({ output: [{ type: 'text', text: 'hello' }] })); h.registry.register(tool); - // Build streak up to 2 prior steps then this one (streak=3). for (let i = 0; i < 2; i += 1) { await runStep(h, 1, i + 1, [toolCall(`p${String(i)}`, 'X', {})]); } const [final] = await runStep(h, 1, 3, [toolCall('final', 'X', {})]); - // The executor normalizes a text-only ContentPart[] into a joined string, - // so the appended reminder shows up as the concatenated text. expect(final!.result.output).toBe('hello' + REMINDER_TEXT_1); }); @@ -477,12 +445,10 @@ describe('AgentToolDedupeService', () => { const h = createHarness(); const tool = new EchoTool('X', () => ({ output: [{ type: 'text', text: 'hello' }] })); h.registry.register(tool); - // Build streak up to 4 prior steps then this one (streak=5). for (let i = 0; i < 4; i += 1) { await runStep(h, 1, i + 1, [toolCall(`p${String(i)}`, 'X', { a: 1 })]); } const [final] = await runStep(h, 1, 5, [toolCall('final', 'X', { a: 1 })]); - // Text-only array is normalized to a joined string by the executor. expect(final!.result.output).toBe('hello' + makeReminderText2(5)); }); @@ -492,16 +458,11 @@ describe('AgentToolDedupeService', () => { output: [{ type: 'image_url', imageUrl: { url: 'data:foo' } }], })); h.registry.register(tool); - // Build streak to 3. for (let i = 0; i < 2; i += 1) { await runStep(h, 1, i + 1, [toolCall(`p${String(i)}`, 'X', {})]); } const [final] = await runStep(h, 1, 3, [toolCall('final', 'X', {})]); const arr = final!.result.output as Array<{ type: string; text?: string }>; - // The executor prepends a non-text companion to media-only output before - // the dedupe hook runs, so the array is [companion, image_url, reminder]; - // the dedupe-specific behavior is the trailing reminder text part it pushed - // because the trailing part was non-text. expect(arr.some((part) => part.type === 'image_url')).toBe(true); expect(arr.at(-1)).toEqual({ type: 'text', text: REMINDER_TEXT_1 }); }); @@ -510,7 +471,6 @@ describe('AgentToolDedupeService', () => { const h = createHarness(); const tool = new EchoTool('X', () => ({ output: 'boom', isError: true })); h.registry.register(tool); - // Build streak to 3. for (let i = 0; i < 2; i += 1) { await runStep(h, 1, i + 1, [toolCall(`p${String(i)}`, 'X', {})]); } @@ -543,11 +503,6 @@ describe('AgentToolDedupeService', () => { describe('arg rewrite between checkSameStep and finalize', () => { it('resolves the dup deferred even when the original call args are rewritten before finalize', async () => { - // Models the loop contract: prepareToolExecution may return - // {updatedArgs}, in which case finalizeToolResult sees the rewritten - // args. The dedupe key is registered at onBeforeExecuteTool time under the - // LLM-issued args (keyed by call id), so the deferred is resolved under - // that same key regardless of the rewritten args seen at finalize time. const h = createHarness(); await beforeStep(h, 1, 1); @@ -558,13 +513,9 @@ describe('AgentToolDedupeService', () => { await h.executor.hooks.onBeforeExecuteTool.run(w2); expect(w2.decision?.syntheticResult).toEqual({ output: '' }); - // Original finalize is called with REWRITTEN args (simulates a hook - // returning updatedArgs). const d1 = didCtx('c1', 'Read', { path: '/REWRITTEN' }, okResult('A')); await h.executor.hooks.onDidExecuteTool.run(d1); - // Dup's finalize must not hang — it should resolve via the deferred - // registered under the original-args key. const d2 = didCtx('c2', 'Read', { path: '/a' }, w2.decision!.syntheticResult!); await Promise.race([ h.executor.hooks.onDidExecuteTool.run(d2), @@ -583,24 +534,15 @@ describe('AgentToolDedupeService', () => { it('resolves leaked deferreds from a prior aborted step with an error result', async () => { const h = createHarness(); await beforeStep(h, 1, 1); - // Register an original but never finalize it (simulates abort mid-step). const w1 = willCtx('leaked', 'Read', { p: 1 }); await h.executor.hooks.onBeforeExecuteTool.run(w1); expect(w1.decision).toBeUndefined(); - // Register a dup that captures the leaked deferred. const w2 = willCtx('dup', 'Read', { p: 1 }); await h.executor.hooks.onBeforeExecuteTool.run(w2); const placeholder = w2.decision!.syntheticResult!; expect(placeholder).toEqual({ output: '' }); - // Next step begins — the leaked deferred should resolve so an awaiter - // doesn't hang. (In production the dup's finalize would have already - // happened before beginStep, but defensively resolving leaked deferreds - // protects against any ordering bug.) await beforeStep(h, 1, 2); - // Finalize the dup that captured the leaked deferred. Since beginStep - // cleared the per-step maps, this is no longer tracked — it just returns - // the placeholder it was passed. const d2 = didCtx('dup', 'Read', { p: 1 }, placeholder); await h.executor.hooks.onDidExecuteTool.run(d2); expect(d2.result).toEqual(placeholder); @@ -628,8 +570,6 @@ describe('AgentToolDedupeService', () => { expect(last.output as string).toContain('<system-reminder>'); expect(last.output as string).toContain('Write your final response now'); expect(last.output as string).toContain('without any further tool calls'); - // 8 is the reminder threshold, not yet force-stop. The executor always - // materializes `stopTurn` as a boolean, so a non-stopped result is `false`. expect(last.isError).toBeUndefined(); expect(stopTurnOf(last)).toBeFalsy(); }); @@ -651,7 +591,6 @@ describe('AgentToolDedupeService', () => { h.registry.register(new EchoTool('Read')); const last = await runStreak(h, 12); expect(last.output as string).toContain('Write your final response now'); - // The underlying tool succeeded — force-stop must not flip it to error. expect(last.isError).toBeUndefined(); expect(stopTurnOf(last)).toBe(true); }); @@ -679,7 +618,6 @@ describe('AgentToolDedupeService', () => { const [result] = await runStep(h, 1, i + 1, [toolCall(`c${String(i)}`, 'Read', { p: 1 })]); last = result!.result; } - // The underlying tool was an error — that must survive force-stop. expect(last!.isError).toBe(true); expect(stopTurnOf(last!)).toBe(true); expect(last!.output as string).toContain('Write your final response now'); @@ -710,8 +648,6 @@ describe('AgentToolDedupeService', () => { args_hash: expect.any(String), }, }); - // Same-step dups reach `tool_call` through the placeholder path and must - // be tagged, not misreported as 'normal'. expect(telemetryEvents).toContainEqual({ event: 'tool_call', properties: expect.objectContaining({ tool_call_id: 'c1', dup_type: 'normal' }), @@ -831,7 +767,6 @@ describe('AgentToolDedupeService', () => { const counts = telemetryEvents .filter((e) => e.event === 'tool_call_repeat') .map((e) => e.properties?.['repeat_count']); - // Only the second Read({p:1}) is a repeat; the streak then breaks. expect(counts).toEqual([2]); }); diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index 7e2e9699cd..8e5e56a658 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -117,8 +117,6 @@ describe('AgentToolExecutorService', () => { it('tags tool_call telemetry with recorded dup types, defaulting to normal', async () => { const tool = new TestTool('echo'); registry.register(tool); - // Dup types are recorded mid-execution through the will-hook (the dedupe - // plugin's path), so tag from a hook like production does. let tag = true; executor.hooks.onBeforeExecuteTool.register('test-dup-tag', async (ctx, next) => { if (tag && ctx.toolCall.id === 'call_dup') executor.recordDupType('call_dup', 'cross_step'); @@ -139,7 +137,6 @@ describe('AgentToolExecutorService', () => { properties: expect.objectContaining({ tool_call_id: 'call_dup', dup_type: 'cross_step' }), }); - // Entries are consumed on read, not sticky. tag = false; await execute([toolCall('call_dup', 'echo', { text: 'c' })]); expect(telemetryEvents).toContainEqual({ @@ -321,8 +318,6 @@ describe('AgentToolExecutorService', () => { }, ]); - // The trailing comma is NOT repaired: args fall back to `{}`, which fails - // schema validation, so the tool is never invoked. expect(tool.calls).toEqual([]); expect(results).toEqual([ expect.objectContaining({ @@ -703,8 +698,6 @@ describe('AgentToolExecutorService', () => { expect(results).toHaveLength(1); expect(results[0]!.output).toBe('ack'); - // The executor only threads `delivery`; an L4 hook (AgentPromptService) is - // what consumes and strips it — that hook is not registered in this unit test. expect(results[0]!.delivery).toMatchObject({ kind: 'steer', message: { content: [{ type: 'text', text: 'injected' }] }, 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 f26fc460eb..09f62146bd 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 @@ -28,7 +28,6 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { TOOL_SELECT_FLAG_ENV } from '#/agent/toolSelect/flag'; import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements'; -// Registers the select_tools tool contribution (mirrors src/index.ts). import '#/agent/toolSelect/tools/select-tools'; import { createTestAgent, type TestAgentContext } from '../../harness'; @@ -107,12 +106,8 @@ describe('progressive tool disclosure end-to-end', () => { let registration: { dispose(): void } | undefined; beforeEach(async () => { - // Stubbed before createTestAgent snapshots the env into bootstrap. vi.stubEnv(TOOL_SELECT_FLAG_ENV, '1'); ctx = createTestAgent(); - // Production mounts these through AgentLifecycleService.create's eager - // gets; the harness builds the Agent scope directly, so force the same - // instantiation here before any loop step runs. ctx.get(IAgentToolSelectService); ctx.get(IAgentToolSelectAnnouncementsService); ctx.get(IAgentToolExecutorService); @@ -143,7 +138,6 @@ describe('progressive tool disclosure end-to-end', () => { expect(ctx.llmCalls).toHaveLength(3); - // Turn-boundary manifest announcement reached the model on the first request. const firstWire = ctx.llmCalls[0]!; expect(toolNames(firstWire.tools)).not.toContain(MCP_ALPHA); expect(toolNames(firstWire.tools)).toContain('select_tools'); @@ -155,7 +149,6 @@ describe('progressive tool disclosure end-to-end', () => { expect(announcementText).toContain('<tools_added>'); expect(announcementText).toContain(MCP_ALPHA); - // The record carries the disclosure gate state (v1 recorder parity). const requests = wireEvents(ctx, 'llm.request').filter( (event) => (event.args as { kind?: string }).kind === 'loop', ); @@ -164,7 +157,6 @@ describe('progressive tool disclosure end-to-end', () => { expect((request.args as { toolSelect?: boolean }).toolSelect).toBe(true); } - // Loaded schema rides the next request as a message-level declaration. const secondWire = ctx.llmCalls[1]!; const schemaMessages = secondWire.history.filter( (message) => message.tools?.some((tool) => tool.name === MCP_ALPHA), @@ -174,18 +166,13 @@ describe('progressive tool disclosure end-to-end', () => { const alphaFromSchema = schemaMessages[0]!.tools!.find((tool) => tool.name === MCP_ALPHA)!; expect(alphaFromSchema.parameters).toEqual(alpha.parameters); - // Top-level table is byte-stable across the load (v1 prompt-cache contract): - // the provider-visible table of the post-load request equals the pre-load one. expect(secondWire.tools).toEqual(firstWire.tools); expect(wireEvents(ctx, 'llm.tools_snapshot')).toHaveLength(1); - // The loaded tool is dispatchable on a later step of the same turn. expect(alpha.calls).toBe(1); }); it('re-injects a selected schema after undo slices the tail of the loaded exchange', async () => { - // Seed an older real user prompt so the undo cut lands at start > 0: the - // F1 stale-ledger window only opens when the cut is not full-prefix. ctx.get(IAgentContextMemoryService).append({ role: 'user', content: [{ type: 'text', text: 'earlier question' }], 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 76de215c06..068eed0f30 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -657,9 +657,6 @@ describe('AgentToolSelectService.load', () => { expect(h.sut.load([MCP_ALPHA]).alreadyAvailable).toEqual([MCP_ALPHA]); expect(h.sut.load([MCP_BETA]).alreadyAvailable).toEqual([MCP_BETA]); - // Undo-style rewrite (v2's undo slices the tail wholesale): beta's schema - // message is gone while alpha's survives; the event is published after the - // memory service has rewritten history. h.contextMemory.history.splice(1, 1); h.eventBus.emit('context.spliced', { start: 1, deleteCount: 2, messages: [] }); diff --git a/packages/agent-core-v2/test/agent/usage/usage.test.ts b/packages/agent-core-v2/test/agent/usage/usage.test.ts index f4e340bfb3..0db1886adc 100644 --- a/packages/agent-core-v2/test/agent/usage/usage.test.ts +++ b/packages/agent-core-v2/test/agent/usage/usage.test.ts @@ -217,8 +217,6 @@ describe('AgentUsageService (wire-backed)', () => { context: { type: 'turn', turnId: 9, step: 3 }, }); - // The model carries only the per-model totals; the per-turn accumulator is - // live-only service state and never comes back from replay. expect(fresh.getModel(UsageModel)).toEqual({ byModel: { 'model-a': a1 }, }); diff --git a/packages/agent-core-v2/test/agent/userTool/userTool.test.ts b/packages/agent-core-v2/test/agent/userTool/userTool.test.ts index df70263895..0fe40248c9 100644 --- a/packages/agent-core-v2/test/agent/userTool/userTool.test.ts +++ b/packages/agent-core-v2/test/agent/userTool/userTool.test.ts @@ -41,8 +41,6 @@ function createProfileStub(): IAgentProfileService & ProfileStub { return { active, _serviceBrand: undefined, - // `undefined` = every tool active (the unrestricted default), matching the - // real profile service's `ActiveToolsModel` initial state. getActiveToolNames: () => undefined, addActiveTool: (name: string) => { active.add(name); @@ -177,7 +175,6 @@ describe('AgentUserToolService (wire-backed)', () => { svc.register(toolA); const before = modelOf(wire); svc.register(toolA); - // apply returns the same reference when the registration is already equal. expect(modelOf(wire)).toBe(before); }); @@ -186,10 +183,6 @@ describe('AgentUserToolService (wire-backed)', () => { svc.register(toolB); const records = await readRecords(); - // Fresh host + wire: replay the persisted records and confirm the post- - // restore side effect (registry.register + profile.addActiveTool) runs from - // the rebuilt model, while the replay itself does not register anything - // before onRestored fires. const ix2 = disposables.add(new TestInstantiationService()); ix2.stub(IFileSystemStorageService, new InMemoryStorageService()); ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); @@ -205,7 +198,6 @@ describe('AgentUserToolService (wire-backed)', () => { const wire2 = ix2.get(IAgentWireService); const registry2 = ix2.get(IAgentToolRegistryService); - // Realize the service so its ctor registers `wire.onRestored` BEFORE replay. ix2.get(IAgentUserToolService); expect(registry2.resolve(toolA.name)).toBeUndefined(); @@ -213,13 +205,11 @@ describe('AgentUserToolService (wire-backed)', () => { expect(modelOf(wire2).get(toolA.name)).toEqual(toolA); expect(modelOf(wire2).get(toolB.name)).toEqual(toolB); - // onRestored re-derived the live side effects from the rebuilt model. expect(registry2.resolve(toolA.name)).toBeDefined(); expect(registry2.resolve(toolB.name)).toBeDefined(); expect(profile2.active.has(toolA.name)).toBe(true); expect(profile2.active.has(toolB.name)).toBe(true); - // Replay is silent: nothing was written back to the replay wire log. const written: PersistedRecord[] = []; for await (const record of ix2 .get(IAppendLogStore) diff --git a/packages/agent-core-v2/test/agent/wireRecord/resume.test.ts b/packages/agent-core-v2/test/agent/wireRecord/resume.test.ts index ea63b08284..43596d781c 100644 --- a/packages/agent-core-v2/test/agent/wireRecord/resume.test.ts +++ b/packages/agent-core-v2/test/agent/wireRecord/resume.test.ts @@ -110,10 +110,8 @@ describe('Agent resume', () => { await ctx.restorePersisted(); - // History ran turnId 0 and 1, so the counter must be restored to 1. expect(turnCurrentId(ctx)).toBe(1); - // After 2 historical turns (turnId 0 and 1), the next fresh turn must be 2. ctx.mockNextResponse({ type: 'text', text: 'Fresh response.' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Fresh prompt' }] }); await ctx.untilTurnEnd(); @@ -126,14 +124,11 @@ describe('Agent resume', () => { }); it('restores the turn counter past goal-continuation turns that have no turn.prompt record', async () => { - // A goal drive allocates a fresh turnId per continuation turn even though - // the internally-driven turns do not have user prompt records. const persistence = new RecordingAgentPersistence(goalContinuationResumeHistory() as unknown as PersistedWireRecord[]); const ctx = testAgent({ persistence, autoConfigure: false }); await ctx.restorePersisted(); - // History ran turnId 0 (prompted) plus continuation turns 1 and 2. expect(turnCurrentId(ctx)).toBe(2); ctx.mockNextResponse({ type: 'text', text: 'Fresh response after goal resume.' }); @@ -148,8 +143,6 @@ describe('Agent resume', () => { }); it('keeps turnIds monotonic across repeated resume cycles', async () => { - // Mirrors a real session that was cold-started several times: each resume - // must continue the counter, never restart it and collide with history. const persistence = new RecordingAgentPersistence(multiTurnResumeHistory() as unknown as PersistedWireRecord[]); const ctx = testAgent({ persistence, autoConfigure: false }); @@ -159,8 +152,6 @@ describe('Agent resume', () => { await ctx.untilTurnEnd(); expect(turnCurrentId(ctx)).toBe(2); - // Cold-start again from everything persisted so far (history + the turn just - // run). The fresh agent must restore the counter to 2 and allocate 3 next. const persistence2 = new RecordingAgentPersistence(persistence.records as unknown as PersistedWireRecord[]); const ctx2 = testAgent({ persistence: persistence2, autoConfigure: false }); @@ -439,30 +430,6 @@ describe('Agent resume', () => { ]); }); - // TODO(phase-4.6): rewrite against wire resume — buildReplay() facade deleted - // it('projects restored cancelled compactions into replay records', async () => { - // const persistence = new RecordingAgentPersistence([ - // { - // type: 'full_compaction.begin', - // source: 'manual', - // instruction: 'preserve implementation notes', - // }, - // { - // type: 'full_compaction.cancel', - // }, - // ] as unknown as PersistedWireRecord[]); - // const ctx = testAgent({ persistence, autoConfigure: false }); - // - // await ctx.restorePersisted(); - // - // expect(ctx.get(IAgentRecordService).buildReplay()).toEqual([ - // expect.objectContaining({ - // type: 'compaction', - // result: 'cancelled', - // instruction: 'preserve implementation notes', - // }), - // ]); - // }); it('persists undelivered restored background notifications during resume', async () => { const persistence = new RecordingAgentPersistence([ @@ -502,8 +469,6 @@ describe('Agent resume', () => { message.origin.taskId === 'agent-new00000', ), ).toBe(true); - // The newly delivered notification is persisted through the current - // context append primitive. expect(persistence.appended).toContainEqual( expect.objectContaining({ type: 'context.append_message', @@ -522,61 +487,6 @@ describe('Agent resume', () => { } }); - // TODO(phase-4.6): rewrite against wire resume — buildReplay() facade deleted - // it('preserves failed tool result state in replay messages', async () => { - // const persistence = new RecordingAgentPersistence([ - // { - // type: 'metadata', - // protocol_version: '1.4', - // created_at: 1, - // }, - // { - // type: 'context.append_loop_event', - // event: { - // type: 'step.begin', - // uuid: 'failed-step', - // turnId: '0', - // step: 1, - // }, - // }, - // { - // type: 'context.append_loop_event', - // event: { - // type: 'tool.call', - // uuid: 'failed-call', - // turnId: '0', - // step: 1, - // stepUuid: 'failed-step', - // toolCallId: 'call_failed_bash', - // name: 'Bash', - // args: { command: 'false' }, - // }, - // }, - // { - // type: 'context.append_loop_event', - // event: { - // type: 'tool.result', - // parentUuid: 'failed-call', - // toolCallId: 'call_failed_bash', - // result: { output: 'failed', isError: true }, - // }, - // }, - // ] as unknown as PersistedWireRecord[]); - // const ctx = testAgent({ persistence, autoConfigure: false }); - // - // await ctx.restorePersisted(); - // - // expect(ctx.get(IAgentRecordService).buildReplay()).toContainEqual( - // expect.objectContaining({ - // type: 'message', - // message: expect.objectContaining({ - // role: 'tool', - // toolCallId: 'call_failed_bash', - // isError: true, - // }), - // }), - // ); - // }); it('drops an orphan tool result whose call was never recorded', async () => { const persistence = new RecordingAgentPersistence([ @@ -616,13 +526,11 @@ describe('Agent resume', () => { await ctx.restorePersisted(); - // Raw history keeps the orphan result as recorded. expect(ctx.context.get().map((message) => message.role)).toEqual([ 'user', 'assistant', 'tool', ]); - // The projector drops the orphan (its call was never recorded). expect(ctx.project().map((message) => message.role)).toEqual(['user', 'assistant']); expect(ctx.project().some((message) => message.role === 'tool')).toBe(false); await ctx.expectResumeMatches(); @@ -1061,8 +969,6 @@ function canonicalContinuationTurn( ]; } -// Loop events for one fully-run turn: a single step that emits text and ends. -// Used to represent both prompted turns and internal (goal-continuation) turns. function loopEventsForTurn(turnId: string, responseText: string): PersistedWireRecord[] { return [ { @@ -1107,7 +1013,6 @@ function multiTurnResumeHistory(): PersistedWireRecord[] { ]; } -// One prompted turn (turnId 0) followed by two internally-driven turns (1, 2). function goalContinuationResumeHistory(): PersistedWireRecord[] { return [ resumeConfigRecord(), diff --git a/packages/agent-core-v2/test/app/auth/auth.test.ts b/packages/agent-core-v2/test/app/auth/auth.test.ts index 2494f58499..28ee757927 100644 --- a/packages/agent-core-v2/test/app/auth/auth.test.ts +++ b/packages/agent-core-v2/test/app/auth/auth.test.ts @@ -54,18 +54,12 @@ const deviceAuth = { const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0)); -/** - * Scoped credential ref derived for the `https://api.example.com` fixture - * environment (default OAuth host) — what login resolves when the configured - * ref does not match its (host, baseUrl) environment. - */ const EXAMPLE_COM_SCOPED_REF = { storage: 'file', key: resolveKimiCodeOAuthKey({ baseUrl: 'https://api.example.com' }), oauthHost: 'https://auth.kimi.com', } as const; -/** Scoped credential ref for the env-override fixture environment. */ const ENV_SCOPED_REF = { storage: 'file', key: resolveKimiCodeOAuthKey({ @@ -238,9 +232,6 @@ describe('OAuthService', () => { expect(toolkit.login).toHaveBeenCalledWith( OAUTH_PROVIDER, expect.objectContaining({ - // The fixture's configured key does not match its (host, baseUrl) - // environment, so login re-derives the slot from the environment - // (v1 parity) instead of trusting the stale ref. oauthRef: EXAMPLE_COM_SCOPED_REF, baseUrl: 'https://api.example.com', oauthHost: undefined, @@ -266,8 +257,6 @@ describe('OAuthService', () => { type: 'kimi', baseUrl: 'https://api.example.com', apiKey: '', - // The provisioned entry records the env-scoped slot explicitly, so - // the runtime reads the same slot login wrote (v1 parity). oauth: EXAMPLE_COM_SCOPED_REF, }), ); @@ -348,8 +337,6 @@ describe('OAuthService', () => { OAUTH_PROVIDER, expect.objectContaining({ type: 'kimi', - // The provisioned entry targets the env environment, not the stale - // configured one — so runtime reads hit the same credential slot. baseUrl: 'https://env-api.example.com/coding/v1', oauth: ENV_SCOPED_REF, }), @@ -368,9 +355,6 @@ describe('OAuthService', () => { await svc.startLogin(OAUTH_PROVIDER); await vi.waitFor(() => expect(svc.getFlow(OAUTH_PROVIDER)?.status).toBe('authenticated')); - // The slot login targeted and the slot the runtime reads must be the - // same env-scoped key — the mismatch was "login succeeds but every - // call 401s". await svc.status(OAUTH_PROVIDER); expect(toolkit.getCachedAccessToken).toHaveBeenCalledWith( OAUTH_PROVIDER, @@ -537,9 +521,6 @@ describe('OAuthService', () => { const result = await svc.logout(OAUTH_PROVIDER); expect(result).toEqual({ logged_out: true, provider: OAUTH_PROVIDER }); - // Logout deletes from the slot the runtime reads: the fixture's configured - // key does not match its (host, baseUrl) environment, so the env-derived - // scoped slot is the one cleared (v1 parity). expect(toolkit.logout).toHaveBeenCalledWith(OAUTH_PROVIDER, EXAMPLE_COM_SCOPED_REF); expect(configReplace).toHaveBeenCalledWith('providers', { [NON_OAUTH_PROVIDER]: { type: 'openai', apiKey: 'sk-test' }, @@ -702,9 +683,6 @@ describe('OAuthService', () => { }), ); expect(configSet).toHaveBeenCalledWith('defaultModel', 'kimi-code/kimi-k2'); - // Regression: the `[thinking] enabled` value computed by the shared oauth - // apply logic must be persisted, not dropped (previously only the legacy - // `default_thinking` key was written). expect(configSet).toHaveBeenCalledWith('thinking', { enabled: true }); expect(events).toEqual([ { @@ -741,8 +719,6 @@ describe('OAuthService', () => { await Promise.all([svc.refreshOAuthProviderModels(), svc.refreshOAuthProviderModels()]); - // Without the refresh chain both remote fetches would overlap (peak 2); the - // chain holds the second run until the first finishes, so the peak stays 1. expect(maxInFlight).toBe(1); expect(fetchMock).toHaveBeenCalledTimes(2); }); diff --git a/packages/agent-core-v2/test/app/bootstrap/stubs.ts b/packages/agent-core-v2/test/app/bootstrap/stubs.ts index 4a730d9ec4..f5420299d8 100644 --- a/packages/agent-core-v2/test/app/bootstrap/stubs.ts +++ b/packages/agent-core-v2/test/app/bootstrap/stubs.ts @@ -12,9 +12,6 @@ import { type PersistenceScopeName, } from '#/app/bootstrap/bootstrap'; -/** - * An `IBootstrapService` rooted at the given home dir with the given env bag. - */ export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv = {}): IBootstrapService { const sessionsScope = 'sessions'; const scopes: Record<PersistenceScopeName, string> = { @@ -54,7 +51,6 @@ export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv }; } -/** Register the default `IBootstrapService` rooted at an isolated temp dir. */ export function registerBootstrapServices(reg: ServiceRegistration): void { const homeDir = `/tmp/kimi-code-agent-core-v2-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; reg.defineInstance(IBootstrapService, stubBootstrap(homeDir)); diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index 9186b2d6ee..c83c2b67f2 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -22,8 +22,6 @@ import { TestInstantiationService } from '#/_base/di/test'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigRegistry, IConfigService } from '#/app/config/config'; import { ConfigRegistry, ConfigService } from '#/app/config/configService'; -// Side-effect: registers the `cron` section (with its env bindings) so the -// live-overlay test below can read `config.get('cron')`. import '#/app/cron/configSection'; import type { CronConfig } from '#/app/cron/configSection'; import '#/app/skillCatalog/configSection'; @@ -31,12 +29,8 @@ import { EXTRA_SKILL_DIRS_SECTION, MERGE_ALL_AVAILABLE_SKILLS_SECTION, } from '#/app/skillCatalog/configSection'; -// Side-effect: registers the `defaultPermissionMode` section so the test below -// can assert its schema (and that `yolo` is not a registered domain). import '#/agent/permissionMode/configSection'; import { DEFAULT_PERMISSION_MODE_SECTION } from '#/agent/permissionMode/configSection'; -// Side-effect: registers the `image` section (with its env bindings) so the -// tests below can assert its schema and live env overlay. import '#/agent/media/configSection'; import { IMAGE_SECTION, type ImageConfig } from '#/agent/media/configSection'; import { @@ -52,10 +46,6 @@ import { TomlAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDo import { stubBootstrap } from '../bootstrap/stubs'; import { stubLog } from '../../_base/log/stubs'; -// Historical `osEnv` shape carried by `useProfile` context — the test only -// exercises the profile-service pass-through; the exact fields don't matter to -// the assertions, so we keep a minimal literal instead of importing an -// external type. const TEST_OS_ENV = { osKind: 'Linux', osArch: 'x86_64', @@ -100,8 +90,6 @@ describe('Agent config', () => { initialCapability, ); - // `getConfig` returns the profile DTO; the raw provider config is not part - // of the v2 wire contract (providers are served by the provider service). await expect(ctx.rpc.getConfig({})).resolves.toMatchObject({ systemPrompt: DEFAULT_TEST_SYSTEM_PROMPT, thinkingLevel: 'off', @@ -387,7 +375,6 @@ describe('defaultPermissionMode config section', () => { expect(registry.validate(DEFAULT_PERMISSION_MODE_SECTION, 'yolo')).toBe('yolo'); expect(() => registry.validate(DEFAULT_PERMISSION_MODE_SECTION, 'bogus')).toThrow(); - // `yolo` is wire sugar, not a config domain — it must never be registered. expect(registry.getSection('yolo')).toBeUndefined(); }); }); @@ -404,7 +391,6 @@ describe('image config section', () => { expect( registry.validate(IMAGE_SECTION, { maxEdgePx: 1500, readByteBudget: 131072 }), ).toEqual({ maxEdgePx: 1500, readByteBudget: 131072 }); - // Partial is fine; non-positive / non-integer values are rejected. expect(registry.validate(IMAGE_SECTION, { maxEdgePx: 1500 })).toEqual({ maxEdgePx: 1500 }); expect(() => registry.validate(IMAGE_SECTION, { maxEdgePx: 0 })).toThrow(); expect(() => registry.validate(IMAGE_SECTION, { readByteBudget: 1.5 })).toThrow(); @@ -423,16 +409,12 @@ describe('image config section', () => { const config = ix.get(IConfigService); await config.ready; - // No env, no file → empty default. expect(config.get<ImageConfig>(IMAGE_SECTION)).toEqual({}); - // Malformed env (non-numeric / non-positive) parses to undefined and is - // ignored rather than thrown or persisted as garbage. env['KIMI_IMAGE_MAX_EDGE_PX'] = 'abc'; env['KIMI_IMAGE_READ_BYTE_BUDGET'] = '-1'; expect(config.get<ImageConfig>(IMAGE_SECTION)).toEqual({}); - // Valid env resolves into the effective value. env['KIMI_IMAGE_MAX_EDGE_PX'] = '1500'; env['KIMI_IMAGE_READ_BYTE_BUDGET'] = '131072'; expect(config.get<ImageConfig>(IMAGE_SECTION)).toEqual({ @@ -440,7 +422,6 @@ describe('image config section', () => { readByteBudget: 131072, }); - // Live re-apply on the next get(). env['KIMI_IMAGE_MAX_EDGE_PX'] = '2500'; expect(config.get<ImageConfig>(IMAGE_SECTION).maxEdgePx).toBe(2500); diff --git a/packages/agent-core-v2/test/app/config/stubs.ts b/packages/agent-core-v2/test/app/config/stubs.ts index 371ae4959f..f21cc2db5a 100644 --- a/packages/agent-core-v2/test/app/config/stubs.ts +++ b/packages/agent-core-v2/test/app/config/stubs.ts @@ -11,12 +11,6 @@ import { ConfigRegistry } from '#/app/config/configService'; import { IAtomicTomlDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { TomlAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; -/** - * Register the default config collaborators: a real `ConfigRegistry` plus an - * empty `IConfigService` placeholder, and the real TOML atomic-document store - * (so tests exercising the real `ConfigService` only need to supply an - * `IFileSystemStorageService` backend and override the `IConfigService` placeholder). - */ export function registerConfigServices(reg: ServiceRegistration): void { reg.defineInstance(IConfigRegistry, new ConfigRegistry()); reg.definePartialInstance(IConfigService, {}); diff --git a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts index 1b89159def..5bbaf6853b 100644 --- a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts +++ b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts @@ -48,11 +48,6 @@ function createTestEnv(home = '/home'): IHostEnvironment { }; } -/** - * Fake fs with spied `readText` / `writeText`. Defaults read to empty content - * and write to a no-op; tests pass their own `vi.fn()` mocks to drive content - * and assert on write calls. - */ function createSpiedEditFs( options: { readText?: ReturnType<typeof vi.fn>; @@ -79,10 +74,6 @@ function buildTool( reg.define(IFileEditService, FileEditService); }, }); - // Direct construction instead of `createInstance(EditTool)`: the optional - // trailing `@ISessionSkillCatalog` param defeats `GetLeadingNonServiceArgs` - // inference (optional tuple element), so the typed overload no longer - // applies. DI resolves the same instances for production construction. return new EditTool(ix.get(IFileEditService), env, workspace); } @@ -168,11 +159,7 @@ describe('EditTool', () => { expect(tool.description).toContain('`old_string` must be unique'); expect(tool.description).toContain('only when they do not target the same file'); expect(tool.description).toContain('DO NOT issue consecutive Edit calls on the same file'); - // Editing files should go through Edit, not Write and not a Bash `sed` - // command. The prompt names both alternatives explicitly. expect(tool.description).toContain('DO NOT use Write or Bash `sed`'); - // Parallel Edit calls on the same file are serialized and applied in - // response order; mismatched old_string fails explicitly. expect(tool.description).toContain('same-file edits in response order'); expect(tool.description).toContain('old_string not found'); expect(tool.parameters).toMatchObject({ @@ -481,13 +468,10 @@ describe('EditTool', () => { }); expect(result.isError).toBe(true); - // Lockdown the negative side-effect: no write should have been issued. expect(writeText).not.toHaveBeenCalled(); }); it('errors with an is-not-a-file phrasing when the path resolves to a directory', async () => { - // The edit tool relies on readText to surface the directory error; an - // EISDIR-coded rejection maps to the "is not a file" output. const { fs } = createSpiedEditFs({ readText: vi.fn().mockRejectedValue( Object.assign(new Error('EISDIR: illegal operation on a directory'), { @@ -508,8 +492,6 @@ describe('EditTool', () => { }); it('maps a HostFsError-wrapped EISDIR to the is-not-a-file phrasing', async () => { - // The real hostFs backend throws `HostFsError(os.fs.is_directory)` with the - // raw errno on the cause; the friendly branch must see through the wrapper. const { fs } = createSpiedEditFs({ readText: vi.fn().mockRejectedValue( new HostFsError(OsFsErrors.codes.OS_FS_IS_DIRECTORY, 'read failed: path is a directory', { @@ -569,8 +551,6 @@ describe('EditTool', () => { }); it('allows absolute edits to a sibling dir that merely shares the work-dir prefix', async () => { - // /workspace-sneaky/* is outside /workspace — string prefix check must not - // mistake "shares a prefix" for "inside workspace". const writeText = vi.fn().mockResolvedValue(undefined); const { fs } = createSpiedEditFs({ readText: vi.fn().mockResolvedValue('content'), @@ -589,11 +569,8 @@ describe('EditTool', () => { }); it('rejects editing a non-UTF-8 file and leaves its bytes untouched', async () => { - // Drives the real HostFileSystem + FileEditService (no fake fs) so the - // strict-decode path is exercised end-to-end against invalid bytes. const dir = await mkdtemp(join(tmpdir(), 'edit-strict-')); const file = join(dir, 'sample.txt'); - // "hi " + 0xFF (invalid UTF-8) + "\n" + "foo" const original = Buffer.from([0x68, 0x69, 0x20, 0xff, 0x0a, 0x66, 0x6f, 0x6f]); await writeFile(file, original); try { @@ -606,11 +583,7 @@ describe('EditTool', () => { replace_all: false, }); - // Strict decoding must surface the invalid bytes as a failed edit... expect(result.ok).toBe(false); - // ...and must not have rewritten the file. The v2 lenient-decode bug - // silently rewrote 0xFF as EF BF BD even though the edit only touched - // 'foo'; locking the byte-for-byte invariant prevents a regression. const after = await readFile(file); expect(Buffer.compare(after, original)).toBe(0); } finally { diff --git a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts index 5c0f729c75..af2ed52895 100644 --- a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts +++ b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts @@ -326,7 +326,6 @@ describe('IExternalHooksRunnerService integration', () => { origin: { kind: 'system_trigger', name: 'stop_hook' }, }), ); - // The queued request only drives the next step; pop it to move on. expect(loop.drainNextBatch(context)).toBeDefined(); const second = makeAfterStep(signal); @@ -522,9 +521,6 @@ describe('IExternalHooksRunnerService integration', () => { ix.set(IExternalHooksRunnerService, stubHookRunner(hookEngine)); ix.set(ISessionExternalHooksService, new SyncDescriptor(SessionExternalHooksService)); - // Construct the observer first so it registers its listeners on the - // agent-lifecycle run-hook slot / stop event, then drive them the way - // `mirrorAgentRun` does. ix.get(ISessionExternalHooksService); const agentLifecycle = ix.get(IAgentLifecycleService); @@ -547,7 +543,6 @@ describe('IExternalHooksRunnerService integration', () => { }, ]); - // SubagentStop is fire-and-forget; flush until it lands. await flushMicrotasks(); await flushMicrotasks(); expect(fired).toEqual([ diff --git a/packages/agent-core-v2/test/app/file/fileService.test.ts b/packages/agent-core-v2/test/app/file/fileService.test.ts index 3a16fa09e5..253b1c4865 100644 --- a/packages/agent-core-v2/test/app/file/fileService.test.ts +++ b/packages/agent-core-v2/test/app/file/fileService.test.ts @@ -120,7 +120,6 @@ describe('FileServiceImpl', () => { await expect(store().save(readable(big), 'big.bin')).rejects.toMatchObject({ code: FileErrors.codes.FILE_TOO_LARGE, }); - // No blob or index entry should have been written. expect(await backend.list('files')).toHaveLength(0); }); @@ -131,7 +130,6 @@ describe('FileServiceImpl', () => { await expect(store().get(meta.id)).rejects.toMatchObject({ code: FileErrors.codes.FILE_NOT_FOUND, }); - // Index entry was pruned, so a second get is still a clean 404. await expect(store().get(meta.id)).rejects.toMatchObject({ code: FileErrors.codes.FILE_NOT_FOUND, }); @@ -140,7 +138,6 @@ describe('FileServiceImpl', () => { it('persists the index across instances sharing the backend', async () => { const meta = await store().save(readable('durable'), 'durable.txt'); - // A fresh store over the same backend reloads the persisted index. const ix2 = createServices(disposables, { additionalServices: (reg) => { reg.defineInstance(IFileSystemStorageService, backend); diff --git a/packages/agent-core-v2/test/app/flag/stubs.ts b/packages/agent-core-v2/test/app/flag/stubs.ts index b37954f3e3..a09c51ef4b 100644 --- a/packages/agent-core-v2/test/app/flag/stubs.ts +++ b/packages/agent-core-v2/test/app/flag/stubs.ts @@ -12,10 +12,6 @@ import type { } from '#/app/flag/flag'; import type { IFlagRegistry } from '#/app/flag/flagRegistry'; -/** - * A minimal `IFlagService`. `enabled` is either a fixed boolean or a per-id - * predicate; everything else is a no-op / empty. - */ export function stubFlag(enabled: boolean | ((id: string) => boolean) = false): IFlagService { const isEnabled = typeof enabled === 'function' ? enabled : (): boolean => enabled; const registry: IFlagRegistry = { diff --git a/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts b/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts index 0e5a2a8f53..5de9f5b1aa 100644 --- a/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts +++ b/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts @@ -207,13 +207,11 @@ describe('isRetryableGenerateError', () => { describe('isImageFormatError', () => { it('matches documented provider image format/data rejections', () => { - // OpenAI expect( isImageFormatError( new APIStatusError(400, 'The image data you provided does not represent a valid image'), ), ).toBe(true); - // Anthropic media_type enum violation expect( isImageFormatError( new APIStatusError( @@ -222,16 +220,13 @@ describe('isImageFormatError', () => { ), ), ).toBe(true); - // Anthropic decode failure expect(isImageFormatError(new APIStatusError(400, 'Could not process image'))).toBe(true); - // Moonshot/Kimi (from the Kimi Code error reference) expect( isImageFormatError( new APIStatusError(400, 'Invalid request: unsupported image url: /tmp/photo.avif'), ), ).toBe(true); expect(isImageFormatError(new APIStatusError(400, 'unsupported image format'))).toBe(true); - // Gemini expect(isImageFormatError(new APIStatusError(400, 'Unable to process input image'))).toBe(true); expect( isImageFormatError( @@ -266,9 +261,6 @@ describe('isImageFormatError', () => { }); it('does not match image count/size/support errors that stripping media cannot fix', () => { - // Stripping media to zero would let these requests "succeed" with the - // model blind to the user's images — hiding the real error. They must - // surface instead of triggering a media-stripped resend. expect(isImageFormatError(new APIStatusError(400, 'too many images in request'))).toBe(false); expect( isImageFormatError(new APIStatusError(400, 'image dimension 5000 exceeds maximum 2048')), @@ -277,9 +269,6 @@ describe('isImageFormatError', () => { isImageFormatError(new APIStatusError(400, 'image input is disabled for this model')), ).toBe(false); expect(isImageFormatError(new APIStatusError(400, 'image_url is not allowed'))).toBe(false); - // Documented provider messages that are image-shaped but not - // format/data errors: Anthropic's per-image size cap, Moonshot's - // capability code, Gemini's unsupported-inlineData rejection. expect( isImageFormatError( new APIStatusError( @@ -292,9 +281,6 @@ describe('isImageFormatError', () => { expect( isImageFormatError(new APIStatusError(400, "`inlineData` isn't supported by this model.")), ).toBe(false); - // Video/audio media_type errors are NOT image errors: they must surface - // (no conversion-guidance path exists for video) instead of triggering a - // blind media-stripped resend. expect( isImageFormatError( new APIStatusError( @@ -303,7 +289,6 @@ describe('isImageFormatError', () => { ), ), ).toBe(false); - // Bare "media type" phrasings for audio/video inputs likewise surface. expect( isImageFormatError(new APIStatusError(400, 'unsupported media type for audio input')), ).toBe(false); @@ -311,9 +296,6 @@ describe('isImageFormatError', () => { }); it('is excluded from the transient-retry fallback so dedicated recovery fires first', () => { - // A base ChatProviderError is normally retried as an unclassified - // transient; image-format errors must not be, or the run would burn the - // retry budget on an identical request before reaching the media strip. expect(isRetryableGenerateError(new ChatProviderError('transient blip'))).toBe(true); expect( isRetryableGenerateError( @@ -437,18 +419,11 @@ describe('normalizeAPIStatusError', () => { }); it.each([ - // Moonshot / Kimi 413 observed in the field when accumulated media pushed - // the request body over the provider's byte ceiling. [413, 'Request exceeds the maximum size'], - // Reverse-proxy (nginx-style) 413 with an HTML body. [413, '413 <html><head><title>413 Request Entity Too Large'], - // Anthropic request_too_large: body over the 32 MB API ceiling. [413, 'request_too_large: Request exceeds the maximum allowed number of bytes'], - // RFC 9110 reason phrase / Node-style wording. [413, 'Payload Too Large'], [413, 'Content Too Large'], - // Plain wordings without "entity": generic gateways say "Request too - // large"; Go's http.MaxBytesReader says "http: request body too large". [413, 'Request too large'], [413, 'Request body too large'], [413, 'http: request body too large'], @@ -460,19 +435,13 @@ describe('normalizeAPIStatusError', () => { }); it('keeps a 413 with token-overflow wording as APIContextOverflowError', () => { - // Vertex phrases prompt-too-long as a 413; that is a token problem - // (recoverable by compaction), not a request-body-size problem. const error = normalizeAPIStatusError(413, 'prompt is too long: 210000 tokens > 200000 maximum'); expect(error).toBeInstanceOf(APIContextOverflowError); expect(error).not.toBeInstanceOf(APIRequestTooLargeError); }); it.each([ - // A bare 413 with unrecognized wording stays unclassified: Vertex abuses - // 413 for prompt-too-long, so the status alone is not proof of a - // body-size rejection. [413, 'Request failed'], - // Size wording without the 413 status is not classified either. [400, 'Payload too large'], [422, 'Request entity too large'], ])('keeps %i "%s" as plain APIStatusError', (statusCode, message) => { @@ -502,8 +471,6 @@ describe('parseRetryAfterMs', () => { }); describe('isToolExchangeAdjacencyError', () => { - // The exact Anthropic message observed in the field when a tool_use was not - // immediately followed by its tool_result. const ANTHROPIC_MISSING_RESULT = 'messages.142: `tool_use` ids were found without `tool_result` blocks immediately after: ' + 'toolu_01MWFhDRqdbB4nzCJNuWYiun. Each `tool_use` block must have a corresponding ' + @@ -536,10 +503,6 @@ describe('isToolExchangeAdjacencyError', () => { ); }); - // The exact OpenAI-compatible (Moonshot / Kimi) message observed in the field - // when a `tool` message's `tool_call_id` has no matching `tool_calls` entry in - // the preceding assistant message. The doubled space is verbatim from the - // provider. const MOONSHOT_TOOL_CALL_ID_NOT_FOUND = '400 tool_call_id is not found'; it('matches the OpenAI/Moonshot tool_call_id-not-found 400', () => { @@ -557,10 +520,6 @@ describe('isToolExchangeAdjacencyError', () => { ).toBe(true); }); - // OpenAI / DeepSeek / vLLM and other OpenAI-compatible providers phrase the - // orphan-`tool`-result case as a `role 'tool'` message that has no preceding - // assistant `tool_calls`. Observed verbatim in the field (see zed #41531, - // llama_index #13715). Quote style varies by provider (straight or backtick). it('matches the OpenAI/DeepSeek role-tool-without-tool_calls 400', () => { expect( isToolExchangeAdjacencyError( @@ -580,10 +539,6 @@ describe('isToolExchangeAdjacencyError', () => { ).toBe(true); }); - // The mirror-image OpenAI-compatible rejection: an assistant `tool_calls` - // message with no following `tool` results. OpenAI/Portkey (#6621, error - // 10067) spell it out; Qwen/DashScope (#454) uses double quotes; some - // providers emit the terse "(insufficient tool messages following ...)". it('matches the assistant-tool_calls-without-response 400', () => { expect( isToolExchangeAdjacencyError( @@ -615,11 +570,7 @@ describe('isToolExchangeAdjacencyError', () => { isToolExchangeAdjacencyError(new APIContextOverflowError(400, 'context length exceeded')), ).toBe(false); expect(isToolExchangeAdjacencyError(new APIStatusError(400, 'Bad request'))).toBe(false); - // A bare "not found" without a tool_call_id anchor must not match, so an - // unrelated 404-style body cannot trip the tool-exchange recovery. expect(isToolExchangeAdjacencyError(new APIStatusError(400, 'resource not found'))).toBe(false); - // A model-availability 400 (observed alongside this family in the field) is a - // config error, not a tool-exchange defect — strict resend must not fire. expect( isToolExchangeAdjacencyError( new APIStatusError(400, '400 Not supported model mimo-v2.5-pro-ultraspeed'), diff --git a/packages/agent-core-v2/test/app/llmProtocol/providers/anthropic-max-tokens.test.ts b/packages/agent-core-v2/test/app/llmProtocol/providers/anthropic-max-tokens.test.ts index f232db6e48..1b01f46a3e 100644 --- a/packages/agent-core-v2/test/app/llmProtocol/providers/anthropic-max-tokens.test.ts +++ b/packages/agent-core-v2/test/app/llmProtocol/providers/anthropic-max-tokens.test.ts @@ -73,13 +73,10 @@ describe('resolveDefaultMaxTokens', () => { }); it('falls back to the nearest lower catalogued minor for unknown minors', () => { - // opus-4-9/4-10 are not in the table; they reuse opus-4-8's 128k - // ceiling (a newer minor inherits at least its predecessor's cap). expect(resolveDefaultMaxTokens('claude-opus-4-9')).toBe(128000); expect(resolveDefaultMaxTokens('claude-opus-4-10')).toBe(128000); expect(resolveDefaultMaxTokens('claude-sonnet-4-9')).toBe(64000); expect(resolveDefaultMaxTokens('claude-haiku-4-9')).toBe(64000); - // A gap between catalogued minors also resolves to the nearest lower one. expect(resolveDefaultMaxTokens('claude-opus-4-3')).toBe(32000); }); 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 4877aab22b..a0d55ac5fd 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 @@ -94,7 +94,6 @@ describe('Kimi messages[].tools serialization', () => { ]; const body = await captureRequestBody([], history); const messages = body['messages'] as Array>; - // [system prompt, user, system+tools] expect(messages).toHaveLength(3); const toolsMessage = messages[2]!; expect(toolsMessage['role']).toBe('system'); @@ -132,7 +131,6 @@ describe('Kimi messages[].tools serialization', () => { for (const message of messages) { expect('tools' in message).toBe(false); } - // Top-level tools[] unchanged by the feature. expect(body['tools']).toEqual([ { type: 'function', @@ -222,8 +220,6 @@ describe('providers without message-level tool declarations', () => { it('classifies tool-declaration-only messages', () => { expect(isToolDeclarationOnlyMessage(TOOLS_ONLY_MESSAGE)).toBe(true); expect(isToolDeclarationOnlyMessage(HISTORY[0]!)).toBe(false); - // A message that also carries content is NOT skipped wholesale (only the - // tools field stays off the wire via explicit field construction). expect( isToolDeclarationOnlyMessage({ ...TOOLS_ONLY_MESSAGE, @@ -278,7 +274,6 @@ describe('providers without message-level tool declarations', () => { const stream = await provider.generate('sys', [], HISTORY); for await (const part of stream) void part; const messages = captured!['messages'] as Array>; - // [system prompt, user] — no content-free leftover entry. expect(messages).toHaveLength(2); for (const message of messages) { expect(message['content']).toBeDefined(); @@ -312,7 +307,6 @@ describe('providers without message-level tool declarations', () => { }); const stream = await provider.generate('sys', [], HISTORY); for await (const part of stream) void part; - // The tools-only message contributes no input item at all. expect(captured!['input'] as unknown[]).toHaveLength(1); expect(JSON.stringify(captured!['input'])).not.toContain('"tools"'); }); diff --git a/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts b/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts index 7170484736..36dfda764c 100644 --- a/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts +++ b/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts @@ -96,14 +96,11 @@ describe('MessageLegacyService', () => { { type: 'context.append_message', message: user }, { type: 'context.append_message', message: assistant }, ], - // Folded context length matches the journal-derived foldedLength, so the - // live-tail merge is a no-op and the output is purely the journal. contextMessages: [user, assistant], }); const page = await svc.list('s1', {}); - // Newest first; both entries come from the journal, not from wire.jsonl. expect(page.items.map((m) => m.role)).toEqual(['assistant', 'user']); expect(page.items[1]?.content[0]).toEqual({ type: 'text', text: 'hi' }); expect(page.has_more).toBe(false); diff --git a/packages/agent-core-v2/test/app/model/model.test.ts b/packages/agent-core-v2/test/app/model/model.test.ts index 83de298a47..858b73ab2f 100644 --- a/packages/agent-core-v2/test/app/model/model.test.ts +++ b/packages/agent-core-v2/test/app/model/model.test.ts @@ -255,8 +255,6 @@ describe('kimiModelEnvOverlay', () => { }); it('honors an explicit baseUrl over the type default', () => { - // The KIMI_MODEL_BASE_URL binding is applied by the provider config section; - // emulate its effect by seeding the resolved provider with the bound baseUrl. const { effective } = applyKimiModelEnvOverlay( { KIMI_MODEL_NAME: 'env-model' }, { @@ -383,12 +381,6 @@ describe('kimiModelEnvOverlay', () => { }); it('self-registers into ConfigRegistry without ModelService instantiation', () => { - // envOverlay.ts calls registerConfigOverlay(kimiModelEnvOverlay) at module - // load, so a freshly constructed ConfigRegistry drains it even though no - // Service (notably ModelService) has been instantiated. This guards the - // release-e2e wire-llm-request-trace scenario, where KIMI_MODEL_NAME must - // synthesize the env model (and its thinking capability) even when nothing - // resolves IModelService. const freshRegistry = new ConfigRegistry(); expect(freshRegistry.listEffectiveOverlays()).toContain(kimiModelEnvOverlay); }); diff --git a/packages/agent-core-v2/test/app/model/modelResolver-runtime.test.ts b/packages/agent-core-v2/test/app/model/modelResolver-runtime.test.ts index 1e36863cb6..94f13a7ed3 100644 --- a/packages/agent-core-v2/test/app/model/modelResolver-runtime.test.ts +++ b/packages/agent-core-v2/test/app/model/modelResolver-runtime.test.ts @@ -5,8 +5,6 @@ import { IConfigService } from '#/app/config/config'; import { IModelResolver } from '#/app/model/modelResolver'; import { createAppScope } from '#/_base/di/scope'; import { ErrorCodes, Error2 } from '#/errors'; -// Load every domain barrel so all App-scope services (provider / platform / -// model / protocol / config registry) are registered before we build a scope. import '#/index'; function stubConfig(sections: Record): IConfigService { 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 185cd5c31e..3e64fba173 100644 --- a/packages/agent-core-v2/test/app/model/modelResolver.test.ts +++ b/packages/agent-core-v2/test/app/model/modelResolver.test.ts @@ -401,10 +401,6 @@ describe('ModelResolverService', () => { tools: [], messages: [], }); - // No OAuth material on the model, so there is no force-refresh/replay: - // the raw status error crosses the model boundary once, translated into - // a coded Error2 with the HTTP fields in `details` and the raw error - // preserved as `cause`. await expect(async () => { for await (const _event of events) { void _event; @@ -493,7 +489,6 @@ describe('ModelResolverService', () => { expect(createdProtocolConfigs[0]).toMatchObject({ defaultHeaders: { 'X-Env': 'env-val', - // provider customHeaders override the env header on conflict 'X-Shared': 'from-provider', 'X-Provider': 'p', }, @@ -509,19 +504,16 @@ describe('ModelResolverService', () => { try { const host = { 'User-Agent': 'kimi-code-cli/1.0', 'X-Msh-Device-Id': 'dev' }; - // kimi provider → full identity (even when routed through anthropic) expect(resolveOutboundHeaders('kimi', undefined, host)).toEqual({ 'User-Agent': 'kimi-code-cli/1.0', 'X-Msh-Device-Id': 'dev', }); - // non-kimi providers → User-Agent only expect(resolveOutboundHeaders('openai', undefined, host)).toEqual({ 'User-Agent': 'kimi-code-cli/1.0', }); expect(resolveOutboundHeaders('anthropic', undefined, host)).toEqual({ 'User-Agent': 'kimi-code-cli/1.0', }); - // provider customHeaders win on conflict expect(resolveOutboundHeaders('kimi', { 'User-Agent': 'custom' }, host)).toEqual({ 'User-Agent': 'custom', 'X-Msh-Device-Id': 'dev', diff --git a/packages/agent-core-v2/test/app/modelCatalog/modelCatalog.test.ts b/packages/agent-core-v2/test/app/modelCatalog/modelCatalog.test.ts index 4f387e9cfa..5ac93de22e 100644 --- a/packages/agent-core-v2/test/app/modelCatalog/modelCatalog.test.ts +++ b/packages/agent-core-v2/test/app/modelCatalog/modelCatalog.test.ts @@ -238,7 +238,6 @@ describe('ModelCatalogService', () => { }); it('registers and validates the modelCatalog config section', () => { - // Constructing the service registers the section as a side effect. catalog(); const registry = ix.get(IConfigRegistry); expect(registry.getSection(MODEL_CATALOG_SECTION)).toBeDefined(); @@ -266,16 +265,12 @@ describe('ModelCatalogService', () => { }); it('refreshProviderModels returns an empty result and stays silent when nothing is refreshable', async () => { - // `kimi` (api_key) and `openai` are plain API-key providers with no - // server-side catalog endpoint, so the orchestrator has nothing to refresh. const result = await catalog().refreshProviderModels({ scope: 'all' }); expect(result).toEqual({ changed: [], unchanged: [], failed: [] }); expect(publishEvent).not.toHaveBeenCalled(); }); it('serializes concurrent refreshProviderModels runs so they never overlap', async () => { - // Seed the managed OAuth provider so the orchestrator actually refreshes it - // (a plain api-key provider is a no-op and would not exercise the chain). backing.providers = { [KIMI_CODE_PROVIDER_NAME]: { type: 'kimi', @@ -314,8 +309,6 @@ describe('ModelCatalogService', () => { catalog().refreshProviderModels({ scope: 'all' }), ]); - // Without the refresh chain both remote fetches would overlap (peak 2); the - // chain holds the second run until the first finishes, so the peak stays 1. expect(maxInFlight).toBe(1); expect(fetchMock).toHaveBeenCalledTimes(2); }); diff --git a/packages/agent-core-v2/test/app/plugin/github-resolver.test.ts b/packages/agent-core-v2/test/app/plugin/github-resolver.test.ts index f20cd82f14..69c96ff503 100644 --- a/packages/agent-core-v2/test/app/plugin/github-resolver.test.ts +++ b/packages/agent-core-v2/test/app/plugin/github-resolver.test.ts @@ -91,8 +91,6 @@ describe('resolveGithubSource', () => { ref: { kind: 'branch', value: 'v5.1.0' }, }); - // Parser cannot distinguish branch from tag in `/tree/`, but codeload's - // short form resolves either — so no `/refs/heads/` 404. expect(result.tarballUrl).toBe('https://codeload.github.com/obra/superpowers/zip/v5.1.0'); expect(fetchMock).not.toHaveBeenCalled(); }); diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index cd2bc1c719..48bfcc813a 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -161,13 +161,11 @@ describe('FileSessionIndex (legacy)', () => { updatedAt: 8, custom: { parent_session_id: 'parent', child_session_kind: 'child' }, }); - // A plain fork carries `parent_session_id` but no `child_session_kind` — excluded. await seedSession('fork', { createdAt: 4, updatedAt: 7, custom: { parent_session_id: 'parent' }, }); - // A grandchild points at `child-a`, not `parent` — excluded. await seedSession('grandchild', { createdAt: 5, updatedAt: 6, @@ -271,8 +269,6 @@ describe('FileSessionIndex (read model)', () => { expect(first.items.map((s) => s.id)).toEqual(['active']); expect(first.items[0]?.title).toBe('hello'); - // A second list is served from the read model: mutate the read model to - // prove the disk is not re-read. await queryStore.put( SESSION_COLLECTION, 'active', @@ -284,7 +280,6 @@ describe('FileSessionIndex (read model)', () => { it('get prefers the read model over disk', async () => { const store = build(); - // Not seeded on disk — only present in the read model. await queryStore.put(SESSION_COLLECTION, 'warm', summary('warm', { title: 'cached' })); const got = await store.get('warm'); expect(got?.title).toBe('cached'); @@ -301,7 +296,6 @@ describe('FileSessionIndex (read model)', () => { updatedAt: 8, custom: { parent_session_id: 'parent', child_session_kind: 'child' }, }); - // Plain fork (no kind) and a grandchild (different parent) are excluded. await seedSession('fork', { createdAt: 4, updatedAt: 7, @@ -325,7 +319,6 @@ describe('FileSessionIndex (read model)', () => { const store = build(); expect(await store.countActive(workspaceId)).toBe(1); - // Archive `a` through the read model (as SessionMetadata would). await queryStore.put(SESSION_COLLECTION, 'a', summary('a', { archived: true })); expect(await store.countActive(workspaceId)).toBe(0); }); @@ -333,7 +326,6 @@ describe('FileSessionIndex (read model)', () => { it('falls back to the legacy disk path when the query store is locked', async () => { await seedSession('active', { title: 'from disk', createdAt: 1, updatedAt: 2 }); - // Another process holds the single-writer lock on the query-store dir. const lockHolder = await MiniDb.open({ dir: join(homeDir, 'cache', 'query-store'), valueCodec: 'json', @@ -351,13 +343,11 @@ describe('FileSessionIndex (read model)', () => { ]); disposeHost = () => { host.dispose(); }; const store = host.app.accessor.get(ISessionIndex); - // The read model throws storage.locked; the index serves from disk. const page = await store.list({ workspaceId }); expect(page.items.map((s) => s.id)).toEqual(['active']); expect(page.items[0]?.title).toBe('from disk'); expect(await store.get('active')).toMatchObject({ id: 'active', title: 'from disk' }); expect(await store.countActive(workspaceId)).toBe(1); - // The lock is warned about once, then the read model stays disabled. expect(warnings).toEqual(['query-store locked by another process; disabling read model']); } finally { await lockHolder.close(); diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index ec0241861d..87bb9e47ae 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -422,9 +422,6 @@ describe('SessionLifecycleService', () => { InstantiationType.Delayed, 'activity', ); - // The unit under test copies session files through hostFs on fork; the - // real backend has no dependencies and operates on the tmp paths the - // fork tests seed, so register it instead of stubbing. registerScopedService( LifecycleScope.App, IHostFileSystem, @@ -483,8 +480,6 @@ describe('SessionLifecycleService', () => { it('create seeds identity and materializes metadata', async () => { const svc = build(); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - // create() awaits ISessionMetadata.ready, so a resolved handle implies the - // metadata service was resolved inside the new session scope. expect(h.kind).toBe(LifecycleScope.Session); }); @@ -796,8 +791,6 @@ describe('SessionLifecycleService', () => { const resumed = svc.resume('s1'); await tick(); - // materialize has registered the handle in `sessions` and is now blocked on - // ensureMcpReady with `resuming` set — the handle must not be observable yet. expect(svc.get('s1')).toBeUndefined(); expect(svc.list()).toEqual([]); @@ -834,9 +827,6 @@ describe('SessionLifecycleService', () => { expect(archived).toEqual(['s1']); }); - // Mirrors v1's runtime.test.ts additional-dirs coverage: session - // creation/resume must merge `.kimi-code/local.toml` dirs with caller - // additionalDirs into the session workspace context. describe('additional dirs', () => { beforeEach(() => { registerScopedService( @@ -1030,9 +1020,6 @@ describe('SessionLifecycleService', () => { await writeFile(join(srcDir, 'agents', 'main', 'tasks', 'bash-1', 'output.log'), 'out'); await mkdir(join(srcDir, 'media-originals'), { recursive: true }); await writeFile(join(srcDir, 'media-originals', 'x.png'), 'png'); - // Excluded from the copy: state.json (rewritten with fork provenance), - // the wire logs (copied with a fork boundary record), and the source's - // debug log. await writeFile(join(srcDir, 'state.json'), '{"source":true}'); await writeFile(join(srcDir, 'agents', 'main', 'wire.jsonl'), '{"type":"metadata"}\n'); await mkdir(join(srcDir, 'logs'), { recursive: true }); @@ -1056,8 +1043,6 @@ describe('SessionLifecycleService', () => { await expect(readFile(join(dstDir, 'media-originals', 'x.png'), 'utf8')).resolves.toBe( 'png', ); - // The materialize path is stubbed to write nothing, so any of these in - // the target could only have come from the copy. await expect(stat(join(dstDir, 'state.json'))).rejects.toThrow(); await expect(stat(join(dstDir, 'agents', 'main', 'wire.jsonl'))).rejects.toThrow(); await expect(stat(join(dstDir, 'logs'))).rejects.toThrow(); @@ -1078,8 +1063,6 @@ describe('SessionLifecycleService', () => { }), ]); await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); - // Seed one file so the copy materializes the target dir before the - // (stubbed) agent creation rejects. await mkdir(join(srcDir, 'agents', 'main', 'plans'), { recursive: true }); await writeFile(join(srcDir, 'agents', 'main', 'plans', 'p1.md'), '# plan'); const dstDir = join(root, 'sessions', 'wd_stub', 'dst'); @@ -1090,8 +1073,6 @@ describe('SessionLifecycleService', () => { expect(svc.get('dst')).toBeUndefined(); await expect(stat(dstDir)).rejects.toThrow(); - // The registry rollback unblocks a retry with the same ids: it fails - // again at agent creation, not with SESSION_ALREADY_EXISTS. await expect(svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' })).rejects.toThrow( 'not implemented', ); diff --git a/packages/agent-core-v2/test/app/skillCatalog/skill-tool-manager.test.ts b/packages/agent-core-v2/test/app/skillCatalog/skill-tool-manager.test.ts index 1ac0bb5a1a..362519657c 100644 --- a/packages/agent-core-v2/test/app/skillCatalog/skill-tool-manager.test.ts +++ b/packages/agent-core-v2/test/app/skillCatalog/skill-tool-manager.test.ts @@ -259,9 +259,6 @@ describe('ToolManager SkillTool wire behavior', () => { }), }), }); - // `skill.activate` is a live-only Op (`persist: false`): the activation - // fact is not a v1 record type, so only the reminder message lands in the - // wire log — there is no separate `skill.activate` record. expect(persistence.records.some((record) => record.type === 'skill.activate')).toBe(false); expect(context.get().at(-1)).toMatchObject({ role: 'assistant', @@ -328,10 +325,6 @@ describe('ToolManager SkillTool restore behavior', () => { { type: 'context.append_message', message }, ]); - // Replay is silent: `skill.activated` derives from the Op on `dispatch` - // only, so restoring the records re-fires neither the domain event nor - // telemetry (matching the former `restoring` guard); only the context - // message lands back in history. expect(emit).not.toHaveBeenCalledWith( expect.objectContaining({ type: 'skill.activated' }), ); diff --git a/packages/agent-core-v2/test/app/task/task.test.ts b/packages/agent-core-v2/test/app/task/task.test.ts index 271abadc16..80f1560a6a 100644 --- a/packages/agent-core-v2/test/app/task/task.test.ts +++ b/packages/agent-core-v2/test/app/task/task.test.ts @@ -19,7 +19,6 @@ describe('TaskService', () => { }); afterEach(() => disposables.dispose()); - // ── run() basics ────────────────────────────────────────── describe('run()', () => { it('transitions running → completed on success', async () => { @@ -61,7 +60,6 @@ describe('TaskService', () => { }); }); - // ── defer() basics ──────────────────────────────────────── describe('defer()', () => { it('starts in pending state', () => { @@ -84,7 +82,6 @@ describe('TaskService', () => { }); }); - // ── Cancellation ────────────────────────────────────────── describe('cancellation', () => { it('run() cancel aborts the signal and settles as cancelled', async () => { @@ -120,7 +117,6 @@ describe('TaskService', () => { }); }); - // ── Disposal ────────────────────────────────────────────── describe('disposal', () => { it('dispose cancels a running task', async () => { @@ -148,7 +144,6 @@ describe('TaskService', () => { }); }); - // ── State change events ─────────────────────────────────── describe('onDidChangeState', () => { it('fires on each transition for run()', async () => { @@ -157,7 +152,6 @@ describe('TaskService', () => { handle.onDidChangeState((s) => states.push(s)); await handle.result; expect(states).toEqual(['completed']); - // 'running' was already fired before listener was attached }); it('resolve/reject after settlement is ignored on deferred', () => { @@ -172,7 +166,6 @@ describe('TaskService', () => { }); }); - // ── Four consumption patterns ───────────────────────────── describe('consumption patterns', () => { it('resolves the value and completes when awaiting handle.result', async () => { @@ -190,7 +183,6 @@ describe('TaskService', () => { }); registry.set(handle.id, handle); - // Later, retrieve and await const retrieved = registry.get(handle.id)!; const result = await retrieved.result; expect(result).toBe('async-result'); @@ -215,7 +207,6 @@ describe('TaskService', () => { ]); expect(winner.kind).toBe('detach'); - // Handle is still running — task continues independently expect(handle.state).toBe('running'); handle.cancel(); }); @@ -223,7 +214,6 @@ describe('TaskService', () => { it('resolves a deferred handle settled from outside the awaiting turn', async () => { const handle = svc.defer(); - // Simulate resolving from a different "turn" setTimeout(() => handle.resolve('from-outside'), 10); const result = await handle.result; @@ -232,7 +222,6 @@ describe('TaskService', () => { }); }); - // ── ID uniqueness ───────────────────────────────────────── describe('IDs', () => { it('handles have unique IDs', () => { diff --git a/packages/agent-core-v2/test/app/telemetry/stubs.ts b/packages/agent-core-v2/test/app/telemetry/stubs.ts index 256b2a5852..f2534b7309 100644 --- a/packages/agent-core-v2/test/app/telemetry/stubs.ts +++ b/packages/agent-core-v2/test/app/telemetry/stubs.ts @@ -55,10 +55,6 @@ export function recordingTelemetry( return service; } -/** - * Register an empty `ITelemetryService` placeholder. Tests that assert on - * telemetry should register a spy via `additionalServices` instead. - */ export function registerTelemetryServices(reg: ServiceRegistration): void { reg.definePartialInstance(ITelemetryService, {}); } 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 e8e479c246..57dbbe0c1f 100644 --- a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts +++ b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts @@ -113,7 +113,6 @@ describe('WorkspaceRegistryService (file-backed)', () => { sessionDir: join(homeDir, 'sessions', encodeWorkDirKey(workB), 's2'), workDir: workB, }, - // Duplicate workDir → still one workspace. { sessionId: 's3', sessionDir: join(homeDir, 'sessions', encodeWorkDirKey(workA), 's3'), @@ -129,7 +128,6 @@ describe('WorkspaceRegistryService (file-backed)', () => { expect(a?.root).toBe(workA); expect(a?.name).toBe('proj-a'); - // The rebuild is persisted, so a fresh instance reads workspaces.json. expect((await restart().list()).map((w) => w.id).toSorted()).toEqual( list.map((w) => w.id).toSorted(), ); @@ -177,7 +175,6 @@ describe('WorkspaceRegistryService (file-backed)', () => { await expect(build().createOrTouch(missing)).rejects.toMatchObject({ code: ErrorCodes.FS_PATH_NOT_FOUND, }); - // The phantom root must not be cataloged. expect(await build().list()).toEqual([]); }); @@ -201,8 +198,6 @@ describe('WorkspaceRegistryService (file-backed)', () => { it('collapses duplicate registered entries for the same root, preferring the canonical id', async () => { const root = join(homeDir, 'dup'); const canonicalId = encodeWorkDirKey(root); - // Simulate a registry that also holds a legacy id for the same folder (e.g. - // one produced by an older encodeWorkDirKey). const legacyId = 'wd_duplegacy_deadbeef0000'; const entry: PersistedWorkspaceEntry = { root, @@ -211,7 +206,6 @@ describe('WorkspaceRegistryService (file-backed)', () => { last_opened_at: '2026-01-01T00:00:00.000Z', }; await writeWorkspacesJson({ - // Legacy first so the canonical entry must actively replace it. [legacyId]: entry, [canonicalId]: entry, }); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index d88b1feb9c..f68e138a62 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -378,24 +378,11 @@ function defineServiceValue( } } -/** - * Scoped overrides for the execution environment and derived atoms. - * - * The session cwd is controlled via `TestAgentOptions.cwd` (seeded into - * `ISessionContext.cwd`). Host fs is registered at its production App scope, - * where `IFileEditService` consumes it and Agent tools inherit it. The process - * runner and workspace context remain Session-scoped. - */ export interface ExecEnvOverride { readonly hostFs?: IHostFileSystem | Partial; readonly processRunner?: ISessionProcessRunner | Partial; } -/** - * Register a fake execution-environment set for a test session. Any - * unspecified atom keeps the harness default and the real services backed by - * it. - */ export function execEnvServices(override: ExecEnvOverride = {}): TestAgentServiceOverride { const session = sessionServices((reg) => { if (override.processRunner !== undefined) { @@ -421,10 +408,6 @@ export function execEnvServices(override: ExecEnvOverride = {}): TestAgentServic } function resolveHostFsOverride(input: IHostFileSystem | Partial): IHostFileSystem { - // A full impl (class instance or `Proxy` over one) exposes every core - // method. A partial override typically covers only a few of them. If every - // core method is a function, pass the input through unchanged; otherwise - // treat it as a partial override and spread it over the fake defaults. if (isFullHostFs(input)) return input as IHostFileSystem; return createFakeHostFs(input as Partial); } @@ -450,9 +433,6 @@ function isFullHostFs(input: unknown): boolean { function resolveProcessRunnerOverride( input: ISessionProcessRunner | Partial, ): ISessionProcessRunner { - // `ISessionProcessRunner` has only one method (`exec`), so a full impl is - // any object with `exec` as a function. Both `SessionProcessRunner` - // instances and `createFakeProcessRunner()` results satisfy this. if ( typeof input === 'object' && input !== null && @@ -481,12 +461,6 @@ export function homeDirServices(homeDir: string | undefined): TestAgentServiceOv }); } -/** - * Override the App-scope `IHostEnvironment` with a fully-populated POSIX - * snapshot whose `homeDir` points at a hermetic test directory. Used by tests - * that render system prompts / resolve user-level config so a developer's real - * `~/.kimi-code` / `~/.agents` files never leak into the assertions. - */ export function hostEnvironmentServices(homeDir: string): TestAgentServiceOverride { return appServices((reg) => { reg.defineInstance( @@ -663,10 +637,6 @@ export function swarmServices( ]; } -/** - * Build a fake `ISessionProcessRunner` whose `exec` returns a scripted - * `IProcess` emitting `stdout` on stdout and exiting with `exitCode`. - */ export function createCommandRunner(stdout: string, exitCode = 0): ISessionProcessRunner { function createProcess(): IProcess { return { @@ -922,11 +892,6 @@ export class AgentTestContext { })) { reg.defineInstance(id, value); } - // In-memory Storage-layer backend. The `InMemoryStorageService` is no - // longer auto-registered, so the harness seeds it here to keep a - // workable default for storage-backed services. Tests that need durable - // (file) storage override this via `homeDirServices(dir)` — overrides - // win over this base seed (see `collectScopeSeed`). const memoryStorage = (): SyncDescriptor => new SyncDescriptor(InMemoryStorageService, [], true); reg.defineDescriptor(IFileSystemStorageService, memoryStorage()); @@ -946,10 +911,6 @@ export class AgentTestContext { ), ); reg.defineInstance(ILogService, createLogService(undefined)); - // Per-scope `*LogService` bindings (e.g. SessionLogService) resolve their - // level/paths from the App-scope `ILogOptions`. Seed a quiet config so - // harness-built agents can construct them; logs go under the throwaway - // test home dir and `level: 'off'` keeps them from being emitted. reg.defineInstance( ILogOptions, { @@ -981,10 +942,6 @@ export class AgentTestContext { ); } reg.defineInstance(IHostTerminalService, createHostTerminalService()); - // The real `HostEnvironmentService` probes the host asynchronously (`ready`); - // builtin tools (e.g. `BashTool`) read `osKind`/`shellName` synchronously at - // construction, which throws "accessed before ready". Seed a fully-populated - // POSIX snapshot so agent-scope tools construct without awaiting the probe. reg.defineInstance( IHostEnvironment, { @@ -1027,10 +984,6 @@ export class AgentTestContext { reg.defineInstance(ISessionInteractionService, this.createInteractionService()); reg.defineInstance(ISessionApprovalService, this.createApprovalService()); reg.defineInstance(ISessionQuestionService, this.createQuestionService()); - // Note: `IHostFileSystem` (App) and `ISessionProcessRunner` - // (Session) are auto-registered by their service files. Tests that - // need a fake filesystem override its App binding through - // `execEnvServices({ hostFs })`; child scopes inherit it. reg.defineDescriptor( ISessionWorkspaceContext, new SyncDescriptor(SessionWorkspaceContextService), @@ -1045,9 +998,6 @@ export class AgentTestContext { 'session', ), }); - // The harness builds scopes directly (bypassing SessionLifecycleService), so - // drive the Session activity kernel to `active` here — the lifecycle would - // do this in `announceCreated` after materialize / replay. this.session.accessor.get(ISessionActivityKernel).markActive(); const workspace = this.session.accessor.get(ISessionWorkspaceContext); @@ -1121,10 +1071,6 @@ export class AgentTestContext { const wire = this.get(IAgentWireService); this.disposables.push( wire.onEmission((e) => { - // `onEmission` is the record-only channel: `dispatch` persists each record - // through the append log and emits it here for `[wire]` snapshot capture. - // Op-derived facts (formerly signals) ride `IEventBus` instead — see the - // subscription below. this.captureRecord(e.record as PersistedWireRecord); }), ); @@ -1198,23 +1144,10 @@ export class AgentTestContext { const permissionRules = this.get(IAgentPermissionRulesService); const cron = this.get(ISessionCronService); const plan = this.get(IAgentPlanService); - // Force-instantiate the Eager builtin-tools registrar: its constructor - // consumes every `registerTool(...)` contribution, so `Read`/`Write`/ - // `Bash`/etc. land in the per-agent registry the same way they would - // under a real Agent scope (see `AgentLifecycleService.create`). this.get(IAgentBuiltinToolsRegistrar); - // The tool-call dedupe plugin is self-wiring too and nothing injects it. - // Ignite it BEFORE external hooks (whose construction transitively builds - // the permission gate) so `toolDedupe` stays ahead of `permission` on - // `onBeforeExecuteTool`, matching `AgentLifecycleService`. this.get(IAgentToolDedupeService); this.get(IAgentExternalHooksService); - // The step-retry plugin registers its loop error handler at construction; - // nothing pulls it lazily, so ignite it the way `AgentLifecycleService` - // does, or turns driven directly through `loop.run` would never retry. this.get(IAgentStepRetryService); - // Same for the loop-continuation aspect: it only observes `afterStep`, so - // without ignition no tool-using turn would ever get its next step. this.get(IAgentLoopContinuationService); const tasks = this.get(IAgentTaskService); const permission = this.get(IAgentPermissionGate); @@ -1849,8 +1782,6 @@ export class AgentTestContext { inputCacheRead: 0, inputCacheCreation: 0, }; - // Persist both the context-size measurement and turn-scoped usage so resume - // rebuilds size and usage the same way the real loop does. const context = this.get(IAgentContextMemoryService); const contextSize = this.get(IAgentContextSizeService); contextSize.measured(context.get(), [], usage); @@ -1966,12 +1897,6 @@ const failOnResumeGenerate: GenerateFn = async () => { function resumeStateSnapshot(ctx: AgentTestContext): ResumeStateSnapshot { const usage = ctx.get(IAgentUsageService); const permission = ctx.get(IAgentPermissionGate); - // Live-only state is excluded from the resume comparison: the measured - // context token count, the per-turn usage accumulator, runtime-added - // permission rules (`permission.rules.add` is persist: false), and the task - // list (`task.started` / `task.terminated` are persist: false — tasks - // restore from their own persistence, not the wire log) are intentionally - // not persisted (v1 parity) and reset on resume. const { currentTurn: _currentTurn, ...usageStatus } = usage.status(); const { rules: _rules, ...permissionData } = permission.data(); return { @@ -1991,8 +1916,6 @@ function stripUndefinedFields(value: T): T { function resumeContextSnapshot(ctx: AgentTestContext) { const context = ctx.contextData(); return { - // `tokenCount` (the measured prefix) is live-only and resets on resume; - // compare the history only. history: context.history .filter((message) => !isSystemReminderMessage(message)) .map(stripMessageId), @@ -2078,9 +2001,6 @@ function taskNotificationKey(taskId: string, status: string): string { function configStateSnapshot(ctx: AgentTestContext): ResumeStateSnapshot['config'] { const profile = ctx.get(IAgentProfileService); const data = profile.data(); - // A restored alias may be unresolvable locally (the model is not in this - // config.toml); the resume comparison then carries no provider rather than - // failing the whole snapshot. let model: ReturnType; try { model = profile.resolveModel(); diff --git a/packages/agent-core-v2/test/index.test.ts b/packages/agent-core-v2/test/index.test.ts index 9e0044f7fd..8a7890952c 100644 --- a/packages/agent-core-v2/test/index.test.ts +++ b/packages/agent-core-v2/test/index.test.ts @@ -30,12 +30,6 @@ import { IAgentWireService } from '#/wire/tokens'; import type { PersistedRecord } from '#/wire/wireService'; import { WireService } from '#/wire/wireServiceImpl'; -/** - * v1's wire-record vocabulary: the `AgentRecordEvents` keys from - * `packages/agent-core/src/agent/records/types.ts`, plus the `metadata` - * envelope. Every record v2 persists must use one of these types so a - * v2-written `wire.jsonl` stays byte-compatible with v1. - */ const V1_RECORD_TYPES: ReadonlySet = new Set([ 'metadata', 'forked', @@ -442,7 +436,6 @@ describe('IAgentWireRecordService.records()', () => { .map((record) => record.type) .filter((type) => type !== 'config.update'); expect(types).toEqual(['context.append_message']); - // A copy is returned, so mutating it must not affect the service. const lengthBefore = records.getRecords().length; (snapshot as unknown as PersistedWireRecord[]).pop(); expect(records.getRecords()).toHaveLength(lengthBefore); @@ -468,252 +461,6 @@ describe('IAgentWireRecordService.records()', () => { }); describe.skip('agent replay range build', () => { - // TODO(phase-4.6): rewrite against wire resume — buildReplay() facade deleted - /* - it('returns the complete replay when no range is requested', async () => { - const firstMessage = userMessage('first'); - const afterClearMessage = userMessage('after-clear'); - const records: PersistedWireRecord[] = [ - { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, - { type: 'context.splice', start: 0, deleteCount: 0, messages: [firstMessage] }, - { type: 'context.splice', start: 0, deleteCount: 1, messages: [] }, - { type: 'context.splice', start: 0, deleteCount: 0, messages: [afterClearMessage] }, - ]; - - await expect(buildReplay(records)).resolves.toEqual([ - expect.objectContaining({ type: 'message', message: expect.objectContaining(firstMessage) }), - expect.objectContaining({ type: 'message', message: expect.objectContaining(afterClearMessage) }), - ]); - }); - - it('applies start and count to replay records instead of wire records', async () => { - const message = userMessage('hello'); - const persistence = new RecordingInMemoryWireRecordPersistence([ - { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, - { - type: 'usage.record', - model: 'mock-model', - usage: { inputOther: 1, inputCacheRead: 0, inputCacheCreation: 0, output: 1 }, - }, - { - type: 'config.update', - cwd: process.cwd(), - thinkingLevel: 'off', - }, - { - type: 'usage.record', - model: 'mock-model', - usage: { inputOther: 2, inputCacheRead: 0, inputCacheCreation: 0, output: 1 }, - }, - { type: 'permission.set_mode', mode: 'yolo' }, - { type: 'context.splice', start: 0, deleteCount: 0, messages: [message] }, - ]); - - const replay = await buildReplayFromPersistence(persistence, { start: 1, count: 2 }); - - expect(replay).toEqual([ - expect.objectContaining({ type: 'permission_updated', mode: 'yolo' }), - expect.objectContaining({ type: 'message', message: expect.objectContaining(message) }), - ]); - expect(persistence.rewrites).toEqual([]); - }); - - it('returns the last count replay records when start is omitted', async () => { - const firstMessage = userMessage('first'); - const secondMessage = userMessage('second'); - const thirdMessage = userMessage('third'); - const records: PersistedWireRecord[] = [ - { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, - { type: 'context.splice', start: 0, deleteCount: 0, messages: [firstMessage] }, - { type: 'permission.set_mode', mode: 'auto' }, - { type: 'context.splice', start: 1, deleteCount: 0, messages: [secondMessage] }, - { type: 'context.splice', start: 2, deleteCount: 0, messages: [thirdMessage] }, - ]; - - await expect(buildReplay(records, { count: 2 })).resolves.toEqual([ - expect.objectContaining({ type: 'message', message: expect.objectContaining(secondMessage) }), - expect.objectContaining({ type: 'message', message: expect.objectContaining(thirdMessage) }), - ]); - await expect(buildReplay(records, { count: 10 })).resolves.toEqual([ - expect.objectContaining({ type: 'message', message: expect.objectContaining(firstMessage) }), - expect.objectContaining({ type: 'permission_updated', mode: 'auto' }), - expect.objectContaining({ type: 'message', message: expect.objectContaining(secondMessage) }), - expect.objectContaining({ type: 'message', message: expect.objectContaining(thirdMessage) }), - ]); - }); - - it('continues reading all segments before returning the last count replay records', async () => { - const beforeClearMessages = Array.from({ length: 50 }, (_item, index) => - userMessage(`before-clear-${String(index)}`), - ); - const afterClearMessages = Array.from({ length: 50 }, (_item, index) => - userMessage(`after-clear-${String(index)}`), - ); - const records: PersistedWireRecord[] = [ - { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, - ...beforeClearMessages.map((message, index) => ({ - type: 'context.splice' as const, - start: index, - deleteCount: 0, - messages: [message], - })), - { type: 'context.splice', start: 0, deleteCount: 50, messages: [] }, - ...afterClearMessages.map((message, index) => ({ - type: 'context.splice' as const, - start: index, - deleteCount: 0, - messages: [message], - })), - ]; - - const replay = await buildReplay(records, { count: 10 }); - - expect(replay).toHaveLength(10); - expect(replay).toEqual( - afterClearMessages.slice(-10).map((message) => - expect.objectContaining({ type: 'message', message: expect.objectContaining(message) }), - ), - ); - }); - - it('continues reading after count so canonical compaction splices can patch captured replay cards', async () => { - const records: PersistedWireRecord[] = [ - { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, - { type: 'full_compaction.begin', source: 'manual', instruction: 'keep facts' }, - { - type: 'context.splice', - start: 0, - deleteCount: 0, - messages: [compactionSummaryMessage('Compacted summary.')], - }, - { - type: 'full_compaction.complete', - compactedCount: 0, - tokensBefore: 10, - tokensAfter: 3, - }, - { type: 'permission.set_mode', mode: 'auto' }, - ]; - - await expect(buildReplay(records, { start: 0, count: 1 })).resolves.toEqual([ - expect.objectContaining({ - type: 'compaction', - instruction: 'keep facts', - result: { - summary: 'Compacted summary.', - compactedCount: 0, - tokensBefore: expect.any(Number), - tokensAfter: expect.any(Number), - }, - }), - ]); - }); - - it('projects canonical compaction summary splices as replay cards', async () => { - const before = userMessage('before compaction'); - const replay = await buildReplay([ - { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, - { type: 'context.splice', start: 0, deleteCount: 0, messages: [before] }, - { type: 'full_compaction.begin', source: 'manual', instruction: 'keep facts' }, - { - type: 'context.splice', - start: 0, - deleteCount: 1, - messages: [compactionSummaryMessage('Compacted summary.')], - }, - { - type: 'full_compaction.complete', - compactedCount: 1, - tokensBefore: 20, - tokensAfter: 4, - }, - ]); - - expect(replay).toEqual([ - expect.objectContaining({ type: 'message', message: expect.objectContaining(before) }), - expect.objectContaining({ - type: 'compaction', - instruction: 'keep facts', - result: { - summary: 'Compacted summary.', - compactedCount: 1, - tokensBefore: expect.any(Number), - tokensAfter: expect.any(Number), - }, - }), - ]); - }); - - it('projects restored cancelled compactions as replay cards', async () => { - await expect(buildReplay([ - { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, - { type: 'full_compaction.begin', source: 'manual', instruction: 'keep facts' }, - { type: 'full_compaction.cancel' }, - ])).resolves.toEqual([ - expect.objectContaining({ - type: 'compaction', - instruction: 'keep facts', - result: 'cancelled', - }), - ]); - }); - - it('does not rewrite migrated wire records while projecting', async () => { - const persistence = new RecordingInMemoryWireRecordPersistence([ - { type: 'metadata', protocol_version: '1.0', created_at: 1 }, - { type: 'permission.set_mode', mode: 'auto' }, - ]); - - await expect(buildReplayFromPersistence(persistence, { start: 0, count: 1 })).resolves.toEqual([ - expect.objectContaining({ type: 'permission_updated', mode: 'auto' }), - ]); - expect(persistence.rewrites).toEqual([]); - }); - - it('keeps the start offset correct when undo removes more messages than count', async () => { - const firstMessage = userMessage('first'); - const removedBeforeStart = userMessage('removed-before-start'); - const removedAtStart = userMessage('removed-at-start'); - const removedAfterStart = userMessage('removed-after-start'); - const nextMessage = userMessage('next'); - const expectedMessage = userMessage('expected'); - const records: PersistedWireRecord[] = [ - { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, - { type: 'context.splice', start: 0, deleteCount: 0, messages: [firstMessage] }, - { type: 'context.splice', start: 1, deleteCount: 0, messages: [removedBeforeStart] }, - { type: 'context.splice', start: 2, deleteCount: 0, messages: [removedAtStart] }, - { type: 'context.splice', start: 3, deleteCount: 0, messages: [removedAfterStart] }, - { type: 'context.splice', start: 1, deleteCount: 3, messages: [] }, - { type: 'context.splice', start: 1, deleteCount: 0, messages: [nextMessage] }, - { type: 'context.splice', start: 2, deleteCount: 0, messages: [expectedMessage] }, - ]; - - await expect(buildReplay(records, { start: 2, count: 1 })).resolves.toEqual([ - expect.objectContaining({ type: 'message', message: expect.objectContaining(expectedMessage) }), - ]); - }); - - it('clamps results at undo boundaries', async () => { - const firstMessage = userMessage('first'); - const secondMessage = userMessage('second'); - const afterClearMessage = userMessage('after-clear'); - const records: PersistedWireRecord[] = [ - { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, - { type: 'context.splice', start: 0, deleteCount: 0, messages: [firstMessage] }, - { type: 'context.splice', start: 1, deleteCount: 0, messages: [secondMessage] }, - { type: 'context.splice', start: 0, deleteCount: 2, messages: [] }, - { type: 'context.splice', start: 0, deleteCount: 0, messages: [afterClearMessage] }, - ]; - - await expect(buildReplay(records, { start: 0, count: 10 })).resolves.toEqual([ - expect.objectContaining({ type: 'message', message: expect.objectContaining(firstMessage) }), - expect.objectContaining({ type: 'message', message: expect.objectContaining(secondMessage) }), - ]); - await expect(buildReplay(records, { start: 2, count: 10 })).resolves.toEqual([ - expect.objectContaining({ type: 'message', message: expect.objectContaining(afterClearMessage) }), - ]); - }); - */ }); class RecordingInMemoryWireRecordPersistence extends InMemoryWireRecordPersistence { @@ -725,40 +472,6 @@ class RecordingInMemoryWireRecordPersistence extends InMemoryWireRecordPersisten } } -// TODO(phase-4.6): rewrite against wire resume — buildReplay()/replayServices facade deleted -// async function buildReplay( -// records: readonly PersistedWireRecord[], -// range?: ReplayRangeOptions, -// ) { -// return buildReplayFromPersistence( -// new InMemoryWireRecordPersistence(records), -// range, -// ); -// } -// -// async function buildReplayFromPersistence( -// persistence: InMemoryWireRecordPersistence, -// range?: ReplayRangeOptions, -// ) { -// const ctx = createTestAgent( -// { persistence, autoConfigure: false }, -// replayServices(range === undefined ? {} : { range }), -// ); -// const fullCompaction = ctx.get(IAgentFullCompactionService); -// const replay = ctx.get(IAgentRecordService); -// try { -// const isCompacting = fullCompaction.isCompacting; -// if (isCompacting) throw new Error('Unexpected active compaction before restore'); -// await ctx.restorePersisted({ rewriteMigratedRecords: false }); -// return replay.buildReplay(); -// } finally { -// try { -// await ctx.expectResumeMatches(); -// } finally { -// await ctx.dispose(); -// } -// } -// } function userMessage(text: string): ContextMessage { return { diff --git a/packages/agent-core-v2/test/lint/op-uniqueness.test.ts b/packages/agent-core-v2/test/lint/op-uniqueness.test.ts index f25dd09669..1637513369 100644 --- a/packages/agent-core-v2/test/lint/op-uniqueness.test.ts +++ b/packages/agent-core-v2/test/lint/op-uniqueness.test.ts @@ -41,7 +41,6 @@ const transientOp = testModel.defineOp('test.op.transient', { }); function typecheckRegisteredOps(): void { - // The registry recovers each Op's payload from the Op's own type. type RegisteredPayload = OpPayload<'test.op.persisted'>; const registeredPayload: RegisteredPayload = { value: 1 }; persistedOp(registeredPayload); diff --git a/packages/agent-core-v2/test/os/backends/node-local/hostFsWatchService.test.ts b/packages/agent-core-v2/test/os/backends/node-local/hostFsWatchService.test.ts index e3305dc769..d4cc51e68e 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/hostFsWatchService.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/hostFsWatchService.test.ts @@ -29,7 +29,6 @@ describe('HostFsWatchService', () => { const svc = new HostFsWatchService(); handle = svc.watch(root, { recursive }); handle.onDidChange((e) => events.push(e)); - // Let chokidar arm before the test mutates the tree. await wait(200); return events; } diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts index 29dfe5579b..dac7067dba 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts @@ -61,7 +61,6 @@ const windowsBashEnv: IHostEnvironment = { ready: Promise.resolve(), }; -// ── Fake IProcess factories ────────────────────────────────────────── function processWithOutput( options: { @@ -283,7 +282,6 @@ function processWithOpenStreamsThatExitOnKill(): IProcess { }; } -// ── Fake IHostEnvironment / ISessionContext ──────────────────────────── function createTestEnv(env: IHostEnvironment = posixEnv): IHostEnvironment { return env; @@ -299,7 +297,6 @@ function createTestCtx(cwd = '/workspace'): ISessionContext { }); } -// ── Fake ISessionProcessRunner ────────────────────────────────────────────── function createTestRunner(proc: IProcess | ReturnType) { const exec = typeof proc === 'function' ? proc : vi.fn().mockResolvedValue(proc); @@ -307,7 +304,6 @@ function createTestRunner(proc: IProcess | ReturnType) { return { runner, exec }; } -// ── Fake IAgentTaskService ────────────────────────────────────────── const TERMINAL_STATUSES: ReadonlySet = new Set([ 'completed', @@ -432,7 +428,6 @@ function createFakeTaskService(options: { maxRunningTasks?: number } = {}): { try { await entry.task.forceStop?.(); } catch { - /* best effort */ } } if (isTerminal(entry.status)) return entryToInfo(entry); @@ -464,7 +459,6 @@ function createFakeTaskService(options: { maxRunningTasks?: number } = {}): { try { entry.task.onDetach?.(); } catch { - /* detach already succeeded */ } release.resolve(viaTimeout ? 'timeout_detached' : 'detached'); return entryToInfo(entry); @@ -513,9 +507,6 @@ function createFakeTaskService(options: { maxRunningTasks?: number } = {}): { const timeoutMs = registerOptions.timeoutMs; if (timeoutMs !== undefined && timeoutMs > 0) { entry.timeoutHandle = setTimeout(() => { - // Mirror production: a foreground task opted into auto-background - // detaches to the background on its first deadline instead of - // being killed. if ( registerOptions.autoBackgroundOnTimeout === true && entry.foregroundRelease !== undefined @@ -607,7 +598,6 @@ function createFakeTaskService(options: { maxRunningTasks?: number } = {}): { }, async suppressTerminalNotification(): Promise { - /* no-op in the fake */ }, detach(taskId: string): AgentTaskInfo | undefined { @@ -678,7 +668,6 @@ function createFakeTaskService(options: { maxRunningTasks?: number } = {}): { return { service, tasks, persisted }; } -// ── Test execution helper ──────────────────────────────────────────── function context( args: BashInput, @@ -728,7 +717,6 @@ function bashTool( return new BashTool(runner, env, ctx, background, profile, config); } -// ── Tests ──────────────────────────────────────────────────────────── describe('BashTool', () => { it('exposes current metadata and schema', () => { @@ -995,8 +983,6 @@ describe('BashTool', () => { await vi.advanceTimersByTimeAsync(1); const result = await running; - // The 2s deadline is interpreted as seconds — and instead of killing, - // the command moves to the background (auto-background is on by default). expect(proc.kill).not.toHaveBeenCalled(); expect(result).toMatchObject({ isError: false, @@ -1176,7 +1162,6 @@ describe('BashTool', () => { expect(output).toContain('[...truncated]'); expect(output).toContain('[Full output saved]'); expect(taskId).toBeTruthy(); - // The inline truncation must have started early persistence of the full log. expect(persisted.has(taskId!)).toBe(true); expect(output).toContain(`output_path: /fake/tasks/${taskId}/output.log`); expect(output).toContain('Use Read with output_path'); @@ -1249,8 +1234,6 @@ describe('BashTool', () => { expect(description).toContain('**Guidelines for efficiency:**'); expect(description).toContain('run_in_background=true'); expect(description).toContain('automatically notified'); - // Moved here from system.md: the "don't block on a background task" nudge belongs in - // the background-enabled Bash description, the only place that documents it. expect(description).toContain('returning control to the user'); }); @@ -1264,8 +1247,6 @@ describe('BashTool', () => { stubProfile((name) => name !== 'TaskList'), ); - // Background management needs TaskList, TaskOutput, and TaskStop; without - // TaskList the description must fall back to the disabled variant. expect(tool.description).toContain('Background execution is disabled for this agent'); const result = await executeTool( @@ -1294,7 +1275,6 @@ describe('BashTool', () => { expect(killOnTimeout.description).not.toContain('moved to the background instead of being killed'); expect(killOnTimeout.description).toContain('hits its timeout is killed'); - // The legacy [background] section opts out the same way while configs migrate. const legacyKillOnTimeout = bashTool( runner, createTestEnv(), @@ -1431,8 +1411,6 @@ describe('BashTool background mode', () => { expect(taskId).toBeDefined(); expect(service.getTask(taskId!)).toMatchObject({ status: 'running', detached: true }); - // The backgrounded command keeps streaming output and settles through - // the manager like any other background task. (proc.stdout as PassThrough).write('after timeout\n'); finish(0); await vi.advanceTimersByTimeAsync(1); @@ -1556,7 +1534,6 @@ describe('BashTool background mode', () => { expect(result.output).toContain('automatic_notification: true'); expect(result).toMatchObject({ message: 'Background task started.' }); expect((result as { brief?: string }).brief).toMatch(/^Started bash-[0-9a-z]{8}$/); - // The launch message must steer away from waiting, not invite a TaskOutput peek. expect(result.output).toContain('do NOT wait, poll, or call TaskOutput on it'); expect(result.output).not.toContain('block=false'); expect(service.list(false)).toHaveLength(1); @@ -1792,9 +1769,6 @@ describe('BashTool prompt / runtime consistency', () => { it('reports unavailable background using only tools the prompt documents', async () => { const { runner } = createTestRunner(processWithOutput()); - // The set of background tools the prompt actually introduces — taken from - // the background-enabled prompt, which is the only variant that documents - // any Task* tool. const enabledTool = bashTool(runner); const promptToolNames = new Set( [...enabledTool.description.matchAll(/`(Task[A-Za-z]+)`/g)].map((match) => match[1]), @@ -1812,9 +1786,6 @@ describe('BashTool prompt / runtime consistency', () => { (match) => match[1], ); - // The unavailable-background error message must not name a tool that the - // prompt never introduces, otherwise the model is told about a tool it - // has no guidance for. for (const name of errorToolNames) { expect(promptToolNames).toContain(name); } @@ -1825,8 +1796,6 @@ describe('BashTool prompt / runtime consistency', () => { const { runner } = createTestRunner(processWithOutput()); const tool = bashTool(runner); - // The implementation reports failures as plain text inside the output - // (`Command failed with exit code: N`), never via a system tag. expect(tool.description).not.toMatch(/exit code will be provided in a system tag/); }); }); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts index 0e31d59f07..7195c4130b 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts @@ -36,10 +36,6 @@ import { probeHostEnvironmentFromNode } from '#/_base/execEnv/environmentProbe'; import type { ITelemetryService, TelemetryProperties } from '#/app/telemetry/telemetry'; import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/tool/toolContract'; -// The ripgrep binary locator is mocked out for unit tests so they assert on -// argument building and output parsing without probing a real `rg`. The -// integration suite below imports the real locator and feeds its resolution -// back into this mock, matching the v1 test flow. vi.mock('#/os/backends/node-local/tools/rgLocator', () => ({ ensureRgPath: vi.fn(async (): Promise<{ path: string; source: string }> => ({ path: 'rg', @@ -60,7 +56,6 @@ function fileStat(): HostFileStat { return { isFile: true, isDirectory: false, size: 0 }; } -/** Fake fs with a spied `stat` for the directory pre-check. */ function createTestFs(opts: { stat?: ReturnType; readdir?: ReturnType } = {}) { const stat = opts.stat ?? vi.fn(async (): Promise => dirStat()); const readdir = opts.readdir ?? vi.fn(async (): Promise => []); @@ -68,7 +63,6 @@ function createTestFs(opts: { stat?: ReturnType; readdir?: ReturnT return { fs, stat, readdir }; } -/** Build a fake `IHostProcess` that emits `stdout` / `stderr` then exits with `exitCode`. */ function fakeProcess(stdout: string, stderr = '', exitCode = 0): IHostProcess { const stdoutStream = Readable.from([Buffer.from(stdout)]); const stderrStream = Readable.from([Buffer.from(stderr)]); @@ -119,7 +113,6 @@ function createRealRgProbe(processService: IHostProcessService): RgProbe { try { proc.stdin.end(); } catch { - /* already gone */ } proc.stdout.resume(); proc.stderr.resume(); @@ -127,18 +120,12 @@ function createRealRgProbe(processService: IHostProcessService): RgProbe { try { proc.dispose(); } catch { - /* best-effort cleanup */ } return { exitCode }; }, }; } -/** - * `withCwd(dir)` shim — the v1 tests asserted on `kaos.withCwd(dir)`; the v2 - * tool passes the search root via `options.cwd` to `processService.spawn`, so - * translate that into a `withCwd` spy for the assertion sites. - */ function withCwdOf(exec: ReturnType): { toHaveBeenCalledWith: (dir: string) => void; toHaveBeenCalled: () => void; not: { toHaveBeenCalled: () => void; toHaveBeenCalledWith: (dir: string) => void } } { const cwds = () => ( @@ -159,7 +146,6 @@ function withCwdOf(exec: ReturnType): { toHaveBeenCalledWith: (dir } function execArgs(exec: ReturnType): string[] { - // spawn(command, args, options) — the rg argv is the second parameter. return (exec.mock.calls[0] as ReadonlyArray)[1] as string[]; } @@ -220,7 +206,6 @@ function toolContentString(result: ExecutableToolResult): string { return c; } -/** Build a `GlobTool` with the given spawn spy, using a fake env + process service. */ function makeTool( workspaceConfig: ISessionWorkspaceContext, opts: { @@ -839,10 +824,6 @@ describe('splitCompletePaths', () => { }); describe('GlobTool integration (real ripgrep)', () => { - // Spawns the actual `rg` binary through a real `HostProcessService` so the - // ripgrep semantics the tool relies on (sort direction, recursion, brace - // handling, cwd-relative matching) are exercised end-to-end — not just the - // argument plumbing. let tmpDir: string | undefined; let realEnv: IHostEnvironment; @@ -862,7 +843,6 @@ describe('GlobTool integration (real ripgrep)', () => { vi.mocked(ensureRgPath).mockResolvedValue(resolution); runRealRg = true; } catch { - // rg unavailable in this environment; beforeEach skips the suite. } }); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts index ce83e15ad6..a6d89464c8 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts @@ -50,9 +50,6 @@ vi.mock('#/os/backends/node-local/tools/rgLocator', () => ({ const signal = new AbortController().signal; const workspace: WorkspaceConfig = { workspaceDir: '/workspace', additionalDirs: ['/extra'] }; -// `--max-columns` is applied only outside `content` output mode, so it is kept -// as a separate segment: non-content modes use `DEFAULT_RG_ARGS`, while -// `content` mode uses `CONTENT_RG_ARGS` without the column cap. const MAX_COLUMNS_RG_ARGS = ['--max-columns', '500'] as const; const COMMON_RG_ARGS = [ '--null', @@ -298,8 +295,6 @@ describe('GrepTool', () => { reg.defineInstance(IHostEnvironment, createTestEnv(kaos)); reg.defineInstance(ISessionWorkspaceContext, stubWorkspaceContext('/workspace')); reg.defineInstance(ITelemetryService, noopTelemetryService); - // Registered at Session scope in production (the strict container - // throws on unresolvable deps, so the stub mirrors that). reg.defineInstance(ISessionSkillCatalog, { _serviceBrand: undefined, catalog: { getSkillRoots: () => [] }, @@ -417,7 +412,6 @@ describe('GrepTool', () => { properties: Record; }; expect(params.properties['output_mode']?.description).toContain('count_matches'); - // count_matches emits per-file `path:count`, not a single total (grep.ts). expect(params.properties['output_mode']?.description).toContain('per-file'); }); @@ -426,7 +420,6 @@ describe('GrepTool', () => { const params = tool.parameters as { properties: Record; }; - // grep.ts sorts files_with_matches by mtime descending (b.mtime - a.mtime). expect(params.properties['output_mode']?.description).toContain('most-recently-modified'); }); @@ -1483,9 +1476,6 @@ describe('GrepTool', () => { }); it('keeps the count summary ahead of the body so the char cap cannot drop it', async () => { - // With head_limit: 0 the count rows are unbounded and can exceed ToolResultBuilder's - // char cap. The aggregate total must still reach the model, so it leads the output - // (a header before the rows) — truncation can only eat the rows, never the total. const fileCount = 5000; const stdout = Array.from({ length: fileCount }, (_, i) => `/workspace/f${String(i)}.txt:3`).join('\n') + '\n'; @@ -1501,7 +1491,6 @@ describe('GrepTool', () => { const output = toolContentString(result); const summary = `Found ${String(fileCount * 3)} total occurrences across ${String(fileCount)} files.`; expect(output).toContain(summary); - // The body was large enough to truncate; the summary survives because it leads it. expect(output).toContain('[...truncated]'); expect(output.indexOf(summary)).toBeLessThan(output.indexOf('[...truncated]')); }); @@ -1524,10 +1513,6 @@ describe('GrepTool', () => { }); it('forces filename in count_matches argv so single-file searches stay consistent', async () => { - // ripgrep omits the filename in --count-matches output when only one file - // is searched, so the tool must pass --with-filename. Otherwise the - // per-file display line and the summary disagree (e.g. `25850` followed by - // `Found 0 total occurrences across 0 files.`). const stdout = `${nullRecord('/workspace/src/only.ts', '25850')}\n`; const exec = vi.fn().mockResolvedValue(processWithOutput(stdout)); const tool = new GrepTool(createFakeKaos({ exec }), workspace); @@ -1740,9 +1725,6 @@ describe('GrepTool', () => { }); it('appends the count-mode summary and pagination to the model-visible output', async () => { - // The "Found N occurrences" summary and the pagination notice must ride in `output`: - // `result.message` is dropped before the result reaches the model, so a side channel - // would hide the total and the "use offset=N to see more" cue. const counts = Array.from( { length: 10 }, (_, i) => `/workspace/f${String(i)}.txt:3`, @@ -1760,16 +1742,13 @@ describe('GrepTool', () => { const output = toolContentString(result); const dataLines = output.split('\n').filter((line) => /^f\d+\.txt:3$/.test(line)); - expect(dataLines).toHaveLength(3); // head_limit=3 path:count lines + expect(dataLines).toHaveLength(3); expect(output).toContain('Found 30 total occurrences across 10 files.'); expect(output).toContain('Results truncated to 3 lines (total: 10). Use offset=3 to see more.'); - // ...and nothing model-relevant is hidden in the dropped message channel. expect((result as { message?: string }).message ?? '').not.toContain('Found'); }); it('truncates extremely long rg output with a byte-level safety cap message', async () => { - // py applies a DEFAULT_MAX_CHARS truncation in addition to head_limit; - // checks the message contains "Output is truncated". const longLine = '/workspace/big.txt:1:' + 'x'.repeat(100); const stdout = `${Array.from({ length: 5000 }, () => longLine).join('\n')}\n`; const exec = vi.fn().mockResolvedValue(processWithOutput(stdout)); @@ -1916,7 +1895,6 @@ describe('GrepTool', () => { ); const flags = exec.mock.calls[0] as string[]; - // -C takes precedence over -A/-B in content mode (matches existing TS lockdown) expect(flags).toContain('-i'); expect(flags).toContain('-U'); expect(flags).toContain('--multiline-dotall'); @@ -1930,9 +1908,6 @@ describe('GrepTool', () => { expect(flags[ddIdx + 1]).toBe('test'); expect(flags[ddIdx + 2]).toBe('/workspace'); - // expanduser on ~ in path: assert the exact post-expansion path so - // the test fails if Grep silently treats `~` as a literal directory - // (canonicalizes to "/home/test/~/foo") instead of expanding it. const homeTool = new GrepTool( createFakeKaos({ exec, gethome: () => '/home/test' }), { workspaceDir: '/home/test', additionalDirs: [] }, @@ -2070,8 +2045,6 @@ describe('GrepTool', () => { const output = toolContentString(result); expect(output).toContain('/tmp/abc/file.py'); expect(output).toContain('file.py'); - // The /tmp/a entry should be relativized to "file.py"; the /tmp/abc - // entry must stay absolute so it does not collide with the relative form. expect(output.split('\n')).toEqual(expect.arrayContaining(['file.py', '/tmp/abc/file.py'])); }); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts index f18067ec8f..2296480fa9 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts @@ -79,10 +79,6 @@ function createTestEnv(home = '/home'): IHostEnvironment { }; } -/** - * Fake fs backed by a single text content for any path. All IO methods are - * vi.fn() spies so tests can assert on the sniff/readLines/readText calls. - */ function createSpiedFs(content: string) { const bytes = Buffer.from(content, 'utf8'); const readBytes = vi.fn(async (_path: string, n?: number) => @@ -106,10 +102,6 @@ interface FakeFile { ) => AsyncGenerator; } -/** - * Fake fs backed by an in-memory path → file map. `stat` throws ENOENT for - * unknown paths; IO methods are vi.fn() spies for call assertions. - */ function createSpiedMapFs(files: Record) { const lookup = (path: string): FakeFile | undefined => files[path]; const readBytes = vi.fn(async (path: string, n?: number) => { @@ -326,8 +318,6 @@ describe('ReadTool', () => { skillCatalog, ); - // Same shape as the rejection above (`../../` escapes the workspace), but - // the canonical path lands inside a catalog skill root. const result = await execute(tool, { path: '../../skills/SKILL.md' }); expect(result.isError ?? false).toBe(false); @@ -424,9 +414,6 @@ describe('ReadTool', () => { }); it('rejects an image-extension file whose bytes are not an image as not readable', async () => { - // A `.png` file with no recognisable image magic and no NUL byte is not a - // real image; it must fall through to the generic "not readable" error - // rather than being misidentified as an image and sent to ReadMediaFile. const plainText = Buffer.from('this is plain ascii text, not a png'); const { fs, readText } = createSpiedMapFs({ '/tmp/fake.png': { bytes: plainText }, @@ -657,12 +644,9 @@ describe('ReadTool', () => { it('description pins line/byte caps, tail mode, and the Grep-over-Read preference', () => { const tool = toolWithContent(''); - // Numeric caps are part of the stable contract. expect(tool.description).toContain(String(MAX_LINES)); expect(tool.description).toContain(String(MAX_LINE_LENGTH)); - // Tail mode (negative line_offset) is documented. expect(tool.description).toMatch(/negative line_offset|reads from the end/i); - // Recommend Grep when searching for unknown content. expect(tool.description).toContain('Grep'); }); @@ -783,7 +767,6 @@ describe('ReadTool', () => { const result = await execute(tool, { path: '/tmp/tail-trunc.txt', line_offset: -3 }); expect(result.isError).toBeFalsy(); - // Last 3 lines = 3, 4, 5; line 4 is the long one. expect(result.note).toContain('Total lines in file: 5.'); expect(result.note).toContain('Lines [4] were truncated.'); }); @@ -801,9 +784,6 @@ describe('ReadTool description and schema parity', () => { const tool = toolWithContent(''); expect(tool.description).toContain(''); - // The status block rides the note side channel and is joined after the - // content at projection time, so the model still sees it after the file - // content. expect(tool.description).toMatch(/after the file content/i); }); @@ -821,7 +801,6 @@ describe('ReadTool description and schema parity', () => { const nLinesProperty = (tool.parameters as { properties: { n_lines: { description: string } } }) .properties.n_lines; - // Omitting n_lines reads up to MAX_LINES; the schema description must say so. expect(nLinesProperty.description).toMatch(/omit/i); expect(nLinesProperty.description).toContain(String(MAX_LINES)); }); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts index 1eacf7d670..894d792e54 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts @@ -46,25 +46,13 @@ function createTestEnv(home = '/home'): IHostEnvironment { } interface WriteFsOptions { - /** Override readText. Default rejects with ENOENT (file missing). */ readText?: (path: string) => Promise; - /** Override writeText. Default no-op. */ writeText?: (path: string, data: string) => Promise; - /** Override appendText. Default no-op. */ appendText?: (path: string, data: string) => Promise; - /** Override stat. Default reports an existing directory. */ stat?: (path: string) => Promise; - /** Override mkdir. Default no-op. */ mkdir?: (path: string) => Promise; } -/** - * Fake fs for WriteTool. All IO methods are `vi.fn()` spies so tests can - * assert on the readText/writeText/stat/mkdir calls. By default `stat` - * reports an existing directory (so `ensureParentDirectory` passes without - * creating anything) and `readText` rejects with ENOENT (so an append to a - * missing file treats existing content as empty). - */ function createWriteFs(options: WriteFsOptions = {}) { const readText = vi.fn( options.readText ?? @@ -122,8 +110,6 @@ describe('WriteTool', () => { expect(tool.name).toBe('Write'); expect(tool.description).toContain('append adds content at EOF without adding a newline'); expect(tool.description).toContain('\\n stays LF, \\r\\n stays CRLF'); - // The prompt steers the agent toward Edit for partial changes to an - // existing file. Pin the prohibition so accidental weakening is caught. expect(tool.description).toContain('Write is NOT ALLOWED for incremental changes'); expect(tool.parameters).toMatchObject({ type: 'object', @@ -194,8 +180,6 @@ describe('WriteTool', () => { it('guides batching large content across multiple write calls', () => { const { tool } = makeTool(); - // The guidance must mention that a file too large for one call should be - // chunked, and spell out the first-overwrite-then-append ordering. expect(tool.description).toMatch(/large/i); expect(tool.description).toContain('content too large for one call'); expect(tool.description).toMatch(/overwrite[^.]*first chunk[^.]*then[^.]*append/i); @@ -236,9 +220,6 @@ describe('WriteTool', () => { }); it('reports the real UTF-8 byte count for non-ASCII content', async () => { - // Six Japanese characters: each encodes to 3 UTF-8 bytes → 18 bytes total, - // even though the JS string length is 6. The reported count must reflect - // the bytes that land on disk, not the code-unit count. const content = 'こんにちは。'; const expectedBytes = Buffer.byteLength(content, 'utf8'); expect(expectedBytes).toBe(18); @@ -252,11 +233,6 @@ describe('WriteTool', () => { }); it('reports the real UTF-8 byte count for content with surrogate-pair emoji', async () => { - // 'hi😀': the emoji is a single code point encoded as a UTF-16 surrogate - // pair, so JS string length is 4 (2 for 'hi' + 2 code units), but the - // UTF-8 encoding is 6 bytes (2 for 'hi' + 4 for the emoji). The reported - // count must reflect the bytes on disk, not the code-unit count — this - // is the sharpest edge of the byte-counting bug. const content = 'hi😀'; expect(content.length).toBe(4); const expectedBytes = Buffer.byteLength(content, 'utf8'); @@ -312,7 +288,6 @@ describe('WriteTool', () => { }); it('rejects writing when the parent path is not a directory', async () => { - // A regular file standing where a directory is expected. const { tool, writeText } = makeTool({ stat: vi.fn().mockResolvedValue({ isFile: true, isDirectory: false, size: 0 }), }); @@ -397,11 +372,6 @@ describe('WriteTool', () => { }); it('still reports parent-directory ENOENT surfaced by writeText itself', async () => { - // When the proactive parent check is inconclusive (e.g. the environment - // has no `stat`) and the underlying write then fails with ENOENT — for - // example a parent directory removed between the check and the write — - // the tool still surfaces a clear "parent directory does not exist" - // message rather than a raw host error. const { tool } = makeTool({ writeText: vi .fn() @@ -417,8 +387,6 @@ describe('WriteTool', () => { }); it('appending to a nonexistent file creates it with just the appended bytes', async () => { - // Native append (fs.appendFile) creates the file when it is missing, so - // append mode on a new path succeeds and writes exactly the appended bytes. const { tool, readText, appendText } = makeTool(); const result = await execute(tool, { @@ -434,8 +402,6 @@ describe('WriteTool', () => { }); it('allows absolute writes to a sibling dir that merely shares the work-dir prefix', async () => { - // Path policy must distinguish "shares a prefix with workspaceDir" from - // "is inside workspaceDir". /workspace-sneaky/* is outside /workspace. const { tool, writeText } = makeTool({}, stubWorkspaceContext('/workspace')); const result = await execute(tool, { path: '/workspace-sneaky/file.txt', content: 'content' }); diff --git a/packages/agent-core-v2/test/os/interface/hostFsErrors.test.ts b/packages/agent-core-v2/test/os/interface/hostFsErrors.test.ts index bf7f8dc795..2f5f4c2cd0 100644 --- a/packages/agent-core-v2/test/os/interface/hostFsErrors.test.ts +++ b/packages/agent-core-v2/test/os/interface/hostFsErrors.test.ts @@ -44,7 +44,6 @@ describe('toHostFsError', () => { }); expect(error.cause).toBe(raw); expect(() => JSON.stringify(error.details)).not.toThrow(); - // The message stays a short human sentence — no path or errno interpolation. expect(error.message).not.toContain('/secret'); expect(error.message).not.toContain('EACCES'); }); diff --git a/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts b/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts index c34f3bd6cf..bc367ad8dd 100644 --- a/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts +++ b/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts @@ -133,14 +133,10 @@ describe('MiniDbQueryStore', () => { }); it('throws storage.locked when the database lock is held by another process', async () => { - // Simulate another kimi process holding the single-writer lock on the - // shared query-store directory. const storeDir = join(homeDir, 'cache', 'query-store'); const lockHolder = await MiniDb.open({ dir: storeDir, valueCodec: 'json' }); try { const store = build(); - // Open failure surfaces as a coded `storage.locked` (memoized), never as - // a silent no-op — consumers decide how to fall back. await expect(store.put(COLLECTION, 'a', { id: 'a' })).rejects.toMatchObject({ code: 'storage.locked', }); @@ -159,7 +155,6 @@ describe('MiniDbQueryStore', () => { await expect(store.query(COLLECTION).execute()).rejects.toMatchObject({ code: 'storage.locked', }); - // A locked (failed) open must not make close/dispose throw. await expect(store.close()).resolves.toBeUndefined(); } finally { await lockHolder.close(); @@ -174,8 +169,6 @@ describe('MiniDbQueryStore', () => { disposeHost?.(); disposeHost = undefined; - // Corrupt the persisted index-definition JSON so `open` throws SyntaxError, - // which `openOrRebuild` turns into a wipe + fresh open. const indexFile = join(homeDir, 'cache', 'query-store', 'db.indexes.json'); await fsp.writeFile(indexFile, '{ definitely not valid json'); diff --git a/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts b/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts index 3ab61326f6..a6b24eb762 100644 --- a/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts +++ b/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts @@ -585,7 +585,6 @@ describe('AppendLogStore', () => { }); it('drops a torn final line (crash mid-flush)', async () => { - // One complete record + a half-written trailing record with no newline. const raw = `${JSON.stringify({ n: 1 })}\n${JSON.stringify({ n: 2 }).slice(0, 4)}`; await storage.append(SCOPE, KEY, enc.encode(raw)); @@ -609,7 +608,6 @@ describe('AppendLogStore', () => { it('reads across chunk boundaries (stream read splits lines)', async () => { const full = `${JSON.stringify({ n: 1 })}\n${JSON.stringify({ n: 2 })}\n${JSON.stringify({ n: 3 })}\n`; const bytes = enc.encode(full); - // Split into chunks that cut through the middle of lines. const chunks = [bytes.slice(0, 7), bytes.slice(7, 23), bytes.slice(23)]; const localIx = disposables.add(new TestInstantiationService()); localIx.stub(IFileSystemStorageService, chunkedStorage(chunks)); @@ -622,18 +620,9 @@ describe('AppendLogStore', () => { }); it('does not leak decoder state into a later read when an earlier read returns early', async () => { - // Regression for fork: `TextDecoder` in `stream` mode buffers a trailing - // incomplete multi-byte sequence. When a read returns early — the way - // `ensureWireMetadata` bails as soon as it sees the leading `metadata` - // record — it skips the final flushing `decode()`. A shared decoder would - // then carry that buffered sequence into the next read and prepend a - // U+FFFD to its first line, corrupting the `metadata` record - // (`append-log ...: corrupted line 1`) and breaking session fork. const line1 = `${JSON.stringify({ type: 'metadata', protocol_version: '1.4' })}\n`; const line2 = `${JSON.stringify({ type: 'context.append_message', s: '中文中文中文' })}\n`; const bytes = enc.encode(line1 + line2); - // Split the first chunk through the middle of a '中' (3-byte UTF-8) in - // line2 so the decoder buffers an incomplete sequence when line1 is read. const cut = bytes.indexOf(enc.encode('中')[0]!) + 1; const chunks = [bytes.slice(0, cut), bytes.slice(cut)]; const localIx = disposables.add(new TestInstantiationService()); @@ -641,7 +630,6 @@ describe('AppendLogStore', () => { localIx.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); const log = localIx.get(IAppendLogStore); - // First read: consume only the leading metadata record, then return early. const first: Array<{ type: string }> = []; for await (const r of log.read<{ type: string }>(SCOPE, KEY)) { first.push(r); @@ -649,7 +637,6 @@ describe('AppendLogStore', () => { } expect(first).toEqual([{ type: 'metadata', protocol_version: '1.4' }]); - // Second read: must start cleanly — no U+FFFD leaked from the first read. const out: Array<{ type: string; s?: string }> = []; for await (const r of log.read<{ type: string; s?: string }>(SCOPE, KEY)) out.push(r); expect(out).toEqual([ @@ -659,11 +646,8 @@ describe('AppendLogStore', () => { }); it('isolates decoder state between concurrent reads', async () => { - // Two reads of the same multi-byte content must not interfere with each - // other through a shared decoder: each read owns its decoder state. const content = `${JSON.stringify({ s: '中文日本語' })}\n`; const bytes = enc.encode(content); - // One byte per chunk to maximize the chance of mid-character splits. const chunks = Array.from(bytes, (b) => new Uint8Array([b])); const localIx = disposables.add(new TestInstantiationService()); localIx.stub(IFileSystemStorageService, chunkedStorage(chunks)); @@ -683,7 +667,6 @@ describe('AppendLogStore', () => { it('reads across chunk boundaries with multi-byte UTF-8 split', async () => { const full = `${JSON.stringify({ n: 1, s: '中文' })}\n${JSON.stringify({ n: 2, s: '日本語' })}\n`; const bytes = enc.encode(full); - // Split at every byte to maximally stress multi-byte decode across chunks. const chunks = Array.from(bytes, (b) => new Uint8Array([b])); const localIx = disposables.add(new TestInstantiationService()); localIx.stub(IFileSystemStorageService, chunkedStorage(chunks)); diff --git a/packages/agent-core-v2/test/persistence/backends/node-fs/fileStorageService.test.ts b/packages/agent-core-v2/test/persistence/backends/node-fs/fileStorageService.test.ts index 5d1e0307a2..c173164776 100644 --- a/packages/agent-core-v2/test/persistence/backends/node-fs/fileStorageService.test.ts +++ b/packages/agent-core-v2/test/persistence/backends/node-fs/fileStorageService.test.ts @@ -37,13 +37,9 @@ describe('FileStorageService — file permissions', () => { }); it.skipIf(isWin)('defaults to the process umask when modes are omitted', async () => { - // Backwards compatibility: an unconfigured FileStorageService must not - // start tightening permissions on its own — bootstrap opts into 0700/0600. const svc = new FileStorageService(dir); await svc.write('scope', 'k.json', encoder.encode('{}')); const fileStat = await stat(join(dir, 'scope', 'k.json')); - // Owner-read/write is always set; we only assert the file is readable by - // its owner (the lower bound) rather than pinning an exact mode. expect(fileStat.mode & 0o400).toBe(0o400); }); }); @@ -68,7 +64,6 @@ describe('FileStorageService — error translation', () => { it.skipIf(isWin)('translates non-ENOENT failures into StorageError(io_failed)', async () => { const svc = new FileStorageService(dir); - // Reading a directory fails with EISDIR — an I/O failure, not a miss. await mkdir(join(dir, 'scope', 'adir'), { recursive: true }); await expect(svc.read('scope', 'adir')).rejects.toSatisfy((error: unknown) => { expect(error).toMatchObject({ code: 'storage.io_failed' }); @@ -85,8 +80,6 @@ describe('FileStorageService — error translation', () => { it.skipIf(isWin)('translates write failures into StorageError(io_failed)', async () => { const svc = new FileStorageService(dir); - // A file blocks the scope directory: mkdir('/blocked/k') fails - // (EEXIST/ENOTDIR depending on platform and fs implementation). await writeFile(join(dir, 'blocked'), 'x'); await expect(svc.write('blocked', 'k.json', encoder.encode('{}'))).rejects.toMatchObject({ code: 'storage.io_failed', diff --git a/packages/agent-core-v2/test/persistence/interface/storage.test.ts b/packages/agent-core-v2/test/persistence/interface/storage.test.ts index 3d453ff1c9..4126e417dd 100644 --- a/packages/agent-core-v2/test/persistence/interface/storage.test.ts +++ b/packages/agent-core-v2/test/persistence/interface/storage.test.ts @@ -33,8 +33,6 @@ function storageServiceSuite( await cleanup?.(); }); - // chokidar-backed storage attaches the OS watcher asynchronously; give it a - // moment to register before mutating. Harmless for the in-memory backend. const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 100)); diff --git a/packages/agent-core-v2/test/persistence/interface/stubs.ts b/packages/agent-core-v2/test/persistence/interface/stubs.ts index 06d72f15a6..77eeea5c05 100644 --- a/packages/agent-core-v2/test/persistence/interface/stubs.ts +++ b/packages/agent-core-v2/test/persistence/interface/stubs.ts @@ -11,7 +11,6 @@ import { type Page, } from '#/persistence/interface/queryStore'; -/** A no-op `IQueryStore`: every read is empty / undefined, every write is dropped. */ export function stubQueryStore(): IQueryStore { return { _serviceBrand: undefined, diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index 3df843cbfb..cb98401497 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -140,9 +140,6 @@ describe('AgentLifecycleService', () => { let didExecuteHookIds: string[]; beforeEach(() => { - // The unit under test force-instantiates the builtin-tools registrar per - // created agent; clear module-level tool contributions so no real tool - // (with its own service dependencies) is constructed in this unit test. _clearToolContributionsForTests(); disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); @@ -215,8 +212,6 @@ describe('AgentLifecycleService', () => { resolve: () => undefined, list: () => [], } as unknown as IAgentToolRegistryService); - // Media registration is capability-driven and exercised in its own tests; - // stub the registrar so agent creation does not need profile/host services. ix.stub(IAgentMediaToolsRegistrar, { _serviceBrand: undefined, } as IAgentMediaToolsRegistrar); @@ -290,10 +285,6 @@ describe('AgentLifecycleService', () => { }); it('ignites the self-wiring toolDedupe plugin so its hooks exist before the first turn', async () => { - // `AgentToolDedupeService` only acts through the loop/executor hooks its - // constructor registers; nothing injects it, so agent creation must ignite - // it explicitly (its ordering ahead of `permission` is enforced by the - // ignition order in `igniteEagerServices`). const svc = ix.get(IAgentLifecycleService); await svc.create({ agentId: 'main' }); expect(beforeExecuteHookIds).toContain('toolDedupe'); @@ -500,8 +491,6 @@ describe('AgentLifecycleService', () => { callerOnly: { transport: 'http', url: 'https://caller.example.com' }, }); - // The initial load is single-flight: later calls only await it and never - // re-merge a different caller payload. await svc.ensureMcpReady({ ignored: { transport: 'stdio', command: 'ignored' } }); expect(connectAll).toHaveBeenCalledTimes(1); }); diff --git a/packages/agent-core-v2/test/session/question/question.test.ts b/packages/agent-core-v2/test/session/question/question.test.ts index 0ff2b1c977..5b7a98bf83 100644 --- a/packages/agent-core-v2/test/session/question/question.test.ts +++ b/packages/agent-core-v2/test/session/question/question.test.ts @@ -128,9 +128,6 @@ describe('ISessionQuestionService (Session scope facade over the interaction ker controller.abort(); - // v1 broker semantics: the abort settles the entry as a dismissal, so the - // caller sees the same `null` result (→ `event.question.dismissed`) as an - // explicit dismiss instead of a rejection. await expect(pending).resolves.toBeNull(); expect(questions.listPending()).toEqual([]); expect(resolved).toEqual([{ id: 'q1', response: null }]); @@ -145,7 +142,6 @@ describe('ISessionQuestionService (Session scope facade over the interaction ker questions.answer('q1', { answers: { q_0: 'Yes' } }); await expect(pending).resolves.toEqual({ answers: { q_0: 'Yes' } }); - // A late abort is a no-op: the entry is already settled. controller.abort(); expect(questions.listPending()).toEqual([]); }); @@ -159,7 +155,6 @@ describe('ISessionQuestionService (Session scope facade over the interaction ker expect(questionsA.listPending().map((r) => r.id)).toEqual(['q1']); expect(questionsB.listPending()).toEqual([]); - // Answering from B is a no-op — the id lives in A's kernel. questionsB.answer('q1', { answers: { q_0: 'Yes' } }); expect(questionsA.listPending().map((r) => r.id)).toEqual(['q1']); }); diff --git a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts b/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts index ce20dc2f8b..089cd27b27 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts +++ b/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts @@ -40,8 +40,6 @@ function stubWorkspace(): ISessionWorkspaceContext { } function fakeFs(files: Record, symlinks: readonly string[] = []): IHostFileSystem { - // Keys are stored as absolute paths; fsService now resolves workspace-relative - // paths to absolute (`join(WORK_DIR, rel)`) before calling into `IHostFileSystem`. const fileMap = new Map(); const dirSet = new Set([WORK_DIR]); const addAncestors = (rel: string): void => { @@ -81,7 +79,6 @@ function fakeFs(files: Record, symlinks: readonly string[] = []) return buf.subarray(0, n ?? buf.length); }, readLines: async function* (): AsyncGenerator { - // not needed by the fs surface under test }, writeBytes: async () => {}, createExclusive: async () => false, @@ -141,8 +138,6 @@ function fakeFs(files: Record, symlinks: readonly string[] = []) const recursive = options?.recursive ?? false; const exists = isDir(p) || fileMap.has(p); if (recursive) { - // Add every ancestor up to (but not including) WORK_DIR, mirroring - // `fs.mkdir(..., { recursive: true })` which never throws EEXIST. let current = p; while (current !== WORK_DIR && current.length > WORK_DIR.length) { dirSet.add(current); @@ -199,11 +194,6 @@ function fakeRunner(handler: RunHandler): ISessionProcessRunner { }; } -/** - * A process whose stdout yields the given `--json` lines one chunk at a time, - * stopping when `kill()` is called. Used to assert that `fs.grep` terminates - * `rg` early once an output cap is reached instead of draining everything. - */ function makeStreamingProcess(lines: readonly string[]): { proc: IProcess; wasKilled: () => boolean; @@ -220,7 +210,6 @@ function makeStreamingProcess(lines: readonly string[]): { if (killed) break; yielded += 1; yield `${line}\n`; - // Hand control back so the consumer can kill between chunks. await new Promise((r) => setImmediate(r)); } resolveWait(0); @@ -419,7 +408,6 @@ describe('SessionFsService.search', () => { const paths = result.items.map((i) => i.path); expect(paths).toContain('src/link'); expect(result.items.find((i) => i.path === 'src/link')?.kind).toBe('symlink'); - // The symlink is never descended into, so nothing under `src/link/` appears. expect(paths.some((p) => p.startsWith('src/link/'))).toBe(false); }); }); @@ -535,7 +523,6 @@ describe('SessionFsService.grep', () => { expect(result.truncated).toBe(true); expect(result.files[0]?.matches).toHaveLength(CAP); expect(streaming?.wasKilled()).toBe(true); - // The consumer stopped reading long before all 200 matches were produced. expect(streaming?.yieldedLines()).toBeLessThan(TOTAL); }); }); diff --git a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts b/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts index 97eb3a8f28..507764b212 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts +++ b/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts @@ -20,7 +20,6 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceCo import type { FsChangeEvent } from '@moonshot-ai/protocol'; import { ISessionFsWatchService } from '#/session/sessionFs/fsWatch'; -// Imported for its scoped-registration side effect. import { SessionFsWatchService } from '#/session/sessionFs/fsWatchService'; const WORK_DIR = '/repo'; @@ -176,7 +175,6 @@ describe('SessionFsWatchService', () => { it('filters out `.gitignore`d paths once loaded', async () => { const { svc, watch, events } = makeSession('dist/\n'); svc.setWatchedPaths(['.']); - // Let the async `.gitignore` load (Promise.then) land on the matcher. await Promise.resolve(); await Promise.resolve(); diff --git a/packages/agent-core-v2/test/session/sessionFs/gitContext.test.ts b/packages/agent-core-v2/test/session/sessionFs/gitContext.test.ts index 64b3b40bc3..7e92fb462e 100644 --- a/packages/agent-core-v2/test/session/sessionFs/gitContext.test.ts +++ b/packages/agent-core-v2/test/session/sessionFs/gitContext.test.ts @@ -28,14 +28,8 @@ function processWith(stdout: string, exitCode: number, stderr = ''): IProcess { }; } -/** Scripted git output keyed by the full git invocation (`args.slice(3)`). */ type GitScript = Record; -/** - * Build a runner whose `git` invocations are driven by `script`. Commands - * not present in the script fail (exit code 1), matching a probe that did - * not produce useful output. - */ function gitRunner(script: GitScript): { runner: ISessionProcessRunner; exec: ReturnType } { const exec = vi.fn(async (args: readonly string[]) => { const key = args.slice(3).join(' '); @@ -99,7 +93,6 @@ describe('collectGitContext', () => { await expect(collectGitContext(runner, '/not-a-repo', logger)).resolves.toBe( '', ); - // A definitive not-a-repo is a user-facing signal, not a failure to log. expect(debug).not.toHaveBeenCalled(); expect(warn).not.toHaveBeenCalled(); }); @@ -255,7 +248,6 @@ describe('collectGitContext', () => { pid: 1, exitCode: null, wait: vi.fn(() => exited), - // A real process exits once it receives SIGKILL. kill: vi.fn(async () => { release(137); }), diff --git a/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts b/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts index c5299d5d16..c64ab3d755 100644 --- a/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts +++ b/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts @@ -123,7 +123,6 @@ describe('SessionLogService', () => { const log = session.accessor.get(ILogService); log.info('on-dispose'); host.dispose(); - // dispose() is synchronous and uses flushSync; read after the call returns. return readSessionLog().then((text) => { expect(text).toContain('on-dispose'); }); @@ -132,9 +131,6 @@ describe('SessionLogService', () => { describe('ILogService cross-scope resolution', () => { beforeEach(() => { - // The module-level hook registers only the Session binding; override with the - // production layout — one token bound at both App and Session — to pin how the - // single ILogService token resolves across scopes. _clearScopedRegistryForTests(); registerScopedService(LifecycleScope.App, ILogService, AppLogService, InstantiationType.Delayed, 'log'); registerScopedService(LifecycleScope.Session, ILogService, SessionLogService, InstantiationType.Delayed, 'log'); @@ -151,10 +147,8 @@ describe('ILogService cross-scope resolution', () => { expect(appLog).toBeInstanceOf(AppLogService); expect(sessionLog).toBeInstanceOf(SessionLogService); - // Agent has no own binding and falls back to the Session logger. expect(agentLog).toBeInstanceOf(SessionLogService); - // Each scope is its own singleton; Agent shares the Session instance. expect(appLog).not.toBe(sessionLog); expect(agentLog).toBe(sessionLog); diff --git a/packages/agent-core-v2/test/session/terminal/terminalService.test.ts b/packages/agent-core-v2/test/session/terminal/terminalService.test.ts index 5124fd20ee..39f323acc7 100644 --- a/packages/agent-core-v2/test/session/terminal/terminalService.test.ts +++ b/packages/agent-core-v2/test/session/terminal/terminalService.test.ts @@ -260,7 +260,6 @@ describe('SessionTerminalService', () => { }); }); -// Sanity check for the App-scoped OS HostTerminalService. describe('HostTerminalService (App scope)', () => { let disposables: DisposableStore; let ix: TestInstantiationService; diff --git a/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts b/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts index d5dcf143da..a387faee3c 100644 --- a/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts +++ b/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts @@ -105,7 +105,6 @@ function makeFakeAgent(agentId: string): FakeAgent { todoState = readTodoItems(record['value']); } } - // Replay is silent: subscribers are NOT notified. onRestored fires after. for (const h of restoredHandlers) h(); return { unknownRecords: 0 }; }, @@ -269,8 +268,6 @@ describe('SessionTodoService', () => { it('does not append to the wire when the main agent is absent', () => { const lifecycle = makeLifecycleStub(); const service = new SessionTodoService(lifecycle.service); - // Should not throw even without a main agent. With no main wire there is - // no source of truth to read from, so the list stays empty. expect(() => service.setTodos([{ title: 'x', status: 'pending' }])).not.toThrow(); expect(service.getTodos()).toEqual([]); }); @@ -285,9 +282,6 @@ describe('SessionTodoService', () => { lifecycle.fireCreate(main.handle); lifecycle.fireCreate(sub.handle); - // The TodoList tool itself is contributed via `registerTool` and registered - // by the Agent-scope builtin-tools registrar — SessionTodoService only owns - // the per-agent reminder. expect(main.registeredVariants).toContain(TODO_LIST_REMINDER_VARIANT); expect(sub.registeredVariants).toContain(TODO_LIST_REMINDER_VARIANT); }); @@ -322,7 +316,6 @@ describe('SessionTodoService', () => { lifecycle.fireCreate(main.handle); expect(main.registeredVariants).toContain(TODO_LIST_REMINDER_VARIANT); - // Disposal should not throw and should leave the service usable. expect(() => lifecycle.fireDispose('main')).not.toThrow(); expect(service.getTodos()).toEqual([]); }); diff --git a/packages/agent-core-v2/test/session/workspaceContext/stub-workspace-context.ts b/packages/agent-core-v2/test/session/workspaceContext/stub-workspace-context.ts index adbc0faf50..3a4fe4b52d 100644 --- a/packages/agent-core-v2/test/session/workspaceContext/stub-workspace-context.ts +++ b/packages/agent-core-v2/test/session/workspaceContext/stub-workspace-context.ts @@ -1,12 +1,5 @@ import type { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -/** - * Builds a minimal `ISessionWorkspaceContext` stub for file-tool unit tests. - * - * The file tools only read `workDir` / `additionalDirs`; the remaining members - * are no-op stubs so tests can construct tools without standing up a full - * session scope. - */ export function stubWorkspaceContext( workDir: string, additionalDirs: readonly string[] = [], diff --git a/packages/agent-core-v2/test/wire/wire-compat.test.ts b/packages/agent-core-v2/test/wire/wire-compat.test.ts index 0498d5fbc3..855f217d7d 100644 --- a/packages/agent-core-v2/test/wire/wire-compat.test.ts +++ b/packages/agent-core-v2/test/wire/wire-compat.test.ts @@ -88,11 +88,8 @@ describe('wire.jsonl round-trip', () => { live.wire.dispatch(tagsAdd({ tag: 'a' }), tagsAdd({ tag: 'b' })); await live.log.flush(); - // Read the bytes back through a fresh reader over the same on-disk storage. const records = await collect(makeReader(storage)); - // Format zero-change: flat `{ type, ...payload }` (plus the engine-stamped - // `time`), no nested `payload` key. expect(records).toEqual([ { type: 'compat.counter.set', value: 3, time: expect.any(Number) }, { type: 'compat.tags.add', tag: 'a', time: expect.any(Number) }, @@ -102,14 +99,11 @@ describe('wire.jsonl round-trip', () => { expect('payload' in record).toBe(false); } - // Replay (with an injected unknown-type record) into a fresh service. const replayTarget = makeContainer(storage, 'replay-target'); const withUnknown: PersistedRecord[] = [ ...records, { type: 'compat.unknown.nope', foo: 1 }, ]; - // Swallow the onUnexpectedError report for the injected unknown record; - // the dedicated unknown-record test asserts that reporting path. setUnexpectedErrorHandler(() => {}); let replayResult; try { @@ -118,8 +112,6 @@ describe('wire.jsonl round-trip', () => { resetUnexpectedErrorHandler(); } - // Rebuilt state equals the live state; the unknown record was skipped and - // counted so the caller knows the replay was lossy. expect(replayResult.unknownRecords).toBe(1); expect(replayTarget.wire.getModel(CounterModel)).toEqual( live.wire.getModel(CounterModel), diff --git a/packages/agent-core-v2/test/wire/wireServiceImpl.test.ts b/packages/agent-core-v2/test/wire/wireServiceImpl.test.ts index 722d389a0e..a949024ebf 100644 --- a/packages/agent-core-v2/test/wire/wireServiceImpl.test.ts +++ b/packages/agent-core-v2/test/wire/wireServiceImpl.test.ts @@ -17,8 +17,6 @@ import { CycleError, WireService } from '#/wire/wireServiceImpl'; const SCOPE = 'wire'; const KEY = 'store-test'; -// Module-level trace: reset in beforeEach, written by op `apply` functions and by -// onChange handlers so tests can assert apply-all-before-onChange-all ordering. const trace: string[] = []; const CounterModel = defineModel('store.counter', () => ({ value: 0 })); @@ -42,7 +40,6 @@ const otherInc = OtherModel.defineOp('store.other.inc', { schema: z.object({}), apply: (s) => ({ value: s.value + 1 }), }); -// Test-only op that violates the new-reference convention by mutating its input. const mutateCounter = CounterModel.defineOp('store.counter.mutate', { schema: z.object({}), apply: (s) => { @@ -111,7 +108,6 @@ describe('WireService', () => { wire.dispatch(counterAdd({ by: 1 }), otherSet({ value: 42 })); - // Both applies ran before any onChange; counter's handler already saw other=42. expect(trace).toEqual([ 'apply.counter', 'apply.other', @@ -127,7 +123,6 @@ describe('WireService', () => { wire.dispatch(counterAdd({ by: 5 })); const records = await readRecords(); - // Fresh service on the shared registry, isolated log key. const ix2 = disposables.add(new TestInstantiationService()); ix2.stub(IFileSystemStorageService, new InMemoryStorageService()); ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); @@ -171,7 +166,6 @@ describe('WireService', () => { }); it('throws CycleError when a dispatch cascade exceeds MAX_DRAIN', () => { - // Counter <-> Other cascade: each onChange dispatches the other, forever. disposables.add(wire.subscribe(CounterModel, () => wire.dispatch(otherInc({})))); disposables.add(wire.subscribe(OtherModel, () => wire.dispatch(counterAdd({ by: 1 })))); @@ -197,7 +191,6 @@ describe('WireService', () => { { type: 'store.counter.add', by: 3 }, ); - // Known records apply; the unknown one is skipped but observable. expect(wire.getModel(CounterModel)).toEqual({ value: 5 }); expect(result).toEqual({ unknownRecords: 1 }); expect(unexpected).toHaveLength(1); @@ -222,10 +215,9 @@ describe('WireService', () => { }); it('throws when an apply mutates its already-frozen incoming state', () => { - wire.dispatch(counterAdd({ by: 1 })); // freezes { value: 1 } + wire.dispatch(counterAdd({ by: 1 })); expect(() => wire.dispatch(mutateCounter({}))).toThrow(TypeError); - // Apply threw before reassignment, so state is unchanged. expect(wire.getModel(CounterModel)).toEqual({ value: 1 }); }); });