diff --git a/flake.nix b/flake.nix index 895b7ffb2..c50d0474e 100644 --- a/flake.nix +++ b/flake.nix @@ -64,6 +64,7 @@ workspacePaths = [ ./packages/acp-adapter ./packages/agent-core + ./packages/agent-core-v2 ./packages/server ./packages/server-e2e ./packages/kaos @@ -85,6 +86,7 @@ workspaceNames = [ "@moonshot-ai/acp-adapter" "@moonshot-ai/agent-core" + "@moonshot-ai/agent-core-v2" "@moonshot-ai/server" "@moonshot-ai/server-e2e" "@moonshot-ai/kaos" diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md new file mode 100644 index 000000000..a0adaaf0b --- /dev/null +++ b/packages/agent-core-v2/AGENTS.md @@ -0,0 +1,42 @@ +# agent-core-v2 Agent Guide + +> New agent engine built on the DI Scope architecture — work-in-progress port of `packages/agent-core`. Design: `plan/PLAN.md`. Porting status: `GAP_ANALYSIS.md`. + +## Comment conventions + +- **Header only, external role only.** Comments live solely in the top-of-file `/** */` block — never beside functions, methods, or statements. Say what the module exposes and the responsibility it owns; the code is the source of truth for how it works, so do not narrate implementation steps, enumerate every export, or note porting / skeleton status. +- **Identity line first.** Start with `` `` domain (Ln) — . `` Keep an existing `(cross-cutting)` label as-is; barrels omit the layer (`` `` domain barrel — … ``). Write the role as a responsibility ("drives the turn lifecycle"), not a symbol list ("turn driver + context + loop runner"). +- **Impl files add collaborators + scope; contract files add the public contract + scope.** For impls, list every imported cross-domain collaborator as a role ("persists records through `records`") — declared dependencies count even if not yet wired in this WIP port; infrastructure imports (`_base/**`) are not collaborators. Read scope from `registerScopedService(LifecycleScope.X, …)`. + +### Examples + +Impl (`src/session/sessionService.ts`): + +```ts +/** + * `session` domain (L6) — `ISessionService` implementation. + * + * Owns the session's child-agent set and session-level operations; drives + * agent lifecycle through `agent-lifecycle`, broadcasts through `event`, + * persists session metadata through `records`, and records activity through + * `session-activity`. Bound at Session scope. + */ +``` + +Barrel (`src/session/index.ts`): + +```ts +/** + * `session` domain barrel — re-exports the session facade contract + * (`session`) and its scoped service (`sessionService`). Importing this + * barrel registers the `ISessionService` binding into the scope registry. + */ +``` + +## Docs + +Per-domain references live in `docs/`. + +- [`docs/flag.md`](docs/flag.md) — Read **before gating behavior behind a feature flag**: defining/registering a flag in `FLAG_DEFINITIONS`, checking `IFlagService.enabled(id)`, wiring the `[experimental]` config section, or deciding whether a flag is Core-scope vs. per-session. +- [`docs/errors.md`](docs/errors.md) — Read **before raising errors from a domain**: defining a co-located `XxxError`, registering a code in `ErrorCodes`/`ERROR_INFO`, translating external errors (provider/HTTP, fs, MCP) at the boundary, or (de)serializing errors across RPC/SDK with `toErrorPayload`/`fromErrorPayload`. +- [`docs/di-testing.md`](docs/di-testing.md) — Read **before writing or touching any DI/Scope test**: picking the right harness (`InstantiationService` vs `TestInstantiationService` vs `createScopedTestHost`), declaring deps with `@IService`, stubbing collaborators, and teardown via `DisposableStore`. diff --git a/packages/agent-core-v2/GAP_ANALYSIS.md b/packages/agent-core-v2/GAP_ANALYSIS.md new file mode 100644 index 000000000..b36e18242 --- /dev/null +++ b/packages/agent-core-v2/GAP_ANALYSIS.md @@ -0,0 +1,701 @@ +# agent-core-v2 功能差距清单(对照 agent-core v1) + +> 目标:基于 v2 的 Domain×Scope 架构,盘点「要把 `agent-core`(v1) 的完整功能在 v2 上实现出来,还差哪些」。 +> +> 生成方式:逐 Domain 对比 v1 对照源(`packages/agent-core/src/**`)与 v2 现状(`packages/agent-core-v2/src/**`),只读探索。 +> 设计依据:`plan/PLAN.md`、`plan/overview.md`、`plan/ROADMAP.md`、`plan/skeleton-spec.md`。 + +--- + +## 0. 结论摘要 + +- **v2 当前阶段**:骨架 + 早期实现。v2 约 **6.6k 行**,v1 约 **54k 行**(≈ 12%)。多数 Domain 已完成「接口 + 构造函数 + DI 注册 + 生命周期骨架」,但**业务逻辑大量为 `TODO` 或完全缺失**。 +- **已实现(基本对齐 v1,可视为完成)**:`telemetry`、`environment`(且为 v1 超集)。 +- **部分实现(有可用内核,但关键能力缺)**:`log`、`kaos`、`event`、`approval`、`question`、`message`(投影很薄)、`session-activity`、`agent-lifecycle`(仅建 scope)、`tooldedup`(含 bug)。 +- **骨架 / 几乎全 stub**:`records`、`config`、`kosong`、`tool`、`skill`、`permission`、`context`、`turn`、`injection`、`compaction`、`plan`、`goal`、`swarm`、`usage`、`background`、`cron`、`mcp`、`session-context`、`session`、`hooks`、`gateway`、`terminal`、`fs`、`workspace`、`filestore`、`auth`。 +- **v2 完全无归宿(v1 有、v2 既未实现也未在新架构落地)**:`_base/errors`(仅留 unexpectedError)、`plugin`、`profile`、`rpc/services` facade、`coreProcess` 子进程 RPC 桥、server 端 gateway 传输层(event journal / ws broadcast / connection registry)。`_base/utils` 已落地(见 §2.3)。 + +**判断**:v2 的「骨架」已覆盖 PLAN §2 的绝大多数 Domain(注册表/scope 树/DI 已通)。距离「功能完整」差的是**业务逻辑回填**,其中**阻塞性缺口**集中在 6 处:`loop/turn` 引擎、`records` restore/replay、`config` 内核、`kosong` LLM 桥、`permission` 策略集、`_base/flags`。详见 §2「全局阻塞性缺口」。 + +--- + +## 1. 图例与阅读方式 + +每个 Domain 条目包含: + +- **对照源 (v1)**:功能来源文件。 +- **v2 状态**:`已实现` / `部分` / `骨架` / `缺失`。 +- **已实现要点**:v2 已经做好的(简要)。 +- **缺失清单**:v1 有而 v2 未实现/未落地的具体功能点(精确到类/函数/行为,附 v1 源文件)。 +- **风险 / 需决策**:跨域依赖、架构差异、无归宿项。 + +> 「架构拆分」本身(如 `KimiCore` facade → `gateway`、`Session` god class → 5 个 Domain)**不算缺口**;只有「v1 有、v2 既未实现也未在新架构找到归宿」的功能才标为「无归宿/需决策」。 + +--- + +## 2. 全局阻塞性缺口(跨 Domain / 无归宿) + +这些是 v2 当前**完全没有**、但 v1 与仓库硬规则(根 `AGENTS.md`)依赖的基础设施,应优先落地。 + +### 2.1 `flag`(实验特性门控)— 已实现(Core scope Service + FlagRegistry) + +- **对照源**:`packages/agent-core/src/flags/{registry,resolver,types,index}.ts` +- **v2 状态**:已实现。`packages/agent-core-v2/src/flag/`(L3 注册中心): + - `registry.ts`:`FLAG_DEFINITIONS` + `FlagId` + `FlagRegistry`(对外暴露的定义目录)+ `ExperimentalConfigSchema`(zod,对应 v1 `[experimental]` 表)。 + - `flag.ts`:`IFlagService` 契约 + resolver 类型。 + - `flagService.ts`:`FlagService`(Core scope),四级优先级(master-env > per-feature env > config override > default)+ `enabled/snapshot/enabledIds/explain/explainAll/setConfigOverrides`。 +- **落地差异**: + - v1 是 `globalThis` `FlagResolver` 单例;v2 改为 Core scope DI Service,无隐式全局态。 + - 因要向下注册 `config` 的 `[experimental]` section 并从 `IConfigService` 读取/订阅覆盖,不能放在 `_base`(L0 禁 import L2),故归为 L3 注册中心。 + - config 覆盖改为订阅 `IConfigService.onDidChange('experimental')` 自动刷新(v1 由 `core-impl` 推 `setConfigOverrides`);`setConfigOverrides` 保留作测试 / 无 config 主机的逃生口。 +- [x] 引入FlagService(Core scope `IFlagService` + `FlagRegistry`,向下注册 `[experimental]` config section) + +### 2.2 `_base/errors`(统一错误码 / 序列化)— 骨架 + +- **对照源**:`packages/agent-core/src/errors/{classes,codes,serialize,index}.ts` +- **v2 状态**:骨架(仅 `_base/errors/unexpectedError.ts` + `_base/di/errors.ts`)。 +- **已实现**:`onUnexpectedError/setUnexpectedErrorHandler/safelyCallListener`。 +- **缺失清单**: + - `KimiError` 类 + `KimiErrorOptions`(`classes.ts`) + - `ErrorCodes` 注册表 + `KIMI_ERROR_INFO`(retryable/public/action 元数据,约 70 个码)(`codes.ts`) + - 序列化层 `toKimiErrorPayload/fromKimiErrorPayload/makeErrorPayload/isKimiError`,含 kosong 错误映射(429→rate_limit、401→auth_error、connection/empty)(`serialize.ts`) +- **风险**:v2 全用裸 `Error('TODO: ...')` 抛错,无协议错误码,跨进程/SDK 无法按 code 分支。 + +- [ ] 先学习 vscode 的错误码,然后看看是统一还是分散定义(可能分散定义,统一注册到一处,按 Domain 走比较好) + +### 2.3 `_base/utils`(通用工具)— 已实现 + +- **对照源**:`packages/agent-core/src/utils/*.ts` +- **v2 状态**:已实现(`src/_base/utils/*`,12 个文件逐字节从 v1 迁入 + barrel `index.ts`;新增依赖 `pathe` / `nunjucks` / `undici` / `socks` + dev `@types/nunjucks`)。 +- **已落地清单**: + - `abort.ts`:`UserCancellationError`、`abortable`、`linkAbortSignal`、`createDeadlineAbortSignal` + - `completion-budget.ts`:`resolveCompletionBudget/computeCompletionBudgetCap/applyCompletionBudget` + - `fs.ts`:`atomicWrite`、`writeFileAtomicDurable`、`syncDir/Sync` + - `per-id-json-store.ts`(路径穿越防护 + 原子写) + - `render-prompt.ts`(nunjucks,`throwOnUndefined`) + - `tokens.ts`:`estimateTokensForMessage/Tools`(WeakMap 缓存) + - `proxy.ts`:HTTP/SOCKS dispatcher、`makeNoProxyMatcher`、`installGlobalProxyDispatcher`、`proxyEnvForChild/reconcileChildNoProxy` + - `promise.ts`(timeoutOutcome)、`hero-slug.ts`、`workdir-slug.ts`、`types.ts`(Promisify/Promisable)、`xml-escape.ts` +- **风险(已解除)**:`records` 已改为复用 `#/_base/utils/workdir-slug` 的 `slugifyWorkDirName`,slug 行为与 v1 对齐;`proxy` 随 utils 一并落地,子进程代理环境可注入。 + +- [x] 统一先 mv 过来 + + +### 2.4 `plugin`(插件系统)— 无归宿 + +- **对照源**:`packages/agent-core/src/plugin/**`(manager/manifest/source/store/archive/github-resolver/types/index)+ `src/plugin.ts` +- **v2 状态**:缺失(全包 grep 无 plugin)。 +- **缺失清单**: + - `PluginManager`:install(local-path / github / zip-url)/ setEnabled / setMcpServerEnabled / remove / reload(`manager.ts`) + - `parseManifest`(`kimi.plugin.json` / `.kimi-plugin/plugin.json`、skills 路径校验、sessionStart、mcpServers、interface、diagnostics)(`manifest.ts`) + - `resolveInstallSource` / `resolveGithubSource` / `downloadZip` / `extractZip`、`store.ts` 的 `installed.json` 持久化 + - 插件能力导出:`pluginSkillRoots` / `enabledSessionStarts` / `enabledMcpServers`、stdio `node` 原生二进制 fallback + - `CoreAPI` plugin 一组方法 +- **需决策**:PLAN §2 未给 plugin 指定 Domain 归宿。需决定是新建 `plugin` Domain(Session/Core scope),还是并入 `skill`/`mcp`。plugin 是 skill 与 mcp 的「来源」之一,建议独立 Domain。 + +- [ ] 实现 PluginServices 并从 agent-core 迁移业务逻辑,在实现前先看看底层Domain 是否都 ok 了(下一章) + + +### 2.5 `profile`(系统提示 / Profile 加载)— 无归宿 + +- **对照源**:`packages/agent-core/src/profile/**`(resolve/context/load/default/types/index) +- **v2 状态**:缺失(全包 grep 无 profile)。 +- **缺失清单**: + - YAML profile 加载(`extends` 继承、环检测、subagent 链接、promptVars 合并)(`resolve.ts`) + - `systemPromptTemplate` 渲染(`renderPrompt` + `KIMI_OS/SHELL/WORK_DIR/AGENTS_MD/SKILLS` 等变量)(`resolve.ts`) + - `prepareSystemPromptContext`:cwd 目录列表、AGENTS.md 多级(brand/.agents/项目 root→leaf)收集、32KB 超量 warning、additionalDirs 列表(`context.ts`) + - 内置 profile(agent / coder / explore / plan.yaml + system.md / init.md) +- **需决策**:PLAN §2 未明确 profile 归宿。它直接决定 agent 启动时的 system prompt 构造,建议归入 `agent-lifecycle`(启动装配)或新建 `profile` Domain。 + + +- [ ] 实现 Profile Domain + +### 2.6 `rpc/services` facade + `coreProcess`(跨进程边界)— 无归宿 + +- **对照源**:`packages/agent-core/src/rpc/**`(core-impl=KimiCore facade、core-api、sdk-api、client、events、resumed、types、index)+ `src/services/coreProcess/**` + `src/services/index.ts` +- **v2 状态**:缺失。`gateway` 域只接住 `prompt/steer/cancel`。 +- **缺失清单**: + - `KimiCore`(`core-impl.ts`)约 60 个 `CoreAPI` 方法(`core-api.ts`):AgentAPI / SessionAPI / Plugin / Config / Export 等。v2 仅接住 prompt/steer/cancel,其余需确认由各 Domain 承接。 + - `coreProcess` 进程内 RPC 桥(`createRPC`、`BridgeClientAPI`、`SDKAPI`),用于 daemon 跨进程部署。 +- **需决策**:PLAN §1 明确「保留一层薄 RPC(跨进程形态仍在),但 RPC 只负责把调用路由到 scope」。需在 `gateway` 域补这层薄 RPC 路由,并逐项核对 `CoreAPI` 方法是否都有 Domain 承接。 + +- [ ] 实现 CoreRPC Service,注入其他相关 Service,作为最上层,为 TUI 提供接口服务 + + +### 2.7 server 端 gateway 传输层(断线重放 / 连接管理)— 无归宿 + +- **对照源**:`packages/server/src/services/gateway/**`(SessionEventJournal、WSBroadcastService、ConnectionRegistry、SessionClientsService、InFlightTurnTracker、ServerShutdownService) +- **v2 状态**:缺失。`gateway.WSGateway.broadcast` 为空、`WSBroadcastService` 不路由。 +- **缺失清单**: + - `SessionEventJournal`(持久化 seq/epoch、断线 replay) + - `WSBroadcastService`(per-session 缓冲 + fan-out) + - `ConnectionRegistry` / `SessionClientsService` / `InFlightTurnTracker` / `ServerShutdownService` +- **需决策**:这些在 v1 属于 server 包,不属于 agent-core。但 v2 若要支撑 server-e2e,需明确这层是留在 server 还是下沉到 v2 `gateway`。M10/M11 切换前必须定稿。 + + +- [ ] 在最后接入到 v2,这部分暂时留在 server 层 + +--- + +## 3. 按 Domain 功能差距清单 + +> 顺序按 PLAN §3 分层:L0 → L1 → L2 → L3 → L4 → L5 → L6 → L7 → 横向能力。 + +--- + +### L0 — `_base`(di / event / lifecycle / errors / utils / flags) + +#### `_base/di` — 已实现 +- **对照源**:`packages/agent-core/src/di/**` +- **v2 状态**:已实现(含 Scope 层)。 +- **已实现要点**:`createDecorator`、`IInstantiationService`、`ServiceCollection`、`SyncDescriptor`、`createChild`、`Disposable`、`LifecycleScope`、`registerScopedService`、`Scope` 树、`IScopeHandle`、scoped `TestInstantiationService`。 +- **缺失清单**:无(M0 已完成)。 +- **待办**:import-boundary lint 规则(M0.6)需 CI 强制。 + +#### `_base/event` — 已实现 +- **对照源**:`packages/agent-core/src/base/common/event.ts` +- **v2 状态**:已实现(`Emitter` / `Event` / `Disposable` 风格)。 +- **缺失清单**:无实质缺口。 + +#### `_base/errors` — 骨架(见 §2.2) +#### `_base/utils` — 已实现(见 §2.3) +#### `_base/flags` — 缺失(见 §2.1) + +--- + +### L1 — 抽象桥 + +#### `log` — 部分 +- **对照源**:`packages/agent-core/src/logging/{logger,sinks,formatter,resolve-config,types,index}.ts` +- **v2 状态**:部分(Core scope,console + memory sink)。 +- **已实现要点**:`ILogService/ILogger/ILogSink`、`ConsoleLogSink`、`MemoryLogSink`、`levelEnabled`、`child(ctx)`、payload 提取。 +- **缺失清单**: + - `RotatingFileSink`(按 maxBytes/files 滚动、AsyncSerialQueue、PENDING_MAX 丢弃通知、fsync + syncDir、flushSync、stderr 节流)(`sinks.ts`) + - 每会话文件 sink:`RootLoggerImpl.attachSession/detachSession`(refCount)、global/session 双 sink、`flush/flushSession/flushSync`、`sessionId/sessionLogId` 路由(`logger.ts`) + - `formatter`:`redactCtx`(REDACTED_KEYS + 原始密钥正则)、字节级 clip、ANSI、stack 缩进、`omitContextKeys`(`formatter.ts`) + - `resolve-config`(`KIMI_LOG_LEVEL` 等 env)(`resolve-config.ts`) + - 去掉 `globalThis[ROOT_SYMBOL]` 隐式单例(v2 已去,符合架构) +- **风险**:v2 仅 console/memory sink,无落盘与脱敏,生产不可用。 + +- [ ] 脱离 + +#### `telemetry` — 已实现 +- **对照源**:`packages/agent-core/src/telemetry.ts` + `packages/telemetry` +- **v2 状态**:已实现(且为 v1 超集)。 +- **已实现要点**:`TelemetryClient`、`noopTelemetryClient`、`ITelemetryService`(DI + `setDelegate` + `withContext({sessionId,agentId,turnId})`)。 +- **缺失清单**:无(埋点调用点散落在其它 Domain,随各域回填)。 + + +#### `environment` — 已实现 +- **对照源**:`packages/agent-core/src/services/environment/**` + `packages/kaos/src/environment.ts` +- **v2 状态**:已实现(且为 v1 超集)。 +- **已实现要点**:`IEnvironmentService`(homeDir/configPath + `detect()` 委托 kaos)、`IEnvironmentOptions` seed token。 +- **缺失清单**:无。 + +#### `kaos` — 部分 +- **对照源**:`packages/agent-core/src/session/index.ts`(toolKaos/persistenceKaos/systemContextKaos/additionalDirs 段)+ agent.kaos + `packages/kaos/**` +- **v2 状态**:部分。 +- **已实现要点**:`IKaosFactory`(local 经 `LocalKaos.create()` + `withCwd`)、`ISessionKaosService`(tool/persistence/systemContext/additionalDirs + set/add/remove)、`IAgentKaos.chdir`。 +- **缺失清单**: + - **SSH kaos 创建**(`KaosFactory.create` 抛 `TODO: ... ssh`);kaos 包已有完整 `SSHKaos`(`packages/kaos/src/ssh.ts`) + - `setToolKaos` 向已就绪 agent 传播(v1 重钉每个 `agent.kaos` + `refreshAgentBuiltinTools`)(`session/index.ts:223-229`) + - additionalDirs 持久化到 workspace-local(v1 `addAdditionalDir` 写 `.kimi-code/local.toml`)(`session/index.ts:242-267`);v2 仅内存 + - agent chdir 后重建内置工具(v1 `ConfigState.update` 中 cwd 变更触发 `initializeBuiltinTools`) +- **风险**:SSH 未接线即远端执行不可用;kaos 工厂自建(v1 由外部创建注入)。 + + +#### `kosong` — 骨架 +- **对照源**:`packages/agent-core/src/services/modelCatalog/**` + `src/session/provider-manager.ts` + `src/agent/turn/kosong-llm.ts` + `src/agent/index.ts`(generate/llm 段) +- **v2 状态**:骨架(`generate` 抛 TODO)。 +- **已实现要点**:`IModelCatalogService.listProviders/listModels/refresh`(读 config `kosong` 节)、`IProviderManager.resolve`(默认 provider/model)。 +- **缺失清单**: + - 完整 `ProviderManager`(`provider-manager.ts`,371 行):6 类 provider 的 `toKosongProviderConfig`(anthropic/openai/kimi/google-genai/openai_responses/vertexai,含 baseUrl/env 回退、defaultHeaders、`prompt_cache_key`、adaptiveThinking、reasoningKey、maxOutputSize)、`resolveModelCapabilities`(declared+detected)、alwaysThinking、`resolveAuth`(OAuth token provider + 401 刷新 + `AUTH_LOGIN_REQUIRED`)、`SingleModelProvider` + - `ModelCatalogService`(`modelCatalogService.ts`,360 行):managed Kimi OAuth 刷新(`refreshOAuthProviderModels`/`fetchManagedKimiCodeModels`/`applyManagedKimiCodeConfig`、alias preserve/restore、default clamp)、`getProvider/setDefaultModel`、按 provider 类型的 credential 状态、`toProtocolModel/Provider` + - `KosongLLM`(`kosong-llm.ts`):流式桥(`onTextDelta/onThinkDelta/onToolCallDelta` + tool_call_part 索引)、排空后 per-block 重放、completion budget、`streamTiming`、`isRetryableError`、`requestLogFields` +- **风险 / 需决策**:v2 读 `kosong` 配置节,而 v1 schema 的 providers/models/defaultProvider/defaultModel 位于顶层——**配置形态不一致,需决策归宿**。 + +--- + +### L2 — 数据基座 + +#### `records` — 骨架 +- **对照源**:`packages/agent-core/src/session/store/**` + `src/agent/records/**`(migration、blobref、persistence、types、index)+ `src/agent/replay/**` +- **v2 状态**:骨架(`SessionStore.read/write`、`AgentRecords.restore` 均为 TODO)。 +- **已实现要点**:`encodeWorkDirKey`、`SessionStore.sessionDir`、`SessionMetaStore`(state.json 读写 flush)、`AgentRecords.logRecord/replay`(整文件读写,无流式)。 +- **缺失清单**: + - `SessionStore.read/write` + `create/fork/get/rename/archive/list*` + `summaryFromDir`(`session-store.ts`,520 行)、`session_index.jsonl`(`session-index.ts`)、`assertSafeSessionId/isSafeSessionId` + - `FileSystemAgentRecordPersistence`(流式读 + 截断末行容忍、批量 drain、fsync+syncDir、rewrite/shouldClear、blobStore offload)+ `InMemoryAgentRecordPersistence`(`persistence.ts`) + - `BlobStore`(offload/rehydrate、`blobref:` 协议、data-uri 阈值、sha256 去重、50MB LRU、`MISSING_MEDIA_PLACEHOLDER`)(`blobref.ts`) + - wire migration v1.0→v1.4(`AGENT_WIRE_PROTOCOL_VERSION`、`resolveWireMigrations`、metadata stamping、newer-version warning、rewrite-migrated)(`migration/*`) + - `AgentRecordEvents` 判别联合(约 30 种)+ `restoreAgentRecord` 分发(`records/index.ts:32-133`)、`replay/build.ts` +- **风险**:restore 全缺,**会话恢复 / resume 完全不可用**。 + +#### `config` — 骨架 +- **对照源**:`packages/agent-core/src/config/{schema,toml,merge,resolve,path,env-model,kimi-env-params,workspace-local,index}.ts` + `src/services/config/**` + `src/agent/config/**` +- **v2 状态**:骨架(内存 Map,无 schema/文件/env)。 +- **已实现要点**:`IConfigRegistry`(registerSection/getSection/deepMerge)、`IConfigService`(内存 Map get/set + `onDidChange` Emitter)、`IAgentConfigService`(读 `agent` 节,setModel/setThinking)。 +- **缺失清单**: + - `schema.ts`(zod `KimiConfigSchema`/`KimiConfigPatchSchema`、`validateConfig`、`getDefaultConfig`、`formatConfigValidationError`) + - `toml.ts`(parse/stringify、snake↔camel、`ensureConfigFile`、strict/safe 双读、`loadRuntimeConfigSafe` 的坏条目丢弃 + fileWarnings/envWarnings/fileError、原子写、`configToTomlData` 保留 raw、permission allow/deny/ask 变换) + - `merge.ts`(mergeConfigPatch+validate)、`resolve.ts`(parseBooleanEnv/parseFloatEnv)、`path.ts`(KIMI_CODE_HOME) + - `env-model.ts`(KIMI_MODEL_* 合成 provider/model + strip 防回写)、`kimi-env-params.ts`(temperature/top_p/thinkingKeep)、`workspace-local.ts`(kaos 版 additional_dir) + - `services/config`(RPC get/set + `event.config.changed`)、`agent/config`(`ConfigState.provider` 叠加 withThinking+sampling+thinkingKeep、`resolveThinkingEffort/Level`、alwaysThinking clamp) +- **风险**:v2 无 schema 校验、无文件持久化、无 env 覆盖、无 workspace-local、无 thinking 解析,本质是内存 Map。 + +--- + +### L3 — 注册中心 + +#### `tool` — 骨架 +- **对照源**:`packages/agent-core/src/agent/tool/**` + `src/tools/store.ts` + `src/tools/args-validator.ts` + `src/tools/display/**` + `src/tools/support/**` + `src/tools/policies/**` + `src/tools/providers/**` +- **v2 状态**:骨架(接口 + 按名路由)。 +- **已实现要点**:`ToolDefinition{name,factory}` 注册表;`ToolService.execute` 按名路由、user/mcp Map;factory 用 `ServicesAccessor` 懒构建。 +- **缺失清单**: + - **全部内置工具**(Read/Write/Edit/Grep/Glob/Bash/ReadMedia/Goal×4/Plan×2/TodoList/Agent/AgentSwarm/WebSearch/FetchURL/Skill/AskUser/Task×3/Cron×3)(`agent/tool/index.ts:359-426`) + - 参数校验:`args-validator.ts`(AJV draft-07/2019/2020 方言选择)、`support/input-schema.ts`(zod→input schema + `additionalProperties:false` 闭合) + - `ToolResultBuilder`(50k char / 2k line 截断、ok/error/brief)(`support/result-builder.ts`) + - 路径安全:`policies/path-access.ts`(canonicalize/resolvePathAccess)、`sensitive.ts`、`workspace.ts` + - 规则匹配:`rule-match.ts` + `path-glob-match.ts`(含 Win 变体) + - MCP:注册/反注册、冲突检测(same/other server)、needs-auth 合成工具、status-change、`qualifyMcpToolName`、glob 门控(`agent/tool/index.ts:136-303`) + - 用户工具经 RPC `toolCall` 执行;`ToolStore`(records 日志);`setActiveTools/loopTools`(隐藏 SetGoalBudget/UpdateGoal);`display/schemas.ts`;`createVideoUploader`;rg-locator/list-directory/git-worktree/file-type/providers +- **风险**:注入了 `IAgentKaos/IPermission/ILLM/IRecords` 但全未用(`_` 前缀);execute 未调用 permission。 + +#### `skill` — 骨架 +- **对照源**:`packages/agent-core/src/skill/**`(builtin 含 sub-skill、parser、scanner、registry、types、index)+ `src/agent/skill/**` +- **v2 状态**:骨架。 +- **已实现要点**:Registry Map(`loadRoots` 仅记 root 不扫描);`activate`→`turn.prompt("Activate skill: X")`。 +- **缺失清单**: + - `parser.ts`:frontmatter 解析、参数展开(`$ARGUMENTS/$0/$name/${KIMI_SKILL_DIR}`)、mermaid/d2 抽取 + - `scanner.ts`:root 发现(project/user/extra/builtin/plugin + .git 根)、SKILL.md bundle + 平铺 .md + sub-skill 递归、`extendWorkspaceWithSkillRoots` + - `registry.ts`:byName+byPlugin 双索引、`renderSkillPrompt`(含 plugin instructions)、`listInvocableSkills`、模型清单分组渲染 + - builtin skills(mcp-config / update-config / write-goal / sub-skill parent / review / consolidate) + - `SkillManager.activate`(类型校验、telemetry、turn origin)+ `prompt.ts` 的 `` 块 +- **风险**:v2 `SkillDefinition` 仅 `{name,root}`,丢失 description/content/metadata/source/plugin;activate 不传 args、不校验 type。 + +#### `permission` — 骨架 +- **对照源**:`packages/agent-core/src/agent/permission/**`(manager + policies 目录全部策略 + matches-rule + types + index) +- **v2 状态**:骨架(registry 首个非 undefined 胜出 + yolo/manual/auto)。 +- **已实现要点**:Policy 注册表(默认 allow);`beforeToolCall`(yolo→allow、manual/ask→approval)。 +- **缺失清单**: + - **20 个策略全部未注册**:PreToolCallHook、AgentSwarmExclusiveDeny、AutoModeAskUserQuestionDeny、PlanModeGuardDeny、UserConfigured Deny/Ask/Allow、AutoModeApprove、SessionApprovalHistory、ExitPlanModeReviewAsk、GoalStartReviewAsk、PlanModeToolApprove、SensitiveFileAccessAsk、GitControlPathAccessAsk、YoloModeApprove、SwarmModeAgentSwarmApprove、DefaultToolApprove、GitCwdWriteApprove、FallbackAsk(`policies/index.ts:28-70`) + - `matches-rule.ts`:DSL `parsePattern`(`Bash(rm *)`/`Read(/etc/**)`)+ `matchPermissionRule` + - `PermissionRule{decision,scope,pattern,reason}` 模型 + `PermissionData(mode,rules)`;`ApprovalRequest{toolCallId,action,display}` / `ApprovalResponse{scope,feedback,selectedLabel}` + - mode 硬编码 `'auto'`,不可从 config 切换;无 `resolveApproval/resolveError` 异步合成结果 +- **风险**:底层域有归宿(`hooks.runPreToolCall`、`plan.active`、`goal.current` 已存在)但 permission 未对接;敏感文件 / git 控制路径检测依赖 tools/policies(v2 无对应)。 + +--- + +### L4 — Agent 行为 + +#### `context` — 部分(toy 级) +- **对照源**:`packages/agent-core/src/agent/context/**`(projector、notification-xml、types、index) +- **v2 状态**:部分(扁平数组 + toy token)。 +- **已实现要点**:history 数组、appendMessage/appendSystemReminder/project(恒等)、applyCompaction(snapshot)、undo(pop/恢复 snapshot)、tokenUsage(chars/4)。 +- **缺失清单**: + - `appendLoopEvent` 状态机(step.begin/end/content.part/tool.call/tool.result)+ openSteps/pendingToolResultIds/deferredMessages(`context/index.ts:257-349`) + - 开 tool exchange 不变量 + flushDeferred(`:363-373`) + - `projector.ts`:mergeAdjacentUserMessages、partial 过滤、空 text 剥离、`trimTrailingOpenToolExchange` + - 真实 token:provider usage + `estimateTokensForMessages` + `tokenCountCoveredMessageCount` 不变量(`:281-303`) + - 多 origin 类型(skill_activation/injection/compaction_summary/background_task/cron/hook_result…)(`types.ts`) + - 真实 applyCompaction(替换前缀 + compactedCount + tokensBefore/After);`undo(count)` 边界(跳 injection、止 compaction_summary)+ REQUEST_INVALID(`:105-194`) + - `notification-xml.ts`、clear/popMatchedMessage/finishResume/closePendingToolResults + - 跨域:records / microCompaction / injection / replayBuilder / background.markDeliveredNotification +- **风险**:`IAgentRecords` 注入未用;microCompaction/injection/replay 接线点缺失。 + +#### `message` — 骨架 +- **对照源**:`packages/agent-core/src/services/message/**`(含 transcript) +- **v2 状态**:骨架。 +- **已实现要点**:list/get,把 `context.project()` 映射为 `{id=msg-${i},role,content}`。 +- **缺失清单**: + - 稳定 id:`deriveMessageId('msg__<6位index>')` + `parseMessageId` 反解(`message.ts:114-139`);v2 用 `msg-${i}`,undo/compaction 后漂移 + - 协议内容映射:content part(think→thinking / image / audio→`[audio:]` / video→`[video:]`)、assistant toolCalls→tool_use、tool role→tool_result(含 is_error)、`metadata.origin`(`:145-267`) + - 分页(before/after_id、page_size 50-100)+ role filter + created_at 单调 + - **transcript 全量历史**:`readWireTranscript` 从 wire.jsonl 还原(含被 compaction 折叠前缀)+ `reduceWireRecords`(镜像 ContextMemory)+ live tail 合并 + size/mtime LRU 缓存(`transcript.ts`、`messageService.ts:155-216`) + - **blobref 还原**(`blobref:;`→data URI,`[media missing]`)(`transcript.ts:358-405`) + - SessionNotFoundError / MessageNotFoundError(40401/40403) +- **风险 / 需决策**:v1 message 是 daemon/REST 面向服务,v2 定位为纯 L4 投影属架构调整;但**稳定 id + 内容映射是 web/vis UI 必需**,归宿需决策。 + +#### `turn` — 骨架(心脏为空) +- **对照源**:`packages/agent-core/src/agent/turn/**`(index、kosong-llm、tool-dedup、canonical-args)+ `src/loop/**`(run-turn、turn-step、tool-call、tool-scheduler、retry、events、llm、tool-access、types、errors、index)+ `src/agent/index.ts`(turn 驱动 / goal 驱动段) +- **v2 状态**:骨架(`ILoopRunner.run` 空、`TurnService.retry`=TODO)。 +- **已实现要点**:turn 生命周期事件发射器、prompt/steer/cancel 壳、active 标志。 +- **缺失清单**: + - 整个 `loop/` 步骤引擎:`runTurn` 收敛循环 + maxSteps + `shouldContinueAfterStop`(run-turn.ts);`executeLoopStep` 的 beforeStep/afterStep hook、step.begin/end 事件、`deriveStepStopReason`、provider diagnostics(turn-step.ts);`chatWithRetry` 指数退避 + `step.retrying` 事件(retry.ts) + - `runToolCallBatch`:参数校验、`prepareToolExecution`/`authorizeToolExecution`/`finalizeToolResult` 三 hook、provider-order 事件、abort + 2s grace timeout、`coerceToolResult`/`normalizeToolResult`、`stopBatchAfterThis`(tool-call.ts,726 行) + - `ToolScheduler` + `ToolAccesses` 冲突感知并发调度(tool-scheduler.ts / tool-access.ts) + - `KosongLLM` 桥(streaming 回调、completion budget、streamTiming、`buildMessagesWithSystem`) + - `TurnFlow`:turnId 单调分配 + `observeRestoredTurnId` 重放、`firstRequest` ControlledPromise、`applyUserPromptHook`(UserPromptSubmit)、`runStepLoop` 中 micro/full compaction + `injectGoal` 编排、Stop hook 续轮、goal outcome 续轮、`classifyApiError` 遥测、turn_interrupted 遥测、`mapLoopEvent` 全套事件映射 + - Turn scope 工厂:`createTurnScope(parentAgentScope, turnId)` + `ITurnContext` + `IInjectionQueue`(per-turn scratch) +- **需决策**:loop 是 L4 心脏,v2 目前 `ILoopRunner` 单方法 `run()`,需决定如何拆分到 Turn scope 服务群(PLAN §1/§6.2 已给出方向)。 + +#### `injection` — 部分(仅 FIFO) +- **对照源**:`packages/agent-core/src/agent/injection/**`(injector、manager、goal、permission-mode、plan-mode、plugin-session-start、todo-list) +- **v2 状态**:部分(仅 FIFO push/flush 队列)。 +- **已实现要点**:Agent-scope `IInjectionService` + Turn-scope `IInjectionQueue`。 +- **缺失清单**: + - `DynamicInjector` 抽象与 `injectedAt` 索引生命周期修正(`onContextClear/Compacted/MessageRemoved`)(injector.ts) + - `InjectionManager` 的 per-step `inject()` + boundary `injectGoal()` 编排(manager.ts) + - `GoalInjector`(active/blocked/paused 三档 + budget guidance + ``)(goal.ts) + - `PlanModeInjector`(full/sparse/reentry 变体去重)(plan-mode.ts) + - `PermissionModeInjector`(auto 进/出提醒) + - `PluginSessionStartInjector`(skill 渲染) + - `TodoListReminderInjector`(基于 history 的 turn 计数) +- **需决策**:v2 的 push/flush 模型与 v1「索引 + 生命周期修正」语义不同,需重新设计归宿。 + +#### `compaction` — 骨架 +- **对照源**:`packages/agent-core/src/agent/compaction/**`(full、micro、strategy、render-messages、types、index) +- **v2 状态**:骨架(仅 token 阈值 → push `compaction_summary` injection;`compact()`=TODO)。 +- **已实现要点**:`onDidEndStep` 阈值检测。 +- **缺失清单**: + - `FullCompaction`:多轮摘要生成 + completion budget + `MAX_COMPACTION_RETRY_ATTEMPTS` 重试 + overflow 时 `reduceCompactOnOverflow` + truncated/empty 处理 + `handleOverflowError` + `beforeStep/afterStep` 自动触发与 block + `maxCompactionPerTurn` + PreCompact/PostCompact hook + todo 后处理 + telemetry + records/replay(full.ts,422 行) + - `DefaultCompactionStrategy`:`shouldCompact/Block`、`computeCompactCount`(`canSplitAfter` 安全切分)、`reduceCompactOnOverflow`、`fitCompactCountToWindow`(strategy.ts) + - `MicroCompaction`:cache age+ratio detect、tool result 截断、experimental flag `micro_compaction`(micro.ts) + - `render-messages.ts` + `compaction-instruction.md` 模板 +- **风险**:v2 仅「push 一条 injection」,远未覆盖摘要 LLM 调用与历史改写。 + +#### `plan` — 骨架 +- **对照源**:`packages/agent-core/src/agent/plan/**` +- **v2 状态**:骨架(仅 boolean + 一条 plan injection)。 +- **已实现要点**:active 标志、enter 推 reminder、turn end 复位。 +- **缺失清单**: + - planId 生成(hero-slug + uuid) + - plan 文件路径解析(homedir/plans vs cwd/plan)+ `ensurePlanDirectory` + `writeEmptyPlanFile`(kaos) + - `data()` 读取 + - records `plan_mode.enter/cancel/exit` + replayBuilder `plan_updated` + - `restoreEnter` 重放 + - `emitStatusUpdated`(plan/index.ts) + +#### `goal` — 骨架 +- **对照源**:`packages/agent-core/src/agent/goal/**` + `src/agent/turn/index.ts`(driveGoal 段) +- **v2 状态**:骨架(仅 `{objective,status}`,continuation drive=TODO)。 +- **已实现要点**:create/update/clear 状态字段。 +- **缺失清单**: + - 完整状态机 active/paused/blocked/complete 语义(goal/index.ts,764 行) + - `createGoal`(长度校验/replace)/ `pauseGoal` / `resumeGoal` / `cancelGoal` / `markBlocked` / `markComplete` / `pauseOnInterrupt` + - `setBudgetLimits` + `GoalBudgetReport`(token/turn/wallClock + overBudget) + - wallClock 锚定计时(`liveWallClockMs`) + - `recordTokenUsage` / `incrementTurn` 会计 + - `normalizeAfterReplay`(active→paused) + - `restoreCreate/Update/Clear/Forked` + - records `goal.create/update/clear` + `goal.updated` 事件 + `GoalChange`(lifecycle/completion) + - telemetry + - `driveGoal` continuation 循环 + `GOAL_CONTINUATION_PROMPT` + `goalFailurePauseReason`(turn/index.ts:357) +- **风险**:预算强制与 continuation driver 跨 turn/goal 两域,是最大行为缺口之一。 + +#### `swarm` — 骨架 +- **对照源**:`packages/agent-core/src/agent/swarm/**` +- **v2 状态**:骨架(仅 boolean)。 +- **已实现要点**:active 标志、enter/exit。 +- **缺失清单**: + - `SwarmModeTrigger` 三态(manual/task/tool) + - enter/exit 注入 `SWARM_MODE_ENTER/EXIT_REMINDER`(md) + - exit 时 `popMatchedMessage` 移除 injection + - `shouldAutoExit`(task/tool) + turn 末 auto exit + - records `swarm_mode.enter/exit` + `restoreEnter` + - 子 agent 编排(经 `IAgentLifecycleService`,后续接) + +#### `usage` — 部分 +- **对照源**:`packages/agent-core/src/agent/usage/**` +- **v2 状态**:部分(仅 input/output 两数累加)。 +- **已实现要点**:`record(input,output)` + `totals`。 +- **缺失清单**: + - `byModel` 分组累计 + `totalUsage` + - `currentTurn` 窗口(`beginTurn/endTurn`)+ turn-scope record + - `UsageRecordScope`(session/turn) + - records `usage.record` + `emitStatusUpdated` + - `data()/status()` → `UsageStatus`(byModel/total/currentTurn) + - model 维度记录(usage/index.ts) + +#### `tooldedup` — 部分(含 bug) +- **对照源**:`packages/agent-core/src/agent/turn/tool-dedup.ts` + `canonical-args.ts` +- **v2 状态**:部分(含 bug)。 +- **已实现要点**:same-step Set 检测 + streak 计数。 +- **缺失 / 错误清单**: + - `fingerprint=JSON.stringify` 非 canonical(key 顺序敏感;v1 用 `canonicalTelemetryArgs`)(canonical-args.ts) + - `finalize(toolCallId)` 误用 toolCallId 作指纹,致 streak 逻辑错误 + - 缺 same-step deferred 结果复用(`ToolCallDeduplicator.checkSameStep/finalizeResult`,重复调用返回占位、finalize await 原调用 deferred 免重复执行)(tool-dedup.ts) + - 缺跨步 streak 的 r1/r2/r3/stop 升级(3/5/8/12)+ system-reminder + `stopTurn` 强停 + - 缺 `beginStep/endStep` 生命周期与悬挂 deferred 错误收尾 + - 缺 `syntheticCallIds/originalCallIndex/callKeyByCallId`(处理 `updatedArgs` 改写) + - 缺 telemetry `tool_call_repeat` +- **风险**:v2 `checkSameStep` 返回 boolean,无法表达「复用原结果」语义,接口需重构。 + +--- + +### L5 — 异步生命周期 + +#### `background` — 骨架 +- **对照源**:`packages/agent-core/src/agent/background/**`(task、process-task、agent-task、question-task、persist、index) +- **v2 状态**:骨架(仅内存 map + 空 output 字符串)。 +- **已实现要点**:`start/stop/list/getOutput` 形状。 +- **缺失清单**: + - 三类具体任务(process / agent / question)完全缺失(`process-task.ts`/`agent-task.ts`/`question-task.ts`) + - SIGTERM→5s grace→SIGKILL 停止、stream drain、超时、前台/后台 detach(`index.ts:431-507,714-838`) + - 1MiB ring buffer + `output.log` 持久化 + 字节窗口读取(`persist.ts`、`index.ts:576-625`) + - 启动 reconcile:磁盘 ghost → `lost` 重分类 + 终端通知恢复(`index.ts:516-560,627-701`) + - legacy snake_case 记录迁移(`persist.ts:169-238`) + - `maxRunningTasks` 准入、`Notification` hook 触发(`index.ts:237-251,688-701`) +- **需决策**:持久化归宿未明(v2 `records` 域?);任务与 `IAgentKaos` / subagent 尚未接线。 + +#### `cron` — 骨架 +- **对照源**:`packages/agent-core/src/tools/cron/**`(scheduler、clock、jitter、cron-expr、persist、session-store、cron-create/list/delete、telemetry-events、time-format、types、cron-fire-xml)+ `src/agent/cron/**`(manager、index) +- **v2 状态**:骨架(最小 `tick`,数字 ms 当间隔)。 +- **已实现要点**:`create/list/delete/tick`、`onDidFire`、idle-gate、one-shot 删除、`CronFireCoordinator` steer main。 +- **缺失清单**: + - 5 字段 cron 解析 + dom/dow OR 规则 + 5 年窗口 / 永不触发检测(`cron-expr.ts`) + - 确定性 jitter(`jitter.ts`)、wall/mono 双时钟 + `KIMI_CRON_CLOCK`(`clock.ts`) + - coalescedCount 合并、`lastFiredAt` 游标持久化防 resume 重放(`scheduler.ts:249-407`) + - 7 天 stale 判定 + 自动过期删除(`agent/cron/manager.ts:376-442`) + - per-id JSON 持久化 + loadFromDisk(`persist.ts`、`session-store.ts`、`manager.ts:198-306`) + - SIGUSR1 manual-tick、`KIMI_DISABLE_CRON`/`MANUAL_TICK`、`cron_fire` XML 渲染(`cron-fire-xml.ts`) + - CronCreate/List/Delete 工具(校验、50 上限、8KiB prompt、一次性 350 天回滚保护、`cron_scheduled/deleted` 遥测) +- **需决策**:v2 缺 `tick()` 的 setInterval 驱动;`CronFiredEvent.origin` 未建模 cron/coalescedCount。 + +#### `mcp` — 骨架 +- **对照源**:`packages/agent-core/src/mcp/**`(client-http/sse/stdio/remote、client-shared、connection-manager、config-loader、session-config、tool-naming、output、types、auth-tool、index、oauth/**)+ `src/services/mcp/**` +- **v2 状态**:骨架(connect/disconnect 只改 map 字符串)。 +- **已实现要点**:状态事件 `onDidChangeServerStatus`;fan-out 落点 `IToolService.registerMcpTools` 已存在。 +- **缺失清单**: + - 三种 transport 客户端(stdio/http/sse/remote)+ 工具发现 + unexpected-close(`client-*.ts`、`connection-manager.ts`) + - 配置加载:user/project-root/project-local 三层 `mcp.json` 合并(`config-loader.ts`、`session-config.ts`) + - 工具名 `mcp__server__tool` 限定 + 64 字符 hash 截断 + 冲突检测(`tool-naming.ts`、`agent/tool/index.ts:136-200`) + - 输出管线:MCP content→ContentPart、媒体 `` 包裹、100K 文本 / 10MB 二进制限额(`output.ts`) + - enabled/disabledTools 过滤、启动超时、needs-auth 状态机(`connection-manager.ts:258-377`) + - **MCP OAuth 整套**:RFC 9728/8414/7591 发现、localhost callback、token store、DCR、`authenticate` 合成工具(`oauth/*`、`auth-tool.ts`) + - agent 工具扇入/扇出 + `tool.list.updated` 事件(`agent/tool/index.ts:61-75,206-303`) +- **需决策**:v2 `IOAuthService` 仅是通用 login/status,**MCP OAuth 无归宿**;stdio 远端 executor v1 也是 NOT_IMPLEMENTED(非差距)。 + +--- + +### L6 — 协调 + +#### `agent-lifecycle` — 部分 +- **对照源**:`packages/agent-core/src/session/index.ts`(createAgent/instantiateAgent/resumeAgent 段)+ `src/session/subagent-host.ts` + `src/session/subagent-batch.ts` +- **v2 状态**:部分(DI 子 scope 创建已实现,业务语义缺失)。 +- **已实现要点**:`create` 建 Agent 子 scope + `IScopeHandle`、`createMain('main')`、handle map get/list/remove。 +- **缺失清单**: + - `instantiateAgent`:装配 Agent(kaos.withCwd、provider、permission、hookEngine、subagentHost、mcp、additionalDirs)(`session/index.ts:661-706`) + - profile bootstrap + AGENTS.md 超大 warning(`session/index.ts:463-483`) + - `resumeAgent`:从 metadata 恢复 + 父子链环检测 + lazy replay(`session/index.ts:724-771`) + - subagent 整套:`spawn/resume/retry`、父子配置继承、`startBtw` 侧问(`subagent-host.ts`) + - swarm batch 调度:5 并发 ramp、rate-limit 退避/恢复(`subagent-batch.ts`) +- **需决策**:handle 当前仅 `accessor`,不含 Agent 业务对象;parent/cwd 选项已声明但未生效。 + +#### `session-context` — 部分 +- **对照源**:`packages/agent-core/src/session/index.ts`(sessionId/metadata 段) +- **v2 状态**:部分(按设计只承载 seed)。 +- **已实现要点**:`ISessionContext{sessionId, meta}` token + `sessionContextSeed`。 +- **缺失清单**: + - v1 `Session.metadata`(title/isCustomTitle/createdAt/updatedAt/agents/custom)+ `writeMetadata/readMetadata/flushMetadata` 串行写盘(`session/index.ts:164-172,555-579`) + - `state.json` 路径、homedir、kaos 切换(`setToolKaos`)、additionalDirs 管理(`session/index.ts:242-285`) +- **需决策**:metadata 所有权拆分给 `records` / `session-context` 何处,需决策。 + +#### `session-activity` — 部分 +- **对照源**:`packages/agent-core/src/session/index.ts`(_computeStatus 段)+ `src/services/session/sessionService.ts` +- **v2 状态**:部分实现。 +- **已实现要点**:`isIdle()` 遍历 handle 读 `ITurnService.hasActiveTurn`。 +- **缺失清单**: + - v1 `_computeStatus` 五态优先级:awaiting_approval → awaiting_question → running → aborted → idle(`sessionService.ts:139-156`) + - 状态变化事件 `event.session.status_changed` + 总线监听(active/aborted turns)(`sessionService.ts:175-232`) + - approval/question 依赖接入 +- **需决策**:v2 `ISessionService.status()` 仅 idle/running,缺 awaiting_approval/question/aborted。 + +#### `session` — 骨架 +- **对照源**:`packages/agent-core/src/services/session/**` + `src/session/rpc.ts` + `src/session/index.ts`(会话级逻辑)+ `src/session/export/**`(manifest、session-export、wire-scan、zip、index)+ `src/session/git-context.ts` + `src/session/prompt-metadata.ts` +- **v2 状态**:骨架(facade,4 个核心方法 TODO 抛错)。 +- **已实现要点**:`status()`、`agents()` 委托 lifecycle。 +- **缺失清单**: + - `fork`/`createChild`/`listChildren`(`sessionService.ts:356-433`) + - `compact`(`beginCompaction`)、`undo`(`canUndoHistory` + 分页)、`archive`(`sessionService.ts:495-556`) + - CRUD:create/list/get/update/getStatus/getSessionWarnings + 分页/排序/过滤(`sessionService.ts:234-354,445-493`) + - RPC 层 `SessionAPIImpl`:prompt/steer/cancel/model/thinking/permission/plan/swarm/goal/background/skill/mcp 等 30+ 方法(`session/rpc.ts`) + - prompt 元数据自动标题 + 敏感信息脱敏(`prompt-metadata.ts`) + - explore subagent `` 注入(`git-context.ts`) + - session export zip:manifest + wire 扫描 + global log(`session/export/*`) + - `generateAgentsMd`、AGENTS.md warning、`SessionStart/End` hook 触发 +- **需决策**:v2 `fork` 返回 `IScopeHandle`,但 v1 fork 语义是会话级 records 复制,归属 session vs records 未定。 + +#### `hooks` — 骨架 +- **对照源**:`packages/agent-core/src/session/hooks/**`(engine、runner、types、user-prompt、index) +- **v2 状态**:骨架(全部 passthrough `continue:true`)。 +- **已实现要点**:4 个 run 方法签名(UserPromptSubmit/PreToolCall/SessionStart/SessionEnd)。 +- **缺失清单**: + - 16 种事件类型 + matcher 正则匹配 + command 去重(`types.ts`、`engine.ts`) + - 子进程 spawn 执行:stdin JSON、exit 2=block、超时、SIGTERM/SIGKILL、abort(`runner.ts`) + - 结构化输出解析(`hookSpecificOutput.permissionDecision=deny`)(`runner.ts:151-179`) + - 其余事件:PreToolUse / PostToolUse(Failure) / PermissionRequest / PermissionResult / Stop / Interrupt / SubagentStart / Stop / PreCompact / PostCompact / Notification + - UserPromptSubmit 结果渲染 `` 注入(`user-prompt.ts`) + - block 决策聚合、`onTriggered/onResolved` 回调、camelToSnake input 转换 +- **风险**:v2 `HookResult` 仅 `continue/message`,缺 action/stdout/stderr/exitCode/timedOut/structuredOutput。 + +--- + +### L7 — 边界 + +#### `event` — 已实现(极简) +- **对照源**:`packages/agent-core/src/services/event/**` +- **v2 状态**:已实现(极简)。 +- **已实现要点**:`Set` 同步 fan-out、subscribe 返回 IDisposable。 +- **缺失清单**: + - `onDidPublish: Event` VSCode 风格访问器(v2 改为命令式 subscribe),导致 server `WSBroadcastService` 无法订阅(v1 `event.ts:48-56`、`eventService.ts:30-31`) + - dispose 后 publish no-op 语义(v1 `eventService.ts:34-35`) + +#### `approval` — 部分 +- **对照源**:`packages/agent-core/src/services/approval/**` + `packages/server/src/services/approval/**` +- **v2 状态**:部分(裸内存 broker 可用)。 +- **已实现要点**:`request/decide/listPending`、`Map`。 +- **缺失清单**: + - ULID id + createdAt/expiresAt;60s 超时 + `ApprovalExpiredError` + `event.approval.expired`(server `approvalService.ts:93-95,206-224`) + - 事件发布 `event.approval.requested/resolved`;`byToolCallId` 索引、`recentlyResolved` 去重(cap 1024) + - 协议适配 `toBrokerRequest/toAgentCoreResponse`(snake_case + 12-arm display 透传)(`approval.ts:118-150`) + - DisposableMap 生命周期、dispose 拒 promise;sessionId/agentId 路由、`isPending` +- **风险**:`ApprovalRequest` 仅 `{id,toolName}`,缺 toolCallId/action/display/sessionId/agentId;id 调用方自填易碰撞。 + +#### `question` — 部分 +- **对照源**:`packages/agent-core/src/services/question/**` + `packages/server/src/services/question/**` +- **v2 状态**:部分(裸内存 broker)。 +- **已实现要点**:`request/answer/listPending`。 +- **缺失清单**: + - ULID + 超时 + `QuestionExpiredError` + `event.question.requested/answered/dismissed/expired` + - `dismiss()` 语义(→null)(`question.ts:82-84,229-230`)(v2 无 dismiss) + - 协议适配:`q_`/`opt_

_` 合成 id、5-kind answer 扁平化(single/multi/other/multi_with_other/skipped)、`allow_other` 恒 true、`method:'click'` 丢弃(`question.ts:116-222`) +- **风险**:`QuestionRequest` 仅 `{id,prompt}`,无 QuestionItem/options/multiSelect/other/turnId,无法表达 AskUserQuestion 真实结构。 + +#### `gateway` — 骨架 +- **对照源**:`packages/agent-core/src/rpc/**`(core-impl=KimiCore facade、core-api、sdk-api、client、events、resumed、types、index)+ `packages/server/src/services/gateway/**` + `src/services/coreProcess/**` +- **v2 状态**:骨架。 +- **已实现要点**:`ScopeRegistry`(Session 子 scope 创建/get/close)、`RestGateway` 路由 prompt/steer/cancel→`ITurnService`。 +- **缺失清单**: + - **WS 全 TODO**:`WSGateway.broadcast` 空实现、`WSBroadcastService` 不路由(`gatewayService.ts:110,120`) + - **整个 server 传输层无归宿**(见 §2.7):`SessionEventJournal`(seq/epoch/replay)、`WSBroadcastService`、`ConnectionRegistry`、`SessionClientsService`、`InFlightTurnTracker`、`ServerShutdownService` + - `KimiCore` facade 的约 60 个 `CoreAPI` 方法(`core-api.ts:340-419`):v2 只接住 prompt/steer/cancel;其余 AgentAPI/SessionAPI/Plugin/Config/Export 需确认由各 Domain 承接 + - `coreProcess` 子进程/进程内 RPC 桥(`coreProcess.ts`、`client.ts createRPC` 双向 RPC)v2 无等价物(见 §2.6) + +--- + +### 横向能力(cross-cutting) + +#### `terminal` — 骨架 +- **对照源**:`packages/agent-core/src/services/terminal/**` +- **v2 状态**:骨架。 +- **已实现要点**:通过 session kaos `exec` spawn、按 pid 记录、write/kill。 +- **缺失清单**: + - **伪终端**:v1 用 `node-pty` 的 `NodePtyTerminalBackend`(`terminalService.ts:237-256`),v2 用 `kaos.exec` 无 PTY、无 cols/rows + - list/get/attach/detach/`detachAllForSink`、frame 环形缓冲(`maxBufferedFrames=2000`)+ `sinceSeq` replay、resize、`terminal_output`/`terminal_exit` 协议帧、`TerminalNotFoundError`、path safety cwd 解析(`terminal.ts:49-74`) + +#### `fs` — 骨架 +- **对照源**:`packages/agent-core/src/services/fs/**`(fs、fsGit、fsSearch、fsWatcher、fsPathSafety、*Service) +- **v2 状态**:骨架(薄 kaos 封装)。 +- **已实现要点**:read/write/stat/mkdir 透传 kaos;grep 用 `grep -r`、glob 用 `find`、git status/diff/log 用 `git` CLI。 +- **缺失清单**: + - 协议化 list/listMany/stat/statMany(深度/limit/gitignore/exclude_globs/sort/binary/mime/language_id/etag/line_count/children_by_path/truncated)(`fsService.ts:60-329`) + - `resolveSafePath` 路径逃逸防护(empty/absolute/dotdot/symlink_outside)(`fsPathSafety.ts:38-74`) + - read 的 offset/length、二进制探测、base64/auto 编码、`resolveDownload`/`resolvePath`(`fsService.ts:163-407`) + - FsSearch:`search` 模糊打分、rg `--json` 解析 + context_lines + 超时 + Node 兜底(`fsSearchService.ts:54-424`) + - FsGit:porcelain/numstat 解析、ahead/behind、`gh pr view` PR 缓存、untracked `/dev/null` diff(`fsGitService.ts`、`fsGit.ts:39-159`) + - FsWatcher:chokidar 引用计数、debounce 合并窗口、`event.fs.changed` 帧、按 connection 投递、`FsWatchLimitError`(`fsWatcherService.ts:94-376`);v2 仅 `Set` + +#### `workspace` — 骨架 +- **对照源**:`packages/agent-core/src/services/workspace/**`(workspaceFs、workspaceRegistry、*Service、index) +- **v2 状态**:骨架(内存)。 +- **已实现要点**:`register/get/list` 内存 Map、`resolve(workspaceId, rel)`。 +- **缺失清单**: + - 持久化 `workspaces.json`(version/opQueue 串行写 / 原子 rename)(`workspaceRegistryService.ts:211-292`) + - `createOrTouch/update/delete/resolveRoot` + `encodeWorkDirKey` id、git 检测(detectGit 含 worktree)、session_count、last_opened_at 排序、`event.workspace.created/updated/deleted`(`:60-186`) + - WorkspaceFs:`browse`/`home`(recent_roots、dir-only、git 标记)、`WorkspaceFsNotAbsolute/NotFound/Permission`(`workspaceFsService.ts`) + +#### `filestore` — 骨架 +- **对照源**:`packages/agent-core/src/services/fileStore/**` +- **v2 状态**:骨架(内存 Map)。 +- **已实现要点**:put/get/delete `Uint8Array`。 +- **缺失清单**: + - 持久化到 `/files/` 磁盘 blob + `index.json` 元数据索引(`fileStoreService.ts:46-204`) + - 流式 `save(source, filename, options)` + `DEFAULT_MAX_UPLOAD_BYTES=50MB` `FileTooLargeError`(`:55-104`) + - `FileMeta`(id/media_type/size/expires_at)、`FileNotFoundError`、blob 丢失自愈(`:106-126`) + +#### `auth` — 骨架 +- **对照源**:`packages/agent-core/src/services/oauth/**` + `src/services/auth/**`(managedAuth)+ `src/services/authSummary/**` +- **v2 状态**:骨架(内存 loggedIn Set)。 +- **已实现要点**:login/logout/status/summarize 内存态。 +- **缺失清单**: + - **device-code OAuth 编排**:startLogin/getFlow/cancelLogin/logout、FlowState/AbortController、supersede、5min GC、15min TTL、`DeviceCodeTimeoutError→expired`/`OAuthError→denied/cancelled` 映射(`oauthService.ts:77-283`) + - `managedAuth` facade:`KimiOAuthToolkit` 配置 provisioning、token 缓存、`getCachedAccessToken`、`resolveOAuthTokenProvider`(`managedAuth.ts:48-174`) + - AuthSummary 真 readiness:`get()` 读 `config.toml`+cached token、`ensureReady()` 四个哨兵错误 `AuthProvisioningRequired/TokenMissing/TokenUnauthorized/ModelNotResolved`(40110-40113)(`authSummaryService.ts:36-103`、`authSummary.ts:55-111`) + +--- + +## 4. 落地优先级建议(映射 ROADMAP) + +> 以下为「功能完整」视角的优先级,**与 `plan/ROADMAP.md` 的 M0–M11 原子步骤一致**,可直接按里程碑推进。 + +### P0 — 阻塞性基础设施(必须先于业务回填) +1. ~~`_base/flags`~~ → `flag` domain(实验门控,根 AGENTS.md 硬规则)— **已完成**(Core scope `IFlagService` + `FlagRegistry` + `[experimental]` config section) +2. `_base/errors`(KimiError / ErrorCodes / serialize)— M1 前 +3. `_base/utils`(abort / completion-budget / fs / proxy / tokens / render-prompt)— ✅ 已完成(见 §2.3) +4. `log` 落盘 + 脱敏(RotatingFileSink / 每会话 sink / formatter)— M1.1 +5. `records` 全链路(restore / migration v1.1–v1.4 / BlobStore / FileSystemPersistence / session store)— M2.1–M2.3 +6. `config` 内核(zod schema / TOML 读写 / env-model / workspace-local / thinking 解析 / safe-load)— M2.4–M2.6 +7. `kosong` LLM 桥(6 类 provider ProviderManager + OAuth + 能力探测 + managed Kimi 刷新 + KosongLLM 流式桥)— M1.6–M1.7 + +### P1 — L3/L4 核心引擎(turn 跑通的最小闭环) +8. `loop/` 步骤引擎 + `tool-call` 生命周期 + `ToolScheduler` — M5.2 +9. `turn` Turn scope 工厂 + `ITurnService` 核心 + 生命周期事件 — M5.3–M5.5 +10. `permission` 20 策略 + matches-rule DSL + mode 可配 — M3.2–M3.3、M9.7 +11. `tool` 内置工具 + args 校验 + 路径/敏感守卫 + ResultBuilder + MCP 注册 — M3.4–M3.5、M9 +12. `context` loop-event 状态机 + projector + 真实 token + 真实 compaction/undo — M4.1 +13. `message` 稳定 id + 内容映射 + transcript 还原 — M4.2 +14. `injection` DynamicInjector 体系(Goal/Plan/Permission/Plugin/Todo)— M6.1 +15. `compaction` Full + Strategy + Micro — M6.3 + +### P2 — 行为 Domain(订阅式装配) +16. `plan` / `goal`(状态机 + 预算 + driveGoal continuation)— M6.4–M6.5 +17. `swarm` / `usage` / `tooldedup`(修 bug + deferred 复用 + streak 升级)— M6.2、M6.6–M6.7 +18. `skill`(parser / scanner / 激活 / builtin skills)— M3.6、M9.6 + +### P3 — 协调 + 异步 +19. `agent-lifecycle`(instantiateAgent / resumeAgent / 父子继承 / subagent host+batch)— M7.4–M7.5 +20. `session-context` / `session-activity` / `session` facade(fork/compact/undo/archive + 30+ RPC)— M7.1–M7.2、M7.6 +21. `hooks`(16 事件 + matcher + 子进程执行 + 结构化输出 + UserPromptSubmit 注入)— M7.3 +22. `background`(process/agent/question 任务 + 生命周期 + 持久化 reconcile)— M8.1 +23. `cron`(5 字段解析 + jitter + 双时钟 + coalesce + stale + 工具)— M8.3–M8.4 +24. `mcp`(三种 transport + 配置加载 + 命名/冲突 + 输出管线 + OAuth)— M8.2 + +### P4 — 边界 + 横向 + 切换 +25. `event`(onDidPublish 访问器)— M10.1 +26. `approval` / `question`(ULID + 超时 + 事件 + 协议适配 + dismiss + RPC 桥)— M3.1、M10.5 +27. `gateway`(WS fan-out + event journal + connection registry + 薄 RPC 路由)— M10.2–M10.4、§2.6/§2.7 +28. `terminal`(PTY)/ `fs`(协议 + 安全 + rg + git + watcher)/ `workspace` / `filestore`(持久化)/ `auth`(device-code OAuth + managedAuth + AuthSummary)— 横向能力(ROADMAP 未单列,需补排期) +29. `plugin` / `profile`(无归宿,需先定 Domain 归属再落地)— §2.4/§2.5 +30. server/SDK 切到 v2 + server-e2e + 删 v1 + changeset — M11 + +--- + +## 5. 附:v1 → v2 子系统处置速查(摘自 PLAN §2) + +| v1 子系统 | 处置 | v2 归宿 | +|---|---|---| +| `di/`、`base/common/event.ts`、`di/lifecycle.ts` | 必拿 | `_base` | +| `rpc/core-impl.ts`(KimiCore) | 扔 | 替换为 `IScopeRegistry` + 薄 gateway | +| `session/index.ts`(Session god) | 扔形重塑 | `session`/`agent-lifecycle`/`session-context`/`session-activity`/`hooks` | +| `session/store/` | 拿 | `records.ISessionStore` | +| `session/provider-manager.ts` | 拿 | `kosong.IProviderManager` | +| `session/hooks.ts` | 拿 | `hooks.IHookEngine` | +| `session/mcp/` | 拿 | `mcp.IMcpService` | +| `session/subagent-host.ts` | 拿形重塑 | 并入 `agent-lifecycle` | +| `session/rpc.ts`(SessionAPIImpl) | 扔形重塑 | 自动标题等并入 `session` facade;RPC 适配由 `gateway` 负责 | +| `agent/index.ts`(Agent god) | 扔形重塑 | Agent 变 Scope 容器 + 薄 composition | +| `agent/turn/index.ts`(TurnFlow) | 拿形重塑 | `turn.ITurnService` + Turn scope + 生命周期事件 | +| `loop/` | 必拿 | `turn.ILoopRunner` | +| `agent/context/`、`agent/records/`、`agent/config/` | 拿 | `context`/`records`/`config` | +| `agent/cron/` + `tools/cron/` | 拿形重塑 | per-Agent → `cron`(Session) + `ICronFireCoordinator` | +| `agent/background/`、`agent/compaction/`、`agent/plan/`、`agent/goal/`、`agent/swarm/`、`agent/usage/`、`agent/injection/` | 拿 | 同名 Domain | +| `agent/turn/tool-dedup.ts` | 拿 | `tooldedup` | +| `agent/permission/` + `policies/` | 拿 + 拿形重塑 | `permission.IPermissionService` + `IPermissionPolicyRegistry`,策略迁回所属 Domain | +| `agent/tool/` + `tools/builtin/**` | 拿形重塑 | `tool.IToolService` + `IToolDefinitionRegistry`,工具迁回所属 Domain `tools/` | +| `agent/skill/` + `skill/registry.ts` | 拿 | `skill` | +| `config/` | 拿 | `config` | +| `services/*`(session/message/tool/skill/task/mcp/config…) | 扔形重塑 | 去 RPC 化,并入对应 Domain | +| `services/event`/`approval`/`question`/`terminal`/`fs`/`workspace`/`fileStore`/`oauth`/`authSummary`/`modelCatalog`/`environment`/`logger` | 拿 | 同名 Domain / `event`/`approval`/`question`/`terminal`/`fs`/`workspace`/`filestore`/`auth`/`kosong`/`environment`/`log` | +| `telemetry.ts` + `packages/telemetry`、`logging/`、`flags/`、`errors/`、`utils/` | 拿 / 必拿 | `telemetry` / `log` / `flag` / `_base` | + +> **本速查未覆盖的 v1 子系统**(需补决策,见 §2):`plugin/**`、`profile/**`、`rpc/**`(coreProcess)、`services/coreProcess`、server 端 `gateway` 传输层。 diff --git a/packages/agent-core-v2/docs/di-testing.md b/packages/agent-core-v2/docs/di-testing.md new file mode 100644 index 000000000..9d00224f6 --- /dev/null +++ b/packages/agent-core-v2/docs/di-testing.md @@ -0,0 +1,117 @@ +# DI testing + +> Conventions for testing services built on the DI × Scope architecture. Mirrors the way VS Code tests `src/vs/platform/instantiation` and its consumers: declare dependencies with `@IService` decorators, build the container through the public API, stub collaborators through `TestInstantiationService`. + +`@IService` parameter decorators run under vitest (the build uses `experimentalDecorators`), so test fixtures declare dependencies exactly like production code. There is **no** `param()` helper, no manual `(Id as …)(Ctor, '', 0)`, and no capturing `accessor` inside a constructor to synchronously `.get()` a peer — those are all workarounds for a decorator transform we already have. + +## Three kinds of tests + +Pick the helper by *what is under test*, not by habit. + +| Under test | Lives in | Helper | Build the container with | +|---|---|---|---| +| The container / Scope machinery itself | `test/di/*` | the plain `InstantiationService` / `Scope` API | flat: `new InstantiationService(new ServiceCollection([Id, new SyncDescriptor(Impl)]))`; scoped: `createCoreScope()` + `registerScopedService()` | +| A real domain service (unit) | `test//*` | `TestInstantiationService` | `disposables.add(new TestInstantiationService())` + `ix.stub(...)` + `ix.createInstance(Sut)` | +| Cross-scope wiring (integration) | `test//*` or `test/di/*` | `createScopedTestHost` | `createScopedTestHost([[ILog, stub]])` → `host.child(LifecycleScope.Session, 's1', …)` | + +Rule of thumb: testing the **container** → use the container; testing a **service** → use `TestInstantiationService`; only reach for the scope host when *which layer a service lives in* is itself the thing being asserted. Never `new` a production service in a unit test and paper over its dependencies with `undefined as never`. + +## Declaring dependencies + +Always use `@IService` constructor decorators — in fixtures and in production services alike. + +```ts +// ✅ +class Consumer { + constructor(@IGreeter private readonly greeter: IGreeter) {} +} + +// ❌ no param() helper, no inline cast +class Consumer { + constructor(private readonly greeter: IGreeter) {} +} +param(IGreeter, Consumer, 0); +``` + +This holds for cycle tests too. Declare the loop with real constructor dependencies (`ServiceLoop1(@IService2)` ↔ `ServiceLoop2(@IService1)`); do not capture `accessor` inside a constructor and call `.get(peer)` to force an edge. + +Because the decorator runs when the class is defined, the `createDecorator` identifier must be initialized **before** the class that uses it. Declare the identifier, then the class: + +```ts +const IDep = createDecorator('dep'); +class Consumer { + constructor(@IDep private readonly dep: IDep) {} +} +``` + +For two services that depend on each other (a cycle), declare both identifiers first, then both classes, so neither class references an uninitialized binding. + +Declare fixtures at module top, interface + decorator + implementation co-located, and keep `_serviceBrand` on the interface when it represents a real service: + +```ts +const IGreeter = createDecorator('greeter'); +interface IGreeter { + readonly _serviceBrand: undefined; + greet(): string; +} +class Greeter implements IGreeter { + declare readonly _serviceBrand: undefined; + greet(): string { return 'hi'; } +} +``` + +Pure throwaway fixtures may omit `_serviceBrand`. + +## Domain service unit tests + +`TestInstantiationService` (from `#/_base/di/test`) is the default harness. It is an `InstantiationService` that also implements `ServicesAccessor`, so you can `.get()` directly, and it owns sinon so `dispose()` restores stubs. + +```ts +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; + +describe('FlagService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(ILogService, { log() {} }); + ix.stub(IConfigService, { get: () => ({}), onDidChange: () => () => {} }); + }); + afterEach(() => disposables.dispose()); + + it('reads a flag', () => { + const svc = disposables.add(ix.createInstance(FlagService)); + expect(svc.isEnabled('x')).toBe(false); + }); +}); +``` + +Stubbing: + +- stub a whole service with a partial: `ix.stub(IId, { method() { return … } })`; +- stub / assert a single method: `ix.stub(IId, 'method', value)` returns a sinon stub; `ix.spy(IId, 'method')` returns a spy; +- replace with a prebuilt instance or descriptor: `ix.set(IId, instance)` / `ix.set(IId, new SyncDescriptor(Impl))`; +- when a collaborator's behavior must vary per test, model it as a `Test*Service` subclass whose methods read suite-scoped `let` variables (the `configurationValue` / `updateArgs` pattern in VS Code) rather than rebuilding the container each test. + +Create the system-under-test through DI (`ix.createInstance(Sut)`) so its `@IService` dependencies are resolved from the container, exactly as in production. + +## Lifecycle / teardown + +One `DisposableStore` per suite. Add the container, the system-under-test, and any event subscriptions to it; dispose in `afterEach`. + +```ts +beforeEach(() => { disposables = new DisposableStore(); /* … */ }); +afterEach(() => disposables.dispose()); +``` + +Scope-host tests call `host.dispose()` in `afterEach` (or at the end of the `it`). Do not scatter bare `ix.dispose()` / `core.dispose()` calls through test bodies — route teardown through the store so ordering is deterministic and nothing leaks when a test fails mid-way. + +## Assertions and naming + +- One behavior per `it`; describe observable behavior (`child shadows parent registration`), not implementation (`calls _getOrCreateServiceInstance`). +- For cycles, assert `CyclicDependencyError` and its `path` array (e.g. `['A', 'B', 'A']`), not merely `toThrow`. +- For disposal order, capture events in an array and assert the sequence (`['C', 'B', 'A']` — children before parents). diff --git a/packages/agent-core-v2/docs/errors.md b/packages/agent-core-v2/docs/errors.md new file mode 100644 index 000000000..456e7e131 --- /dev/null +++ b/packages/agent-core-v2/docs/errors.md @@ -0,0 +1,67 @@ +# errors + +> Error infrastructure for agent-core-v2: base classes, the public code registry, wire serialization, and the conventions domains follow when raising errors. + +Design is borrowed from VSCode (`src/vs/base/common/errors.ts` + per-service error classes) with one addition: a central **code registry** for the RPC/SDK boundary. Mechanism is centralized in `_base/errors`; error *classes* are decentralized (co-located per domain); error *codes* are decentralized too but aggregated into one registry with metadata. + +## Where things live + +- `src/_base/errors/errors.ts`: base classes — `KimiError`, `CancellationError`, `ExpectedError`, `ErrorNoTelemetry`, `BugIndicatingError`, `NotImplementedError`. +- `src/_base/errors/codes.ts`: `ErrorCodes` registry, `ErrorCode` type, `ERROR_INFO` metadata (`title` / `retryable` / `public` / `action`), `errorInfo(code)`. +- `src/_base/errors/serialize.ts`: `ErrorPayload`, `isCodedError`, `toErrorPayload`, `fromErrorPayload`, `makeErrorPayload`. +- `src/_base/errors/errorMessage.ts`: `toErrorMessage(error, verbose?)` for logs/CLI. +- `src/_base/errors/unexpectedError.ts`: `onUnexpectedError` / `setUnexpectedErrorHandler` / `safelyCallListener` (global handler). +- `src/_base/di/errors.ts`: DI-only `CyclicDependencyError` (kept separate; the DI layer exposes no general error taxonomy, like VSCode). + +## Conventions (hard rules) + +- **Throw a coded error, not a bare string.** Define a domain error that `extends KimiError` and carries a `code`. `throw new Error('x')` only for unreachable guards; use `NotImplementedError('feature')` for stubs. +- **Co-locate the error class with the domain's interfaces.** `ToolError` lives in `tool/tool.ts` next to `IToolService`, not in a separate `*Errors.ts` and not in `_base/errors`. +- **One `code` per failure mode.** Codes read `domain.reason` (e.g. `tool.unknown_tool`). Adding a code is minor; renaming/removing one is a major (breaks SDK clients). +- **Register codes centrally.** After defining a domain's `XxxErrorCode` const, spread it into `ErrorCodes` in `codes.ts` and add an `ERROR_INFO` entry per code. +- **Translate foreign errors at the boundary.** Provider/HTTP, fs, MCP errors are caught at the domain boundary and re-thrown as the domain's coded error. `_base/errors` never imports a business domain. +- **Branch on `code`, never `instanceof`, across the wire.** Class identity does not survive serialization. In-process, `instanceof KimiError` / `isCodedError` are fine. + +## Adding a domain error (recipe) + +In `/.ts`: + +```ts +import { KimiError, type ErrorCode } from '#/_base/errors'; + +export const ToolErrorCode = { + UnknownTool: 'tool.unknown_tool', + ExecutionFailed: 'tool.execution_failed', +} as const; +export type ToolErrorCode = (typeof ToolErrorCode)[keyof typeof ToolErrorCode]; + +export class ToolError extends KimiError { + constructor(code: ToolErrorCode, message: string, details?: Record) { + super(code as ErrorCode, message, { details }); + this.name = 'ToolError'; + } +} +``` + +Then in `src/_base/errors/codes.ts`, spread `...ToolErrorCode` into `ErrorCodes` and add an `ERROR_INFO` entry for `tool.unknown_tool` and `tool.execution_failed`. + +## Serialization & boundary translation + +- `toErrorPayload(error)`: `CancellationError` → `canceled`; any coded error (incl. deserialized shapes) → its code + `retryable` from `ERROR_INFO`; anything else → `internal`. +- `fromErrorPayload(payload)`: rehydrates a `KimiError` for in-process `instanceof` / `isCodedError` use at the SDK/RPC boundary. +- `isCodedError(error)`: structural guard (checks `code` against `ERROR_INFO`), so it works for both `KimiError` instances and plain objects revived from a payload. +- Foreign-error mapping lives in the domain that owns the foreign dependency, e.g. kosong maps `APIStatusError` (429/401/…) → `KosongError` codes at its client boundary. A `registerErrorNormalizer` escape hatch is intentionally **not** provided until a second use case appears. + +## Deliberately omitted + +- No `IErrorWithActions` / action buttons — there is no notification surface in agent-core; add when one exists. +- No class registry / revival — payloads carry `code` + data only; rehydration always yields a base `KimiError`. +- No `IllegalArgumentError` / `NotSupportedError` yet — add a base class when a second throw site needs it. + +## References + +- `packages/agent-core-v2/src/_base/errors/` — implementation. +- `packages/agent-core/src/errors/` — v1 source this was ported from. +- `packages/agent-core-v2/GAP_ANALYSIS.md` §2.2 — gap closure note (`_base/errors`). +- `packages/agent-core-v2/GAP_ANALYSIS.md` §2.6 — RPC/SDK boundary that motivates the code registry. +- VSCode upstream: `src/vs/base/common/errors.ts`, `src/vs/base/common/errorMessage.ts`, `src/vs/platform/files/common/files.ts` (`FileOperationError` + `FileOperationResult`), `src/vs/platform/userDataSync/common/userDataSync.ts` (`UserDataSyncError` + code enum + normalizer). diff --git a/packages/agent-core-v2/docs/flag.md b/packages/agent-core-v2/docs/flag.md new file mode 100644 index 000000000..c8d9fefd3 --- /dev/null +++ b/packages/agent-core-v2/docs/flag.md @@ -0,0 +1,86 @@ +# flag + +> Experimental feature-flag gating for agent-core-v2 — a Core-scope `IFlagService` resolver plus an exported `FlagRegistry` catalog, backed by the `[experimental]` config section. + +Gates not-yet-public features behind `IFlagService.enabled(id)`, per the repository hard rule that unreleased behavior must be flag-gated. Ported from `packages/agent-core/src/flags/**`; v1 was a process-global `FlagResolver` singleton, v2 is a scoped DI service with no implicit global state. + +## Layout + +- `src/flag/registry.ts` — `FLAG_DEFINITIONS`, `FlagId`, `FlagDefinition`, `FlagRegistry` (catalog), `ExperimentalConfigSchema` / `ExperimentalConfig` (zod). +- `src/flag/flag.ts` — `IFlagService` token + resolver types (`ExperimentalFlagMap`, `ExperimentalFlagConfig`, `ExperimentalFlagSource`, `ExperimentalFeatureState`). +- `src/flag/flagService.ts` — `FlagService` impl + `MASTER_ENV` (`KIMI_CODE_EXPERIMENTAL_FLAG`) + `EXPERIMENTAL_SECTION` (`experimental`); self-registers at Core scope. +- `src/flag/index.ts` — barrel; re-exported by `src/index.ts` at the L3 block. + +## Public surface + +- `IFlagService` (DI token, Core scope): `enabled(id)`, `explain(id)`, `snapshot()`, `enabledIds()`, `explainAll()`, `setConfigOverrides(overrides)`, `registry`. +- `FlagRegistry`: `get(id)`, `list()`, `definitions` — read-only catalog for hosts/UI to enumerate flags without resolving them. +- `FlagService`: exported for tests and hosts that construct it directly. + +## Resolution precedence + +Highest wins; env is read live on every call (nothing cached): + +1. L1 master env `KIMI_CODE_EXPERIMENTAL_FLAG` truthy → every flag on. +2. L2 per-feature `def.env` (e.g. `KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION`) → forces on/off. +3. L3 `[experimental]` config section per-flag override. +4. L4 registry `default`. + +`explain(id)` returns the winning `source` (`master-env` | `env` | `config` | `default`) plus the effective `configValue`. + +## Config integration + +- `FlagService` registers the `[experimental]` section into `IConfigRegistry` at construction (`registerSection('experimental', ExperimentalConfigSchema)`) and reads overrides from `IConfigService`. +- It subscribes `IConfigService.onDidChange` and refreshes overrides whenever the `experimental` domain changes, so config edits apply live. +- `IConfigRegistry.registerSection` throws if a domain is registered twice — `experimental` is owned exclusively by `FlagService`. +- `setConfigOverrides(overrides)` is an imperative escape hatch for tests and hosts without an `IConfigService`; hosts on `IConfigService` should set the `[experimental]` section instead. + +Config shape mirrors v1: + +```toml +[experimental] +micro_compaction = false +``` + +Keys are intentionally loose (`z.record(z.string(), z.boolean())`), so obsolete flags stay inert config. + +## Add a flag + +Append to `FLAG_DEFINITIONS` in `src/flag/registry.ts`: + +```ts +{ id: 'my_feature', title: 'My feature', description: '...', env: 'KIMI_CODE_EXPERIMENTAL_MY_FEATURE', default: false, surface: 'both' } +``` + +- Keep the `as const satisfies` — it derives the `FlagId` union that gives `enabled()` autocomplete and typo-checking. +- `env` must start with `KIMI_CODE_EXPERIMENTAL_`, be unique, and not equal `KIMI_CODE_EXPERIMENTAL_FLAG`. +- `id` must not be `flag`. +- `surface`: `core` | `tui` | `both` (documentation/grouping only; not used in resolution). + +## Consume a flag + +Inject `IFlagService` and gate on it. It is resolvable from any scope (Core ancestor): + +```ts +constructor(@IFlagService private readonly flags: IFlagService) {} +// ... +if (!this.flags.enabled('micro_compaction')) return; +``` + +Current consumer: `compaction` (L4) gates `micro_compaction`. + +## Layering & scope + +- Domain `flag` is registered at **L3** (`scripts/check-domain-layers.mjs` → `['flag', 3]`). It imports only `config` (L2) downward. +- It cannot live in `_base` (L0): registering/reading the config section requires importing `config`, and L0 must not import L2. +- Scope: `Core` (`registerScopedService(LifecycleScope.Core, IFlagService, FlagService, Delayed, 'flag')`). Env + config are process-global inputs, so there is no per-session/agent state. +- Tests construct `FlagService` directly with a real `ConfigRegistry`/`ConfigService` and an injected env map (`test/flag/flag.test.ts`). + +## References + +- `packages/agent-core-v2/src/flag/` — implementation. +- `packages/agent-core-v2/test/flag/flag.test.ts` — precedence + config subscription tests. +- `packages/agent-core/src/flags/` — v1 source this was ported from. +- `plan/PLAN.md` §2/§3 — domain placement (`flag` at L3, not `_base/flags`). +- `packages/agent-core-v2/GAP_ANALYSIS.md` §2.1 — gap closure note. +- Root `AGENTS.md` — experimental-feature gating rule. diff --git a/packages/agent-core-v2/package.json b/packages/agent-core-v2/package.json new file mode 100644 index 000000000..52c6689a7 --- /dev/null +++ b/packages/agent-core-v2/package.json @@ -0,0 +1,74 @@ +{ + "name": "@moonshot-ai/agent-core-v2", + "version": "0.0.0", + "private": true, + "description": "The unified agent engine for Kimi (v2 — DI Scope architecture)", + "license": "MIT", + "author": "Moonshot AI", + "homepage": "https://github.com/MoonshotAI/kimi-code/tree/main/packages/agent-core-v2#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/MoonshotAI/kimi-code.git", + "directory": "packages/agent-core-v2" + }, + "bugs": { + "url": "https://github.com/MoonshotAI/kimi-code/issues" + }, + "keywords": [ + "kimi", + "agent", + "ai", + "llm", + "session", + "tools" + ], + "files": [ + "dist" + ], + "type": "module", + "imports": { + "#/*": [ + "./src/*.ts", + "./src/*/index.ts" + ] + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./package.json": { + "types": "./package.json", + "default": "./package.json" + }, + "./*": { + "types": "./src/*.ts", + "default": "./src/*.ts" + } + }, + "scripts": { + "build": "tsdown", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint:domain": "node scripts/check-domain-layers.mjs", + "clean": "rm -rf dist" + }, + "dependencies": { + "@moonshot-ai/kaos": "workspace:^", + "@moonshot-ai/kimi-code-oauth": "workspace:^", + "@moonshot-ai/kimi-telemetry": "workspace:^", + "@moonshot-ai/kosong": "workspace:^", + "@moonshot-ai/protocol": "workspace:^", + "nunjucks": "^3.2.4", + "pathe": "^2.0.3", + "smol-toml": "^1.6.1", + "socks": "^2.8.9", + "undici": "^7.27.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/nunjucks": "^3.2.6", + "@types/sinon": "^21.0.1", + "sinon": "^22.0.0" + } +} diff --git a/packages/agent-core-v2/scripts/check-domain-layers.d.mts b/packages/agent-core-v2/scripts/check-domain-layers.d.mts new file mode 100644 index 000000000..b637dccd7 --- /dev/null +++ b/packages/agent-core-v2/scripts/check-domain-layers.d.mts @@ -0,0 +1,10 @@ +export interface Violation { + file: string; + line: number; + message: string; +} + +export const SRC_ROOT: string; + +export function checkSource(source: string, absFile: string): Violation[]; +export function checkFile(absFile: string): Violation[]; diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs new file mode 100644 index 000000000..529bcd490 --- /dev/null +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node +/** + * Domain-layer import boundary checker for `agent-core-v2`. + * + * Enforces two rules over `packages/agent-core-v2/src/**` (and the v1-import + * ban over `test/**` too): + * + * 1. **No v1 imports** — v2 must never `import '@moonshot-ai/agent-core'` + * (or any subpath). v2 ports logic; it never depends on v1. + * 2. **Domain layering** — a domain at layer L may only import domains at + * layer `<= L`. Lower layers must not reach upward. See + * `plan/PLAN.md` §3 / §5 for the layer table. + * + * Intra-package relative imports and `#/`-alias imports are resolved to a + * domain by the first path segment under `src/`. Sibling packages + * (`@moonshot-ai/*` other than v1) and third-party imports are out of scope. + * + * Run: `node scripts/check-domain-layers.mjs`. Exits non-zero on violation. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = resolve(__dirname, '..'); +export const SRC_ROOT = join(PKG_ROOT, 'src'); +const TEST_ROOT = join(PKG_ROOT, 'test'); + +/** + * Domain → layer. A domain may only import domains at its own layer or lower. + * Keep in sync with `plan/PLAN.md` §3. Domains not listed here that appear + * under `src/` are reported so the table stays current. + */ +const DOMAIN_LAYER = new Map([ + // L0 — base infrastructure + ['_base', 0], + // L1 — abstraction bridges + ['log', 1], + ['telemetry', 1], + ['environment', 1], + ['kaos', 1], + ['kosong', 1], + // L2 — data + ['records', 2], + ['config', 2], + // L3 — registries + ['tool', 3], + ['skill', 3], + ['permission', 3], + ['flag', 3], + // L4 — agent behaviour + ['context', 4], + ['message', 4], + ['turn', 4], + ['injection', 4], + ['compaction', 4], + ['plan', 4], + ['goal', 4], + ['swarm', 4], + ['usage', 4], + ['tooldedup', 4], + // L5 — async lifecycle + ['background', 5], + ['mcp', 5], + ['cron', 5], + // L6 — coordination + ['agent-lifecycle', 6], + ['session-context', 6], + ['session-activity', 6], + ['session', 6], + ['hooks', 6], + // L7 — boundary + ['event', 7], + ['approval', 7], + ['question', 7], + ['gateway', 7], + // Cross-cutting capabilities (depend on L1; consumed by upper layers). + ['terminal', 2], + ['fs', 2], + ['workspace', 2], + ['filestore', 2], + ['auth', 2], +]); + +const V1_PACKAGE = '@moonshot-ai/agent-core'; + +/** + * Deliberate, documented exceptions to the strict low→high layering rule. + * Each entry is `[fromDomain, toDomain]`. + * + * These are *real* dependencies taken from `plan/overview.md` §2 (Domain × + * Scope table). They are "upward" only by the coarse L1–L7 numbering; the + * plan's parent–child Scope mechanism (handles) is the intended long-term + * shape for several of them. They are surfaced here (and in the dependency + * report) for review rather than hidden. + * + * - `kosong>config` : model catalog reads its config section (PLAN §Dep graph). + * - `permission>approval` : permission(Agent) requests approval(Session broker). + * - `skill>turn` : skill activate starts a turn (same Agent scope intent). + * - `turn>agent-lifecycle` : turn cancels sub-agents via lifecycle handle. + * - `swarm>agent-lifecycle`: swarm spawns/manages sub-agents. + * - `background>agent-lifecycle`: background agent-tasks spawn sub-agents. + * - `cron>agent-lifecycle` : cron coordinator steers the main agent. + * - `cron>session-context` : cron needs sessionId. + * - `cron>session-activity`: cron scheduler gates on session idle. + * - `session>event` : session facade publishes status events. + */ +const ALLOWED_EXCEPTIONS = new Set([ + 'kosong>config', + 'permission>approval', + 'skill>turn', + 'turn>agent-lifecycle', + 'swarm>agent-lifecycle', + 'background>agent-lifecycle', + 'cron>agent-lifecycle', + 'cron>session-context', + 'cron>session-activity', + 'session>event', +]); + +// Matches: import ... from 'x' | export ... from 'x' | import('x') | require('x') +const IMPORT_RE = + /(?:import|export)\s+(?:type\s+)?(?:[^'";]*?\s+from\s+)?['"]([^'"]+)['"]|(?:import|require)\s*\(\s*['"]([^'"]+)['"]\s*\)/g; + +/** + * @typedef {{ file: string, line: number, message: string }} Violation + */ + +/** + * Determine the v2 domain (first `src/`-relative path segment) for an + * absolute file path. Returns `undefined` for files outside `src/`. + * @param {string} absPath + */ +function domainOf(absPath) { + const rel = relative(SRC_ROOT, absPath); + if (rel.startsWith('..') || rel === '') return undefined; + const segments = rel.split(/[\\/]/); + // Top-level `src/*.ts` files (e.g. the package barrel `index.ts`) are not + // domains — they re-export other domains and are exempt from layering. + if (segments.length < 2) return undefined; + return segments[0]; +} + +/** + * Resolve an import specifier to an absolute v2 `src/` path, or `undefined` + * when the specifier is not an intra-v2 import. + * @param {string} specifier + * @param {string} fromFile absolute path of the importing file + */ +function resolveIntraV2(specifier, fromFile) { + if (specifier.startsWith('#/')) { + return join(SRC_ROOT, specifier.slice(2)); + } + if (specifier.startsWith('.')) { + return resolve(dirname(fromFile), specifier); + } + return undefined; +} + +/** + * Check source text for boundary violations. `absFile` is used only to + * resolve relative specifiers and determine the source domain; the file need + * not exist on disk (handy for tests). + * @param {string} source + * @param {string} absFile + * @returns {Violation[]} + */ +export function checkSource(source, absFile) { + const violations = []; + const inSrc = !relative(SRC_ROOT, absFile).startsWith('..'); + const sourceDomain = inSrc ? domainOf(absFile) : undefined; + const sourceLayer = sourceDomain === undefined ? undefined : DOMAIN_LAYER.get(sourceDomain); + + let match; + IMPORT_RE.lastIndex = 0; + while ((match = IMPORT_RE.exec(source)) !== null) { + const specifier = match[1] ?? match[2]; + if (!specifier) continue; + const line = source.slice(0, match.index).split('\n').length; + + // Rule 1: v2 must not import v1. + if (specifier === V1_PACKAGE || specifier.startsWith(`${V1_PACKAGE}/`)) { + violations.push({ + file: absFile, + line, + message: `v2 must not import v1 (${specifier})`, + }); + continue; + } + + // Rule 2: domain layering (production code only). + if (!inSrc) continue; + if (sourceDomain === undefined) continue; // top-level barrel / non-domain file + const targetAbs = resolveIntraV2(specifier, absFile); + if (targetAbs === undefined) continue; + const targetDomain = domainOf(targetAbs); + if (targetDomain === undefined) continue; + if (targetDomain === sourceDomain) continue; // same domain is always fine + + const targetLayer = DOMAIN_LAYER.get(targetDomain); + if (sourceLayer === undefined) { + violations.push({ + file: absFile, + line, + message: `source domain '${sourceDomain}' is not registered in DOMAIN_LAYER`, + }); + continue; + } + if (targetLayer === undefined) { + violations.push({ + file: absFile, + line, + message: `target domain '${targetDomain}' (imported as '${specifier}') is not registered in DOMAIN_LAYER`, + }); + continue; + } + if (targetLayer > sourceLayer) { + if (ALLOWED_EXCEPTIONS.has(`${sourceDomain}>${targetDomain}`)) continue; + violations.push({ + file: absFile, + line, + message: `layer violation: '${sourceDomain}' (L${sourceLayer}) imports '${targetDomain}' (L${targetLayer}) via '${specifier}' — lower layers must not import higher layers`, + }); + } + } + + return violations; +} + +/** + * Check a single source file for boundary violations. + * @param {string} absFile + * @returns {Violation[]} + */ +export function checkFile(absFile) { + return checkSource(readFileSync(absFile, 'utf8'), absFile); +} + +function walk(dir) { + /** @type {string[]} */ + const out = []; + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules' || entry === 'dist') continue; + const abs = join(dir, entry); + const st = statSync(abs); + if (st.isDirectory()) out.push(...walk(abs)); + else if (abs.endsWith('.ts')) out.push(abs); + } + return out; +} + +function main() { + const files = [...walk(SRC_ROOT), ...walk(TEST_ROOT)]; + const violations = files.flatMap((f) => checkFile(f)); + if (violations.length === 0) { + console.log(`check-domain-layers: OK (${files.length} files)`); + return 0; + } + for (const v of violations) { + console.error(`${relative(PKG_ROOT, v.file)}:${v.line}: ${v.message}`); + } + console.error(`\ncheck-domain-layers: ${violations.length} violation(s)`); + return 1; +} + +const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + process.exit(main()); +} diff --git a/packages/agent-core-v2/scripts/dep-graph.mjs b/packages/agent-core-v2/scripts/dep-graph.mjs new file mode 100644 index 000000000..927acffea --- /dev/null +++ b/packages/agent-core-v2/scripts/dep-graph.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/** + * Dump the agent-core-v2 Service dependency graph. + * + * Walks every `src//*Service.ts` impl file, and for each registered + * service extracts: + * - its `LifecycleScope` (from the `registerScopedService(...)` call), + * - its constructor DI dependencies (the `@IToken` parameter decorators). + * + * Output is grouped by domain so the whole graph can be reviewed in one pass. + * + * Run: `node scripts/dep-graph.mjs`. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SRC_ROOT = join(__dirname, '..', 'src'); + +const SCOPE_OF = ['Core', 'Session', 'Agent', 'Turn']; + +function walk(dir) { + const out = []; + for (const entry of readdirSync(dir)) { + const abs = join(dir, entry); + const st = statSync(abs); + if (st.isDirectory()) out.push(...walk(abs)); + else if (entry.endsWith('.ts') && entry !== 'index.ts') out.push(abs); + } + return out; +} + +/** + * Extract services from one impl file. + * @returns {Array<{impl:string, token:string, scope:string, deps:string[]}>} + */ +function extract(source) { + const services = []; + + // Map impl class -> ctor deps (via @IToken decorators in the constructor). + const classRe = /export\s+class\s+(\w+)\s*(?:extends\s+\w+\s*)?(?:implements\s+[\w,\s]+)?\s*\{/g; + let cls; + const classDeps = new Map(); + while ((cls = classRe.exec(source)) !== null) { + const impl = cls[1]; + const start = cls.index; + // Find the constructor belonging to this class (before the next top-level class). + const nextClass = classRe.exec(source); + classRe.lastIndex = cls.index + 1; // allow re-match + const slice = source.slice(start, nextClass ? nextClass.index : source.length); + if (nextClass) classRe.lastIndex = nextClass.index; + const ctorMatch = /constructor\s*\(([^)]*)\)/.exec(slice); + const deps = []; + if (ctorMatch) { + const decRe = /@(I[A-Za-z]\w*)\s+(?:(?:private|protected|public|readonly)\s+)*_?\w+\s*:/g; + let d; + while ((d = decRe.exec(ctorMatch[1])) !== null) deps.push(d[1]); + } + classDeps.set(impl, deps); + } + + // Pair each registerScopedService call with scope + token + impl. + const regRe = + /registerScopedService\(\s*LifecycleScope\.(\w+)\s*,\s*(I[A-Za-z]\w*)\s*,\s*(\w+)\s*,/g; + let r; + while ((r = regRe.exec(source)) !== null) { + const [, scope, token, impl] = r; + services.push({ + impl, + token, + scope, + deps: classDeps.get(impl) ?? [], + }); + } + return services; +} + +function main() { + const files = walk(SRC_ROOT); + /** @type {Map>} */ + const byDomain = new Map(); + for (const f of files) { + const domain = relative(SRC_ROOT, f).split(/[\\/]/)[0]; + const services = extract(readFileSync(f, 'utf8')); + if (!byDomain.has(domain)) byDomain.set(domain, []); + byDomain.get(domain).push(...services); + } + + const domains = [...byDomain.keys()].sort(); + let total = 0; + for (const domain of domains) { + const services = byDomain.get(domain).sort((a, b) => a.token.localeCompare(b.token)); + console.log(`\n## ${domain}`); + for (const s of services) { + total++; + const deps = s.deps.length > 0 ? s.deps.join(', ') : '—'; + console.log(`- ${s.token} [${s.scope}] → ${deps}`); + } + } + console.log(`\n${total} services across ${domains.length} domains.`); + return 0; +} + +process.exit(main()); diff --git a/packages/agent-core-v2/src/_base/di/descriptors.ts b/packages/agent-core-v2/src/_base/di/descriptors.ts new file mode 100644 index 000000000..044261c7c --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/descriptors.ts @@ -0,0 +1,22 @@ +/** + * `di` domain (L0) — `SyncDescriptor` packaging a constructor + static args for lazy instantiation. + */ + +export class SyncDescriptor { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public readonly ctor: any; + + constructor( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ctor: new (...args: any[]) => T, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public readonly staticArguments: ReadonlyArray = [], + public readonly supportsDelayedInstantiation: boolean = false, + ) { + this.ctor = ctor; + } +} + +export interface SyncDescriptor0 { + readonly ctor: new () => T; +} diff --git a/packages/agent-core-v2/src/_base/di/errors.ts b/packages/agent-core-v2/src/_base/di/errors.ts new file mode 100644 index 000000000..eeb4dc749 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/errors.ts @@ -0,0 +1,26 @@ +/** + * `di` domain (L0) — `CyclicDependencyError` raised on DI dependency cycles. + */ + +import type { Graph } from './graph'; + +export class CyclicDependencyError extends Error { + readonly path: ReadonlyArray; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(pathOrGraph: ReadonlyArray | Graph) { + if (Array.isArray(pathOrGraph)) { + const path = pathOrGraph as ReadonlyArray; + super(`Cyclic DI dependency detected: ${path.join(' → ')}`); + this.path = path; + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const graph = pathOrGraph as Graph; + const cycle = graph.findCycleSlow(); + const detail = cycle ?? `UNABLE to detect cycle, dumping graph:\n${graph.toString()}`; + super(`cyclic dependency between services: ${detail}`); + this.path = cycle ? cycle.split(' -> ') : []; + } + this.name = 'CyclicDependencyError'; + } +} diff --git a/packages/agent-core-v2/src/_base/di/extensions.ts b/packages/agent-core-v2/src/_base/di/extensions.ts new file mode 100644 index 000000000..0cbf5d68d --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/extensions.ts @@ -0,0 +1,55 @@ +/** + * `di` domain (L0) — module-global singleton registry (`registerSingleton` / `getSingletonServiceDescriptors`). + */ + +import { SyncDescriptor } from './descriptors'; +import type { BrandedService, ServiceIdentifier } from './instantiation'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const _registry: Array<[ServiceIdentifier, SyncDescriptor]> = []; + +export enum InstantiationType { + Eager = 0, + Delayed = 1, +} + +export function registerSingleton( + id: ServiceIdentifier, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ctor: new (...services: Services) => T, + instantiationType?: InstantiationType, +): void; +export function registerSingleton( + id: ServiceIdentifier, + descriptor: SyncDescriptor, +): void; +export function registerSingleton( + id: ServiceIdentifier, + ctorOrDescriptor: + | SyncDescriptor + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | (new (...services: Services) => T), + instantiationType?: boolean | InstantiationType, +): void { + const descriptor = + ctorOrDescriptor instanceof SyncDescriptor + ? ctorOrDescriptor + : new SyncDescriptor( + ctorOrDescriptor as new (...args: unknown[]) => T, + [], + Boolean(instantiationType), + ); + + _registry.push([id, descriptor]); +} + +export function getSingletonServiceDescriptors(): ReadonlyArray< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly [ServiceIdentifier, SyncDescriptor] +> { + return _registry; +} + +export function _clearRegistryForTests(): void { + _registry.length = 0; +} diff --git a/packages/agent-core-v2/src/_base/di/graph.ts b/packages/agent-core-v2/src/_base/di/graph.ts new file mode 100644 index 000000000..ae5c3541f --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/graph.ts @@ -0,0 +1,93 @@ +/** + * `di` domain (L0) — directed `Graph` with cycle detection for DI instantiation. + */ + +export class Node { + readonly incoming = new Map>(); + readonly outgoing = new Map>(); + + constructor( + readonly key: string, + readonly data: T + ) { } +} + +export class Graph { + private readonly _nodes = new Map>(); + + constructor(private readonly _hashFn: (element: T) => string) { } + + roots(): Node[] { + const ret: Node[] = []; + for (const node of this._nodes.values()) { + if (node.outgoing.size === 0) { + ret.push(node); + } + } + return ret; + } + + insertEdge(from: T, to: T): void { + const fromNode = this.lookupOrInsertNode(from); + const toNode = this.lookupOrInsertNode(to); + fromNode.outgoing.set(toNode.key, toNode); + toNode.incoming.set(fromNode.key, fromNode); + } + + removeNode(data: T): void { + const key = this._hashFn(data); + this._nodes.delete(key); + for (const node of this._nodes.values()) { + node.outgoing.delete(key); + node.incoming.delete(key); + } + } + + lookupOrInsertNode(data: T): Node { + const key = this._hashFn(data); + let node = this._nodes.get(key); + if (!node) { + node = new Node(key, data); + this._nodes.set(key, node); + } + return node; + } + + isEmpty(): boolean { + return this._nodes.size === 0; + } + + toString(): string { + const data: string[] = []; + for (const [key, value] of this._nodes) { + data.push(`${key}\n\t(-> incoming)[${[...value.incoming.keys()].join(', ')}]\n\t(outgoing ->)[${[...value.outgoing.keys()].join(',')}]\n`); + } + return data.join('\n'); + } + + findCycleSlow() { + for (const [id, node] of this._nodes) { + const seen = new Set([id]); + const res = this._findCycle(node, seen); + if (res) { + return res; + } + } + return undefined; + } + + private _findCycle(node: Node, seen: Set): string | undefined { + for (const [id, outgoing] of node.outgoing) { + if (seen.has(id)) { + return [...seen, id].join(' -> '); + } + seen.add(id); + const value = this._findCycle(outgoing, seen); + if (value) { + return value; + } + seen.delete(id); + } + return undefined; + } +} diff --git a/packages/agent-core-v2/src/_base/di/index.ts b/packages/agent-core-v2/src/_base/di/index.ts new file mode 100644 index 000000000..9827e5f33 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/index.ts @@ -0,0 +1,15 @@ +/** + * `di` domain barrel — re-exports the dependency-injection primitives: + * service identifiers and decorators, descriptors, the instantiation service, + * scope registration, the service collection, and disposable lifecycle. + */ + +export * from './descriptors'; +export * from './errors'; +export * from './extensions'; +export * from './graph'; +export * from './instantiation'; +export * from './instantiationService'; +export * from './lifecycle'; +export * from './scope'; +export * from './serviceCollection'; diff --git a/packages/agent-core-v2/src/_base/di/instantiation.ts b/packages/agent-core-v2/src/_base/di/instantiation.ts new file mode 100644 index 000000000..5642c087b --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/instantiation.ts @@ -0,0 +1,149 @@ +/** + * `di` domain (L0) — service identifiers, `createDecorator`, and the `IInstantiationService` contract. + */ + +import type { SyncDescriptor0 } from './descriptors'; +import type { DisposableStore } from './lifecycle'; +import type { ServiceCollection } from './serviceCollection'; + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace _util { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + export const serviceIds = new Map>(); + export const DI_TARGET = '$di$target'; + export const DI_DEPENDENCIES = '$di$dependencies'; + + export function getServiceDependencies( + ctor: DI_TARGET_OBJ, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): { id: ServiceIdentifier; index: number }[] { + return ctor[DI_DEPENDENCIES] || []; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + export interface DI_TARGET_OBJ extends Function { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + [DI_TARGET]: Function; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [DI_DEPENDENCIES]: { id: ServiceIdentifier; index: number }[]; + } +} + +export type BrandedService = { _serviceBrand: undefined }; + +export interface IConstructorSignature { + new (...args: [...Args, ...Services]): T; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type GetLeadingNonServiceArgs = + TArgs extends [] ? [] + : TArgs extends [...infer TFirst, BrandedService] ? GetLeadingNonServiceArgs + : TArgs; + +export interface ServiceIdentifier { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (target: any, key: string | symbol | undefined, index: number): void; + + readonly type: T; + + toString(): string; +} + +function storeServiceDependency( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + id: ServiceIdentifier, + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + target: Function, + index: number, +): void { + const t = target as _util.DI_TARGET_OBJ; + if (t[_util.DI_TARGET] === target) { + t[_util.DI_DEPENDENCIES].push({ id, index }); + } else { + t[_util.DI_DEPENDENCIES] = [{ id, index }]; + t[_util.DI_TARGET] = target; + } +} + +export function createDecorator(name: string): ServiceIdentifier { + const existing = _util.serviceIds.get(name); + if (existing) { + return existing as ServiceIdentifier; + } + + const id = function serviceDecorator( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + target: any, + _key: string | symbol | undefined, + index: number, + ): void { + if (arguments.length !== 3) { + throw new Error( + '@IServiceName-decorator can only be used to decorate a parameter', + ); + } + storeServiceDependency(id, target, index); + } as unknown as ServiceIdentifier; + + Object.defineProperty(id, 'toString', { + value: function toString(): string { + return name; + }, + enumerable: false, + writable: false, + configurable: false, + }); + + _util.serviceIds.set(name, id); + return id; +} + +export function refineServiceDecorator( + serviceIdentifier: ServiceIdentifier, +): ServiceIdentifier { + return serviceIdentifier as ServiceIdentifier; +} + +export interface ServicesAccessor { + get(id: ServiceIdentifier): T; +} + +export interface IInstantiationService { + readonly _serviceBrand: undefined; + + invokeFunction( + fn: (accessor: ServicesAccessor, ...args: TS) => R, + ...args: TS + ): R; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createInstance(descriptor: SyncDescriptor0): T; + createInstance< + Ctor extends new ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...args: any[] + ) => unknown, + R extends InstanceType, + >( + ctor: Ctor, + ...args: GetLeadingNonServiceArgs> + ): R; + createChild(services: ServiceCollection, store?: DisposableStore): IInstantiationService; + dispose(): void; +} + +export const IInstantiationService: ServiceIdentifier = + createDecorator('instantiationService'); + +export interface ServiceCollectionLike { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + set(id: ServiceIdentifier, instanceOrDescriptor: any): unknown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get(id: ServiceIdentifier): any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + has(id: ServiceIdentifier): boolean; + forEach( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (id: ServiceIdentifier, value: any) => void, + ): void; +} diff --git a/packages/agent-core-v2/src/_base/di/instantiationService.ts b/packages/agent-core-v2/src/_base/di/instantiationService.ts new file mode 100644 index 000000000..52084a85b --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/instantiationService.ts @@ -0,0 +1,585 @@ +/** + * `di` domain (L0) — `InstantiationService` container (instantiation, child scopes, cycle detection). + */ + +import { SyncDescriptor } from './descriptors'; +import { CyclicDependencyError } from './errors'; +import { Graph } from './graph'; +import { + IInstantiationService as IInstantiationServiceDecorator, + _util, + type IInstantiationService, + type ServiceIdentifier, + type ServicesAccessor, +} from './instantiation'; +import { + dispose, + isDisposable, + toDisposable, + type DisposableStore, + type IDisposable, +} from './lifecycle'; +import { ServiceCollection } from './serviceCollection'; +import { GlobalIdleValue } from './util/idleValue'; +import { LinkedList } from './util/linkedList'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const enum TraceType { + None = 0, + Creation = 1, + Invocation = 2, + Branch = 3, +} + +export class Trace { + static readonly all = new Set(); + + private static readonly _None = new class extends Trace { + constructor() { super(TraceType.None, null); } + override stop() { } + override branch() { return this; } + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static traceInvocation(_enableTracing: boolean, fn: any): Trace { + return !_enableTracing + ? Trace._None + : new Trace( + TraceType.Invocation, + fn.name ?? new Error('Trace invocation').stack!.split('\n').slice(3, 4).join('\n'), + ); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static traceCreation(_enableTracing: boolean, ctor: any): Trace { + return !_enableTracing ? Trace._None : new Trace(TraceType.Creation, ctor.name); + } + + private static _totals: number = 0; + private readonly _start: number = Date.now(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly _dep: [ServiceIdentifier, boolean, Trace?][] = []; + + private constructor( + readonly type: TraceType, + readonly name: string | null + ) { } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + branch(id: ServiceIdentifier, first: boolean): Trace { + const child = new Trace(TraceType.Branch, id.toString()); + this._dep.push([id, first, child]); + return child; + } + + stop() { + const dur = Date.now() - this._start; + Trace._totals += dur; + + let causedCreation = false; + + function printChild(n: number, trace: Trace) { + const res: string[] = []; + const prefix = '\t'.repeat(n); + for (const [id, first, child] of trace._dep) { + if (first && child) { + causedCreation = true; + res.push(`${prefix}CREATES -> ${String(id)}`); + const nested = printChild(n + 1, child); + if (nested) { + res.push(nested); + } + } else { + res.push(`${prefix}uses -> ${String(id)}`); + } + } + return res.join('\n'); + } + + const lines = [ + `${this.type === TraceType.Creation ? 'CREATE' : 'CALL'} ${this.name}`, + printChild(1, this), + `DONE, took ${dur.toFixed(2)}ms (grand total ${Trace._totals.toFixed(2)}ms)`, + ]; + + if (dur > 2 || causedCreation) { + Trace.all.add(lines.join('\n')); + } + } + +} + +export class InstantiationService implements IInstantiationService { + declare readonly _serviceBrand: undefined; + + readonly _globalGraph?: Graph; + private _globalGraphImplicitDependency?: string; + + protected readonly _parent?: InstantiationService; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected readonly _constructionOrder: any[] = []; + + protected readonly _children = new Set(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly _inProgress: ServiceIdentifier[] = []; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly _activeInstantiations = new Set>(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly _servicesToMaybeDispose = new Set(); + + private _disposed = false; + + constructor( + private readonly _services: ServiceCollection = new ServiceCollection(), + private readonly _strict: boolean = false, + parent?: InstantiationService, + protected readonly _enableTracing: boolean = false, + ) { + this._parent = parent; + this._globalGraph = _enableTracing ? parent?._globalGraph ?? new Graph(e => e) : undefined; + this._services.set(IInstantiationServiceDecorator, this); + } + + invokeFunction( + fn: (accessor: ServicesAccessor, ...args: TS) => R, + ...args: TS + ): R { + this._assertNotDisposed(); + const _trace = Trace.traceInvocation(this._enableTracing, fn); + let done = false; + try { + const accessor: ServicesAccessor = { + get: (id: ServiceIdentifier): T => { + if (done) { + throw new Error( + 'service accessor is only valid during the invocation of its target method', + ); + } + const result = this._getOrCreateServiceInstance(id, _trace); + if (!result) { + this._throwIfStrict(`[invokeFunction] unknown service '${String(id)}'`, false); + } + return result; + }, + }; + return fn(accessor, ...args); + } finally { + done = true; + _trace.stop(); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createInstance(descriptor: SyncDescriptor, ...rest: any[]): T; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createInstance(ctor: new (...args: any[]) => T, ...rest: any[]): T; + createInstance( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ctorOrDescriptor: SyncDescriptor | (new (...args: any[]) => T), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...rest: any[] + ): T { + this._assertNotDisposed(); + let _trace: Trace; + let result: T; + if (ctorOrDescriptor instanceof SyncDescriptor) { + _trace = Trace.traceCreation(this._enableTracing, ctorOrDescriptor.ctor); + result = this._createInstance( + ctorOrDescriptor.ctor, + ctorOrDescriptor.staticArguments.concat(rest), + _trace, + ); + } else { + _trace = Trace.traceCreation(this._enableTracing, ctorOrDescriptor); + result = this._createInstance(ctorOrDescriptor, rest, _trace); + } + _trace.stop(); + return result; + } + + createChild(services: ServiceCollection, store?: DisposableStore): IInstantiationService { + this._assertNotDisposed(); + if (!(services instanceof ServiceCollection)) { + throw new TypeError( + 'createChild requires a ServiceCollection instance (got something else)', + ); + } + const child = new InstantiationService(services, this._strict, this, this._enableTracing); + this._children.add(child); + store?.add(child); + return child; + } + + dispose(): void { + if (this._disposed) { + return; + } + this._disposed = true; + + const childSnapshot = Array.from(this._children); + this._children.clear(); + + const ownInstances: IDisposable[] = []; + for (let i = this._constructionOrder.length - 1; i >= 0; i--) { + const instance = this._constructionOrder[i]!; + if (isDisposable(instance)) { + ownInstances.push(instance); + this._servicesToMaybeDispose.delete(instance); + } + } + + const remainingInstances: IDisposable[] = []; + for (const candidate of this._servicesToMaybeDispose) { + if (isDisposable(candidate)) { + remainingInstances.push(candidate); + } + } + + try { + dispose([...childSnapshot, ...ownInstances, ...remainingInstances]); + } finally { + this._constructionOrder.length = 0; + this._servicesToMaybeDispose.clear(); + if (this._parent) { + this._parent._children.delete(this); + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _createInstance(ctor: any, args: unknown[], _trace: Trace): T { + const serviceDependencies = _util.getServiceDependencies(ctor).toSorted((a, b) => a.index - b.index); + const serviceArgs: unknown[] = []; + for (const dependency of serviceDependencies) { + const service = this._getOrCreateServiceInstance(dependency.id, _trace); + if (!service) { + this._throwIfStrict( + `[createInstance] ${ctor.name} depends on UNKNOWN service ${String(dependency.id)}.`, + false, + ); + } + serviceArgs.push(service); + } + + const firstServiceArgPos = + serviceDependencies.length > 0 ? serviceDependencies[0]!.index : args.length; + + if (args.length !== firstServiceArgPos) { + // eslint-disable-next-line no-console + globalThis.console.trace( + `[createInstance] First service dependency of ${(ctor as { name?: string }).name} at position ${firstServiceArgPos + 1} conflicts with ${args.length} static arguments`, + ); + const delta = firstServiceArgPos - args.length; + if (delta > 0) { + args = args.concat(Array.from({ length: delta })); + } else { + args = args.slice(0, firstServiceArgPos); + } + } + + return Reflect.construct(ctor, args.concat(serviceArgs)); + } + + protected _getOrCreateServiceInstance(id: ServiceIdentifier, _trace: Trace): T { + if (this._globalGraph && this._globalGraphImplicitDependency) { + this._globalGraph.insertEdge(this._globalGraphImplicitDependency, String(id)); + } + const entry = this._getServiceInstanceOrDescriptor(id); + + if (entry instanceof SyncDescriptor) { + const root = this._root(); + if (root._inProgress.includes(id)) { + const path = [...root._inProgress, id].map(String); + throw new CyclicDependencyError(path); + } + + return this._safeCreateAndCacheServiceInstance(id, entry, _trace.branch(id, true)); + } + + _trace.branch(id, false); + return entry as T; + } + + private _safeCreateAndCacheServiceInstance( + id: ServiceIdentifier, + desc: SyncDescriptor, + _trace: Trace, + ): T { + if (this._activeInstantiations.has(id)) { + throw new Error(`illegal state - RECURSIVELY instantiating service '${String(id)}'`); + } + this._activeInstantiations.add(id); + try { + return this._createAndCacheServiceInstance(id, desc, _trace); + } finally { + this._activeInstantiations.delete(id); + } + } + + private _createAndCacheServiceInstance( + id: ServiceIdentifier, + desc: SyncDescriptor, + _trace: Trace, + ): T { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type Triple = { id: ServiceIdentifier; desc: SyncDescriptor; _trace: Trace }; + const graph = new Graph(data => data.id.toString()); + + let cycleCount = 0; + const stack: Triple[] = [{ id, desc, _trace }]; + const seen = new Set(); + while (stack.length > 0) { + const item = stack.pop()!; + + if (seen.has(String(item.id))) { + continue; + } + seen.add(String(item.id)); + + graph.lookupOrInsertNode(item); + + if (cycleCount++ > 1000) { + throw new CyclicDependencyError(graph); + } + + for (const dependency of _util.getServiceDependencies(item.desc.ctor)) { + const instanceOrDesc = this._getServiceInstanceOrDescriptor(dependency.id); + if (!instanceOrDesc) { + this._throwIfStrict( + `[createInstance] ${String(item.id)} depends on ${String(dependency.id)} which is NOT registered.`, + true, + ); + } + + this._globalGraph?.insertEdge(String(item.id), String(dependency.id)); + + if (instanceOrDesc instanceof SyncDescriptor) { + const d: Triple = { + id: dependency.id, + desc: instanceOrDesc, + _trace: item._trace.branch(dependency.id, true), + }; + graph.insertEdge(item, d); + stack.push(d); + } + } + } + + while (true) { + const roots = graph.roots(); + + if (roots.length === 0) { + if (!graph.isEmpty()) { + throw new CyclicDependencyError(graph); + } + break; + } + + for (const { data } of roots) { + const instanceOrDesc = this._getServiceInstanceOrDescriptor(data.id); + if (instanceOrDesc instanceof SyncDescriptor) { + const instance = this._createServiceInstanceWithOwner( + data.id, + data.desc.ctor, + data.desc.staticArguments, + data.desc.supportsDelayedInstantiation, + data._trace, + ); + this._setCreatedServiceInstance(data.id, instance); + } + graph.removeNode(data); + } + } + return this._getServiceInstanceOrDescriptor(id) as T; + } + + private _createServiceInstanceWithOwner( + id: ServiceIdentifier, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ctor: any, + args: ReadonlyArray = [], + supportsDelayedInstantiation: boolean, + _trace: Trace, + ): T { + if (this._services.get(id) instanceof SyncDescriptor) { + return this._createServiceInstance( + id, + ctor, + args, + supportsDelayedInstantiation, + _trace, + this._servicesToMaybeDispose, + ); + } + if (this._parent) { + return this._parent._createServiceInstanceWithOwner( + id, + ctor, + args, + supportsDelayedInstantiation, + _trace, + ); + } + throw new Error(`illegalState - creating UNKNOWN service instance ${ctor.name}`); + } + + private _createServiceInstance( + id: ServiceIdentifier, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ctor: any, + args: ReadonlyArray = [], + supportsDelayedInstantiation: boolean, + _trace: Trace, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + disposeBucket: Set, + ): T { + if (!supportsDelayedInstantiation) { + const root = this._root(); + root._inProgress.push(id); + try { + const result = this._createInstance(ctor, args.slice(), _trace); + disposeBucket.add(result); + this._constructionOrder.push(result); + return result; + } finally { + const popIdx = root._inProgress.lastIndexOf(id); + if (popIdx >= 0) { + root._inProgress.splice(popIdx, 1); + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type EventLike = (callback: (e: any) => void, thisArg?: unknown, disposables?: IDisposable[]) => IDisposable; + type EarlyListenerData = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + listener: Parameters; + disposable?: IDisposable; + }; + const earlyListeners = new Map>(); + const child = new InstantiationService(undefined, this._strict, this, this._enableTracing); + child._globalGraphImplicitDependency = String(id); + const _ctor = ctor; + const _args = args.slice(); + const idle = new GlobalIdleValue(() => { + const result = child._createInstance(_ctor, _args.slice(), _trace); + for (const [key, values] of earlyListeners) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const candidate = (result as any)[key] as EventLike | undefined; + if (typeof candidate === 'function') { + for (const value of values) { + value.disposable = candidate.apply(result, value.listener); + } + } + } + earlyListeners.clear(); + disposeBucket.add(result); + this._constructionOrder.push(result); + return result; + }); + + return new Proxy(Object.create(null), { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get(target: any, key: PropertyKey): unknown { + if (!idle.isInitialized) { + if ( + typeof key === 'string' && + (key.startsWith('onDid') || key.startsWith('onWill')) + ) { + let list = earlyListeners.get(key); + if (!list) { + list = new LinkedList(); + earlyListeners.set(key, list); + } + const event: EventLike = (callback, thisArg, disposables) => { + if (idle.isInitialized) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (idle.value as any)[key](callback, thisArg, disposables); + } + const entry: EarlyListenerData = { + listener: [callback, thisArg, disposables], + disposable: undefined, + }; + const rm = list.push(entry); + return toDisposable(() => { + rm(); + entry.disposable?.dispose(); + }); + }; + return event; + } + } + + if (key in target) { + return target[key]; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj = idle.value as any; + let prop = obj[key]; + if (typeof prop !== 'function') { + return prop; + } + prop = prop.bind(obj); + target[key] = prop; + return prop; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + set(_target: T, p: PropertyKey, value: any): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (idle.value as any)[p] = value; + return true; + }, + getPrototypeOf(_target: T): object { + return _ctor.prototype as object; + }, + }) as T; + } + + private _setCreatedServiceInstance(id: ServiceIdentifier, instance: T): void { + if (this._services.get(id) instanceof SyncDescriptor) { + this._services.set(id, instance); + } else if (this._parent) { + this._parent._setCreatedServiceInstance(id, instance); + } else { + throw new Error( + `illegal state - setting UNKNOWN service instance '${String(id)}'`, + ); + } + } + + private _getServiceInstanceOrDescriptor( + id: ServiceIdentifier, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): T | SyncDescriptor | undefined { + const instanceOrDesc = this._services.get(id); + if (instanceOrDesc === undefined && this._parent) { + return this._parent._getServiceInstanceOrDescriptor(id); + } + return instanceOrDesc; + } + + private _throwIfStrict(msg: string, printWarning: boolean): void { + if (printWarning) { + // eslint-disable-next-line no-console + globalThis.console.warn(msg); + } + if (this._strict) { + throw new Error(msg); + } + } + + private _root(): InstantiationService { + return this._parent?._root() ?? this; + } + + private _assertNotDisposed(): void { + if (this._disposed) { + throw new Error('InstantiationService has been disposed'); + } + } +} diff --git a/packages/agent-core-v2/src/_base/di/lifecycle.ts b/packages/agent-core-v2/src/_base/di/lifecycle.ts new file mode 100644 index 000000000..9611077e9 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/lifecycle.ts @@ -0,0 +1,665 @@ +/** + * `di` domain (L0) — disposable lifecycle primitives (`Disposable`, `DisposableStore`, `IDisposable`). + */ + +import { onUnexpectedError } from '../errors/unexpectedError'; + +export interface IDisposableTracker { + trackDisposable(disposable: IDisposable): void; + setParent(child: IDisposable, parent: IDisposable | null): void; + markAsDisposed(disposable: IDisposable): void; + markAsSingleton(disposable: IDisposable): void; +} + +interface DisposableInfo { + value: IDisposable; + source: string | null; + parent: IDisposable | null; + isSingleton: boolean; + idx: number; +} + +export class DisposableTracker implements IDisposableTracker { + private static idx = 0; + private readonly livingDisposables = new Map(); + + private getDisposableData(d: IDisposable): DisposableInfo { + let val = this.livingDisposables.get(d); + if (!val) { + val = { + parent: null, + source: null, + isSingleton: false, + value: d, + idx: DisposableTracker.idx++, + }; + this.livingDisposables.set(d, val); + } + return val; + } + + trackDisposable(d: IDisposable): void { + const data = this.getDisposableData(d); + data.source ??= new Error('Disposable tracking').stack ?? null; + } + + setParent(child: IDisposable, parent: IDisposable | null): void { + this.getDisposableData(child).parent = parent; + } + + markAsDisposed(x: IDisposable): void { + this.livingDisposables.delete(x); + } + + markAsSingleton(d: IDisposable): void { + this.getDisposableData(d).isSingleton = true; + } + + private getRootParent( + data: DisposableInfo, + cache: Map, + ): DisposableInfo { + const cached = cache.get(data); + if (cached) return cached; + const result = data.parent + ? this.getRootParent(this.getDisposableData(data.parent), cache) + : data; + cache.set(data, result); + return result; + } + + getTrackedDisposables(): IDisposable[] { + const cache = new Map(); + return [...this.livingDisposables.entries()] + .filter( + ([, v]) => v.source !== null && !this.getRootParent(v, cache).isSingleton, + ) + .map(([k]) => k); + } +} + +let disposableTracker: IDisposableTracker | null = null; + +export function setDisposableTracker(tracker: IDisposableTracker | null): void { + disposableTracker = tracker; +} + +export function trackDisposable(x: T): T { + disposableTracker?.trackDisposable(x); + return x; +} + +export function markAsDisposed(disposable: IDisposable): void { + disposableTracker?.markAsDisposed(disposable); +} + +function setParentOfDisposable( + child: IDisposable, + parent: IDisposable | null, +): void { + disposableTracker?.setParent(child, parent); +} + +function setParentOfDisposables( + children: IDisposable[], + parent: IDisposable | null, +): void { + if (!disposableTracker) return; + for (const child of children) { + disposableTracker.setParent(child, parent); + } +} + +export function markAsSingleton(singleton: T): T { + disposableTracker?.markAsSingleton(singleton); + return singleton; +} + +export interface IDisposable { + dispose(): void; +} + +export function isDisposable(thing: E): thing is E & IDisposable { + return ( + typeof thing === 'object' && + thing !== null && + typeof (thing as unknown as IDisposable).dispose === 'function' && + (thing as unknown as IDisposable).dispose.length === 0 + ); +} + +export function dispose(disposable: T): T; +export function dispose( + disposable: T | undefined, +): T | undefined; +export function dispose = Iterable>( + disposables: A, +): A; +export function dispose(disposables: Array): Array; +export function dispose( + disposables: ReadonlyArray, +): ReadonlyArray; +export function dispose( + arg: T | Iterable | undefined, +): unknown { + if (arg === undefined || arg === null) return arg; + if (isIterable(arg)) { + const errors: unknown[] = []; + for (const d of arg) { + if (d) { + try { + d.dispose(); + } catch (error) { + errors.push(error); + } + } + } + + if (errors.length === 1) { + throw errors[0]; + } + if (errors.length > 1) { + throw new AggregateError( + errors, + 'Encountered errors while disposing of store', + ); + } + + return Array.isArray(arg) ? [] : arg; + } + (arg).dispose(); + return arg; +} + +function isIterable(arg: unknown): arg is Iterable { + return ( + typeof arg === 'object' && + arg !== null && + typeof (arg as { [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function' + ); +} + +export function disposeIfDisposable( + disposables: Array, +): Array { + const disposableValues: IDisposable[] = []; + for (const d of disposables) { + if (isDisposable(d)) { + disposableValues.push(d); + } + } + dispose(disposableValues); + return []; +} + +class FunctionDisposable implements IDisposable { + private _isDisposed = false; + private readonly _fn: () => void; + + constructor(fn: () => void) { + this._fn = fn; + trackDisposable(this); + } + + dispose(): void { + if (this._isDisposed) return; + this._isDisposed = true; + markAsDisposed(this); + this._fn(); + } +} + +export function toDisposable(fn: () => void): IDisposable { + return new FunctionDisposable(fn); +} + +export function combinedDisposable(...disposables: IDisposable[]): IDisposable { + const parent = toDisposable(() => dispose(disposables)); + setParentOfDisposables(disposables, parent); + return parent; +} + +export class DisposableStore implements IDisposable { + private readonly _toDispose = new Set(); + private _isDisposed = false; + + constructor() { + trackDisposable(this); + } + + add(d: T): T { + if ((d as unknown as DisposableStore) === this) { + throw new Error('Cannot register a disposable on itself!'); + } + setParentOfDisposable(d, this); + if (this._isDisposed) { + d.dispose(); + return d; + } + this._toDispose.add(d); + return d; + } + + delete(d: T): void { + if (this._isDisposed) return; + if ((d as unknown as DisposableStore) === this) { + throw new Error('Cannot dispose a disposable on itself!'); + } + this._toDispose.delete(d); + d.dispose(); + } + + deleteAndLeak(d: T): void { + if (this._isDisposed) return; + if (this._toDispose.delete(d)) { + setParentOfDisposable(d, null); + } + } + + clear(): void { + if (this._toDispose.size === 0) return; + try { + dispose(this._toDispose); + } finally { + this._toDispose.clear(); + } + } + + dispose(): void { + if (this._isDisposed) return; + this._isDisposed = true; + markAsDisposed(this); + this.clear(); + } + + get isDisposed(): boolean { + return this._isDisposed; + } + + assertNotDisposed(): void { + if (this._isDisposed) { + onUnexpectedError(new Error('Object disposed')); + } + } +} + +export abstract class Disposable implements IDisposable { + protected readonly _store = new DisposableStore(); + + constructor() { + trackDisposable(this); + setParentOfDisposable(this._store, this); + } + + protected _register(d: T): T { + if ((d as unknown as Disposable) === this) { + throw new Error('Cannot register a disposable on itself!'); + } + return this._store.add(d); + } + + dispose(): void { + markAsDisposed(this); + this._store.dispose(); + } +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Disposable { + export const None: IDisposable = Object.freeze({ + dispose(): void {}, + }); +} + +export class MutableDisposable implements IDisposable { + private _value: T | undefined; + private _isDisposed = false; + + constructor() { + trackDisposable(this); + } + + get value(): T | undefined { + return this._isDisposed ? undefined : this._value; + } + + set value(value: T | undefined) { + if (this._isDisposed) { + if (value !== undefined) { + value.dispose(); + } + return; + } + if (this._value === value) return; + this._value?.dispose(); + if (value) setParentOfDisposable(value, this); + this._value = value; + } + + dispose(): void { + if (this._isDisposed) return; + this._isDisposed = true; + markAsDisposed(this); + const prev = this._value; + if (prev !== undefined) { + prev.dispose(); + } + this._value = undefined; + } + + clear(): void { + if (this._isDisposed) return; + this.value = undefined; + } + + clearAndLeak(): T | undefined { + if (this._isDisposed) return undefined; + const prev = this._value; + this._value = undefined; + if (prev !== undefined) setParentOfDisposable(prev, null); + return prev; + } +} + +export class MandatoryMutableDisposable implements IDisposable { + private readonly _disposable = new MutableDisposable(); + private _isDisposed = false; + + constructor(initialValue: T) { + this._disposable.value = initialValue; + } + + get value(): T { + return this._disposable.value!; + } + + set value(value: T) { + if (this._isDisposed || value === this._disposable.value) return; + this._disposable.value = value; + } + + dispose(): void { + if (this._isDisposed) return; + this._isDisposed = true; + this._disposable.dispose(); + } +} + +export class RefCountedDisposable { + private _counter = 1; + + constructor(private readonly _disposable: IDisposable) {} + + acquire(): this { + this._counter += 1; + return this; + } + + release(): this { + this._counter -= 1; + if (this._counter === 0) { + this._disposable.dispose(); + } + return this; + } +} + +export interface IReference extends IDisposable { + readonly object: T; +} + +export abstract class ReferenceCollection { + private readonly references = new Map< + string, + { readonly object: T; counter: number } + >(); + + acquire(key: string, ...args: unknown[]): IReference { + let reference = this.references.get(key); + if (!reference) { + reference = { + counter: 0, + object: this.createReferencedObject(key, ...args), + }; + this.references.set(key, reference); + } + + const { object } = reference; + let disposed = false; + const dispose = () => { + if (disposed) return; + disposed = true; + reference.counter -= 1; + if (reference.counter === 0) { + this.destroyReferencedObject(key, reference.object); + this.references.delete(key); + } + }; + + reference.counter += 1; + return { object, dispose }; + } + + protected abstract createReferencedObject(key: string, ...args: unknown[]): T; + protected abstract destroyReferencedObject(key: string, object: T): void; +} + +export class AsyncReferenceCollection { + constructor(private readonly referenceCollection: ReferenceCollection>) {} + + async acquire(key: string, ...args: unknown[]): Promise> { + const ref = this.referenceCollection.acquire(key, ...args); + + try { + const object = await ref.object; + return { + object, + dispose: () => { ref.dispose(); }, + }; + } catch (error) { + ref.dispose(); + throw error; + } + } +} + +export class ImmortalReference implements IReference { + constructor(public readonly object: T) {} + dispose(): void {} +} + +export class DisposableMap + implements IDisposable +{ + private readonly _store: Map; + private _isDisposed = false; + + constructor(store: Map = new Map()) { + this._store = store; + trackDisposable(this); + } + + dispose(): void { + if (this._isDisposed) return; + this._isDisposed = true; + markAsDisposed(this); + this.clearAndDisposeAll(); + } + + clearAndDisposeAll(): void { + if (this._store.size === 0) return; + try { + dispose(this._store.values()); + } finally { + this._store.clear(); + } + } + + has(key: K): boolean { + return this._store.has(key); + } + + get size(): number { + return this._store.size; + } + + get(key: K): V | undefined { + return this._store.get(key); + } + + set(key: K, value: V, skipDisposeOnOverwrite = false): void { + if (this._isDisposed) { + // eslint-disable-next-line no-console + console.warn( + new Error( + 'Trying to add a disposable to a DisposableMap that has already been disposed of. The added object will be leaked!', + ).stack, + ); + return; + } + if (!skipDisposeOnOverwrite) { + const prev = this._store.get(key); + if (prev !== undefined && prev !== value) { + prev.dispose(); + } + } + this._store.set(key, value); + setParentOfDisposable(value, this); + } + + deleteAndDispose(key: K): void { + const value = this._store.get(key); + if (value !== undefined) { + value.dispose(); + } + this._store.delete(key); + } + + deleteAndLeak(key: K): V | undefined { + const value = this._store.get(key); + if (value !== undefined) setParentOfDisposable(value, null); + this._store.delete(key); + return value; + } + + keys(): IterableIterator { + return this._store.keys(); + } + + values(): IterableIterator { + return this._store.values(); + } + + [Symbol.iterator](): IterableIterator<[K, V]> { + return this._store[Symbol.iterator](); + } +} + +export class DisposableSet + implements IDisposable +{ + private readonly _store: Set; + private _isDisposed = false; + + constructor(store: Set = new Set()) { + this._store = store; + trackDisposable(this); + } + + dispose(): void { + if (this._isDisposed) return; + this._isDisposed = true; + markAsDisposed(this); + this.clearAndDisposeAll(); + } + + clearAndDisposeAll(): void { + if (this._store.size === 0) return; + try { + dispose(this._store.values()); + } finally { + this._store.clear(); + } + } + + has(value: V): boolean { + return this._store.has(value); + } + + get size(): number { + return this._store.size; + } + + add(value: V): void { + if (this._isDisposed) { + // eslint-disable-next-line no-console + console.warn( + new Error( + 'Trying to add a disposable to a DisposableSet that has already been disposed of. The added object will be leaked!', + ).stack, + ); + return; + } + this._store.add(value); + setParentOfDisposable(value, this); + } + + deleteAndDispose(value: V): void { + if (this._store.delete(value)) { + value.dispose(); + } + } + + deleteAndLeak(value: V): V | undefined { + if (this._store.delete(value)) { + setParentOfDisposable(value, null); + return value; + } + return undefined; + } + + values(): IterableIterator { + return this._store.values(); + } + + [Symbol.iterator](): IterableIterator { + return this._store[Symbol.iterator](); + } +} + +export function disposeOnReturn(fn: (store: DisposableStore) => void): void { + const store = new DisposableStore(); + try { + fn(store); + } finally { + store.dispose(); + } +} + +export function thenIfNotDisposed( + promise: Promise, + then: (result: T) => void, +): IDisposable { + let disposed = false; + void promise.then((result) => { + if (disposed) return; + then(result); + }); + return toDisposable(() => { + disposed = true; + }); +} + +export function thenRegisterOrDispose( + promise: Promise, + store: DisposableStore, +): Promise { + return promise.then((disposable) => { + if (store.isDisposed) { + disposable.dispose(); + } else { + store.add(disposable); + } + return disposable; + }); +} diff --git a/packages/agent-core-v2/src/_base/di/scope.ts b/packages/agent-core-v2/src/_base/di/scope.ts new file mode 100644 index 000000000..678e3c94d --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/scope.ts @@ -0,0 +1,164 @@ +/** + * `di` domain (L0) — DI Scope tree (`Scope`, `LifecycleScope`) and scoped service registry. + */ + +import { SyncDescriptor } from './descriptors'; +import { InstantiationType } from './extensions'; +import type { ServiceIdentifier, ServicesAccessor, IInstantiationService } from './instantiation'; +import { InstantiationService } from './instantiationService'; +import { DisposableStore, type IDisposable } from './lifecycle'; +import { ServiceCollection } from './serviceCollection'; + +export enum LifecycleScope { + Core = 0, + Session = 1, + Agent = 2, + Turn = 3, +} + +export interface ScopedEntry { + readonly scope: LifecycleScope; + readonly id: ServiceIdentifier; + readonly descriptor: SyncDescriptor; + readonly domain: string; +} + +const _scopedRegistry: ScopedEntry[] = []; + +export function registerScopedService( + scope: LifecycleScope, + id: ServiceIdentifier, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ctor: new (...args: any[]) => T, + type: InstantiationType = InstantiationType.Delayed, + domain: string = 'unknown', +): void { + const descriptor = new SyncDescriptor( + ctor, + [], + type === InstantiationType.Delayed, + ); + _scopedRegistry.push({ + scope, + id: id as ServiceIdentifier, + descriptor: descriptor as SyncDescriptor, + domain, + }); +} + +export function getScopedServiceDescriptors(scope: LifecycleScope): ReadonlyArray { + return _scopedRegistry.filter((entry) => entry.scope === scope); +} + +export function _clearScopedRegistryForTests(): void { + _scopedRegistry.length = 0; +} + +export type ScopeSeed = ReadonlyArray< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly [ServiceIdentifier, unknown] +>; + +export interface ScopeOptions { + readonly id?: string; + readonly extra?: ScopeSeed; +} + +export interface IScopeHandle { + readonly id: string; + readonly kind: LifecycleScope; + readonly accessor: ServicesAccessor; +} + +function buildCollection(kind: LifecycleScope, extra?: ScopeSeed): ServiceCollection { + const collection = new ServiceCollection(); + for (const entry of _scopedRegistry) { + if (entry.scope === kind) { + collection.set(entry.id, entry.descriptor); + } + } + if (extra) { + for (const [id, value] of extra) { + collection.set(id, value); + } + } + return collection; +} + +export class Scope implements IDisposable { + readonly children = new Map(); + readonly accessor: ServicesAccessor; + + private readonly _store = new DisposableStore(); + private _disposed = false; + + private constructor( + readonly id: string, + readonly kind: LifecycleScope, + readonly instantiation: IInstantiationService, + private readonly _parent?: Scope, + ) { + this.accessor = { + get: (serviceId: ServiceIdentifier): T => + instantiation.invokeFunction((a) => a.get(serviceId)), + }; + } + + static createCore(options: ScopeOptions = {}): Scope { + const kind = LifecycleScope.Core; + const collection = buildCollection(kind, options.extra); + const instantiation = new InstantiationService(collection, true); + return new Scope(options.id ?? 'core', kind, instantiation); + } + + private _assertNotDisposed(): void { + if (this._disposed) { + throw new Error(`Scope '${this.id}' has been disposed`); + } + } + + createChild(kind: LifecycleScope, id: string, options: ScopeOptions = {}): Scope { + this._assertNotDisposed(); + if (kind <= this.kind) { + throw new Error( + `child scope kind ${LifecycleScope[kind]}(${kind}) must be greater than parent kind ${LifecycleScope[this.kind]}(${this.kind})`, + ); + } + if (this.children.has(id)) { + throw new Error(`Scope '${this.id}' already has a child with id '${id}'`); + } + const collection = buildCollection(kind, options.extra); + const childInstantiation = this.instantiation.createChild(collection); + const child = new Scope(id, kind, childInstantiation, this); + this.children.set(id, child); + return child; + } + + toHandle(): IScopeHandle { + return { id: this.id, kind: this.kind, accessor: this.accessor }; + } + + dispose(): void { + if (this._disposed) { + return; + } + this._disposed = true; + + const kids = Array.from(this.children.values()); + this.children.clear(); + for (const child of kids) { + child.dispose(); + } + + this._store.dispose(); + this.instantiation.dispose(); + + if (this._parent) { + this._parent.children.delete(this.id); + } + } +} + +export function createCoreScope(options: ScopeOptions = {}): Scope { + return Scope.createCore(options); +} diff --git a/packages/agent-core-v2/src/_base/di/serviceCollection.ts b/packages/agent-core-v2/src/_base/di/serviceCollection.ts new file mode 100644 index 000000000..81a292f11 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/serviceCollection.ts @@ -0,0 +1,48 @@ +/** + * `di` domain (L0) — `ServiceCollection` map of service id → descriptor or instance. + */ + +import type { SyncDescriptor } from './descriptors'; +import type { ServiceIdentifier } from './instantiation'; + +export class ServiceCollection { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly _entries = new Map, unknown>(); + + constructor( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...entries: ReadonlyArray, unknown]> + ) { + for (const [id, value] of entries) { + this._entries.set(id, value); + } + } + + set( + id: ServiceIdentifier, + instanceOrDescriptor: T | SyncDescriptor, + ): T | SyncDescriptor | undefined { + const prev = this._entries.get(id); + this._entries.set(id, instanceOrDescriptor); + return prev as T | SyncDescriptor | undefined; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + has(id: ServiceIdentifier): boolean { + return this._entries.has(id); + } + + get(id: ServiceIdentifier): T | SyncDescriptor | undefined { + return this._entries.get(id) as T | SyncDescriptor | undefined; + } + + forEach( + callback: ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + id: ServiceIdentifier, + value: unknown, + ) => void, + ): void { + this._entries.forEach((value, id) => callback(id, value)); + } +} diff --git a/packages/agent-core-v2/src/_base/di/test.ts b/packages/agent-core-v2/src/_base/di/test.ts new file mode 100644 index 000000000..769417637 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/test.ts @@ -0,0 +1,42 @@ +/** + * `di` domain (L0) — scoped test host and service-stub helpers for DI domain tests. + */ + +export { + createServices, + TestInstantiationService, +} from './testInstantiationService'; +export type { ServiceIdCtorPair } from './testInstantiationService'; + +import { type ServiceIdentifier } from './instantiation'; +import { createCoreScope, LifecycleScope, Scope, type ScopeSeed } from './scope'; + +export interface ScopedTestHost { + readonly core: Scope; + child(kind: LifecycleScope, id: string, stubs?: ScopeSeed): Scope; + childOf(parent: Scope, kind: LifecycleScope, id: string, stubs?: ScopeSeed): Scope; + dispose(): void; +} + +export function createScopedTestHost(coreStubs: ScopeSeed = []): ScopedTestHost { + const core = createCoreScope({ extra: coreStubs }); + return { + core, + child(kind, id, stubs = []) { + return core.createChild(kind, id, { extra: stubs }); + }, + childOf(parent, kind, id, stubs = []) { + return parent.createChild(kind, id, { extra: stubs }); + }, + dispose() { + core.dispose(); + }, + }; +} + +export function stubPair( + id: ServiceIdentifier, + instance: T, +): readonly [ServiceIdentifier, T] { + return [id, instance]; +} diff --git a/packages/agent-core-v2/src/_base/di/testInstantiationService.ts b/packages/agent-core-v2/src/_base/di/testInstantiationService.ts new file mode 100644 index 000000000..375e23226 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/testInstantiationService.ts @@ -0,0 +1,339 @@ +/** + * `di` domain (L0) — `TestInstantiationService` and scoped test-container helpers. + */ + +import * as sinon from 'sinon'; + +import { SyncDescriptor, type SyncDescriptor0 } from './descriptors'; +import { + type GetLeadingNonServiceArgs, + type ServiceIdentifier, + type ServicesAccessor, +} from './instantiation'; +import { InstantiationService, Trace } from './instantiationService'; +import { DisposableStore, dispose, isDisposable, toDisposable, type IDisposable } from './lifecycle'; +import { ServiceCollection } from './serviceCollection'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyConstructor = new (...args: any[]) => T; + +interface IServiceMock { + id: ServiceIdentifier; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + service?: any; +} + +const isSinonSpyLike = (fn: Function): fn is sinon.SinonSpy => + fn && 'callCount' in fn; + +export class TestInstantiationService extends InstantiationService implements IDisposable, ServicesAccessor { + private readonly _classStubs = new Map(); + private readonly _parentTestService?: TestInstantiationService; + + constructor( + private readonly _serviceCollection: ServiceCollection = new ServiceCollection(), + strict: boolean = false, + parent?: InstantiationService, + private readonly _properDispose?: boolean, + ) { + super(_serviceCollection, strict, parent); + if (parent instanceof TestInstantiationService) { + this._parentTestService = parent; + } + } + + public get(id: ServiceIdentifier): T { + return super._getOrCreateServiceInstance( + id, + Trace.traceCreation(false, TestInstantiationService), + ); + } + + public set( + id: ServiceIdentifier, + instanceOrDescriptor: T | SyncDescriptor, + ): T | SyncDescriptor | undefined { + return this._serviceCollection.set(id, instanceOrDescriptor); + } + + public mock(id: ServiceIdentifier): T | sinon.SinonMock { + return this._create({ id }, { mock: true }); + } + + public stubInstance(ctor: AnyConstructor, instance: Partial): void { + this._classStubs.set(ctor, instance); + } + + protected _getClassStub(ctor: Function): unknown { + return this._classStubs.get(ctor) ?? this._parentTestService?._getClassStub(ctor); + } + + public override createInstance(descriptor: SyncDescriptor0): T; + public override createInstance< + Ctor extends AnyConstructor, + R extends InstanceType, + >( + ctor: Ctor, + ...args: GetLeadingNonServiceArgs> + ): R; + public override createInstance( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ctorOrDescriptor: any, + ...rest: unknown[] + ): unknown { + const stub = + ctorOrDescriptor instanceof SyncDescriptor + ? this._getClassStub(ctorOrDescriptor.ctor) + : this._getClassStub(ctorOrDescriptor); + + if (stub !== undefined) { + return stub; + } + + if (ctorOrDescriptor instanceof SyncDescriptor) { + return super.createInstance(ctorOrDescriptor, ...rest); + } + return super.createInstance(ctorOrDescriptor, ...rest); + } + + public stub( + id: ServiceIdentifier, + instanceOrDescriptor: Partial> | SyncDescriptor, + ): T | SyncDescriptor; + public stub(id: ServiceIdentifier, ctor: AnyConstructor): T; + public stub( + id: ServiceIdentifier, + obj: Partial> | Function, + property: string, + value: V, + ): V extends Function ? sinon.SinonSpy : sinon.SinonStub; + public stub( + id: ServiceIdentifier, + property: string, + value: V, + ): V extends Function ? sinon.SinonSpy : sinon.SinonStub; + public stub( + id: ServiceIdentifier, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + arg2: any, + arg3?: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + arg4?: any, + ): T | SyncDescriptor | sinon.SinonStub | sinon.SinonSpy { + if (arg2 instanceof SyncDescriptor && typeof arg3 !== 'string') { + this._serviceCollection.set(id, arg2); + return arg2; + } + + if (typeof arg2 !== 'string' && typeof arg3 !== 'string') { + const service = this._create(arg2, { stub: true }) as T; + this._serviceCollection.set(id, service); + return service; + } + + const service = typeof arg2 !== 'string' ? arg2 : undefined; + const property = typeof arg2 === 'string' ? arg2 : arg3; + const value = typeof arg2 === 'string' ? arg3 : arg4; + + if (typeof property !== 'string') { + throw new TypeError('stub requires a method/property name'); + } + + const serviceMock: IServiceMock = { id, service }; + const stubObject = this._create(serviceMock, { stub: true }, Boolean(service && !property)) as Record; + const replacement = this._createReplacement(value); + + const current = stubObject[property] as { restore?: () => void } | undefined; + if (current && typeof current.restore === 'function') { + current.restore(); + } + stubObject[property] = replacement; + return replacement; + } + + public stubPromise( + id?: ServiceIdentifier, + fnProperty?: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value?: any, + ): T | sinon.SinonStub; + public stubPromise( + id?: ServiceIdentifier, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ctor?: any, + fnProperty?: string, + value?: V, + ): V extends Function ? sinon.SinonSpy : sinon.SinonStub; + public stubPromise( + id?: ServiceIdentifier, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + obj?: any, + fnProperty?: string, + value?: V, + ): V extends Function ? sinon.SinonSpy : sinon.SinonStub; + public stubPromise( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + arg1?: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + arg2?: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + arg3?: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + arg4?: any, + ): unknown { + arg3 = typeof arg2 === 'string' ? Promise.resolve(arg3) : arg3; + arg4 = typeof arg2 !== 'string' && typeof arg3 === 'string' ? Promise.resolve(arg4) : arg4; + return this.stub(arg1, arg2, arg3, arg4); + } + + public spy(id: ServiceIdentifier, property: string): sinon.SinonSpy { + const spy = sinon.spy(); + this.stub(id, property, spy); + return spy; + } + + private _create(serviceMock: IServiceMock, options: SinonOptions, reset?: boolean): T; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _create(ctor: any, options: SinonOptions): T | sinon.SinonMock; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _create(arg1: any, options: SinonOptions, reset: boolean = false): any { + if (this._isServiceMock(arg1)) { + const service = this._getOrCreateService(arg1, options, reset); + if (options.mock) { + return sinon.mock(service); + } + this._serviceCollection.set(arg1.id, service); + return service; + } + return options.mock ? sinon.mock(arg1) : this._createStub(arg1); + } + + private _getOrCreateService( + serviceMock: IServiceMock, + opts: SinonOptions, + reset?: boolean, + ): T { + const service = this._serviceCollection.get(serviceMock.id); + if (!reset && service && !(service instanceof SyncDescriptor)) { + if (opts.stub && this._hasSinonOption(service, 'stub')) { + return service as T; + } + if (opts.mock && this._hasSinonOption(service, 'mock')) { + return service as T; + } + return service as T; + } + return this._createService(serviceMock, opts); + } + + private _createService(serviceMock: IServiceMock, opts: SinonOptions): T { + const existing = this._serviceCollection.get(serviceMock.id); + const source = + serviceMock.service + ?? (existing instanceof SyncDescriptor ? existing.ctor : undefined); + const service = this._createStub(source); + service.sinonOptions = opts; + return service as T; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _createStub(arg: any): any { + if (arg instanceof SyncDescriptor) { + return sinon.createStubInstance(arg.ctor); + } + if (typeof arg === 'function') { + return sinon.createStubInstance(arg); + } + if (arg && typeof arg === 'object') { + return arg; + } + return Object.create(null); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _createReplacement(value: any): sinon.SinonStub | sinon.SinonSpy { + if (typeof value === 'function') { + return isSinonSpyLike(value) ? value : sinon.spy(value); + } + return value ? sinon.stub().returns(value) : sinon.stub(); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _hasSinonOption(service: any, key: keyof SinonOptions): boolean { + return Boolean(service?.sinonOptions?.[key]); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _isServiceMock(arg: any): arg is IServiceMock { + return typeof arg === 'object' && arg !== null && 'id' in arg; + } + + public override createChild(services: ServiceCollection): TestInstantiationService { + if (!(services instanceof ServiceCollection)) { + throw new TypeError( + 'createChild requires a ServiceCollection instance (got something else)', + ); + } + const child = new TestInstantiationService(services, false, this); + (this as unknown as { _children: Set })._children.add(child); + return child; + } + + public override dispose(): void { + sinon.restore(); + if (this._properDispose) { + super.dispose(); + } + } +} + +interface SinonOptions { + mock?: boolean; + stub?: boolean; +} + +export type ServiceIdCtorPair = [ + id: ServiceIdentifier, + ctorOrInstance: T | AnyConstructor, +]; + +export function createServices( + disposables: DisposableStore, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + services: ServiceIdCtorPair[], +): TestInstantiationService { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const serviceIdentifiers: ServiceIdentifier[] = []; + const serviceCollection = new ServiceCollection(); + + const define = ( + id: ServiceIdentifier, + ctorOrInstance: T | AnyConstructor, + ): void => { + if (!serviceCollection.has(id)) { + if (typeof ctorOrInstance === 'function') { + serviceCollection.set(id, new SyncDescriptor(ctorOrInstance as AnyConstructor)); + } else { + serviceCollection.set(id, ctorOrInstance); + } + } + serviceIdentifiers.push(id); + }; + + for (const [id, ctorOrInstance] of services) { + define(id, ctorOrInstance); + } + + const instantiationService = disposables.add(new TestInstantiationService(serviceCollection, true)); + disposables.add(toDisposable(() => { + const serviceDisposables: IDisposable[] = []; + for (const id of serviceIdentifiers) { + const instanceOrDescriptor = serviceCollection.get(id); + if (isDisposable(instanceOrDescriptor)) { + serviceDisposables.push(instanceOrDescriptor); + } + } + dispose(serviceDisposables); + })); + return instantiationService; +} diff --git a/packages/agent-core-v2/src/_base/di/util/idleValue.ts b/packages/agent-core-v2/src/_base/di/util/idleValue.ts new file mode 100644 index 000000000..49137fefd --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/util/idleValue.ts @@ -0,0 +1,106 @@ +/** + * `di` domain (L0) — `GlobalIdleValue` lazy-initializer backing delayed DI services. + */ + +import type { IDisposable } from '../lifecycle'; + +interface IdleDeadline { + readonly didTimeout: boolean; + timeRemaining(): number; +} + +function runWhenGlobalIdle( + callback: (idle: IdleDeadline) => void, + timeout?: number, +): IDisposable { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const safeGlobal: any = globalThis; + + if ( + typeof safeGlobal.requestIdleCallback === 'function' && + typeof safeGlobal.cancelIdleCallback === 'function' + ) { + const handle: number = safeGlobal.requestIdleCallback( + callback, + typeof timeout === 'number' ? { timeout } : undefined, + ); + let disposed = false; + return { + dispose() { + if (disposed) { + return; + } + disposed = true; + safeGlobal.cancelIdleCallback(handle); + }, + }; + } else { + let disposed = false; + const handle = setTimeout(() => { + if (disposed) { + return; + } + const end = Date.now() + 15; + const deadline: IdleDeadline = { + didTimeout: true, + timeRemaining() { + return Math.max(0, end - Date.now()); + }, + }; + callback(Object.freeze(deadline)); + }); + return { + dispose() { + if (disposed) { + return; + } + disposed = true; + clearTimeout(handle); + }, + }; + } +} + +export class GlobalIdleValue { + private readonly _executor: () => void; + private readonly _handle: IDisposable; + + private _didRun: boolean = false; + private _value?: T; + private _error: unknown; + + constructor(executor: () => T) { + this._executor = () => { + try { + this._value = executor(); + } catch (err) { + this._error = err; + } finally { + this._didRun = true; + } + }; + this._handle = runWhenGlobalIdle(() => this._executor()); + } + + dispose(): void { + this._handle.dispose(); + } + + get value(): T { + if (!this._didRun) { + this._handle.dispose(); + this._executor(); + } + if (this._error) { + if (this._error instanceof Error) { + throw this._error; + } + throw new Error('Lazy value initialization failed'); + } + return this._value!; + } + + get isInitialized(): boolean { + return this._didRun; + } +} diff --git a/packages/agent-core-v2/src/_base/di/util/linkedList.ts b/packages/agent-core-v2/src/_base/di/util/linkedList.ts new file mode 100644 index 000000000..b520a715f --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/util/linkedList.ts @@ -0,0 +1,79 @@ +/** + * `di` domain (L0) — `LinkedList` with O(1) push/removal for parked event listeners. + */ + +class Node { + static readonly Undefined = new Node(undefined); + + element: E; + next: Node | typeof Node.Undefined; + prev: Node | typeof Node.Undefined; + + constructor(element: E) { + this.element = element; + this.next = Node.Undefined; + this.prev = Node.Undefined; + } +} + +export class LinkedList { + private _first: Node | typeof Node.Undefined = Node.Undefined; + private _last: Node | typeof Node.Undefined = Node.Undefined; + private _size: number = 0; + + get size(): number { + return this._size; + } + + isEmpty(): boolean { + return this._first === Node.Undefined; + } + + push(element: E): () => void { + const newNode = new Node(element); + if (this._first === Node.Undefined) { + this._first = newNode; + this._last = newNode; + } else { + const oldLast = this._last as Node; + this._last = newNode; + newNode.prev = oldLast; + oldLast.next = newNode; + } + this._size += 1; + + let didRemove = false; + return () => { + if (!didRemove) { + didRemove = true; + this._remove(newNode); + } + }; + } + + private _remove(node: Node): void { + if (node.prev !== Node.Undefined && node.next !== Node.Undefined) { + const anchor = node.prev as Node; + anchor.next = node.next; + (node.next as Node).prev = anchor; + } else if (node.prev === Node.Undefined && node.next === Node.Undefined) { + this._first = Node.Undefined; + this._last = Node.Undefined; + } else if (node.next === Node.Undefined) { + this._last = (this._last as Node).prev!; + (this._last as Node).next = Node.Undefined; + } else if (node.prev === Node.Undefined) { + this._first = (this._first as Node).next!; + (this._first as Node).prev = Node.Undefined; + } + this._size -= 1; + } + + *[Symbol.iterator](): Iterator { + let node = this._first; + while (node !== Node.Undefined) { + yield (node as Node).element; + node = (node as Node).next; + } + } +} diff --git a/packages/agent-core-v2/src/_base/errors/codes.ts b/packages/agent-core-v2/src/_base/errors/codes.ts new file mode 100644 index 000000000..c74d33655 --- /dev/null +++ b/packages/agent-core-v2/src/_base/errors/codes.ts @@ -0,0 +1,44 @@ +/** + * Public error-code registry (`ErrorCodes`, `ErrorCode`) and per-code metadata + * (`ERROR_INFO`, `errorInfo`) surfaced to SDK/RPC consumers. + */ + +export const ErrorCodes = { + INTERNAL: 'internal', + NOT_IMPLEMENTED: 'not_implemented', + CANCELED: 'canceled', +} as const; + +export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; + +export interface ErrorInfo { + readonly title: string; + readonly retryable: boolean; + readonly public: boolean; + readonly action?: string; +} + +export const ERROR_INFO = { + internal: { + title: 'Internal error', + retryable: false, + public: true, + action: 'Inspect logs or report the issue with diagnostics.', + }, + not_implemented: { + title: 'Not implemented', + retryable: false, + public: true, + action: 'This feature is not implemented yet.', + }, + canceled: { + title: 'Canceled', + retryable: false, + public: true, + action: 'The operation was canceled by the user or an abort signal.', + }, +} as const satisfies Record; + +export function errorInfo(code: ErrorCode): ErrorInfo { + return ERROR_INFO[code]; +} diff --git a/packages/agent-core-v2/src/_base/errors/errorMessage.ts b/packages/agent-core-v2/src/_base/errors/errorMessage.ts new file mode 100644 index 000000000..c99b1dbde --- /dev/null +++ b/packages/agent-core-v2/src/_base/errors/errorMessage.ts @@ -0,0 +1,31 @@ +/** + * Render thrown values as human-readable lines for logs and CLI output. + */ + +import { isCancellationError } from './errors'; +import { isCodedError } from './serialize'; + +export function toErrorMessage(error: unknown, verbose = false): string { + if (isCancellationError(error)) { + return ''; + } + if (isCodedError(error)) { + const base = `[${error.code}] ${error.message}`; + return verbose && error.details ? `${base} ${JSON.stringify(error.details)}` : base; + } + if (error instanceof Error) { + const base = error.message || error.name; + if (verbose && error.cause !== undefined) { + return `${base} (caused by: ${toErrorMessage(error.cause)})`; + } + return base; + } + if (typeof error === 'string') { + return error; + } + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} diff --git a/packages/agent-core-v2/src/_base/errors/errors.ts b/packages/agent-core-v2/src/_base/errors/errors.ts new file mode 100644 index 000000000..81eb01141 --- /dev/null +++ b/packages/agent-core-v2/src/_base/errors/errors.ts @@ -0,0 +1,74 @@ +/** + * Base error classes shared by every domain — `KimiError`, + * `CancellationError`, and related control-flow errors. + */ + +import { ErrorCodes } from './codes'; +import type { ErrorCode } from './codes'; + +export class CancellationError extends Error { + constructor() { + super('Canceled'); + this.name = 'CancellationError'; + } +} + +export function isCancellationError(error: unknown): error is CancellationError { + return error instanceof CancellationError; +} + +export class ExpectedError extends Error { + readonly isExpected = true; +} + +export class ErrorNoTelemetry extends Error { + constructor(message?: string) { + super(message); + this.name = 'CodeExpectedError'; + } + + static fromError(error: Error): ErrorNoTelemetry { + const wrapped = new ErrorNoTelemetry(error.message); + wrapped.stack = error.stack; + return wrapped; + } + + static isErrorNoTelemetry(error: unknown): error is ErrorNoTelemetry { + return error instanceof Error && error.name === 'CodeExpectedError'; + } +} + +export class BugIndicatingError extends Error { + constructor(message?: string) { + super(message ?? 'An unexpected bug occurred.'); + this.name = 'BugIndicatingError'; + } +} + +export interface KimiErrorOptions { + readonly details?: Readonly>; + readonly cause?: unknown; + readonly name?: string; +} + +export class KimiError extends Error { + readonly code: ErrorCode; + readonly details?: Readonly>; + + constructor(code: ErrorCode, message: string, options?: KimiErrorOptions) { + super(message, options?.cause === undefined ? undefined : { cause: options.cause }); + this.name = options?.name ?? 'KimiError'; + this.code = code; + this.details = options?.details; + } +} + +export class NotImplementedError extends KimiError { + constructor(feature?: string) { + super( + ErrorCodes.NOT_IMPLEMENTED, + feature ? `Not implemented: ${feature}` : 'Not implemented', + ); + this.name = 'NotImplementedError'; + } +} diff --git a/packages/agent-core-v2/src/_base/errors/index.ts b/packages/agent-core-v2/src/_base/errors/index.ts new file mode 100644 index 000000000..ce85929e8 --- /dev/null +++ b/packages/agent-core-v2/src/_base/errors/index.ts @@ -0,0 +1,11 @@ +/** + * `errors` domain barrel — re-exports the error primitives: the code registry, + * base error classes, message rendering, wire serialization, and unexpected- + * error reporting. + */ + +export * from './codes'; +export * from './errorMessage'; +export * from './errors'; +export * from './serialize'; +export * from './unexpectedError'; diff --git a/packages/agent-core-v2/src/_base/errors/serialize.ts b/packages/agent-core-v2/src/_base/errors/serialize.ts new file mode 100644 index 000000000..c0fa33d4c --- /dev/null +++ b/packages/agent-core-v2/src/_base/errors/serialize.ts @@ -0,0 +1,77 @@ +/** + * Wire serialization of errors — converts between thrown values and the + * portable `ErrorPayload` that crosses process / language boundaries. + */ + +import { ERROR_INFO, ErrorCodes } from './codes'; +import type { ErrorCode } from './codes'; +import { KimiError, isCancellationError } from './errors'; + +export interface ErrorPayload { + readonly code: ErrorCode; + readonly message: string; + readonly name?: string; + readonly details?: Readonly>; + readonly retryable: boolean; +} + +export interface CodedErrorShape { + readonly code: ErrorCode; + readonly message: string; + readonly name?: string; + readonly details?: Readonly>; +} + +export function isCodedError(error: unknown): error is CodedErrorShape { + if (error === null || typeof error !== 'object') { + return false; + } + const code = (error as { readonly code?: unknown }).code; + return ( + typeof code === 'string' && + Object.prototype.hasOwnProperty.call(ERROR_INFO, code) + ); +} + +export function makeErrorPayload( + code: ErrorCode, + message: string, + options?: { + readonly details?: Readonly>; + readonly name?: string; + }, +): ErrorPayload { + return { + code, + message, + name: options?.name, + details: options?.details, + retryable: ERROR_INFO[code].retryable, + }; +} + +export function toErrorPayload(error: unknown): ErrorPayload { + if (isCancellationError(error)) { + return makeErrorPayload(ErrorCodes.CANCELED, error.message); + } + if (isCodedError(error)) { + return { + code: error.code, + message: error.message, + name: error.name, + details: error.details, + retryable: ERROR_INFO[error.code].retryable, + }; + } + if (error instanceof Error) { + return makeErrorPayload(ErrorCodes.INTERNAL, error.message, { name: error.name }); + } + return makeErrorPayload(ErrorCodes.INTERNAL, String(error)); +} + +export function fromErrorPayload(payload: ErrorPayload): KimiError { + return new KimiError(payload.code, payload.message, { + name: payload.name, + details: payload.details, + }); +} diff --git a/packages/agent-core-v2/src/_base/errors/unexpectedError.ts b/packages/agent-core-v2/src/_base/errors/unexpectedError.ts new file mode 100644 index 000000000..b16c766b0 --- /dev/null +++ b/packages/agent-core-v2/src/_base/errors/unexpectedError.ts @@ -0,0 +1,38 @@ +/** + * Unexpected-error reporting hook (`onUnexpectedError`) used by the Emitter to + * surface exceptions thrown by listener callbacks. + */ + +export type UnexpectedErrorHandler = (err: unknown) => void; + +const defaultHandler: UnexpectedErrorHandler = (err) => { + // eslint-disable-next-line no-console + console.error('[unexpected]', err); +}; + +let currentHandler: UnexpectedErrorHandler = defaultHandler; + +export function setUnexpectedErrorHandler(handler: UnexpectedErrorHandler): void { + currentHandler = handler; +} + +export function resetUnexpectedErrorHandler(): void { + currentHandler = defaultHandler; +} + +export function onUnexpectedError(err: unknown): void { + try { + currentHandler(err); + } catch (handlerErr) { + // eslint-disable-next-line no-console + console.error('[unexpected] handler threw', handlerErr, 'while reporting', err); + } +} + +export function safelyCallListener(listener: () => void): void { + try { + listener(); + } catch (err) { + onUnexpectedError(err); + } +} diff --git a/packages/agent-core-v2/src/_base/event.ts b/packages/agent-core-v2/src/_base/event.ts new file mode 100644 index 000000000..8a7e8bc82 --- /dev/null +++ b/packages/agent-core-v2/src/_base/event.ts @@ -0,0 +1,148 @@ +/** + * `event` domain (L0) — `Event` / `Emitter` primitives and event combinators (`once` / `map` / `filter` / `any`). + */ + +import { onUnexpectedError, safelyCallListener } from './errors/unexpectedError'; +import { + Disposable, + DisposableStore, + combinedDisposable, + type IDisposable, +} from './di/lifecycle'; + +export interface Event { + ( + listener: (e: T) => unknown, + thisArg?: unknown, + disposables?: IDisposable[] | DisposableStore, + ): IDisposable; +} + +interface ListenerEntry { + listener: (e: T) => unknown; + thisArg: unknown; +} + +export class Emitter { + private _listeners: Set> | undefined; + private _disposed = false; + private _event: Event | undefined; + + get event(): Event { + this._event ??= (listener, thisArg, disposables) => { + if (this._disposed) { + return Disposable.None; + } + this._listeners ??= new Set(); + const entry: ListenerEntry = { listener, thisArg }; + this._listeners.add(entry); + + let removed = false; + const subscription: IDisposable = { + dispose: () => { + if (removed) return; + removed = true; + if (this._disposed) { + return; + } + this._listeners?.delete(entry); + }, + }; + + if (disposables !== undefined) { + if (disposables instanceof DisposableStore) { + disposables.add(subscription); + } else { + disposables.push(subscription); + } + } + return subscription; + }; + return this._event; + } + + fire(value: T): void { + if (this._disposed || this._listeners === undefined) { + return; + } + const snapshot = Array.from(this._listeners); + for (const entry of snapshot) { + safelyCallListener(() => { + entry.listener.call(entry.thisArg, value); + }); + } + } + + dispose(): void { + if (this._disposed) return; + this._disposed = true; + this._listeners?.clear(); + this._listeners = undefined; + } + + get isDisposed(): boolean { + return this._disposed; + } +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Event { + export const None: Event = () => Disposable.None; + + export function once(event: Event): Event { + return (listener, thisArg, disposables) => { + let fired = false; + const subscription = event( + (e) => { + if (fired) return; + fired = true; + subscription.dispose(); + try { + listener.call(thisArg, e); + } catch (error) { + onUnexpectedError(error); + } + }, + undefined, + disposables, + ); + return subscription; + }; + } + + export function map(event: Event, map: (i: I) => O): Event { + return (listener, thisArg, disposables) => + event( + (i) => listener.call(thisArg, map(i)), + undefined, + disposables, + ); + } + + export function filter(event: Event, filter: (e: T) => boolean): Event { + return (listener, thisArg, disposables) => + event( + (e) => { + if (filter(e)) listener.call(thisArg, e); + }, + undefined, + disposables, + ); + } + + export function any(...events: Event[]): Event { + return (listener, thisArg, disposables) => { + const combined = combinedDisposable( + ...events.map((e) => e((value) => listener.call(thisArg, value))), + ); + if (disposables !== undefined) { + if (disposables instanceof DisposableStore) { + disposables.add(combined); + } else { + disposables.push(combined); + } + } + return combined; + }; + } +} diff --git a/packages/agent-core-v2/src/_base/utils/abort.ts b/packages/agent-core-v2/src/_base/utils/abort.ts new file mode 100644 index 000000000..ed519bc43 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/abort.ts @@ -0,0 +1,94 @@ +/** + * Abort-signal helpers — user-cancellation errors, abortable promises, signal + * linking, and deadline abort signals. + */ + +export function abortError(message = 'Aborted'): Error { + const error = new Error(message); + error.name = 'AbortError'; + return error; +} + +export class UserCancellationError extends Error { + readonly userCancelled = true; + + constructor() { + super('Aborted by the user'); + this.name = 'AbortError'; + } +} + +export function userCancellationReason(): UserCancellationError { + return new UserCancellationError(); +} + +export function isUserCancellation(value: unknown): value is UserCancellationError { + return value instanceof UserCancellationError; +} + +export function abortable(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(abortReason(signal)); + return new Promise((resolve, reject) => { + const onAbort = () => { + reject(abortReason(signal)); + }; + signal.addEventListener('abort', onAbort, { once: true }); + promise.then(resolve, reject).finally(() => { + signal.removeEventListener('abort', onAbort); + }); + }); +} + +export function linkAbortSignal(source: AbortSignal, target: AbortController): () => void { + const onAbort = () => { + target.abort(source.reason); + }; + if (source.aborted) { + onAbort(); + return () => {}; + } + source.addEventListener('abort', onAbort, { once: true }); + return () => { + source.removeEventListener('abort', onAbort); + }; +} + +function abortReason(signal: AbortSignal): Error { + if (signal.reason instanceof Error && !isDefaultAbortReason(signal.reason)) { + return signal.reason; + } + return abortError(); +} + +function isDefaultAbortReason(reason: Error): boolean { + return reason.name === 'AbortError' && reason.message === 'This operation was aborted'; +} + +export interface DeadlineAbortSignal { + readonly signal: AbortSignal; + readonly timedOut: () => boolean; + readonly clear: () => void; +} + +export function createDeadlineAbortSignal( + source: AbortSignal, + timeoutMs: number, +): DeadlineAbortSignal { + const controller = new AbortController(); + const unlinkAbortSignal = linkAbortSignal(source, controller); + let didTimeout = false; + let timeout: ReturnType | undefined = setTimeout(() => { + didTimeout = true; + controller.abort(abortError()); + }, timeoutMs); + + return { + signal: controller.signal, + timedOut: () => didTimeout, + clear: () => { + if (timeout !== undefined) clearTimeout(timeout); + timeout = undefined; + unlinkAbortSignal(); + }, + }; +} diff --git a/packages/agent-core-v2/src/_base/utils/completion-budget.ts b/packages/agent-core-v2/src/_base/utils/completion-budget.ts new file mode 100644 index 000000000..77503170d --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/completion-budget.ts @@ -0,0 +1,72 @@ +/** + * Completion-token budget — resolves env/config caps and applies them to a + * chat provider. + */ + +import type { ChatProvider, ModelCapability } from '@moonshot-ai/kosong'; + +export interface CompletionBudgetConfig { + readonly hardCap?: number; + readonly fallback?: number; +} + +const MIN_FLOOR = 1; +const DEFAULT_UNKNOWN_CONTEXT_FALLBACK = 32000; + +export function resolveCompletionBudget(args: { + readonly maxOutputSize?: number; + readonly reservedContextSize?: number; + readonly env?: NodeJS.ProcessEnv; +}): CompletionBudgetConfig | undefined { + const env = args.env ?? process.env; + const fromNew = parseEnvBudget(env['KIMI_MODEL_MAX_COMPLETION_TOKENS']); + if (fromNew !== 'absent') { + return fromNew === 'disabled' ? undefined : { hardCap: fromNew }; + } + const fromLegacy = parseEnvBudget(env['KIMI_MODEL_MAX_TOKENS']); + if (fromLegacy !== 'absent') { + return fromLegacy === 'disabled' ? undefined : { hardCap: fromLegacy }; + } + if (args.maxOutputSize !== undefined && args.maxOutputSize > 0) { + return { hardCap: args.maxOutputSize }; + } + if (args.reservedContextSize !== undefined && args.reservedContextSize > 0) { + return { fallback: args.reservedContextSize }; + } + return { fallback: DEFAULT_UNKNOWN_CONTEXT_FALLBACK }; +} + +type EnvBudget = number | 'disabled' | 'absent'; + +function parseEnvBudget(raw: string | undefined): EnvBudget { + if (raw === undefined || raw === '') return 'absent'; + const n = Number(raw); + if (!Number.isFinite(n) || !Number.isInteger(n)) return 'absent'; + if (n <= 0) return 'disabled'; + return n; +} + +export function computeCompletionBudgetCap(args: { + readonly budget: CompletionBudgetConfig; + readonly capability: ModelCapability | undefined; +}): number { + const maxCtx = args.capability?.max_context_tokens ?? 0; + const cap = + args.budget.hardCap ?? + (maxCtx > 0 ? maxCtx : args.budget.fallback ?? DEFAULT_UNKNOWN_CONTEXT_FALLBACK); + return Math.max(MIN_FLOOR, cap); +} + +export function applyCompletionBudget(args: { + readonly provider: ChatProvider; + readonly budget: CompletionBudgetConfig | undefined; + readonly capability: ModelCapability | undefined; +}): ChatProvider { + if (args.budget === undefined) return args.provider; + if (args.provider.withMaxCompletionTokens === undefined) return args.provider; + const cap = computeCompletionBudgetCap({ + budget: args.budget, + capability: args.capability, + }); + return args.provider.withMaxCompletionTokens(cap); +} diff --git a/packages/agent-core-v2/src/_base/utils/fs.ts b/packages/agent-core-v2/src/_base/utils/fs.ts new file mode 100644 index 000000000..262d717e6 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/fs.ts @@ -0,0 +1,113 @@ +/** + * Low-level durable file-write primitives — atomic writes plus file and + * directory fsync helpers. + */ + +import { randomBytes } from 'node:crypto'; +import { closeSync, fsyncSync, openSync } from 'node:fs'; +import * as nodeFs from 'node:fs'; +import { open, rename, unlink } from 'node:fs/promises'; +import { dirname } from 'pathe'; + +export async function syncDir(dirPath: string): Promise { + if (process.platform === 'win32') return; + const dirFh = await open(dirPath, 'r'); + try { + await dirFh.sync(); + } finally { + await dirFh.close(); + } +} + +export function syncDirSync(dirPath: string): void { + if (process.platform === 'win32') return; + const fd = openSync(dirPath, 'r'); + try { + fsyncSync(fd); + } finally { + closeSync(fd); + } +} + +export async function writeFileAtomicDurable( + filePath: string, + content: string | Uint8Array, +): Promise { + const tmpPath = filePath + '.tmp'; + let renamed = false; + try { + const fh = await open(tmpPath, 'w'); + try { + await fh.writeFile(content); + await fh.sync(); + } finally { + await fh.close(); + } + if (process.platform === 'win32') { + try { + await unlink(filePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw error; + } + } + await rename(tmpPath, filePath); + renamed = true; + await syncDir(dirname(filePath)); + } finally { + if (!renamed) { + try { + await unlink(tmpPath); + } catch { + } + } + } +} + +function syncFd(fd: number): Promise { + return new Promise((resolve, reject) => { + nodeFs.fsync(fd, (err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); +} + +export async function atomicWrite( + filePath: string, + content: string | Uint8Array, + _syncOverride?: (fd: number) => Promise, +): Promise { + const hex = randomBytes(4).toString('hex'); + const tmpPath = `${filePath}.tmp.${process.pid}.${hex}`; + let renamed = false; + try { + const fh = await open(tmpPath, 'w'); + try { + await fh.writeFile(content); + await (_syncOverride ?? syncFd)(fh.fd); + } finally { + await fh.close(); + } + if (process.platform === 'win32') { + try { + await unlink(filePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw error; + } + } + await rename(tmpPath, filePath); + renamed = true; + } finally { + if (!renamed) { + try { + await unlink(tmpPath); + } catch { + } + } + } +} diff --git a/packages/agent-core-v2/src/_base/utils/hero-slug.ts b/packages/agent-core-v2/src/_base/utils/hero-slug.ts new file mode 100644 index 000000000..e4d78a174 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/hero-slug.ts @@ -0,0 +1,267 @@ +/** + * Hero-name slug generator for readable, memorable identifiers. + */ + +import { randomInt } from 'node:crypto'; + +export const HERO_NAMES = [ + 'iron-man', + 'spider-man', + 'captain-america', + 'thor', + 'hulk', + 'black-widow', + 'hawkeye', + 'black-panther', + 'doctor-strange', + 'scarlet-witch', + 'vision', + 'falcon', + 'war-machine', + 'ant-man', + 'wasp', + 'captain-marvel', + 'gamora', + 'star-lord', + 'groot', + 'rocket', + 'drax', + 'mantis', + 'nebula', + 'shang-chi', + 'moon-knight', + 'ms-marvel', + 'she-hulk', + 'echo', + 'wolverine', + 'cyclops', + 'storm', + 'jean-grey', + 'rogue', + 'beast', + 'nightcrawler', + 'colossus', + 'shadowcat', + 'jubilee', + 'cable', + 'deadpool', + 'bishop', + 'magik', + 'iceman', + 'archangel', + 'psylocke', + 'dazzler', + 'forge', + 'havok', + 'polaris', + 'emma-frost', + 'namor', + 'silver-surfer', + 'adam-warlock', + 'nova', + 'quasar', + 'sentry', + 'blue-marvel', + 'spectrum', + 'squirrel-girl', + 'cloak', + 'dagger', + 'punisher', + 'elektra', + 'luke-cage', + 'iron-fist', + 'jessica-jones', + 'daredevil', + 'blade', + 'ghost-rider', + 'morbius', + 'venom', + 'carnage', + 'silk', + 'spider-gwen', + 'miles-morales', + 'america-chavez', + 'kate-bishop', + 'yelena-belova', + 'white-tiger', + 'moon-girl', + 'devil-dinosaur', + 'amadeus-cho', + 'riri-williams', + 'kamala-khan', + 'sam-alexander', + 'nova-prime', + 'medusa', + 'black-bolt', + 'crystal', + 'karnak', + 'gorgon', + 'lockjaw', + 'quake', + 'mockingbird', + 'bobbi-morse', + 'maria-hill', + 'nick-fury', + 'phil-coulson', + 'winter-soldier', + 'us-agent', + 'patriot', + 'speed', + 'wiccan', + 'hulkling', + 'stature', + 'yellowjacket', + 'tigra', + 'hellcat', + 'valkyrie', + 'sif', + 'beta-ray-bill', + 'hercules', + 'wonder-man', + 'taskmaster', + 'domino', + 'cannonball', + 'sunspot', + 'wolfsbane', + 'warpath', + 'multiple-man', + 'banshee', + 'siryn', + 'monet', + 'rictor', + 'shatterstar', + 'longshot', + 'daken', + 'x-23', + 'fantomex', + 'batman', + 'superman', + 'wonder-woman', + 'flash', + 'aquaman', + 'green-lantern', + 'martian-manhunter', + 'cyborg', + 'hawkgirl', + 'green-arrow', + 'black-canary', + 'zatanna', + 'constantine', + 'shazam', + 'blue-beetle', + 'booster-gold', + 'firestorm', + 'atom', + 'hawkman', + 'plastic-man', + 'red-tornado', + 'starfire', + 'raven', + 'beast-boy', + 'robin', + 'nightwing', + 'batgirl', + 'batwoman', + 'red-hood', + 'signal', + 'orphan', + 'spoiler', + 'catwoman', + 'huntress', + 'supergirl', + 'superboy', + 'power-girl', + 'steel', + 'stargirl', + 'wildcat', + 'doctor-fate', + 'mister-terrific', + 'hourman', + 'sandman', + 'spectre', + 'phantom-stranger', + 'swamp-thing', + 'animal-man', + 'deadman', + 'vixen', + 'black-lightning', + 'static', + 'icon', + 'rocket-dc', + 'captain-atom', + 'fire', + 'ice', + 'elongated-man', + 'metamorpho', + 'black-hawk', + 'crimson-avenger', + 'doctor-mid-nite', + 'jakeem-thunder', + 'mister-miracle', + 'big-barda', + 'orion', + 'lightray', + 'forager', + 'killer-frost', + 'jessica-cruz', + 'simon-baz', + 'john-stewart', + 'guy-gardner', + 'kyle-rayner', + 'hal-jordan', + 'wally-west', + 'barry-allen', + 'jay-garrick', + 'impulse', + 'kid-flash', + 'donna-troy', + 'tempest', + 'aqualad', + 'miss-martian', + 'terra', + 'jericho', + 'ravager', + 'red-star', + 'pantha', + 'argent', + 'damage', + 'jade', + 'obsidian', + 'cyclone', + 'atom-smasher', + 'maxima', + 'starman', + 'liberty-belle', + 'dove', + 'hawk', + 'blue-devil', + 'creeper', + 'ragman', + 'thunder', +] as const satisfies readonly [string, ...string[]]; + +const MAX_ATTEMPTS = 20; + +function pickHero(): string { + return HERO_NAMES[randomInt(HERO_NAMES.length)]!; +} + +function assembleSlug(): string { + return `${pickHero()}-${pickHero()}-${pickHero()}`; +} + +export function generateHeroSlug(id: string, existing: Set): string { + let slug = ''; + let collided = true; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + slug = assembleSlug(); + if (!existing.has(slug)) { + collided = false; + break; + } + } + if (collided) { + slug = `${slug}-${id.slice(0, 8)}`; + } + return slug; +} diff --git a/packages/agent-core-v2/src/_base/utils/index.ts b/packages/agent-core-v2/src/_base/utils/index.ts new file mode 100644 index 000000000..6b71ef166 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/index.ts @@ -0,0 +1,17 @@ +/** + * `_base` utility barrel — re-exports the shared primitive helpers used + * across domains. + */ + +export * from './abort'; +export * from './completion-budget'; +export * from './fs'; +export * from './hero-slug'; +export * from './per-id-json-store'; +export * from './promise'; +export * from './proxy'; +export * from './render-prompt'; +export * from './tokens'; +export * from './types'; +export * from './workdir-slug'; +export * from './xml-escape'; diff --git a/packages/agent-core-v2/src/_base/utils/per-id-json-store.ts b/packages/agent-core-v2/src/_base/utils/per-id-json-store.ts new file mode 100644 index 000000000..dcb9b521c --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/per-id-json-store.ts @@ -0,0 +1,92 @@ +/** + * Per-id JSON record store (`createPerIdJsonStore`) — atomically persists one + * `.json` file per record under a session-scoped directory. + */ + +import { mkdir, readdir, readFile, unlink } from 'node:fs/promises'; +import { join } from 'pathe'; + +import { atomicWrite } from './fs'; + +export interface PerIdJsonStore { + write(id: string, value: T): Promise; + read(id: string): Promise; + list(): Promise; + remove(id: string): Promise; +} + +export interface PerIdJsonStoreOptions { + readonly rootDir: string; + readonly subdir: string; + readonly idRegex: RegExp; + readonly isValid?: (obj: unknown) => obj is T; + readonly entityName?: string; +} + +export function createPerIdJsonStore( + opts: PerIdJsonStoreOptions, +): PerIdJsonStore { + const { rootDir, subdir, idRegex, isValid, entityName = 'id' } = opts; + const dir = join(rootDir, subdir); + + function fileFor(id: string): string { + if (!idRegex.test(id)) { + throw new Error(`Invalid ${entityName}: "${id}"`); + } + return join(dir, `${id}.json`); + } + + async function write(id: string, value: T): Promise { + const target = fileFor(id); + await mkdir(dir, { recursive: true, mode: 0o700 }); + await atomicWrite(target, JSON.stringify(value, null, 2)); + } + + async function read(id: string): Promise { + const path = fileFor(id); + let raw: string; + try { + raw = await readFile(path, 'utf-8'); + } catch { + return undefined; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (isValid !== undefined && !isValid(parsed)) return undefined; + return parsed as T; + } + + async function list(): Promise { + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return []; + } + const out: T[] = []; + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + const id = entry.slice(0, -'.json'.length); + if (!idRegex.test(id)) continue; + const value = await read(id); + if (value === undefined) continue; + out.push(value); + } + return out; + } + + async function remove(id: string): Promise { + const path = fileFor(id); + try { + await unlink(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + + return { write, read, list, remove }; +} diff --git a/packages/agent-core-v2/src/_base/utils/promise.ts b/packages/agent-core-v2/src/_base/utils/promise.ts new file mode 100644 index 000000000..811bbda9d --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/promise.ts @@ -0,0 +1,33 @@ +/** + * Timeout outcome promise — resolves with a fixed value after a delay. + */ + +const NEVER = new Promise(() => {}); + +export type TimeoutOutcomePromise = Promise & { + clear(): void; +}; + +export function timeoutOutcome( + timeoutMs: number | undefined, + outcome: Outcome, +): TimeoutOutcomePromise { + let timeout: ReturnType | undefined; + const promise: Promise = + timeoutMs === undefined || timeoutMs <= 0 + ? NEVER + : new Promise((resolve) => { + timeout = setTimeout(() => { + timeout = undefined; + resolve(outcome); + }, timeoutMs); + }); + + return Object.assign(promise, { + clear() { + if (timeout === undefined) return; + clearTimeout(timeout); + timeout = undefined; + }, + }); +} diff --git a/packages/agent-core-v2/src/_base/utils/proxy.ts b/packages/agent-core-v2/src/_base/utils/proxy.ts new file mode 100644 index 000000000..fa548db48 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/proxy.ts @@ -0,0 +1,264 @@ +/** + * Resolve and install proxy configuration for outbound `fetch` and spawned + * child processes (HTTP/HTTPS and SOCKS, honoring `NO_PROXY`). + */ + +import { + Agent, + buildConnector, + type Dispatcher, + EnvHttpProxyAgent, + setGlobalDispatcher as undiciSetGlobalDispatcher, +} from 'undici'; +import { SocksClient } from 'socks'; + +type Env = Readonly>; + +export interface SocksProxyConfig { + readonly type: 4 | 5; + readonly host: string; + readonly port: number; + readonly userId?: string; + readonly password?: string; +} + +const LOOPBACK_NO_PROXY = ['localhost', '127.0.0.1', '::1', '[::1]'] as const; + +const SOCKS_SCHEMES = new Set(['socks', 'socks4', 'socks4a', 'socks5', 'socks5h']); + +function schemeOf(value: string): string | undefined { + return /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase(); +} + +function firstNonBlank(env: Env, keys: readonly string[]): string | undefined { + for (const key of keys) { + const value = env[key]?.trim(); + if (value !== undefined && value.length > 0) return value; + } + return undefined; +} + +function httpSchemeValue(value: string | undefined): string | undefined { + return value !== undefined && !SOCKS_SCHEMES.has(schemeOf(value) ?? '') ? value : undefined; +} + +function hasHttpProxy(env: Env): boolean { + return [ + firstNonBlank(env, ['http_proxy', 'HTTP_PROXY']), + firstNonBlank(env, ['https_proxy', 'HTTPS_PROXY']), + firstNonBlank(env, ['all_proxy', 'ALL_PROXY']), + ].some((value) => httpSchemeValue(value) !== undefined); +} + +function resolveHttpProxyUrls(env: Env): { httpProxy?: string; httpsProxy?: string } { + const allProxy = httpSchemeValue(firstNonBlank(env, ['all_proxy', 'ALL_PROXY'])); + return { + httpProxy: httpSchemeValue(firstNonBlank(env, ['http_proxy', 'HTTP_PROXY'])) ?? allProxy, + httpsProxy: httpSchemeValue(firstNonBlank(env, ['https_proxy', 'HTTPS_PROXY'])) ?? allProxy, + }; +} + +export function resolveSocksProxy(env: Env = process.env): SocksProxyConfig | undefined { + const candidates = [ + firstNonBlank(env, ['all_proxy', 'ALL_PROXY']), + firstNonBlank(env, ['https_proxy', 'HTTPS_PROXY']), + firstNonBlank(env, ['http_proxy', 'HTTP_PROXY']), + ]; + for (const value of candidates) { + if (value === undefined) continue; + const scheme = schemeOf(value); + if (scheme === undefined || !SOCKS_SCHEMES.has(scheme)) continue; + let url: URL; + try { + url = new URL(value); + } catch { + continue; + } + const config: SocksProxyConfig = { + type: scheme === 'socks4' || scheme === 'socks4a' ? 4 : 5, + host: url.hostname.replaceAll(/^\[|\]$/g, ''), + port: url.port ? Number(url.port) : 1080, + ...(url.username ? { userId: decodeURIComponent(url.username) } : {}), + ...(url.password ? { password: decodeURIComponent(url.password) } : {}), + }; + return config; + } + return undefined; +} + +export function isProxyConfigured(env: Env = process.env): boolean { + return hasHttpProxy(env) || resolveSocksProxy(env) !== undefined; +} + +export function resolveNoProxy(env: Env = process.env): string { + const raw = [env['no_proxy'], env['NO_PROXY']].find((value) => (value?.trim() ?? '').length > 0) ?? ''; + const hosts = raw + .split(',') + .map((host) => host.trim()) + .filter((host) => host.length > 0); + if (hosts.includes('*')) return '*'; + for (const loopback of LOOPBACK_NO_PROXY) { + if (!hosts.includes(loopback)) hosts.push(loopback); + } + return hosts.join(','); +} + +export function makeNoProxyMatcher(noProxy: string): (host: string, port?: number | string) => boolean { + const entries = noProxy + .split(',') + .map((entry) => entry.trim().toLowerCase()) + .filter((entry) => entry.length > 0); + if (entries.includes('*')) return () => true; + const parsed = entries.map(parseNoProxyEntry); + return (host: string, port?: number | string) => { + const target = host.toLowerCase().replaceAll(/^\[|\]$/g, ''); + const targetPort = port === undefined ? undefined : String(port); + return parsed.some( + ({ host: entry, port: entryPort }) => + (entryPort === undefined || entryPort === targetPort) && + (target === entry || target.endsWith(`.${entry}`)), + ); + }; +} + +function parseNoProxyEntry(entry: string): { host: string; port?: string } { + let host = entry; + let port: string | undefined; + if (entry.startsWith('[')) { + const close = entry.indexOf(']'); + host = entry.slice(1, close); + const rest = entry.slice(close + 1); + if (rest.startsWith(':')) port = rest.slice(1); + } else { + const colon = entry.indexOf(':'); + if (colon !== -1 && colon === entry.lastIndexOf(':') && /^\d+$/.test(entry.slice(colon + 1))) { + host = entry.slice(0, colon); + port = entry.slice(colon + 1); + } + } + if (host.startsWith('*.')) host = host.slice(2); + else if (host.startsWith('.')) host = host.slice(1); + return port === undefined ? { host } : { host, port }; +} + +export interface ProxyAgentFactories { + readonly makeHttpAgent: (options: { + httpProxy?: string; + httpsProxy?: string; + noProxy: string; + }) => Dispatcher; + readonly makeSocksAgent: (options: { proxy: SocksProxyConfig; noProxy: string }) => Dispatcher; +} + +const defaultMakeHttpAgent: ProxyAgentFactories['makeHttpAgent'] = ({ httpProxy, httpsProxy, noProxy }) => + new EnvHttpProxyAgent({ httpProxy, httpsProxy, noProxy }); + +const defaultMakeSocksAgent: ProxyAgentFactories['makeSocksAgent'] = ({ proxy, noProxy }) => { + const directConnect = buildConnector({}); + const bypass = makeNoProxyMatcher(noProxy); + const connect: typeof directConnect = (options, callback) => { + if (bypass(options.hostname, options.port)) { + directConnect(options, callback); + return; + } + void (async () => { + try { + const isTls = options.protocol === 'https:'; + const port = Number(options.port) || (isTls ? 443 : 80); + const { socket } = await SocksClient.createConnection({ + proxy: { host: proxy.host, port: proxy.port, type: proxy.type, userId: proxy.userId, password: proxy.password }, + command: 'connect', + destination: { host: options.hostname, port }, + }); + if (isTls) { + directConnect({ ...options, httpSocket: socket } as Parameters[0], callback); + } else { + socket.setNoDelay(true); + callback(null, socket); + } + } catch (error) { + callback(error instanceof Error ? error : new Error(String(error)), null); + } + })(); + }; + return new Agent({ connect }); +}; + +export function createProxyDispatcher( + env: Env = process.env, + factories: Partial = {}, +): Dispatcher | undefined { + const { makeHttpAgent = defaultMakeHttpAgent, makeSocksAgent = defaultMakeSocksAgent } = factories; + try { + if (hasHttpProxy(env)) { + const { httpProxy, httpsProxy } = resolveHttpProxyUrls(env); + return makeHttpAgent({ + httpProxy: httpProxy ?? '', + httpsProxy: httpsProxy ?? '', + noProxy: resolveNoProxy(env), + }); + } + const socks = resolveSocksProxy(env); + if (socks !== undefined) { + return makeSocksAgent({ proxy: socks, noProxy: resolveNoProxy(env) }); + } + return undefined; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + process.stderr.write(`kimi: ignoring invalid proxy configuration (${reason}); connecting directly\n`); + return undefined; + } +} + +export interface InstallProxyDeps { + readonly setGlobalDispatcher: (dispatcher: Dispatcher) => void; + readonly createProxyDispatcher: (env: Env) => Dispatcher | undefined; +} + +const defaultInstallProxyDeps: InstallProxyDeps = { + setGlobalDispatcher: undiciSetGlobalDispatcher, + createProxyDispatcher, +}; + +export function installGlobalProxyDispatcher( + env: Env = process.env, + deps: InstallProxyDeps = defaultInstallProxyDeps, +): boolean { + const dispatcher = deps.createProxyDispatcher(env); + if (dispatcher === undefined) return false; + deps.setGlobalDispatcher(dispatcher); + return true; +} + +export function proxyEnvForChild(env: Env = process.env): Record { + if (!hasHttpProxy(env)) return {}; + const noProxy = resolveNoProxy(env); + const result: Record = { + NODE_USE_ENV_PROXY: '1', + NO_PROXY: noProxy, + no_proxy: noProxy, + }; + const { httpProxy, httpsProxy } = resolveHttpProxyUrls(env); + if (httpProxy !== undefined) { + result['HTTP_PROXY'] = httpProxy; + result['http_proxy'] = httpProxy; + } + if (httpsProxy !== undefined) { + result['HTTPS_PROXY'] = httpsProxy; + result['https_proxy'] = httpsProxy; + } + return result; +} + +export function reconcileChildNoProxy( + childEnv: Record, + configEnv?: Record, +): void { + const override = [configEnv?.['no_proxy'], configEnv?.['NO_PROXY']].find( + (value) => (value?.trim() ?? '').length > 0, + ); + if (override === undefined) return; + const noProxy = resolveNoProxy({ no_proxy: override, NO_PROXY: override }); + childEnv['NO_PROXY'] = noProxy; + childEnv['no_proxy'] = noProxy; +} diff --git a/packages/agent-core-v2/src/_base/utils/render-prompt.ts b/packages/agent-core-v2/src/_base/utils/render-prompt.ts new file mode 100644 index 000000000..be21a9393 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/render-prompt.ts @@ -0,0 +1,11 @@ +/** + * Shared prompt-template renderer (`renderPrompt`). + */ + +import nunjucks from 'nunjucks'; + +const env = new nunjucks.Environment(null, { autoescape: false, throwOnUndefined: true }); + +export function renderPrompt(template: string, vars: Record): string { + return env.renderString(template, vars); +} diff --git a/packages/agent-core-v2/src/_base/utils/tokens.ts b/packages/agent-core-v2/src/_base/utils/tokens.ts new file mode 100644 index 000000000..194f2ef32 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/tokens.ts @@ -0,0 +1,73 @@ +/** + * Character-based token-count estimates for messages, tools, and content parts. + */ + +import type { ContentPart, Message, Tool } from '@moonshot-ai/kosong'; + +const messageTokenEstimateCache = new WeakMap(); + +export function estimateTokens(text: string): number { + let asciiCount = 0; + let nonAsciiCount = 0; + for (const char of text) { + if (char.codePointAt(0)! <= 127) { + asciiCount++; + } else { + nonAsciiCount++; + } + } + return Math.ceil(asciiCount / 4) + nonAsciiCount; +} + +export function estimateTokensForMessages(messages: readonly Message[]): number { + let total = 0; + for (const message of messages) { + total += estimateTokensForMessage(message); + } + return total; +} + +export function estimateTokensForTools(tools: readonly Tool[]): number { + let total = 0; + for (const tool of tools) { + total += estimateTokens(tool.name); + total += estimateTokens(tool.description); + total += estimateTokens(JSON.stringify(tool.parameters)); + } + return total; +} + +export function estimateTokensForMessage(message: Message): number { + const cached = messageTokenEstimateCache.get(message); + if (cached !== undefined) { + return cached; + } + + let total = estimateTokens(message.role); + total += estimateTokensForContentParts(message.content); + if (message.toolCalls !== undefined) { + for (const call of message.toolCalls) { + total += estimateTokens(call.name); + total += estimateTokens(JSON.stringify(call.arguments)); + } + } + messageTokenEstimateCache.set(message, total); + return total; +} + +export function estimateTokensForContentParts(parts: readonly ContentPart[]): number { + let total = 0; + for (const part of parts) { + total += estimateTokensForContentPart(part); + } + return total; +} + +export function estimateTokensForContentPart(part: ContentPart): number { + if (part.type === 'text') { + return estimateTokens(part.text); + } else if (part.type === 'think') { + return estimateTokens(part.think); + } + return 0; +} diff --git a/packages/agent-core-v2/src/_base/utils/types.ts b/packages/agent-core-v2/src/_base/utils/types.ts new file mode 100644 index 000000000..9d50459d7 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/types.ts @@ -0,0 +1,17 @@ +/** + * Promise-aware utility types for function and method signatures. + */ + +export type Promisify = [T] extends [Promise] ? T : Promise; +export type PromisifyMethods = { + [K in keyof T]: T[K] extends (...args: infer Args) => infer Return + ? (...args: Args) => Promisify + : never; +}; + +export type Promisable = [T] extends [Promise] ? T | Awaited : T | Promise; +export type PromisableMethods = { + [K in keyof T]: T[K] extends (...args: infer Args) => infer Return + ? (...args: Args) => Promisable + : never; +}; diff --git a/packages/agent-core-v2/src/_base/utils/workdir-slug.ts b/packages/agent-core-v2/src/_base/utils/workdir-slug.ts new file mode 100644 index 000000000..4e69696c2 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/workdir-slug.ts @@ -0,0 +1,15 @@ +/** + * Slugify a working-directory name into a safe, bounded identifier. + */ + +const MAX_WORKDIR_SLUG_LENGTH = 40; + +export function slugifyWorkDirName(name: string): string { + const slug = name + .toLowerCase() + .replaceAll(/[^a-z0-9._-]+/g, '-') + .replaceAll(/^-+|-+$/g, '') + .slice(0, MAX_WORKDIR_SLUG_LENGTH) + .replaceAll(/^-+|-+$/g, ''); + return slug === '' || slug === '.' || slug === '..' ? 'workspace' : slug; +} diff --git a/packages/agent-core-v2/src/_base/utils/xml-escape.ts b/packages/agent-core-v2/src/_base/utils/xml-escape.ts new file mode 100644 index 000000000..832645aa7 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/xml-escape.ts @@ -0,0 +1,19 @@ +/** + * XML escaping helpers for content, attribute values, and tag delimiters. + */ + +export function escapeXml(input: string): string { + return input + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +export function escapeXmlAttr(input: string): string { + return input.replaceAll('&', '&').replaceAll('"', '"'); +} + +export function escapeXmlTags(input: string): string { + return input.replaceAll('<', '<').replaceAll('>', '>'); +} diff --git a/packages/agent-core-v2/src/agent-lifecycle/agentLifecycle.ts b/packages/agent-core-v2/src/agent-lifecycle/agentLifecycle.ts new file mode 100644 index 000000000..1f99e98e9 --- /dev/null +++ b/packages/agent-core-v2/src/agent-lifecycle/agentLifecycle.ts @@ -0,0 +1,29 @@ +/** + * `agent-lifecycle` domain (L6) — creates and tracks agents within a session. + * + * Defines the public contract of agent lifecycle: the `CreateAgentOptions` and + * the `IAgentLifecycleService` used to create agents (`create` / `createMain`), + * look them up (`getHandle` / `list`), and remove them. Session-scoped — one + * instance per session. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IScopeHandle } from '#/_base/di/scope'; + +export interface CreateAgentOptions { + readonly agentId?: string; + readonly parentAgentId?: string; + readonly cwd?: string; +} + +export interface IAgentLifecycleService { + readonly _serviceBrand: undefined; + create(opts: CreateAgentOptions): Promise; + createMain(): Promise; + getHandle(agentId: string): IScopeHandle | undefined; + list(): readonly IScopeHandle[]; + remove(agentId: string): Promise; +} + +export const IAgentLifecycleService: ServiceIdentifier = + createDecorator('agentLifecycleService'); diff --git a/packages/agent-core-v2/src/agent-lifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/agent-lifecycle/agentLifecycleService.ts new file mode 100644 index 000000000..ebdc0c275 --- /dev/null +++ b/packages/agent-core-v2/src/agent-lifecycle/agentLifecycleService.ts @@ -0,0 +1,75 @@ +/** + * `agent-lifecycle` domain (L6) — `IAgentLifecycleService` implementation. + * + * Creates and tracks the session's agents as child scopes; persists records + * through `records` and reads session context through `session-context`. Bound + * at Session scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { + type IScopeHandle, + LifecycleScope, + getScopedServiceDescriptors, + registerScopedService, +} from '#/_base/di/scope'; +import { + IInstantiationService, + type ServiceIdentifier, + type ServicesAccessor, +} from '#/_base/di/instantiation'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; +import { ISessionMetaStore } from '#/records/records'; +import { ISessionContext } from '#/session-context/sessionContext'; + +import { type CreateAgentOptions, IAgentLifecycleService } from './agentLifecycle'; + +let nextAgentId = 0; + +export class AgentLifecycleService extends Disposable implements IAgentLifecycleService { + declare readonly _serviceBrand: undefined; + private readonly handles = new Map(); + + constructor( + @ISessionContext _ctx: ISessionContext, + @ISessionMetaStore _meta: ISessionMetaStore, + @IInstantiationService private readonly instantiation: IInstantiationService, + ) { + super(); + } + + create(opts: CreateAgentOptions): Promise { + const agentId = opts.agentId ?? `agent-${nextAgentId++}`; + const collection = new ServiceCollection(); + for (const entry of getScopedServiceDescriptors(LifecycleScope.Agent)) { + collection.set(entry.id, entry.descriptor); + } + const child = this.instantiation.createChild(collection); + const accessor: ServicesAccessor = { + get: (id: ServiceIdentifier): T => child.invokeFunction((a) => a.get(id)), + }; + const handle: IScopeHandle = { id: agentId, kind: LifecycleScope.Agent, accessor }; + this.handles.set(agentId, handle); + return Promise.resolve(handle); + } + + createMain(): Promise { + return this.create({ agentId: 'main' }); + } + + getHandle(agentId: string): IScopeHandle | undefined { + return this.handles.get(agentId); + } + + list(): readonly IScopeHandle[] { + return [...this.handles.values()]; + } + + remove(agentId: string): Promise { + this.handles.delete(agentId); + return Promise.resolve(); + } +} + +registerScopedService(LifecycleScope.Session, IAgentLifecycleService, AgentLifecycleService, InstantiationType.Delayed, 'agent-lifecycle'); diff --git a/packages/agent-core-v2/src/agent-lifecycle/index.ts b/packages/agent-core-v2/src/agent-lifecycle/index.ts new file mode 100644 index 000000000..daf6cae34 --- /dev/null +++ b/packages/agent-core-v2/src/agent-lifecycle/index.ts @@ -0,0 +1,9 @@ +/** + * `agent-lifecycle` domain barrel — re-exports the agent-lifecycle contract + * (`agentLifecycle`) and its scoped service (`agentLifecycleService`). + * Importing this barrel registers the `IAgentLifecycleService` binding into the + * scope registry. + */ + +export * from './agentLifecycle'; +export * from './agentLifecycleService'; diff --git a/packages/agent-core-v2/src/approval/approval.ts b/packages/agent-core-v2/src/approval/approval.ts new file mode 100644 index 000000000..1eba02a00 --- /dev/null +++ b/packages/agent-core-v2/src/approval/approval.ts @@ -0,0 +1,27 @@ +/** + * `approval` domain (L7) — session-scope approval broker. + * + * Defines the public contract of approval brokering: the `ApprovalRequest` / + * `ApprovalDecision` models and the `IApprovalService` used to request a + * decision, resolve it, and list pending approvals. Session-scoped — one + * broker per session. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ApprovalRequest { + readonly id: string; + readonly toolName: string; +} + +export type ApprovalDecision = 'allow' | 'deny'; + +export interface IApprovalService { + readonly _serviceBrand: undefined; + request(req: ApprovalRequest): Promise; + decide(id: string, decision: ApprovalDecision): void; + listPending(): readonly ApprovalRequest[]; +} + +export const IApprovalService: ServiceIdentifier = + createDecorator('approvalService'); diff --git a/packages/agent-core-v2/src/approval/approvalService.ts b/packages/agent-core-v2/src/approval/approvalService.ts new file mode 100644 index 000000000..5dec36eb0 --- /dev/null +++ b/packages/agent-core-v2/src/approval/approvalService.ts @@ -0,0 +1,44 @@ +/** + * `approval` domain (L7) — `IApprovalService` implementation. + * + * Owns the pending-approval set and resolves requests when a decision arrives. + * Bound at Session scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; + +import { + type ApprovalDecision, + type ApprovalRequest, + IApprovalService, +} from './approval'; + +interface Pending { + readonly req: ApprovalRequest; + readonly resolve: (decision: ApprovalDecision) => void; +} + +export class ApprovalService implements IApprovalService { + declare readonly _serviceBrand: undefined; + private readonly pending = new Map(); + + request(req: ApprovalRequest): Promise { + return new Promise((resolve) => { + this.pending.set(req.id, { req, resolve }); + }); + } + + decide(id: string, decision: ApprovalDecision): void { + const entry = this.pending.get(id); + if (entry === undefined) return; + this.pending.delete(id); + entry.resolve(decision); + } + + listPending(): readonly ApprovalRequest[] { + return [...this.pending.values()].map((p) => p.req); + } +} + +registerScopedService(LifecycleScope.Session, IApprovalService, ApprovalService, InstantiationType.Delayed, 'approval'); diff --git a/packages/agent-core-v2/src/approval/index.ts b/packages/agent-core-v2/src/approval/index.ts new file mode 100644 index 000000000..f85c1d5dc --- /dev/null +++ b/packages/agent-core-v2/src/approval/index.ts @@ -0,0 +1,8 @@ +/** + * `approval` domain barrel — re-exports the approval contract (`approval`) and + * its scoped service (`approvalService`). Importing this barrel registers the + * `IApprovalService` binding into the scope registry. + */ + +export * from './approval'; +export * from './approvalService'; diff --git a/packages/agent-core-v2/src/auth/auth.ts b/packages/agent-core-v2/src/auth/auth.ts new file mode 100644 index 000000000..cb36b870f --- /dev/null +++ b/packages/agent-core-v2/src/auth/auth.ts @@ -0,0 +1,33 @@ +/** + * `auth` domain (cross-cutting) — core-scope OAuth + auth summary. + * + * Defines the public contracts of authentication: the `AuthStatus` model, the + * `IOAuthService` used to log in/out and query status, and the + * `IAuthSummaryService` used to summarize auth state. Core-scoped — shared + * across the application. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface AuthStatus { + readonly loggedIn: boolean; + readonly provider?: string; +} + +export interface IOAuthService { + readonly _serviceBrand: undefined; + login(provider: string): Promise; + logout(provider: string): Promise; + status(): Promise; +} + +export const IOAuthService: ServiceIdentifier = + createDecorator('oauthService'); + +export interface IAuthSummaryService { + readonly _serviceBrand: undefined; + summarize(): Promise; +} + +export const IAuthSummaryService: ServiceIdentifier = + createDecorator('authSummaryService'); diff --git a/packages/agent-core-v2/src/auth/authService.ts b/packages/agent-core-v2/src/auth/authService.ts new file mode 100644 index 000000000..2975d300d --- /dev/null +++ b/packages/agent-core-v2/src/auth/authService.ts @@ -0,0 +1,60 @@ +/** + * `auth` domain (cross-cutting) — `IOAuthService` / `IAuthSummaryService` + * implementation. + * + * Owns the OAuth login state and auth summary; reads settings through `config`, + * reads the environment through `environment`, and reports through `telemetry`. + * Bound at Core scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IConfigService } from '#/config/config'; +import { IEnvironmentService } from '#/environment/environment'; +import { ITelemetryService } from '#/telemetry/telemetry'; + +import { type AuthStatus, IAuthSummaryService, IOAuthService } from './auth'; + +export class OAuthService implements IOAuthService { + declare readonly _serviceBrand: undefined; + private readonly loggedIn = new Set(); + + constructor( + @IConfigService _config: IConfigService, + @IEnvironmentService _env: IEnvironmentService, + @ITelemetryService _telemetry: ITelemetryService, + ) {} + + login(provider: string): Promise { + this.loggedIn.add(provider); + return Promise.resolve(); + } + logout(provider: string): Promise { + this.loggedIn.delete(provider); + return Promise.resolve(); + } + status(): Promise { + const [provider] = this.loggedIn; + return Promise.resolve( + provider === undefined ? { loggedIn: false } : { loggedIn: true, provider }, + ); + } +} + +export class AuthSummaryService implements IAuthSummaryService { + declare readonly _serviceBrand: undefined; + + constructor( + @IConfigService _config: IConfigService, + @ITelemetryService _telemetry: ITelemetryService, + private readonly oauth?: OAuthService, + ) {} + + summarize(): Promise { + if (this.oauth === undefined) return Promise.resolve([]); + return this.oauth.status().then((s) => [s]); + } +} + +registerScopedService(LifecycleScope.Core, IOAuthService, OAuthService, InstantiationType.Delayed, 'auth'); +registerScopedService(LifecycleScope.Core, IAuthSummaryService, AuthSummaryService, InstantiationType.Delayed, 'auth'); diff --git a/packages/agent-core-v2/src/auth/index.ts b/packages/agent-core-v2/src/auth/index.ts new file mode 100644 index 000000000..7e0989751 --- /dev/null +++ b/packages/agent-core-v2/src/auth/index.ts @@ -0,0 +1,8 @@ +/** + * `auth` domain barrel — re-exports the auth contract (`auth`) and its scoped + * services (`authService`). Importing this barrel registers the `IOAuthService` + * and `IAuthSummaryService` bindings into the scope registry. + */ + +export * from './auth'; +export * from './authService'; diff --git a/packages/agent-core-v2/src/background/background.ts b/packages/agent-core-v2/src/background/background.ts new file mode 100644 index 000000000..c83754433 --- /dev/null +++ b/packages/agent-core-v2/src/background/background.ts @@ -0,0 +1,25 @@ +/** + * `background` domain (L5) — per-agent background task manager. + * + * Defines the public contract of background tasks: the `BackgroundTask` model + * and the `IBackgroundService` used to start, stop, list, and read the output + * of tasks. Agent-scoped — one instance per agent. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface BackgroundTask { + readonly id: string; + readonly kind: string; +} + +export interface IBackgroundService { + readonly _serviceBrand: undefined; + start(task: BackgroundTask): Promise; + stop(id: string): Promise; + list(): readonly BackgroundTask[]; + getOutput(id: string): Promise; +} + +export const IBackgroundService: ServiceIdentifier = + createDecorator('backgroundService'); diff --git a/packages/agent-core-v2/src/background/backgroundService.ts b/packages/agent-core-v2/src/background/backgroundService.ts new file mode 100644 index 000000000..baedda42c --- /dev/null +++ b/packages/agent-core-v2/src/background/backgroundService.ts @@ -0,0 +1,64 @@ +/** + * `background` domain (L5) — `IBackgroundService` implementation. + * + * Tracks running background tasks and their captured output; drives agent + * lifecycle through `agent-lifecycle`, runs processes through `kaos`, logs + * through `log`, persists records through `records`, and reports telemetry + * through `telemetry`. Bound at Agent scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle'; +import { IAgentKaos } from '#/kaos/kaos'; +import { ILogService } from '#/log/log'; +import { IAgentRecords } from '#/records/records'; +import { ITelemetryService } from '#/telemetry/telemetry'; + +import { type BackgroundTask, IBackgroundService } from './background'; + +interface RunningTask { + readonly task: BackgroundTask; + output: string; + stopped: boolean; +} + +let nextTaskId = 0; + +export class BackgroundService extends Disposable implements IBackgroundService { + declare readonly _serviceBrand: undefined; + private readonly tasks = new Map(); + + constructor( + @IAgentKaos _agentKaos: IAgentKaos, + @IAgentRecords _records: IAgentRecords, + @ILogService _log: ILogService, + @ITelemetryService _telemetry: ITelemetryService, + @IAgentLifecycleService _agentLifecycle: IAgentLifecycleService, + ) { + super(); + } + + start(task: BackgroundTask): Promise { + const id = `task-${nextTaskId++}`; + this.tasks.set(id, { task, output: '', stopped: false }); + return Promise.resolve(id); + } + + stop(id: string): Promise { + const t = this.tasks.get(id); + if (t !== undefined) t.stopped = true; + return Promise.resolve(); + } + + list(): readonly BackgroundTask[] { + return [...this.tasks.values()].map((t) => t.task); + } + + getOutput(id: string): Promise { + return Promise.resolve(this.tasks.get(id)?.output ?? ''); + } +} + +registerScopedService(LifecycleScope.Agent, IBackgroundService, BackgroundService, InstantiationType.Delayed, 'background'); diff --git a/packages/agent-core-v2/src/background/index.ts b/packages/agent-core-v2/src/background/index.ts new file mode 100644 index 000000000..bfcd9431c --- /dev/null +++ b/packages/agent-core-v2/src/background/index.ts @@ -0,0 +1,8 @@ +/** + * `background` domain barrel — re-exports the background contract + * (`background`) and its scoped service (`backgroundService`). Importing this + * barrel registers the `IBackgroundService` binding into the scope registry. + */ + +export * from './background'; +export * from './backgroundService'; diff --git a/packages/agent-core-v2/src/compaction/compaction.ts b/packages/agent-core-v2/src/compaction/compaction.ts new file mode 100644 index 000000000..4a101659e --- /dev/null +++ b/packages/agent-core-v2/src/compaction/compaction.ts @@ -0,0 +1,16 @@ +/** + * `compaction` domain (L4) — context compaction (full + micro). + * + * Defines the public contract for compaction: the `ICompactionService` used to + * trigger context compaction for a reason. Agent-scoped — one instance per agent. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ICompactionService { + readonly _serviceBrand: undefined; + compact(reason: string): Promise; +} + +export const ICompactionService: ServiceIdentifier = + createDecorator('compactionService'); diff --git a/packages/agent-core-v2/src/compaction/compactionService.ts b/packages/agent-core-v2/src/compaction/compactionService.ts new file mode 100644 index 000000000..1f350fb78 --- /dev/null +++ b/packages/agent-core-v2/src/compaction/compactionService.ts @@ -0,0 +1,52 @@ +/** + * `compaction` domain (L4) — `ICompactionService` implementation. + * + * Triggers context compaction on overflow; reads configuration through `config`, + * reads history through `context`, enqueues follow-up through `injection`, + * persists records through `records`, reports telemetry through `telemetry`, and + * observes turns through `turn`. Bound at Agent scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentConfigService } from '#/config/config'; +import { IContextService } from '#/context/context'; +import { IInjectionService } from '#/injection/injection'; +import { IAgentRecords } from '#/records/records'; +import { ITelemetryService } from '#/telemetry/telemetry'; +import { ITurnService } from '#/turn/turn'; + +import { ICompactionService } from './compaction'; + +const DEFAULT_TOKEN_THRESHOLD = 8_000; + +export class CompactionService extends Disposable implements ICompactionService { + declare readonly _serviceBrand: undefined; + private readonly threshold: number; + + constructor( + @IContextService private readonly context: IContextService, + @IAgentConfigService _agentConfig: IAgentConfigService, + @IAgentRecords _records: IAgentRecords, + @ITelemetryService _telemetry: ITelemetryService, + @ITurnService turn: ITurnService, + @IInjectionService private readonly injection: IInjectionService, + threshold: number = DEFAULT_TOKEN_THRESHOLD, + ) { + super(); + this.threshold = threshold; + this._register(turn.onDidEndStep(() => this.afterStep())); + } + + private afterStep(): void { + if (this.context.tokenUsage() <= this.threshold) return; + this.injection.push({ kind: 'compaction_summary', content: 'context overflow — compact pending' }); + } + + compact(_reason: string): Promise { + throw new Error('TODO: CompactionService.compact'); + } +} + +registerScopedService(LifecycleScope.Agent, ICompactionService, CompactionService, InstantiationType.Delayed, 'compaction'); diff --git a/packages/agent-core-v2/src/compaction/index.ts b/packages/agent-core-v2/src/compaction/index.ts new file mode 100644 index 000000000..1dc2d834f --- /dev/null +++ b/packages/agent-core-v2/src/compaction/index.ts @@ -0,0 +1,8 @@ +/** + * `compaction` domain barrel — re-exports the compaction contract + * (`compaction`) and its scoped service (`compactionService`). Importing this + * barrel registers the `ICompactionService` binding into the scope registry. + */ + +export * from './compaction'; +export * from './compactionService'; diff --git a/packages/agent-core-v2/src/config/config.ts b/packages/agent-core-v2/src/config/config.ts new file mode 100644 index 000000000..b497c29c9 --- /dev/null +++ b/packages/agent-core-v2/src/config/config.ts @@ -0,0 +1,55 @@ +/** + * `config` domain (L2) — configuration registry, service, and per-agent view. + * + * Defines the config service identifiers and the `ConfigSection` / + * `ConfigChangedEvent` models: the `IConfigRegistry` for section schemas, the + * `IConfigService` used to read and mutate config, and the per-agent + * `IAgentConfigService` view. The registry and service are Core-scoped; the + * agent view is Agent-scoped. + */ + +import type { Event } from '#/_base/event'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ConfigSection { + readonly domain: string; + readonly schema: unknown; +} + +export interface IConfigRegistry { + readonly _serviceBrand: undefined; + registerSection(domain: string, schema: unknown): void; + getSection(domain: string): ConfigSection | undefined; + merge(base: unknown, patch: unknown): unknown; +} + +export const IConfigRegistry: ServiceIdentifier = + createDecorator('configRegistry'); + +export interface ConfigChangedEvent { + readonly domain: string; +} + +export interface IConfigService { + readonly _serviceBrand: undefined; + readonly onDidChange: Event; + get(domain: string): T; + set(domain: string, patch: unknown): Promise; +} + +export const IConfigService: ServiceIdentifier = + createDecorator('configService'); + +export interface IAgentConfigService { + readonly _serviceBrand: undefined; + readonly modelAlias: string | undefined; + readonly thinkingLevel: string | undefined; + readonly systemPrompt: string | undefined; + readonly provider: string | undefined; + readonly cwd: string; + setModel(alias: string): Promise; + setThinking(level: string): Promise; +} + +export const IAgentConfigService: ServiceIdentifier = + createDecorator('agentConfigService'); diff --git a/packages/agent-core-v2/src/config/configService.ts b/packages/agent-core-v2/src/config/configService.ts new file mode 100644 index 000000000..c5026e6f2 --- /dev/null +++ b/packages/agent-core-v2/src/config/configService.ts @@ -0,0 +1,149 @@ +/** + * `config` domain (L2) — `IConfigRegistry`, `IConfigService`, and + * `IAgentConfigService` implementations. + * + * Owns the in-memory config store, the section registry, and the per-agent + * config view; reads the environment through `environment`, resolves the agent + * cwd through `kaos`, records through `records`, and logs through `log`. Bound + * at Core (registry and service) and Agent (agent view) scopes. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IEnvironmentService } from '#/environment/environment'; +import { IAgentKaos } from '#/kaos/kaos'; +import { ILogService } from '#/log/log'; +import { IAgentRecords } from '#/records/records'; + +import { + type ConfigChangedEvent, + type ConfigSection, + IAgentConfigService, + IConfigRegistry, + IConfigService, +} from './config'; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function deepMerge(base: T, patch: unknown): T { + if (!isPlainObject(base) || !isPlainObject(patch)) { + return (patch ?? base) as T; + } + const out: Record = { ...base }; + for (const key of Object.keys(patch)) { + const pv = patch[key]; + const bv = out[key]; + out[key] = isPlainObject(bv) && isPlainObject(pv) ? deepMerge(bv, pv) : pv; + } + return out as T; +} + +export class ConfigRegistry implements IConfigRegistry { + declare readonly _serviceBrand: undefined; + private readonly sections = new Map(); + + registerSection(domain: string, schema: unknown): void { + if (this.sections.has(domain)) { + throw new Error(`ConfigRegistry: section '${domain}' is already registered`); + } + this.sections.set(domain, schema); + } + + getSection(domain: string): ConfigSection | undefined { + const schema = this.sections.get(domain); + return schema === undefined ? undefined : { domain, schema }; + } + + merge(base: unknown, patch: unknown): unknown { + return deepMerge(base, patch); + } +} + +export class ConfigService extends Disposable implements IConfigService { + declare readonly _serviceBrand: undefined; + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + private readonly root = new Map(); + + constructor( + @IConfigRegistry _registry: IConfigRegistry, + @IEnvironmentService _env: IEnvironmentService, + @ILogService _log: ILogService, + ) { + super(); + } + + get(domain: string): T { + return this.root.get(domain) as T; + } + + set(domain: string, patch: unknown): Promise { + const current = this.root.get(domain); + const next = deepMerge(current ?? {}, patch); + this.root.set(domain, next); + this._onDidChange.fire({ domain }); + return Promise.resolve(); + } +} + +interface AgentSection { + readonly modelAlias?: string; + readonly thinkingLevel?: string; + readonly systemPrompt?: string; + readonly provider?: string; +} + +export class AgentConfigService implements IAgentConfigService { + declare readonly _serviceBrand: undefined; + private modelAliasValue: string | undefined; + private thinkingLevelValue: string | undefined; + private systemPromptValue: string | undefined; + private providerValue: string | undefined; + private readonly cwdValue: string; + + constructor( + @IConfigService config: IConfigService, + @IAgentRecords _records: IAgentRecords, + @IAgentKaos agentKaos: IAgentKaos, + ) { + const section = config.get('agent'); + this.modelAliasValue = section?.modelAlias; + this.thinkingLevelValue = section?.thinkingLevel; + this.systemPromptValue = section?.systemPrompt; + this.providerValue = section?.provider; + this.cwdValue = agentKaos.cwd; + } + + get modelAlias(): string | undefined { + return this.modelAliasValue; + } + get thinkingLevel(): string | undefined { + return this.thinkingLevelValue; + } + get systemPrompt(): string | undefined { + return this.systemPromptValue; + } + get provider(): string | undefined { + return this.providerValue; + } + get cwd(): string { + return this.cwdValue; + } + + setModel(alias: string): Promise { + this.modelAliasValue = alias; + return Promise.resolve(); + } + setThinking(level: string): Promise { + this.thinkingLevelValue = level; + return Promise.resolve(); + } +} + +registerScopedService(LifecycleScope.Core, IConfigRegistry, ConfigRegistry, InstantiationType.Delayed, 'config'); +registerScopedService(LifecycleScope.Core, IConfigService, ConfigService, InstantiationType.Delayed, 'config'); +registerScopedService(LifecycleScope.Agent, IAgentConfigService, AgentConfigService, InstantiationType.Delayed, 'config'); diff --git a/packages/agent-core-v2/src/config/index.ts b/packages/agent-core-v2/src/config/index.ts new file mode 100644 index 000000000..2aeac6632 --- /dev/null +++ b/packages/agent-core-v2/src/config/index.ts @@ -0,0 +1,8 @@ +/** + * `config` domain barrel — re-exports the config contract (`config`) and its + * scoped services (`configService`). Importing this barrel registers the config + * bindings into the scope registry. + */ + +export * from './config'; +export * from './configService'; diff --git a/packages/agent-core-v2/src/context/context.ts b/packages/agent-core-v2/src/context/context.ts new file mode 100644 index 000000000..e5c0b7251 --- /dev/null +++ b/packages/agent-core-v2/src/context/context.ts @@ -0,0 +1,27 @@ +/** + * `context` domain (L4) — per-agent conversation context and memory. + * + * Defines the `ContextMessage` model and the `IContextService` used to append + * messages, project the conversation, and apply compaction and undo. + * Agent-scoped — one instance per agent. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ContextMessage { + readonly role: string; + readonly content: unknown; +} + +export interface IContextService { + readonly _serviceBrand: undefined; + appendMessage(msg: ContextMessage): void; + appendSystemReminder(text: string): void; + project(): readonly ContextMessage[]; + applyCompaction(summary: string): void; + undo(): void; + tokenUsage(): number; +} + +export const IContextService: ServiceIdentifier = + createDecorator('contextService'); diff --git a/packages/agent-core-v2/src/context/contextService.ts b/packages/agent-core-v2/src/context/contextService.ts new file mode 100644 index 000000000..bb64d38c9 --- /dev/null +++ b/packages/agent-core-v2/src/context/contextService.ts @@ -0,0 +1,60 @@ +/** + * `context` domain (L4) — `IContextService` implementation. + * + * Owns the agent's conversation history, projection, compaction, and undo; + * records context through `records`. Bound at Agent scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentRecords } from '#/records/records'; + +import { type ContextMessage, IContextService } from './context'; + +function estimateTokens(messages: readonly ContextMessage[]): number { + let chars = 0; + for (const m of messages) { + chars += typeof m.content === 'string' ? m.content.length : JSON.stringify(m.content).length; + } + return Math.ceil(chars / 4); +} + +export class ContextService implements IContextService { + declare readonly _serviceBrand: undefined; + private history: ContextMessage[] = []; + private snapshot: ContextMessage[] | undefined; + + constructor(@IAgentRecords _records: IAgentRecords) {} + + appendMessage(msg: ContextMessage): void { + this.history.push(msg); + } + + appendSystemReminder(text: string): void { + this.history.push({ role: 'system', content: text }); + } + + project(): readonly ContextMessage[] { + return this.history; + } + + applyCompaction(summary: string): void { + this.snapshot = this.history; + this.history = [{ role: 'system', content: summary }]; + } + + undo(): void { + if (this.snapshot !== undefined) { + this.history = this.snapshot; + this.snapshot = undefined; + return; + } + this.history.pop(); + } + + tokenUsage(): number { + return estimateTokens(this.history); + } +} + +registerScopedService(LifecycleScope.Agent, IContextService, ContextService, InstantiationType.Delayed, 'context'); diff --git a/packages/agent-core-v2/src/context/index.ts b/packages/agent-core-v2/src/context/index.ts new file mode 100644 index 000000000..0bb030aa2 --- /dev/null +++ b/packages/agent-core-v2/src/context/index.ts @@ -0,0 +1,8 @@ +/** + * `context` domain barrel — re-exports the context contract (`context`) and + * its scoped service (`contextService`). Importing this barrel registers the + * `IContextService` binding into the scope registry. + */ + +export * from './context'; +export * from './contextService'; diff --git a/packages/agent-core-v2/src/cron/cron.ts b/packages/agent-core-v2/src/cron/cron.ts new file mode 100644 index 000000000..155ddc565 --- /dev/null +++ b/packages/agent-core-v2/src/cron/cron.ts @@ -0,0 +1,42 @@ +/** + * `cron` domain (L5) — schedules cron tasks and coordinates their firing. + * + * Defines the public contract of session cron: the `CronTask` / `CronFiredEvent` + * models, the `ICronService` used to create, list, and delete tasks and observe + * `onDidFire`, and the `ICronFireCoordinator` that reacts to fires. + * Session-scoped — one instance per session. + */ + +import type { Event } from '#/_base/event'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface CronTask { + readonly id: string; + readonly cron: string; + readonly prompt: string; + readonly recurring?: boolean; +} + +export interface CronFiredEvent { + readonly taskId: string; + readonly content: string; + readonly origin?: string; +} + +export interface ICronService { + readonly _serviceBrand: undefined; + readonly onDidFire: Event; + create(task: CronTask): Promise; + list(): readonly CronTask[]; + delete(id: string): Promise; +} + +export const ICronService: ServiceIdentifier = + createDecorator('cronService'); + +export interface ICronFireCoordinator { + readonly _serviceBrand: undefined; +} + +export const ICronFireCoordinator: ServiceIdentifier = + createDecorator('cronFireCoordinator'); diff --git a/packages/agent-core-v2/src/cron/cronService.ts b/packages/agent-core-v2/src/cron/cronService.ts new file mode 100644 index 000000000..33bd007dd --- /dev/null +++ b/packages/agent-core-v2/src/cron/cronService.ts @@ -0,0 +1,118 @@ +/** + * `cron` domain (L5) — `ICronService` + `ICronFireCoordinator` implementation. + * + * Owns the scheduled task set and fires due tasks; drives agent lifecycle + * through `agent-lifecycle`, resolves paths through `environment`, logs + * through `log`, persists records through `records`, records activity through + * `session-activity`, reads session context through `session-context`, reports + * telemetry through `telemetry`, and observes turns through `turn`. Bound at + * Session scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle'; +import { IEnvironmentService } from '#/environment/environment'; +import { ILogService } from '#/log/log'; +import { ISessionMetaStore } from '#/records/records'; +import { ISessionActivity } from '#/session-activity/sessionActivity'; +import { ISessionContext } from '#/session-context/sessionContext'; +import { ITelemetryService } from '#/telemetry/telemetry'; +import { ITurnService } from '#/turn/turn'; + +import { + type CronFiredEvent, + type CronTask, + ICronFireCoordinator, + ICronService, +} from './cron'; + +const DEFAULT_INTERVAL_MS = 60_000; + +function parseIntervalMs(cron: string): number { + const n = Number(cron); + return Number.isFinite(n) && n > 0 ? n : DEFAULT_INTERVAL_MS; +} + +interface ScheduledTask { + readonly task: CronTask; + nextFireAt: number; +} + +let nextCronId = 0; + +export class CronService extends Disposable implements ICronService { + declare readonly _serviceBrand: undefined; + private readonly _onDidFire = this._register(new Emitter()); + readonly onDidFire: Event = this._onDidFire.event; + private readonly tasks = new Map(); + + constructor( + @ISessionContext _ctx: ISessionContext, + @ISessionActivity private readonly activity: ISessionActivity, + @ITelemetryService _telemetry: ITelemetryService, + @ILogService _log: ILogService, + @IEnvironmentService _env: IEnvironmentService, + @ISessionMetaStore _meta: ISessionMetaStore, + ) { + super(); + } + + create(task: CronTask): Promise { + const id = task.id || `cron-${nextCronId++}`; + const stored: CronTask = { ...task, id }; + this.tasks.set(id, { + task: stored, + nextFireAt: Date.now() + parseIntervalMs(task.cron), + }); + return Promise.resolve(id); + } + + list(): readonly CronTask[] { + return [...this.tasks.values()].map((s) => s.task); + } + + delete(id: string): Promise { + this.tasks.delete(id); + return Promise.resolve(); + } + + tick(now: number = Date.now()): void { + if (!this.activity.isIdle()) return; + for (const scheduled of this.tasks.values()) { + if (scheduled.nextFireAt > now) continue; + this._onDidFire.fire({ + taskId: scheduled.task.id, + content: scheduled.task.prompt, + }); + if (scheduled.task.recurring === false) { + this.tasks.delete(scheduled.task.id); + } else { + scheduled.nextFireAt = now + parseIntervalMs(scheduled.task.cron); + } + } + } +} + +export class CronFireCoordinator extends Disposable implements ICronFireCoordinator { + declare readonly _serviceBrand: undefined; + + constructor( + @ICronService cron: ICronService, + @IAgentLifecycleService private readonly agents: IAgentLifecycleService, + ) { + super(); + this._register(cron.onDidFire((e) => this.onFire(e))); + } + + private onFire(e: CronFiredEvent): void { + const main = this.agents.getHandle('main'); + if (main === undefined) return; + main.accessor.get(ITurnService).steer(e.content, e.origin); + } +} + +registerScopedService(LifecycleScope.Session, ICronService, CronService, InstantiationType.Delayed, 'cron'); +registerScopedService(LifecycleScope.Session, ICronFireCoordinator, CronFireCoordinator, InstantiationType.Delayed, 'cron'); diff --git a/packages/agent-core-v2/src/cron/index.ts b/packages/agent-core-v2/src/cron/index.ts new file mode 100644 index 000000000..63221b878 --- /dev/null +++ b/packages/agent-core-v2/src/cron/index.ts @@ -0,0 +1,8 @@ +/** + * `cron` domain barrel — re-exports the cron contract (`cron`) and its scoped + * service (`cronService`). Importing this barrel registers the `ICronService` + * and `ICronFireCoordinator` bindings into the scope registry. + */ + +export * from './cron'; +export * from './cronService'; diff --git a/packages/agent-core-v2/src/environment/environment.ts b/packages/agent-core-v2/src/environment/environment.ts new file mode 100644 index 000000000..a48f090a2 --- /dev/null +++ b/packages/agent-core-v2/src/environment/environment.ts @@ -0,0 +1,34 @@ +/** + * `environment` domain (L1) — resolved environment paths and OS probe. + * + * Defines the public contract of the environment: the resolved paths + * (`homeDir`, `configPath`) and the `IEnvironmentService` used by other + * domains to locate config and detect the host `Environment`, plus the + * Core-scope `environmentSeed`. Core-scoped — one shared instance. + */ + +import type { Environment } from '@moonshot-ai/kaos'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; + +export interface IEnvironmentOptions { + readonly homeDir: string; +} + +export const IEnvironmentOptions: ServiceIdentifier = + createDecorator('environmentOptions'); + +export interface IEnvironmentService { + readonly _serviceBrand: undefined; + readonly homeDir: string; + readonly configPath: string; + detect(): Promise; +} + +export const IEnvironmentService: ServiceIdentifier = + createDecorator('environmentService'); + +export function environmentSeed(homeDir: string): ScopeSeed { + return [[IEnvironmentOptions as ServiceIdentifier, { homeDir } satisfies IEnvironmentOptions]]; +} diff --git a/packages/agent-core-v2/src/environment/environmentService.ts b/packages/agent-core-v2/src/environment/environmentService.ts new file mode 100644 index 000000000..cdd041ddc --- /dev/null +++ b/packages/agent-core-v2/src/environment/environmentService.ts @@ -0,0 +1,43 @@ +/** + * `environment` domain (L1) — `IEnvironmentService` implementation. + * + * Resolves `homeDir` / `configPath` from the injected options and detects the + * host `Environment` on demand. Bound at Core scope. + */ + +import { join } from 'node:path'; + +import { type Environment, detectEnvironmentFromNode } from '@moonshot-ai/kaos'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; + +import { + IEnvironmentOptions, + IEnvironmentService, +} from './environment'; + +export class EnvironmentService implements IEnvironmentService { + declare readonly _serviceBrand: undefined; + readonly homeDir: string; + readonly configPath: string; + private detected?: Promise; + + constructor(@IEnvironmentOptions options: IEnvironmentOptions) { + this.homeDir = options.homeDir; + this.configPath = join(options.homeDir, 'config.toml'); + } + + detect(): Promise { + this.detected ??= detectEnvironmentFromNode(); + return this.detected; + } +} + +registerScopedService( + LifecycleScope.Core, + IEnvironmentService, + EnvironmentService, + InstantiationType.Eager, + 'environment', +); diff --git a/packages/agent-core-v2/src/environment/index.ts b/packages/agent-core-v2/src/environment/index.ts new file mode 100644 index 000000000..c282688c8 --- /dev/null +++ b/packages/agent-core-v2/src/environment/index.ts @@ -0,0 +1,8 @@ +/** + * `environment` domain barrel — re-exports the `environment` contract and its + * scoped service (`environmentService`). Importing this barrel registers the + * `IEnvironmentService` binding into the scope registry. + */ + +export * from './environment'; +export * from './environmentService'; diff --git a/packages/agent-core-v2/src/event/event.ts b/packages/agent-core-v2/src/event/event.ts new file mode 100644 index 000000000..625307ef8 --- /dev/null +++ b/packages/agent-core-v2/src/event/event.ts @@ -0,0 +1,24 @@ +/** + * `event` domain (L7) — core-scope global pub-sub. + * + * Defines the public contract of the event bus: the `ProtocolEvent` model and + * the `IEventService` used by other domains to publish and subscribe to + * protocol events. Core-scoped — one shared bus for the application. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IDisposable } from '#/_base/di/lifecycle'; + +export interface ProtocolEvent { + readonly type: string; + readonly payload: unknown; +} + +export interface IEventService { + readonly _serviceBrand: undefined; + publish(event: ProtocolEvent): void; + subscribe(handler: (event: ProtocolEvent) => void): IDisposable; +} + +export const IEventService: ServiceIdentifier = + createDecorator('eventService'); diff --git a/packages/agent-core-v2/src/event/eventService.ts b/packages/agent-core-v2/src/event/eventService.ts new file mode 100644 index 000000000..64530e735 --- /dev/null +++ b/packages/agent-core-v2/src/event/eventService.ts @@ -0,0 +1,34 @@ +/** + * `event` domain (L7) — `IEventService` implementation. + * + * Owns the in-process pub-sub listener set and event fan-out. Bound at Core + * scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { type IDisposable, toDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; + +import { type ProtocolEvent, IEventService } from './event'; + +type Listener = (event: ProtocolEvent) => void; + +export class EventService implements IEventService { + declare readonly _serviceBrand: undefined; + private readonly listeners = new Set(); + + publish(event: ProtocolEvent): void { + for (const listener of this.listeners) { + listener(event); + } + } + + subscribe(handler: (event: ProtocolEvent) => void): IDisposable { + this.listeners.add(handler); + return toDisposable(() => { + this.listeners.delete(handler); + }); + } +} + +registerScopedService(LifecycleScope.Core, IEventService, EventService, InstantiationType.Delayed, 'event'); diff --git a/packages/agent-core-v2/src/event/index.ts b/packages/agent-core-v2/src/event/index.ts new file mode 100644 index 000000000..d1d9bf5cc --- /dev/null +++ b/packages/agent-core-v2/src/event/index.ts @@ -0,0 +1,8 @@ +/** + * `event` domain barrel — re-exports the event contract (`event`) and its + * scoped service (`eventService`). Importing this barrel registers the + * `IEventService` binding into the scope registry. + */ + +export * from './event'; +export * from './eventService'; diff --git a/packages/agent-core-v2/src/filestore/fileStoreService.ts b/packages/agent-core-v2/src/filestore/fileStoreService.ts new file mode 100644 index 000000000..18de284ad --- /dev/null +++ b/packages/agent-core-v2/src/filestore/fileStoreService.ts @@ -0,0 +1,33 @@ +/** + * `filestore` domain (cross-cutting) — `IFileStore` implementation. + * + * Stores and retrieves blobs keyed by string; uses the execution environment + * through `kaos`. Bound at Core scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IKaosFactory } from '#/kaos/kaos'; + +import { IFileStore } from './filestore'; + +export class FileStore implements IFileStore { + declare readonly _serviceBrand: undefined; + private readonly blobs = new Map(); + + constructor(@IKaosFactory _kaosFactory: IKaosFactory) {} + + put(key: string, data: Uint8Array): Promise { + this.blobs.set(key, data); + return Promise.resolve(); + } + get(key: string): Promise { + return Promise.resolve(this.blobs.get(key)); + } + delete(key: string): Promise { + this.blobs.delete(key); + return Promise.resolve(); + } +} + +registerScopedService(LifecycleScope.Core, IFileStore, FileStore, InstantiationType.Delayed, 'filestore'); diff --git a/packages/agent-core-v2/src/filestore/filestore.ts b/packages/agent-core-v2/src/filestore/filestore.ts new file mode 100644 index 000000000..f5b1e7f9e --- /dev/null +++ b/packages/agent-core-v2/src/filestore/filestore.ts @@ -0,0 +1,19 @@ +/** + * `filestore` domain (cross-cutting) — core-scope blob/file store. + * + * Defines the public contract of the file store: the `IFileStore` used by + * other domains to store and retrieve opaque blobs by key. Core-scoped — one + * shared instance. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IFileStore { + readonly _serviceBrand: undefined; + put(key: string, data: Uint8Array): Promise; + get(key: string): Promise; + delete(key: string): Promise; +} + +export const IFileStore: ServiceIdentifier = + createDecorator('fileStore'); diff --git a/packages/agent-core-v2/src/filestore/index.ts b/packages/agent-core-v2/src/filestore/index.ts new file mode 100644 index 000000000..c8ff6d2fb --- /dev/null +++ b/packages/agent-core-v2/src/filestore/index.ts @@ -0,0 +1,8 @@ +/** + * `filestore` domain barrel — re-exports the `filestore` contract and its + * scoped service (`fileStoreService`). Importing this barrel registers the + * `IFileStore` binding into the scope registry. + */ + +export * from './filestore'; +export * from './fileStoreService'; diff --git a/packages/agent-core-v2/src/flag/flag.ts b/packages/agent-core-v2/src/flag/flag.ts new file mode 100644 index 000000000..0d08818de --- /dev/null +++ b/packages/agent-core-v2/src/flag/flag.ts @@ -0,0 +1,45 @@ +/** + * `flag` domain (L3) — experimental-flag resolution contract. + * + * Defines the `IFlagService` used to check whether a flag is enabled, snapshot + * and explain flag state, and apply config overrides, together with the + * flag-resolution types (`ExperimentalFeatureState`, `ExperimentalFlagConfig`, + * `ExperimentalFlagSource`). Core-scoped — one instance shared across the + * process. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { FlagId, FlagRegistry, FlagSurface } from './registry'; + +export type ExperimentalFlagMap = Record; + +export type ExperimentalFlagConfig = Partial>; + +export type ExperimentalFlagSource = 'master-env' | 'env' | 'config' | 'default'; + +export interface ExperimentalFeatureState { + readonly id: FlagId; + readonly title: string; + readonly description: string; + readonly surface: FlagSurface; + readonly env: string; + readonly defaultEnabled: boolean; + readonly enabled: boolean; + readonly source: ExperimentalFlagSource; + readonly configValue?: boolean; +} + +export interface IFlagService { + readonly _serviceBrand: undefined; + readonly registry: FlagRegistry; + enabled(id: FlagId): boolean; + snapshot(): ExperimentalFlagMap; + enabledIds(): readonly FlagId[]; + explain(id: FlagId): ExperimentalFeatureState | undefined; + explainAll(): readonly ExperimentalFeatureState[]; + setConfigOverrides(overrides: ExperimentalFlagConfig | undefined): void; +} + +export const IFlagService: ServiceIdentifier = + createDecorator('flagService'); diff --git a/packages/agent-core-v2/src/flag/flagService.ts b/packages/agent-core-v2/src/flag/flagService.ts new file mode 100644 index 000000000..063b0498d --- /dev/null +++ b/packages/agent-core-v2/src/flag/flagService.ts @@ -0,0 +1,140 @@ +/** + * `flag` domain (L3) — `IFlagService` implementation. + * + * Resolves experimental flags from environment, the `[experimental]` config + * section, and defaults; reads and watches config through `config`. Bound at + * Core scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IConfigRegistry, IConfigService } from '#/config/config'; + +import { + type ExperimentalFeatureState, + type ExperimentalFlagConfig, + type ExperimentalFlagMap, + type ExperimentalFlagSource, + IFlagService, +} from './flag'; +import { + ExperimentalConfigSchema, + type FlagDefinitionInput, + type FlagId, + FlagRegistry, +} from './registry'; + +export const MASTER_ENV = 'KIMI_CODE_EXPERIMENTAL_FLAG'; + +export const EXPERIMENTAL_SECTION = 'experimental'; + +const TRUE_BOOLEAN_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']); +const FALSE_BOOLEAN_ENV_VALUES = new Set(['0', 'false', 'no', 'off']); + +function parseBooleanEnv(value: string | undefined): boolean | undefined { + const normalized = value?.trim().toLowerCase(); + if (normalized === undefined || normalized.length === 0) return undefined; + if (TRUE_BOOLEAN_ENV_VALUES.has(normalized)) return true; + if (FALSE_BOOLEAN_ENV_VALUES.has(normalized)) return false; + return undefined; +} + +export class FlagService extends Disposable implements IFlagService { + declare readonly _serviceBrand: undefined; + readonly registry: FlagRegistry; + private readonly env: Readonly>; + private configOverrides: ExperimentalFlagConfig; + + constructor( + @IConfigRegistry configRegistry: IConfigRegistry, + @IConfigService private readonly config: IConfigService, + env: Readonly> = process.env, + registry: FlagRegistry = new FlagRegistry(), + ) { + super(); + this.env = env; + this.registry = registry; + configRegistry.registerSection(EXPERIMENTAL_SECTION, ExperimentalConfigSchema); + this.configOverrides = this.readConfig(); + this._register( + this.config.onDidChange((e) => { + if (e.domain === EXPERIMENTAL_SECTION) { + this.configOverrides = this.readConfig(); + } + }), + ); + } + + private readConfig(): ExperimentalFlagConfig { + return this.config.get(EXPERIMENTAL_SECTION) ?? {}; + } + + setConfigOverrides(overrides: ExperimentalFlagConfig | undefined): void { + this.configOverrides = overrides ?? {}; + } + + enabled(id: FlagId): boolean { + return this.explain(id)?.enabled ?? false; + } + + explain(id: FlagId): ExperimentalFeatureState | undefined { + const def = this.registry.get(id); + if (def === undefined) return undefined; + const configValue = this.configOverrides[def.id as FlagId]; + if (parseBooleanEnv(this.env[MASTER_ENV]) === true) { + return this.state(def, true, 'master-env', configValue); + } + const override = parseBooleanEnv(this.env[def.env]); + if (override !== undefined) return this.state(def, override, 'env', configValue); + if (configValue !== undefined) return this.state(def, configValue, 'config', configValue); + return this.state(def, def.default, 'default', undefined); + } + + snapshot(): ExperimentalFlagMap { + return Object.fromEntries( + this.registry.list().map((def) => [def.id, this.enabled(def.id as FlagId)]), + ); + } + + enabledIds(): readonly FlagId[] { + return this.registry + .list() + .filter((def) => this.enabled(def.id as FlagId)) + .map((def) => def.id as FlagId); + } + + explainAll(): readonly ExperimentalFeatureState[] { + return this.registry + .list() + .map((def) => this.explain(def.id as FlagId)) + .filter((state): state is ExperimentalFeatureState => state !== undefined); + } + + private state( + def: FlagDefinitionInput, + enabled: boolean, + source: ExperimentalFlagSource, + configValue: boolean | undefined, + ): ExperimentalFeatureState { + return { + id: def.id as FlagId, + title: def.title, + description: def.description, + surface: def.surface, + env: def.env, + defaultEnabled: def.default, + enabled, + source, + configValue, + }; + } +} + +registerScopedService( + LifecycleScope.Core, + IFlagService, + FlagService, + InstantiationType.Delayed, + 'flag', +); diff --git a/packages/agent-core-v2/src/flag/index.ts b/packages/agent-core-v2/src/flag/index.ts new file mode 100644 index 000000000..09922052d --- /dev/null +++ b/packages/agent-core-v2/src/flag/index.ts @@ -0,0 +1,10 @@ +/** + * `flag` domain barrel — re-exports the flag-definition catalog (`registry`), + * the resolution contract (`flag`), and the scoped service (`flagService`). + * Importing this barrel registers the `IFlagService` binding into the scope + * registry. + */ + +export * from './registry'; +export * from './flag'; +export * from './flagService'; diff --git a/packages/agent-core-v2/src/flag/registry.ts b/packages/agent-core-v2/src/flag/registry.ts new file mode 100644 index 000000000..139eb201b --- /dev/null +++ b/packages/agent-core-v2/src/flag/registry.ts @@ -0,0 +1,51 @@ +/** + * `flag` domain (L3) — flag definition catalog (`FlagRegistry`) and the + * `[experimental]` config section schema. + */ + +import { z } from 'zod'; + +export type FlagSurface = 'core' | 'tui' | 'both'; + +export interface FlagDefinitionInput { + readonly id: string; + readonly title: string; + readonly description: string; + readonly env: string; + readonly default: boolean; + readonly surface: FlagSurface; +} + +export const FLAG_DEFINITIONS = [ + { + id: 'micro_compaction', + title: 'Micro compaction', + description: 'Trim older large tool results from context while keeping recent conversation intact.', + env: 'KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION', + default: true, + surface: 'core', + }, +] as const satisfies readonly FlagDefinitionInput[]; + +export type FlagId = (typeof FLAG_DEFINITIONS)[number]['id']; + +export type FlagDefinition = FlagDefinitionInput & { readonly id: FlagId }; + +export const ExperimentalConfigSchema = z.record(z.string(), z.boolean()); +export type ExperimentalConfig = z.infer; + +export class FlagRegistry { + private readonly byId: ReadonlyMap; + + constructor(readonly definitions: readonly FlagDefinitionInput[] = FLAG_DEFINITIONS) { + this.byId = new Map(definitions.map((def) => [def.id, def])); + } + + get(id: FlagId): FlagDefinition | undefined { + return this.byId.get(id) as FlagDefinition | undefined; + } + + list(): readonly FlagDefinition[] { + return this.definitions as readonly FlagDefinition[]; + } +} diff --git a/packages/agent-core-v2/src/fs/fs.ts b/packages/agent-core-v2/src/fs/fs.ts new file mode 100644 index 000000000..9955cf932 --- /dev/null +++ b/packages/agent-core-v2/src/fs/fs.ts @@ -0,0 +1,49 @@ +/** + * `fs` domain (cross-cutting) — session-scope filesystem services. + * + * Defines the public contracts of filesystem access: the `IFsService`, + * `IFsSearchService`, `IFsGitService`, and `IFsWatcher` used by tools to read + * and write files, search, inspect git state, and watch paths. Session-scoped + * — one set of services per session. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IFsService { + readonly _serviceBrand: undefined; + read(path: string): Promise; + write(path: string, content: string): Promise; + stat(path: string): Promise; + mkdir(path: string): Promise; +} + +export const IFsService: ServiceIdentifier = + createDecorator('fsService'); + +export interface IFsSearchService { + readonly _serviceBrand: undefined; + grep(pattern: string, path: string): Promise; + glob(pattern: string): Promise; +} + +export const IFsSearchService: ServiceIdentifier = + createDecorator('fsSearchService'); + +export interface IFsGitService { + readonly _serviceBrand: undefined; + status(cwd: string): Promise; + diff(cwd: string): Promise; + log(cwd: string): Promise; +} + +export const IFsGitService: ServiceIdentifier = + createDecorator('fsGitService'); + +export interface IFsWatcher { + readonly _serviceBrand: undefined; + watch(path: string): void; + unwatch(path: string): void; +} + +export const IFsWatcher: ServiceIdentifier = + createDecorator('fsWatcher'); diff --git a/packages/agent-core-v2/src/fs/fsService.ts b/packages/agent-core-v2/src/fs/fsService.ts new file mode 100644 index 000000000..8a8edf057 --- /dev/null +++ b/packages/agent-core-v2/src/fs/fsService.ts @@ -0,0 +1,141 @@ +/** + * `fs` domain (cross-cutting) — `IFsService` / `IFsSearchService` / + * `IFsGitService` / `IFsWatcher` implementation. + * + * Owns file I/O, search, git inspection, and path watching; accesses the + * filesystem through `kaos` and logs through `log`. Bound at Session scope. + */ + +import type { Readable } from 'node:stream'; + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import type { Kaos } from '@moonshot-ai/kaos'; +import { ISessionKaosService } from '#/kaos/kaos'; +import { ILogService } from '#/log/log'; + +import { + IFsGitService, + IFsSearchService, + IFsService, + IFsWatcher, +} from './fs'; + +function readAll(stream: Readable): Promise { + return new Promise((resolve, reject) => { + let data = ''; + stream.setEncoding('utf8'); + stream.on('data', (chunk: string) => { data += chunk; }); + stream.on('end', () => resolve(data)); + stream.on('error', reject); + }); +} + +export class FsService implements IFsService { + declare readonly _serviceBrand: undefined; + + constructor( + @ISessionKaosService private readonly sessionKaos: ISessionKaosService, + @ILogService _log: ILogService, + ) {} + + private get kaos(): Kaos { + return this.sessionKaos.toolKaos; + } + + read(path: string): Promise { + return this.kaos.readText(path); + } + write(path: string, content: string): Promise { + return this.kaos.writeText(path, content).then(() => undefined); + } + stat(path: string): Promise { + return this.kaos.stat(path); + } + async mkdir(path: string): Promise { + await this.kaos.mkdir(path, { parents: true, existOk: true }); + } +} + +export class FsSearchService implements IFsSearchService { + declare readonly _serviceBrand: undefined; + + constructor( + @ISessionKaosService private readonly sessionKaos: ISessionKaosService, + @ILogService _log: ILogService, + ) {} + + private get kaos(): Kaos { + return this.sessionKaos.toolKaos; + } + + async grep(pattern: string, path: string): Promise { + const proc = await this.kaos.exec('grep', '-r', '-n', pattern, path); + const out = await readAll(proc.stdout); + await proc.wait(); + return out.split('\n').filter((l) => l.length > 0); + } + + async glob(pattern: string): Promise { + const proc = await this.kaos.exec('find', '.', '-name', pattern); + const out = await readAll(proc.stdout); + await proc.wait(); + return out.split('\n').filter((l) => l.length > 0); + } +} + +export class FsGitService implements IFsGitService { + declare readonly _serviceBrand: undefined; + + constructor( + @ISessionKaosService private readonly sessionKaos: ISessionKaosService, + @ILogService _log: ILogService, + ) {} + + private get kaos(): Kaos { + return this.sessionKaos.toolKaos; + } + + private async git(...args: string[]): Promise { + const proc = await this.kaos.exec('git', ...args); + const out = await readAll(proc.stdout); + await proc.wait(); + return out; + } + + status(_cwd: string): Promise { + return this.git('status', '--short'); + } + diff(_cwd: string): Promise { + return this.git('diff'); + } + async log(_cwd: string): Promise { + const out = await this.git('log', '--oneline', '-n', '20'); + return out.split('\n').filter((l) => l.length > 0); + } +} + +export class FsWatcher extends Disposable implements IFsWatcher { + declare readonly _serviceBrand: undefined; + private readonly watched = new Set(); + + constructor( + @ISessionKaosService _sessionKaos: ISessionKaosService, + @ILogService _log: ILogService, + ) { + super(); + } + + watch(path: string): void { + this.watched.add(path); + } + unwatch(path: string): void { + this.watched.delete(path); + } +} + +registerScopedService(LifecycleScope.Session, IFsService, FsService, InstantiationType.Delayed, 'fs'); +registerScopedService(LifecycleScope.Session, IFsSearchService, FsSearchService, InstantiationType.Delayed, 'fs'); +registerScopedService(LifecycleScope.Session, IFsGitService, FsGitService, InstantiationType.Delayed, 'fs'); +registerScopedService(LifecycleScope.Session, IFsWatcher, FsWatcher, InstantiationType.Delayed, 'fs'); diff --git a/packages/agent-core-v2/src/fs/index.ts b/packages/agent-core-v2/src/fs/index.ts new file mode 100644 index 000000000..442b55805 --- /dev/null +++ b/packages/agent-core-v2/src/fs/index.ts @@ -0,0 +1,8 @@ +/** + * `fs` domain barrel — re-exports the filesystem contract (`fs`) and its scoped + * services (`fsService`). Importing this barrel registers the `IFsService`, + * `IFsSearchService`, and `IFsGitService` bindings into the scope registry. + */ + +export * from './fs'; +export * from './fsService'; diff --git a/packages/agent-core-v2/src/gateway/gateway.ts b/packages/agent-core-v2/src/gateway/gateway.ts new file mode 100644 index 000000000..924887236 --- /dev/null +++ b/packages/agent-core-v2/src/gateway/gateway.ts @@ -0,0 +1,53 @@ +/** + * `gateway` domain (L7) — scope registry and REST/WS gateways. + * + * Defines the public contracts of the gateway layer: the `IScopeRegistry` used + * to create and look up sessions, plus the `IRestGateway` / `IWSGateway` / + * `IWSBroadcastService` entry points. Core-scoped — shared across the + * application. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IScopeHandle } from '#/_base/di/scope'; + +export interface CreateSessionOptions { + readonly sessionId: string; + readonly workDir: string; +} + +export interface IScopeRegistry { + readonly _serviceBrand: undefined; + createSession(opts: CreateSessionOptions): Promise; + get(sessionId: string): IScopeHandle | undefined; + close(sessionId: string): Promise; +} + +export const IScopeRegistry: ServiceIdentifier = + createDecorator('scopeRegistry'); + +export interface IRestGateway { + readonly _serviceBrand: undefined; + prompt(sessionId: string, agentId: string, input: string): Promise; + steer(sessionId: string, agentId: string, content: string): Promise; + cancel(sessionId: string, agentId: string, reason?: string): Promise; + getStatus(sessionId: string): Promise; +} + +export const IRestGateway: ServiceIdentifier = + createDecorator('restGateway'); + +export interface IWSGateway { + readonly _serviceBrand: undefined; + connect(connectionId: string): void; + broadcast(sessionId: string, event: unknown): void; +} + +export const IWSGateway: ServiceIdentifier = + createDecorator('wsGateway'); + +export interface IWSBroadcastService { + readonly _serviceBrand: undefined; +} + +export const IWSBroadcastService: ServiceIdentifier = + createDecorator('wsBroadcastService'); diff --git a/packages/agent-core-v2/src/gateway/gatewayService.ts b/packages/agent-core-v2/src/gateway/gatewayService.ts new file mode 100644 index 000000000..e439a74d4 --- /dev/null +++ b/packages/agent-core-v2/src/gateway/gatewayService.ts @@ -0,0 +1,125 @@ +/** + * `gateway` domain (L7) — `IScopeRegistry` / `IRestGateway` / `IWSGateway` / + * `IWSBroadcastService` implementation. + * + * Owns the session scope registry and the REST/WS entry points; resolves agents + * through `agent-lifecycle`, drives turns through `turn`, and subscribes to + * broadcasts through `event`. Bound at Core scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { + type IScopeHandle, + LifecycleScope, + getScopedServiceDescriptors, + registerScopedService, +} from '#/_base/di/scope'; +import { + IInstantiationService, + type ServiceIdentifier, + type ServicesAccessor, +} from '#/_base/di/instantiation'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; +import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle'; +import { IEventService } from '#/event/event'; +import { ITurnService } from '#/turn/turn'; + +import { + type CreateSessionOptions, + IRestGateway, + IScopeRegistry, + IWSBroadcastService, + IWSGateway, +} from './gateway'; + +export class ScopeRegistry implements IScopeRegistry { + declare readonly _serviceBrand: undefined; + private readonly sessions = new Map(); + + constructor(@IInstantiationService private readonly instantiation: IInstantiationService) {} + + createSession(opts: CreateSessionOptions): Promise { + const collection = new ServiceCollection(); + for (const entry of getScopedServiceDescriptors(LifecycleScope.Session)) { + collection.set(entry.id, entry.descriptor); + } + const child = this.instantiation.createChild(collection); + const accessor: ServicesAccessor = { + get: (id: ServiceIdentifier): T => child.invokeFunction((a) => a.get(id)), + }; + const handle: IScopeHandle = { id: opts.sessionId, kind: LifecycleScope.Session, accessor }; + this.sessions.set(opts.sessionId, handle); + return Promise.resolve(handle); + } + + get(sessionId: string): IScopeHandle | undefined { + return this.sessions.get(sessionId); + } + + close(sessionId: string): Promise { + this.sessions.delete(sessionId); + return Promise.resolve(); + } +} + +export class RestGateway implements IRestGateway { + declare readonly _serviceBrand: undefined; + + constructor(@IScopeRegistry private readonly scopes: IScopeRegistry) {} + + private turn(sessionId: string, agentId: string): ITurnService { + const session = this.scopes.get(sessionId); + if (session === undefined) throw new Error(`unknown session '${sessionId}'`); + const agents = session.accessor.get(IAgentLifecycleService); + const agent = agents.getHandle(agentId); + if (agent === undefined) throw new Error(`unknown agent '${agentId}'`); + return agent.accessor.get(ITurnService); + } + + prompt(sessionId: string, agentId: string, input: string): Promise { + return this.turn(sessionId, agentId).prompt(input); + } + steer(sessionId: string, agentId: string, content: string): Promise { + this.turn(sessionId, agentId).steer(content); + return Promise.resolve(); + } + cancel(sessionId: string, agentId: string, reason?: string): Promise { + this.turn(sessionId, agentId).cancel(reason); + return Promise.resolve(); + } + getStatus(sessionId: string): Promise { + return Promise.resolve(this.scopes.get(sessionId) !== undefined); + } +} + +export class WSGateway implements IWSGateway { + declare readonly _serviceBrand: undefined; + private readonly connections = new Set(); + + constructor( + @IScopeRegistry _scopes: IScopeRegistry, + @IEventService _event: IEventService, + ) {} + + connect(connectionId: string): void { + this.connections.add(connectionId); + } + broadcast(_sessionId: string, _event: unknown): void { + } +} + +export class WSBroadcastService extends Disposable implements IWSBroadcastService { + declare readonly _serviceBrand: undefined; + + constructor(@IEventService event: IEventService) { + super(); + event.subscribe(() => { + }); + } +} + +registerScopedService(LifecycleScope.Core, IScopeRegistry, ScopeRegistry, InstantiationType.Delayed, 'gateway'); +registerScopedService(LifecycleScope.Core, IRestGateway, RestGateway, InstantiationType.Delayed, 'gateway'); +registerScopedService(LifecycleScope.Core, IWSGateway, WSGateway, InstantiationType.Delayed, 'gateway'); +registerScopedService(LifecycleScope.Core, IWSBroadcastService, WSBroadcastService, InstantiationType.Delayed, 'gateway'); diff --git a/packages/agent-core-v2/src/gateway/index.ts b/packages/agent-core-v2/src/gateway/index.ts new file mode 100644 index 000000000..7667d2de9 --- /dev/null +++ b/packages/agent-core-v2/src/gateway/index.ts @@ -0,0 +1,9 @@ +/** + * `gateway` domain barrel — re-exports the gateway contract (`gateway`) and its + * scoped services (`gatewayService`). Importing this barrel registers the + * `IScopeRegistry`, `IRestGateway`, and `IWSGateway` bindings into the scope + * registry. + */ + +export * from './gateway'; +export * from './gatewayService'; diff --git a/packages/agent-core-v2/src/goal/goal.ts b/packages/agent-core-v2/src/goal/goal.ts new file mode 100644 index 000000000..b55b5934f --- /dev/null +++ b/packages/agent-core-v2/src/goal/goal.ts @@ -0,0 +1,25 @@ +/** + * `goal` domain (L4) — active-goal tracking. + * + * Defines the public contract of goal mode: the `GoalState` model and the + * `IGoalService` used to create, update, and clear the current goal. + * Agent-scoped — one instance per agent. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface GoalState { + readonly objective: string; + readonly status: string; +} + +export interface IGoalService { + readonly _serviceBrand: undefined; + readonly current: GoalState | undefined; + create(objective: string): void; + update(patch: Partial): void; + clear(): void; +} + +export const IGoalService: ServiceIdentifier = + createDecorator('goalService'); diff --git a/packages/agent-core-v2/src/goal/goalService.ts b/packages/agent-core-v2/src/goal/goalService.ts new file mode 100644 index 000000000..3d1de8ed9 --- /dev/null +++ b/packages/agent-core-v2/src/goal/goalService.ts @@ -0,0 +1,47 @@ +/** + * `goal` domain (L4) — `IGoalService` implementation. + * + * Holds the active goal state; enqueues follow-up through `injection`, + * persists records through `records`, and observes turns through `turn`. Bound + * at Agent scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IInjectionService } from '#/injection/injection'; +import { IAgentRecords } from '#/records/records'; +import { ITurnService } from '#/turn/turn'; + +import { type GoalState, IGoalService } from './goal'; + +export class GoalService extends Disposable implements IGoalService { + declare readonly _serviceBrand: undefined; + private state: GoalState | undefined; + + constructor( + @IAgentRecords _records: IAgentRecords, + @ITurnService turn: ITurnService, + @IInjectionService _injection: IInjectionService, + ) { + super(); + this._register(turn.onDidEndTurn(() => {})); + } + + get current(): GoalState | undefined { + return this.state; + } + + create(objective: string): void { + this.state = { objective, status: 'active' }; + } + update(patch: Partial): void { + if (this.state === undefined) return; + this.state = { ...this.state, ...patch }; + } + clear(): void { + this.state = undefined; + } +} + +registerScopedService(LifecycleScope.Agent, IGoalService, GoalService, InstantiationType.Delayed, 'goal'); diff --git a/packages/agent-core-v2/src/goal/index.ts b/packages/agent-core-v2/src/goal/index.ts new file mode 100644 index 000000000..fb6ca77f4 --- /dev/null +++ b/packages/agent-core-v2/src/goal/index.ts @@ -0,0 +1,8 @@ +/** + * `goal` domain barrel — re-exports the goal contract (`goal`) and its scoped + * service (`goalService`). Importing this barrel registers the `IGoalService` + * binding into the scope registry. + */ + +export * from './goal'; +export * from './goalService'; diff --git a/packages/agent-core-v2/src/hooks/hookEngine.ts b/packages/agent-core-v2/src/hooks/hookEngine.ts new file mode 100644 index 000000000..3866f37ca --- /dev/null +++ b/packages/agent-core-v2/src/hooks/hookEngine.ts @@ -0,0 +1,43 @@ +/** + * `hooks` domain (L6) — `IHookEngine` implementation. + * + * Evaluates the session's hook points and returns their pass/fail results; + * reads configuration through `config` and logs through `log`. Bound at + * Session scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IConfigService } from '#/config/config'; +import { ILogService } from '#/log/log'; + +import { type HookResult, IHookEngine } from './hooks'; + +const PASS: HookResult = { continue: true }; + +export class HookEngine extends Disposable implements IHookEngine { + declare readonly _serviceBrand: undefined; + + constructor( + @IConfigService _config: IConfigService, + @ILogService _log: ILogService, + ) { + super(); + } + + runUserPromptSubmit(_prompt: string): Promise { + return Promise.resolve(PASS); + } + runPreToolCall(_toolName: string, _args: unknown): Promise { + return Promise.resolve(PASS); + } + runSessionStart(): Promise { + return Promise.resolve(); + } + runSessionEnd(): Promise { + return Promise.resolve(); + } +} + +registerScopedService(LifecycleScope.Session, IHookEngine, HookEngine, InstantiationType.Delayed, 'hooks'); diff --git a/packages/agent-core-v2/src/hooks/hooks.ts b/packages/agent-core-v2/src/hooks/hooks.ts new file mode 100644 index 000000000..7c220cfc9 --- /dev/null +++ b/packages/agent-core-v2/src/hooks/hooks.ts @@ -0,0 +1,25 @@ +/** + * `hooks` domain (L6) — user hook engine. + * + * Defines the public contract of the hook engine: the `HookResult` model and the + * `IHookEngine` used to run the user-prompt-submit, pre-tool-call, and session + * start/end hook points. Session-scoped — one instance per session. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface HookResult { + readonly continue: boolean; + readonly message?: string; +} + +export interface IHookEngine { + readonly _serviceBrand: undefined; + runUserPromptSubmit(prompt: string): Promise; + runPreToolCall(toolName: string, args: unknown): Promise; + runSessionStart(): Promise; + runSessionEnd(): Promise; +} + +export const IHookEngine: ServiceIdentifier = + createDecorator('hookEngine'); diff --git a/packages/agent-core-v2/src/hooks/index.ts b/packages/agent-core-v2/src/hooks/index.ts new file mode 100644 index 000000000..73de13406 --- /dev/null +++ b/packages/agent-core-v2/src/hooks/index.ts @@ -0,0 +1,8 @@ +/** + * `hooks` domain barrel — re-exports the hook contract (`hooks`) and its scoped + * service (`hookEngine`). Importing this barrel registers the `IHookEngine` + * binding into the scope registry. + */ + +export * from './hooks'; +export * from './hookEngine'; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts new file mode 100644 index 000000000..663c763d5 --- /dev/null +++ b/packages/agent-core-v2/src/index.ts @@ -0,0 +1,53 @@ +/** + * agent-core-v2 public surface — re-exports every domain barrel (grouped by + * layer) so importing the package loads all scoped-registry registrations. + */ + +export * from './_base/di/index'; +export * from './_base/errors/index'; + +export * from './log/index'; +export * from './telemetry/index'; +export * from './environment/index'; +export * from './kaos/index'; +export * from './kosong/index'; + +export * from './records/index'; +export * from './config/index'; + +export * from './tool/index'; +export * from './skill/index'; +export * from './permission/index'; +export * from './flag/index'; + +export * from './context/index'; +export * from './message/index'; +export * from './turn/index'; +export * from './injection/index'; +export * from './compaction/index'; +export * from './plan/index'; +export * from './goal/index'; +export * from './swarm/index'; +export * from './usage/index'; +export * from './tooldedup/index'; + +export * from './background/index'; +export * from './cron/index'; +export * from './mcp/index'; + +export * from './agent-lifecycle/index'; +export * from './session-context/index'; +export * from './session-activity/index'; +export * from './session/index'; +export * from './hooks/index'; + +export * from './event/index'; +export * from './approval/index'; +export * from './question/index'; +export * from './gateway/index'; + +export * from './terminal/index'; +export * from './fs/index'; +export * from './workspace/index'; +export * from './filestore/index'; +export * from './auth/index'; diff --git a/packages/agent-core-v2/src/injection/index.ts b/packages/agent-core-v2/src/injection/index.ts new file mode 100644 index 000000000..ecfb1e49b --- /dev/null +++ b/packages/agent-core-v2/src/injection/index.ts @@ -0,0 +1,9 @@ +/** + * `injection` domain barrel — re-exports the injection contract + * (`injection`) and its scoped service (`injectionService`). Importing this + * barrel registers the `IInjectionService` and `IInjectionQueue` bindings into + * the scope registry. + */ + +export * from './injection'; +export * from './injectionService'; diff --git a/packages/agent-core-v2/src/injection/injection.ts b/packages/agent-core-v2/src/injection/injection.ts new file mode 100644 index 000000000..8e957b793 --- /dev/null +++ b/packages/agent-core-v2/src/injection/injection.ts @@ -0,0 +1,34 @@ +/** + * `injection` domain (L4) — agent injection service and per-turn queue. + * + * Defines the public contract for injections: the `InjectionItem` model, the + * `IInjectionService` used by an agent to queue and flush pending injections, + * and the `IInjectionQueue` for per-turn injection buffering. `IInjectionService` + * is Agent-scoped (one per agent); `IInjectionQueue` is Turn-scoped (one per + * turn). + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface InjectionItem { + readonly kind: string; + readonly content: string; +} + +export interface IInjectionService { + readonly _serviceBrand: undefined; + push(item: InjectionItem): void; + flush(): readonly InjectionItem[]; +} + +export const IInjectionService: ServiceIdentifier = + createDecorator('injectionService'); + +export interface IInjectionQueue { + readonly _serviceBrand: undefined; + push(item: InjectionItem): void; + flush(): readonly InjectionItem[]; +} + +export const IInjectionQueue: ServiceIdentifier = + createDecorator('injectionQueue'); diff --git a/packages/agent-core-v2/src/injection/injectionService.ts b/packages/agent-core-v2/src/injection/injectionService.ts new file mode 100644 index 000000000..f8637eefc --- /dev/null +++ b/packages/agent-core-v2/src/injection/injectionService.ts @@ -0,0 +1,58 @@ +/** + * `injection` domain (L4) — `IInjectionService` and `IInjectionQueue` + * implementation. + * + * Holds the agent-level and per-turn queues of pending injections; reads + * history through `context`. Service bound at Agent scope; queue bound at Turn + * scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IContextService } from '#/context/context'; + +import { type InjectionItem, IInjectionQueue, IInjectionService } from './injection'; + +class Queue { + private items: InjectionItem[] = []; + push(item: InjectionItem): void { + this.items.push(item); + } + flush(): readonly InjectionItem[] { + const out = this.items; + this.items = []; + return out; + } + get pending(): number { + return this.items.length; + } +} + +export class InjectionService implements IInjectionService { + declare readonly _serviceBrand: undefined; + private readonly queue = new Queue(); + + constructor(@IContextService _context: IContextService) {} + + push(item: InjectionItem): void { + this.queue.push(item); + } + flush(): readonly InjectionItem[] { + return this.queue.flush(); + } +} + +export class InjectionQueue implements IInjectionQueue { + declare readonly _serviceBrand: undefined; + private readonly queue = new Queue(); + + push(item: InjectionItem): void { + this.queue.push(item); + } + flush(): readonly InjectionItem[] { + return this.queue.flush(); + } +} + +registerScopedService(LifecycleScope.Agent, IInjectionService, InjectionService, InstantiationType.Delayed, 'injection'); +registerScopedService(LifecycleScope.Turn, IInjectionQueue, InjectionQueue, InstantiationType.Delayed, 'injection'); diff --git a/packages/agent-core-v2/src/kaos/agentKaos.ts b/packages/agent-core-v2/src/kaos/agentKaos.ts new file mode 100644 index 000000000..1172117f0 --- /dev/null +++ b/packages/agent-core-v2/src/kaos/agentKaos.ts @@ -0,0 +1,43 @@ +/** + * `kaos` domain (L1) — `IAgentKaos` implementation. + * + * Exposes the agent's active `Kaos` instance and working directory, and + * switches the working directory on `chdir`. Bound at Agent scope. + */ + +import type { Kaos } from '@moonshot-ai/kaos'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; + +import { IAgentKaos, ISessionKaosService } from './kaos'; + +export class AgentKaos implements IAgentKaos { + declare readonly _serviceBrand: undefined; + private _kaos: Kaos; + + constructor(@ISessionKaosService sessionKaos: ISessionKaosService) { + this._kaos = sessionKaos.toolKaos; + } + + get kaos(): Kaos { + return this._kaos; + } + + get cwd(): string { + return this._kaos.getcwd(); + } + + chdir(cwd: string): Promise { + this._kaos = this._kaos.withCwd(cwd); + return Promise.resolve(); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentKaos, + AgentKaos, + InstantiationType.Delayed, + 'kaos', +); diff --git a/packages/agent-core-v2/src/kaos/index.ts b/packages/agent-core-v2/src/kaos/index.ts new file mode 100644 index 000000000..5bc830a5b --- /dev/null +++ b/packages/agent-core-v2/src/kaos/index.ts @@ -0,0 +1,11 @@ +/** + * `kaos` domain barrel — re-exports the `kaos` contract and its scoped + * services (`kaosFactory`, `sessionKaosService`, `agentKaos`). Importing this + * barrel registers the `IKaosFactory`, `ISessionKaosService`, and `IAgentKaos` + * bindings into the scope registry. + */ + +export * from './kaos'; +export * from './kaosFactory'; +export * from './sessionKaosService'; +export * from './agentKaos'; diff --git a/packages/agent-core-v2/src/kaos/kaos.ts b/packages/agent-core-v2/src/kaos/kaos.ts new file mode 100644 index 000000000..87cb68d30 --- /dev/null +++ b/packages/agent-core-v2/src/kaos/kaos.ts @@ -0,0 +1,50 @@ +/** + * `kaos` domain (L1) — execution-environment service contracts. + * + * Defines the execution-environment contracts: `IKaosFactory` for creating + * `Kaos` instances (Core), `ISessionKaosService` for the session's tool / + * persistence / system-context environments (Session), and `IAgentKaos` for + * the per-agent working directory (Agent). + */ + +import type { Kaos } from '@moonshot-ai/kaos'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface KaosFactoryOptions { + readonly kind: 'local' | 'ssh'; + readonly cwd?: string; + readonly host?: string; +} + +export interface IKaosFactory { + readonly _serviceBrand: undefined; + create(options: KaosFactoryOptions): Promise; +} + +export const IKaosFactory: ServiceIdentifier = + createDecorator('kaosFactory'); + +export interface ISessionKaosService { + readonly _serviceBrand: undefined; + readonly toolKaos: Kaos; + readonly persistenceKaos: Kaos; + readonly systemContextKaos: Kaos; + readonly additionalDirs: readonly string[]; + setToolKaos(kaos: Kaos): void; + addAdditionalDir(dir: string): void; + removeAdditionalDir(dir: string): void; +} + +export const ISessionKaosService: ServiceIdentifier = + createDecorator('sessionKaosService'); + +export interface IAgentKaos { + readonly _serviceBrand: undefined; + readonly kaos: Kaos; + readonly cwd: string; + chdir(cwd: string): Promise; +} + +export const IAgentKaos: ServiceIdentifier = + createDecorator('agentKaos'); diff --git a/packages/agent-core-v2/src/kaos/kaosFactory.ts b/packages/agent-core-v2/src/kaos/kaosFactory.ts new file mode 100644 index 000000000..43d30ae8c --- /dev/null +++ b/packages/agent-core-v2/src/kaos/kaosFactory.ts @@ -0,0 +1,40 @@ +/** + * `kaos` domain (L1) — `IKaosFactory` implementation. + * + * Creates `Kaos` instances for the requested kind; resolves paths through + * `environment` and logs through `log`. Bound at Core scope. + */ + +import { type Kaos, LocalKaos } from '@moonshot-ai/kaos'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IEnvironmentService } from '#/environment/environment'; +import { ILogService } from '#/log/log'; + +import { type KaosFactoryOptions, IKaosFactory } from './kaos'; + +export class KaosFactory implements IKaosFactory { + declare readonly _serviceBrand: undefined; + + constructor( + @IEnvironmentService _env: IEnvironmentService, + @ILogService _log: ILogService, + ) {} + + async create(options: KaosFactoryOptions): Promise { + if (options.kind === 'ssh') { + throw new Error('TODO: KaosFactory.create ssh'); + } + const base = await LocalKaos.create(); + return options.cwd !== undefined ? base.withCwd(options.cwd) : base; + } +} + +registerScopedService( + LifecycleScope.Core, + IKaosFactory, + KaosFactory, + InstantiationType.Delayed, + 'kaos', +); diff --git a/packages/agent-core-v2/src/kaos/sessionKaosService.ts b/packages/agent-core-v2/src/kaos/sessionKaosService.ts new file mode 100644 index 000000000..6c5571f14 --- /dev/null +++ b/packages/agent-core-v2/src/kaos/sessionKaosService.ts @@ -0,0 +1,75 @@ +/** + * `kaos` domain (L1) — `ISessionKaosService` implementation. + * + * Holds the session's tool, persistence, and system-context `Kaos` + * environments plus additional search directories; logs through `log`. Bound + * at Session scope. + */ + +import type { Kaos } from '@moonshot-ai/kaos'; + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/log/log'; + +import { ISessionKaosService } from './kaos'; + +export class SessionKaosService extends Disposable implements ISessionKaosService { + declare readonly _serviceBrand: undefined; + private _toolKaos: Kaos | undefined; + private _persistenceKaos: Kaos | undefined; + private _additionalDirs: string[] = []; + + constructor(@ILogService _log: ILogService) { + super(); + } + + get toolKaos(): Kaos { + if (this._toolKaos === undefined) { + throw new Error('SessionKaosService.toolKaos accessed before setToolKaos'); + } + return this._toolKaos; + } + + get persistenceKaos(): Kaos { + return this._persistenceKaos ?? this.toolKaos; + } + + get systemContextKaos(): Kaos { + return this.persistenceKaos.withCwd(this.toolKaos.getcwd()); + } + + get additionalDirs(): readonly string[] { + return this._additionalDirs; + } + + setToolKaos(kaos: Kaos): void { + this._toolKaos = kaos; + if (this._persistenceKaos === undefined) { + this._persistenceKaos = kaos; + } + } + + setPersistenceKaos(kaos: Kaos): void { + this._persistenceKaos = kaos; + } + + addAdditionalDir(dir: string): void { + if (!this._additionalDirs.includes(dir)) { + this._additionalDirs.push(dir); + } + } + + removeAdditionalDir(dir: string): void { + this._additionalDirs = this._additionalDirs.filter((d) => d !== dir); + } +} + +registerScopedService( + LifecycleScope.Session, + ISessionKaosService, + SessionKaosService, + InstantiationType.Delayed, + 'kaos', +); diff --git a/packages/agent-core-v2/src/kosong/index.ts b/packages/agent-core-v2/src/kosong/index.ts new file mode 100644 index 000000000..a9ecec560 --- /dev/null +++ b/packages/agent-core-v2/src/kosong/index.ts @@ -0,0 +1,9 @@ +/** + * `kosong` domain barrel — re-exports the `kosong` contract and its scoped + * service (`kosongService`). Importing this barrel registers the + * `IModelCatalogService`, `IProviderManager`, and `ILLMService` bindings into + * the scope registry. + */ + +export * from './kosong'; +export * from './kosongService'; diff --git a/packages/agent-core-v2/src/kosong/kosong.ts b/packages/agent-core-v2/src/kosong/kosong.ts new file mode 100644 index 000000000..6e5af246a --- /dev/null +++ b/packages/agent-core-v2/src/kosong/kosong.ts @@ -0,0 +1,60 @@ +/** + * `kosong` domain (L1) — LLM / provider service contracts. + * + * Defines the provider and model contracts: `IModelCatalogService` for the + * provider / model catalog (Core), `IProviderManager` for resolving the active + * provider and model (Session), and `ILLMService` for generating completions + * (Agent). + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ProviderInfo { + readonly id: string; + readonly name: string; +} + +export interface ModelInfo { + readonly id: string; + readonly providerId: string; +} + +export interface IModelCatalogService { + readonly _serviceBrand: undefined; + listProviders(): Promise; + listModels(providerId?: string): Promise; + refresh(): Promise; +} + +export const IModelCatalogService: ServiceIdentifier = + createDecorator('modelCatalogService'); + +export interface ResolvedProvider { + readonly providerId: string; + readonly modelId: string; +} + +export interface IProviderManager { + readonly _serviceBrand: undefined; + resolve(providerId?: string, modelId?: string): Promise; +} + +export const IProviderManager: ServiceIdentifier = + createDecorator('providerManager'); + +export interface GenerateArgs { + readonly messages: readonly unknown[]; + readonly tools?: readonly unknown[]; +} + +export interface GenerateResult { + readonly text: string; +} + +export interface ILLMService { + readonly _serviceBrand: undefined; + generate(args: GenerateArgs): AsyncIterable; +} + +export const ILLMService: ServiceIdentifier = + createDecorator('llmService'); diff --git a/packages/agent-core-v2/src/kosong/kosongService.ts b/packages/agent-core-v2/src/kosong/kosongService.ts new file mode 100644 index 000000000..37d60ed5b --- /dev/null +++ b/packages/agent-core-v2/src/kosong/kosongService.ts @@ -0,0 +1,104 @@ +/** + * `kosong` domain (L1) — `IModelCatalogService` / `IProviderManager` / + * `ILLMService` implementation. + * + * Serves the provider / model catalog, resolves the active provider and model, + * and drives LLM generation; reads configuration through `config` and resolves + * paths through `environment`. Bound at Core, Session, and Agent scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentConfigService, IConfigService } from '#/config/config'; +import { IEnvironmentService } from '#/environment/environment'; + +import { + type GenerateArgs, + type GenerateResult, + type ModelInfo, + type ProviderInfo, + type ResolvedProvider, + ILLMService, + IModelCatalogService, + IProviderManager, +} from './kosong'; + +interface KosongSection { + readonly providers?: readonly ProviderInfo[]; + readonly models?: readonly ModelInfo[]; + readonly defaultProviderId?: string; + readonly defaultModelId?: string; +} + +export class ModelCatalogService implements IModelCatalogService { + declare readonly _serviceBrand: undefined; + + constructor( + @IConfigService private readonly config: IConfigService, + @IEnvironmentService _env: IEnvironmentService, + ) {} + + private section(): KosongSection { + return this.config.get('kosong') ?? {}; + } + + listProviders(): Promise { + return Promise.resolve(this.section().providers ?? []); + } + + listModels(providerId?: string): Promise { + const models = this.section().models ?? []; + return Promise.resolve( + providerId === undefined ? models : models.filter((m) => m.providerId === providerId), + ); + } + + refresh(): Promise { + return Promise.resolve(); + } +} + +export class ProviderManager implements IProviderManager { + declare readonly _serviceBrand: undefined; + + constructor( + @IModelCatalogService private readonly catalog: IModelCatalogService, + @IConfigService private readonly config: IConfigService, + ) {} + + async resolve(providerId?: string, modelId?: string): Promise { + const section = this.config.get('kosong') ?? {}; + const resolvedProvider = providerId ?? section.defaultProviderId; + const resolvedModel = modelId ?? section.defaultModelId; + if (resolvedProvider === undefined || resolvedModel === undefined) { + throw new Error('ProviderManager.resolve: no provider/model specified and no defaults configured'); + } + const providers = await this.catalog.listProviders(); + if (!providers.some((p) => p.id === resolvedProvider)) { + throw new Error(`ProviderManager.resolve: unknown provider '${resolvedProvider}'`); + } + return { providerId: resolvedProvider, modelId: resolvedModel }; + } +} + +export class LLMService implements ILLMService { + declare readonly _serviceBrand: undefined; + + constructor( + @IProviderManager private readonly providers: IProviderManager, + @IAgentConfigService private readonly agentConfig: IAgentConfigService, + ) {} + + // eslint-disable-next-line require-yield -- TODO stub: yields the kosong stream once wired. + async *generate(_args: GenerateArgs): AsyncIterable { + const resolved = await this.providers.resolve( + this.agentConfig.provider, + this.agentConfig.modelAlias, + ); + throw new Error(`TODO: LLMService.generate (${resolved.providerId}/${resolved.modelId})`); + } +} + +registerScopedService(LifecycleScope.Core, IModelCatalogService, ModelCatalogService, InstantiationType.Delayed, 'kosong'); +registerScopedService(LifecycleScope.Session, IProviderManager, ProviderManager, InstantiationType.Delayed, 'kosong'); +registerScopedService(LifecycleScope.Agent, ILLMService, LLMService, InstantiationType.Delayed, 'kosong'); diff --git a/packages/agent-core-v2/src/log/index.ts b/packages/agent-core-v2/src/log/index.ts new file mode 100644 index 000000000..93b3b5000 --- /dev/null +++ b/packages/agent-core-v2/src/log/index.ts @@ -0,0 +1,8 @@ +/** + * `log` domain barrel — re-exports the `log` contract and its scoped service + * (`logService`). Importing this barrel registers the `ILogService` and + * `ILogSink` bindings into the scope registry. + */ + +export * from './log'; +export * from './logService'; diff --git a/packages/agent-core-v2/src/log/log.ts b/packages/agent-core-v2/src/log/log.ts new file mode 100644 index 000000000..8c8359ff1 --- /dev/null +++ b/packages/agent-core-v2/src/log/log.ts @@ -0,0 +1,66 @@ +/** + * `log` domain (L1) — structured logging facade. + * + * Defines the public contract of logging: the `LogEntry` / `LogLevel` model, + * the `ILogger` / `ILogService` used by other domains to emit leveled entries, + * and the `ILogSink` they are written to. Core-scoped — one shared instance + * for the process. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export type LogLevel = 'off' | 'error' | 'warn' | 'info' | 'debug'; + +export type LogContext = Record; + +export type LogPayload = unknown; + +export interface LogEntryError { + readonly message: string; + readonly stack?: string; +} + +export interface LogEntry { + readonly t: number; + readonly level: Exclude; + readonly msg: string; + readonly ctx?: LogContext; + readonly error?: LogEntryError; +} + +export interface ILogSink { + write(entry: LogEntry): void; +} + +export const ILogSink: ServiceIdentifier = + createDecorator('logSink'); + +export interface ILogger { + error(message: string, payload?: LogPayload): void; + warn(message: string, payload?: LogPayload): void; + info(message: string, payload?: LogPayload): void; + debug(message: string, payload?: LogPayload): void; + child(ctx: LogContext): ILogger; +} + +export interface ILogService extends ILogger { + readonly _serviceBrand: undefined; + readonly level: LogLevel; + setLevel(level: LogLevel): void; +} + +export const ILogService: ServiceIdentifier = + createDecorator('logService'); + +const LEVEL_ORDER: Record = { + off: 0, + error: 1, + warn: 2, + info: 3, + debug: 4, +}; + +export function levelEnabled(level: LogLevel, configured: LogLevel): boolean { + if (level === 'off' || configured === 'off') return false; + return LEVEL_ORDER[level] <= LEVEL_ORDER[configured]; +} diff --git a/packages/agent-core-v2/src/log/logService.ts b/packages/agent-core-v2/src/log/logService.ts new file mode 100644 index 000000000..a19adb34a --- /dev/null +++ b/packages/agent-core-v2/src/log/logService.ts @@ -0,0 +1,153 @@ +/** + * `log` domain (L1) — `ILogService` implementation and built-in sinks. + * + * Filters entries by the configured `LogLevel` and writes them to the bound + * `ILogSink`; provides the console and in-memory `ILogSink` implementations. + * Bound at Core scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; + +import { + type ILogger, + type LogContext, + type LogEntry, + type LogEntryError, + type LogLevel, + type LogPayload, + ILogService, + ILogSink, + levelEnabled, +} from './log'; + +function extractError(payload: LogPayload): LogEntryError | undefined { + if (payload instanceof Error) { + return { message: payload.message, stack: payload.stack }; + } + if ( + typeof payload === 'object' && + payload !== null && + 'error' in payload && + (payload as { error: unknown }).error instanceof Error + ) { + const err = (payload as { error: Error }).error; + return { message: err.message, stack: err.stack }; + } + return undefined; +} + +function extractContext(payload: LogPayload): LogContext | undefined { + if (typeof payload === 'object' && payload !== null && !(payload instanceof Error)) { + return { ...(payload as LogContext) }; + } + if (payload !== undefined && !(payload instanceof Error)) { + return { reason: typeof payload === 'string' ? payload : JSON.stringify(payload) }; + } + return undefined; +} + +export class MemoryLogSink implements ILogSink { + readonly entries: LogEntry[] = []; + write(entry: LogEntry): void { + this.entries.push(entry); + } +} + +export class ConsoleLogSink implements ILogSink { + write(entry: LogEntry): void { + const line = entry.ctx !== undefined ? `${entry.msg} ${JSON.stringify(entry.ctx)}` : entry.msg; + switch (entry.level) { + case 'error': + // eslint-disable-next-line no-console + console.error(line); + break; + case 'warn': + // eslint-disable-next-line no-console + console.warn(line); + break; + case 'debug': + // eslint-disable-next-line no-console + console.debug(line); + break; + default: + // eslint-disable-next-line no-console + console.log(line); + } + } +} + +export class LogService implements ILogService { + declare readonly _serviceBrand: undefined; + private _level: LogLevel; + + constructor( + @ILogSink private readonly sink: ILogSink, + private readonly bound: LogContext = {}, + level: LogLevel = 'info', + ) { + this._level = level; + } + + get level(): LogLevel { + return this._level; + } + + setLevel(level: LogLevel): void { + this._level = level; + } + + error(message: string, payload?: LogPayload): void { + this.emit('error', message, payload); + } + warn(message: string, payload?: LogPayload): void { + this.emit('warn', message, payload); + } + info(message: string, payload?: LogPayload): void { + this.emit('info', message, payload); + } + debug(message: string, payload?: LogPayload): void { + this.emit('debug', message, payload); + } + + child(ctx: LogContext): ILogger { + return new LogService(this.sink, { ...this.bound, ...ctx }, this._level); + } + + private emit( + level: Exclude, + message: string, + payload?: LogPayload, + ): void { + if (!levelEnabled(level, this._level)) return; + const payloadCtx = extractContext(payload); + const error = extractError(payload); + const ctx = + payloadCtx !== undefined || Object.keys(this.bound).length > 0 + ? { ...payloadCtx, ...this.bound } + : undefined; + const entry: LogEntry = { + t: Date.now(), + level, + msg: message, + ...(ctx !== undefined ? { ctx } : {}), + ...(error !== undefined ? { error } : {}), + }; + this.sink.write(entry); + } +} + +registerScopedService( + LifecycleScope.Core, + ILogSink, + ConsoleLogSink, + InstantiationType.Eager, + 'log', +); +registerScopedService( + LifecycleScope.Core, + ILogService, + LogService, + InstantiationType.Eager, + 'log', +); diff --git a/packages/agent-core-v2/src/mcp/index.ts b/packages/agent-core-v2/src/mcp/index.ts new file mode 100644 index 000000000..8316393d8 --- /dev/null +++ b/packages/agent-core-v2/src/mcp/index.ts @@ -0,0 +1,8 @@ +/** + * `mcp` domain barrel — re-exports the MCP contract (`mcp`) and its scoped + * service (`mcpService`). Importing this barrel registers the `IMcpService` + * binding into the scope registry. + */ + +export * from './mcp'; +export * from './mcpService'; diff --git a/packages/agent-core-v2/src/mcp/mcp.ts b/packages/agent-core-v2/src/mcp/mcp.ts new file mode 100644 index 000000000..6f4a7d03f --- /dev/null +++ b/packages/agent-core-v2/src/mcp/mcp.ts @@ -0,0 +1,26 @@ +/** + * `mcp` domain (L5) — manages MCP server connections. + * + * Defines the public contract of MCP: the `McpServerStatusEvent` model and the + * `IMcpService` used to connect, disconnect, and list servers and observe + * `onDidChangeServerStatus`. Session-scoped — one instance per session. + */ + +import type { Event } from '#/_base/event'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface McpServerStatusEvent { + readonly serverId: string; + readonly status: string; +} + +export interface IMcpService { + readonly _serviceBrand: undefined; + readonly onDidChangeServerStatus: Event; + connect(serverId: string): Promise; + disconnect(serverId: string): Promise; + list(): readonly string[]; +} + +export const IMcpService: ServiceIdentifier = + createDecorator('mcpService'); diff --git a/packages/agent-core-v2/src/mcp/mcpService.ts b/packages/agent-core-v2/src/mcp/mcpService.ts new file mode 100644 index 000000000..11d924a9f --- /dev/null +++ b/packages/agent-core-v2/src/mcp/mcpService.ts @@ -0,0 +1,56 @@ +/** + * `mcp` domain (L5) — `IMcpService` implementation. + * + * Owns the connected MCP server set and broadcasts server status changes; + * authenticates through `auth`, reads configuration through `config`, logs + * through `log`, and reports telemetry through `telemetry`. Bound at Session + * scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IOAuthService } from '#/auth/auth'; +import { IConfigService } from '#/config/config'; +import { ILogService } from '#/log/log'; +import { ITelemetryService } from '#/telemetry/telemetry'; + +import { type McpServerStatusEvent, IMcpService } from './mcp'; + +export class McpService extends Disposable implements IMcpService { + declare readonly _serviceBrand: undefined; + private readonly _onDidChangeServerStatus = this._register( + new Emitter(), + ); + readonly onDidChangeServerStatus: Event = + this._onDidChangeServerStatus.event; + private readonly servers = new Map(); + + constructor( + @IConfigService _config: IConfigService, + @ILogService _log: ILogService, + @ITelemetryService _telemetry: ITelemetryService, + @IOAuthService _oauth: IOAuthService, + ) { + super(); + } + + connect(serverId: string): Promise { + this.servers.set(serverId, 'connected'); + this._onDidChangeServerStatus.fire({ serverId, status: 'connected' }); + return Promise.resolve(); + } + + disconnect(serverId: string): Promise { + this.servers.delete(serverId); + this._onDidChangeServerStatus.fire({ serverId, status: 'disconnected' }); + return Promise.resolve(); + } + + list(): readonly string[] { + return [...this.servers.keys()]; + } +} + +registerScopedService(LifecycleScope.Session, IMcpService, McpService, InstantiationType.Delayed, 'mcp'); diff --git a/packages/agent-core-v2/src/message/index.ts b/packages/agent-core-v2/src/message/index.ts new file mode 100644 index 000000000..376fa7a85 --- /dev/null +++ b/packages/agent-core-v2/src/message/index.ts @@ -0,0 +1,8 @@ +/** + * `message` domain barrel — re-exports the message contract (`message`) and + * its scoped service (`messageService`). Importing this barrel registers the + * `IMessageService` binding into the scope registry. + */ + +export * from './message'; +export * from './messageService'; diff --git a/packages/agent-core-v2/src/message/message.ts b/packages/agent-core-v2/src/message/message.ts new file mode 100644 index 000000000..91d3697b7 --- /dev/null +++ b/packages/agent-core-v2/src/message/message.ts @@ -0,0 +1,24 @@ +/** + * `message` domain (L4) — protocol message projection over context. + * + * Defines the public contract for messages: the `ProtocolMessage` model and the + * `IMessageService` used to list and look up projected protocol messages. + * Agent-scoped — one instance per agent. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ProtocolMessage { + readonly id: string; + readonly role: string; + readonly content: unknown; +} + +export interface IMessageService { + readonly _serviceBrand: undefined; + list(): readonly ProtocolMessage[]; + get(id: string): ProtocolMessage | undefined; +} + +export const IMessageService: ServiceIdentifier = + createDecorator('messageService'); diff --git a/packages/agent-core-v2/src/message/messageService.ts b/packages/agent-core-v2/src/message/messageService.ts new file mode 100644 index 000000000..3b4f69bb5 --- /dev/null +++ b/packages/agent-core-v2/src/message/messageService.ts @@ -0,0 +1,36 @@ +/** + * `message` domain (L4) — `IMessageService` implementation. + * + * Projects context history into protocol messages; reads history through + * `context`. Bound at Agent scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IContextService } from '#/context/context'; + +import { type ProtocolMessage, IMessageService } from './message'; + +function deriveId(index: number): string { + return `msg-${index}`; +} + +export class MessageService implements IMessageService { + declare readonly _serviceBrand: undefined; + + constructor(@IContextService private readonly context: IContextService) {} + + list(): readonly ProtocolMessage[] { + return this.context.project().map((m, i) => ({ + id: deriveId(i), + role: m.role, + content: m.content, + })); + } + + get(id: string): ProtocolMessage | undefined { + return this.list().find((m) => m.id === id); + } +} + +registerScopedService(LifecycleScope.Agent, IMessageService, MessageService, InstantiationType.Delayed, 'message'); diff --git a/packages/agent-core-v2/src/permission/index.ts b/packages/agent-core-v2/src/permission/index.ts new file mode 100644 index 000000000..70db62c28 --- /dev/null +++ b/packages/agent-core-v2/src/permission/index.ts @@ -0,0 +1,8 @@ +/** + * `permission` domain barrel — re-exports the permission contract + * (`permission`) and its scoped services (`permissionService`). Importing this + * barrel registers the permission bindings into the scope registry. + */ + +export * from './permission'; +export * from './permissionService'; diff --git a/packages/agent-core-v2/src/permission/permission.ts b/packages/agent-core-v2/src/permission/permission.ts new file mode 100644 index 000000000..452b7a29e --- /dev/null +++ b/packages/agent-core-v2/src/permission/permission.ts @@ -0,0 +1,39 @@ +/** + * `permission` domain (L3) — tool-call permission policy and decision contract. + * + * Defines the `Decision`, `PermissionContext`, and `PermissionPolicy` models, + * the `IPermissionPolicyRegistry` for registering and evaluating policies, and + * the `IPermissionService` used to decide a tool call before it runs. The + * registry is Core-scoped; the decision service is Agent-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export type Decision = 'allow' | 'deny' | 'ask'; + +export interface PermissionContext { + readonly toolName: string; + readonly args: unknown; +} + +export interface PermissionPolicy { + readonly name: string; + evaluate(ctx: PermissionContext): Decision | undefined; +} + +export interface IPermissionPolicyRegistry { + readonly _serviceBrand: undefined; + register(policy: PermissionPolicy): void; + evaluate(ctx: PermissionContext): Decision; +} + +export const IPermissionPolicyRegistry: ServiceIdentifier = + createDecorator('permissionPolicyRegistry'); + +export interface IPermissionService { + readonly _serviceBrand: undefined; + beforeToolCall(ctx: PermissionContext): Promise; +} + +export const IPermissionService: ServiceIdentifier = + createDecorator('permissionService'); diff --git a/packages/agent-core-v2/src/permission/permissionService.ts b/packages/agent-core-v2/src/permission/permissionService.ts new file mode 100644 index 000000000..a49b8a33d --- /dev/null +++ b/packages/agent-core-v2/src/permission/permissionService.ts @@ -0,0 +1,74 @@ +/** + * `permission` domain (L3) — `IPermissionPolicyRegistry` and + * `IPermissionService` implementations. + * + * Owns the policy registry and the per-agent permission decision; requests user + * approval through `approval`, reads agent config through `config`, records + * through `records`, and logs through `log`. Bound at Core (policy registry) + * and Agent (decision service) scopes. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IApprovalService } from '#/approval/approval'; +import { IAgentConfigService } from '#/config/config'; +import { ILogService } from '#/log/log'; +import { IAgentRecords } from '#/records/records'; + +import { + type Decision, + type PermissionContext, + type PermissionPolicy, + IPermissionPolicyRegistry, + IPermissionService, +} from './permission'; + +type PermissionMode = 'yolo' | 'manual' | 'auto'; + +export class PermissionPolicyRegistry implements IPermissionPolicyRegistry { + declare readonly _serviceBrand: undefined; + private readonly policies: PermissionPolicy[] = []; + + register(policy: PermissionPolicy): void { + this.policies.push(policy); + } + + evaluate(ctx: PermissionContext): Decision { + for (const policy of this.policies) { + const decision = policy.evaluate(ctx); + if (decision !== undefined) return decision; + } + return 'allow'; + } +} + +export class PermissionService implements IPermissionService { + declare readonly _serviceBrand: undefined; + private readonly mode: PermissionMode; + + constructor( + @IPermissionPolicyRegistry private readonly registry: IPermissionPolicyRegistry, + @IAgentConfigService _agentConfig: IAgentConfigService, + @IAgentRecords _records: IAgentRecords, + @IApprovalService private readonly approval: IApprovalService, + @ILogService _log: ILogService, + mode: PermissionMode = 'auto', + ) { + this.mode = mode; + } + + async beforeToolCall(ctx: PermissionContext): Promise { + if (this.mode === 'yolo') return 'allow'; + if (this.mode === 'manual') { + return this.approval.request({ id: ctx.toolName, toolName: ctx.toolName }); + } + const decision = this.registry.evaluate(ctx); + if (decision === 'ask') { + return this.approval.request({ id: ctx.toolName, toolName: ctx.toolName }); + } + return decision; + } +} + +registerScopedService(LifecycleScope.Core, IPermissionPolicyRegistry, PermissionPolicyRegistry, InstantiationType.Delayed, 'permission'); +registerScopedService(LifecycleScope.Agent, IPermissionService, PermissionService, InstantiationType.Delayed, 'permission'); diff --git a/packages/agent-core-v2/src/plan/index.ts b/packages/agent-core-v2/src/plan/index.ts new file mode 100644 index 000000000..8d5c97a0b --- /dev/null +++ b/packages/agent-core-v2/src/plan/index.ts @@ -0,0 +1,8 @@ +/** + * `plan` domain barrel — re-exports the plan contract (`plan`) and its scoped + * service (`planService`). Importing this barrel registers the `IPlanService` + * binding into the scope registry. + */ + +export * from './plan'; +export * from './planService'; diff --git a/packages/agent-core-v2/src/plan/plan.ts b/packages/agent-core-v2/src/plan/plan.ts new file mode 100644 index 000000000..e819b7ffb --- /dev/null +++ b/packages/agent-core-v2/src/plan/plan.ts @@ -0,0 +1,21 @@ +/** + * `plan` domain (L4) — plan-mode state machine. + * + * Defines the public contract of plan mode: the `IPlanService` used to enter, + * cancel, exit, and clear plan mode and to query whether it is active. + * Agent-scoped — one instance per agent. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IPlanService { + readonly _serviceBrand: undefined; + readonly active: boolean; + enter(): Promise; + cancel(): void; + exit(): Promise; + clear(): void; +} + +export const IPlanService: ServiceIdentifier = + createDecorator('planService'); diff --git a/packages/agent-core-v2/src/plan/planService.ts b/packages/agent-core-v2/src/plan/planService.ts new file mode 100644 index 000000000..080dbe5df --- /dev/null +++ b/packages/agent-core-v2/src/plan/planService.ts @@ -0,0 +1,57 @@ +/** + * `plan` domain (L4) — `IPlanService` implementation. + * + * Tracks plan-mode activation; reads configuration through `config`, enqueues + * follow-up through `injection`, runs processes through `kaos`, persists + * records through `records`, and observes turns through `turn`. Bound at Agent + * scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentConfigService } from '#/config/config'; +import { IInjectionService } from '#/injection/injection'; +import { IAgentKaos } from '#/kaos/kaos'; +import { IAgentRecords } from '#/records/records'; +import { ITurnService } from '#/turn/turn'; + +import { IPlanService } from './plan'; + +export class PlanService extends Disposable implements IPlanService { + declare readonly _serviceBrand: undefined; + private isActive = false; + + constructor( + @IAgentRecords _records: IAgentRecords, + @IAgentKaos _agentKaos: IAgentKaos, + @IAgentConfigService _agentConfig: IAgentConfigService, + @IInjectionService private readonly injection: IInjectionService, + @ITurnService turn: ITurnService, + ) { + super(); + this._register(turn.onDidEndTurn(() => { this.isActive = false; })); + } + + get active(): boolean { + return this.isActive; + } + + enter(): Promise { + this.isActive = true; + this.injection.push({ kind: 'plan', content: 'Plan mode active — propose a plan before acting.' }); + return Promise.resolve(); + } + cancel(): void { + this.isActive = false; + } + exit(): Promise { + this.isActive = false; + return Promise.resolve(); + } + clear(): void { + this.isActive = false; + } +} + +registerScopedService(LifecycleScope.Agent, IPlanService, PlanService, InstantiationType.Delayed, 'plan'); diff --git a/packages/agent-core-v2/src/question/index.ts b/packages/agent-core-v2/src/question/index.ts new file mode 100644 index 000000000..c5ace199e --- /dev/null +++ b/packages/agent-core-v2/src/question/index.ts @@ -0,0 +1,8 @@ +/** + * `question` domain barrel — re-exports the question contract (`question`) and + * its scoped service (`questionService`). Importing this barrel registers the + * `IQuestionService` binding into the scope registry. + */ + +export * from './question'; +export * from './questionService'; diff --git a/packages/agent-core-v2/src/question/question.ts b/packages/agent-core-v2/src/question/question.ts new file mode 100644 index 000000000..7bb0f86d2 --- /dev/null +++ b/packages/agent-core-v2/src/question/question.ts @@ -0,0 +1,24 @@ +/** + * `question` domain (L7) — ask-user request broker. + * + * Defines the public contract of asking the user: the `QuestionRequest` model + * and the `IQuestionService` used to post a request, supply its answer, and + * list pending requests. Session-scoped — one instance per session. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface QuestionRequest { + readonly id: string; + readonly prompt: string; +} + +export interface IQuestionService { + readonly _serviceBrand: undefined; + request(req: QuestionRequest): Promise; + answer(id: string, answer: string): void; + listPending(): readonly QuestionRequest[]; +} + +export const IQuestionService: ServiceIdentifier = + createDecorator('questionService'); diff --git a/packages/agent-core-v2/src/question/questionService.ts b/packages/agent-core-v2/src/question/questionService.ts new file mode 100644 index 000000000..f80e669d9 --- /dev/null +++ b/packages/agent-core-v2/src/question/questionService.ts @@ -0,0 +1,39 @@ +/** + * `question` domain (L7) — `IQuestionService` implementation. + * + * Brokers ask-user requests and resolves their answers. Bound at Session scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; + +import { type QuestionRequest, IQuestionService } from './question'; + +interface Pending { + readonly req: QuestionRequest; + readonly resolve: (answer: string) => void; +} + +export class QuestionService implements IQuestionService { + declare readonly _serviceBrand: undefined; + private readonly pending = new Map(); + + request(req: QuestionRequest): Promise { + return new Promise((resolve) => { + this.pending.set(req.id, { req, resolve }); + }); + } + + answer(id: string, answer: string): void { + const entry = this.pending.get(id); + if (entry === undefined) return; + this.pending.delete(id); + entry.resolve(answer); + } + + listPending(): readonly QuestionRequest[] { + return [...this.pending.values()].map((p) => p.req); + } +} + +registerScopedService(LifecycleScope.Session, IQuestionService, QuestionService, InstantiationType.Delayed, 'question'); diff --git a/packages/agent-core-v2/src/records/index.ts b/packages/agent-core-v2/src/records/index.ts new file mode 100644 index 000000000..802fac8fd --- /dev/null +++ b/packages/agent-core-v2/src/records/index.ts @@ -0,0 +1,8 @@ +/** + * `records` domain barrel — re-exports the records contract (`records`) and + * its scoped services (`recordsService`). Importing this barrel registers the + * records bindings into the scope registry. + */ + +export * from './records'; +export * from './recordsService'; diff --git a/packages/agent-core-v2/src/records/records.ts b/packages/agent-core-v2/src/records/records.ts new file mode 100644 index 000000000..fd9c3d81b --- /dev/null +++ b/packages/agent-core-v2/src/records/records.ts @@ -0,0 +1,44 @@ +/** + * `records` domain (L2) — persistence and replay contracts. + * + * Defines the records service identifiers and the `AgentRecord` model used by + * other domains to read and write session state, session metadata, and the + * agent record stream. Spans Core, Session, and Agent scopes — each store lives + * at the scope matching the data it owns. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ISessionStore { + readonly _serviceBrand: undefined; + read(sessionId: string): Promise; + write(sessionId: string, data: unknown): Promise; +} + +export const ISessionStore: ServiceIdentifier = + createDecorator('sessionStore'); + +export interface ISessionMetaStore { + readonly _serviceBrand: undefined; + read(): Promise>; + write(patch: Record): Promise; + flush(): Promise; +} + +export const ISessionMetaStore: ServiceIdentifier = + createDecorator('sessionMetaStore'); + +export interface AgentRecord { + readonly kind: string; + readonly payload: unknown; +} + +export interface IAgentRecords { + readonly _serviceBrand: undefined; + logRecord(record: AgentRecord): Promise; + replay(): AsyncIterable; + restore(): Promise; +} + +export const IAgentRecords: ServiceIdentifier = + createDecorator('agentRecords'); diff --git a/packages/agent-core-v2/src/records/recordsService.ts b/packages/agent-core-v2/src/records/recordsService.ts new file mode 100644 index 000000000..35f5d646b --- /dev/null +++ b/packages/agent-core-v2/src/records/recordsService.ts @@ -0,0 +1,134 @@ +/** + * `records` domain (L2) — `ISessionStore`, `ISessionMetaStore`, and + * `IAgentRecords` implementations. + * + * Owns session state, session metadata, and the agent record stream; persists + * through `kaos` and logs through `log`. Bound at Core (session store), Session + * (session metadata), and Agent (agent records) scopes. + */ + +import { createHash } from 'node:crypto'; + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { slugifyWorkDirName } from '#/_base/utils/workdir-slug'; +import { IKaosFactory, IAgentKaos, ISessionKaosService } from '#/kaos/kaos'; +import { ILogService } from '#/log/log'; + +import { + type AgentRecord, + IAgentRecords, + ISessionMetaStore, + ISessionStore, +} from './records'; + +const WORKDIR_KEY_PREFIX = 'wd_'; +const HASH_LENGTH = 12; + +export function encodeWorkDirKey(workDir: string): string { + const normalized = workDir.replace(/\\/g, '/').replace(/\/+$/, ''); + const base = normalized.split('/').pop() ?? normalized; + const slug = slugifyWorkDirName(base); + const hash = createHash('sha256').update(normalized).digest('hex').slice(0, HASH_LENGTH); + return `${WORKDIR_KEY_PREFIX}${slug}_${hash}`; +} + +export class SessionStore implements ISessionStore { + declare readonly _serviceBrand: undefined; + constructor(@IKaosFactory _kaosFactory: IKaosFactory) {} + + sessionDir(sessionsRoot: string, workDir: string, sessionId: string): string { + return `${sessionsRoot}/${encodeWorkDirKey(workDir)}/${sessionId}`; + } + + read(_sessionId: string): Promise { + throw new Error('TODO: SessionStore.read'); + } + write(_sessionId: string, _data: unknown): Promise { + throw new Error('TODO: SessionStore.write'); + } +} + +export class SessionMetaStore extends Disposable implements ISessionMetaStore { + declare readonly _serviceBrand: undefined; + private data: Record = {}; + private readonly path: string; + + constructor( + @ISessionKaosService private readonly sessionKaos: ISessionKaosService, + @ILogService _log: ILogService, + path: string = 'state.json', + ) { + super(); + this.path = path; + } + + async read(): Promise> { + try { + const text = await this.sessionKaos.persistenceKaos.readText(this.path); + this.data = JSON.parse(text) as Record; + } catch { + this.data = {}; + } + return this.data; + } + + async write(patch: Record): Promise { + this.data = { ...this.data, ...patch }; + await this.flush(); + } + + async flush(): Promise { + await this.sessionKaos.persistenceKaos.writeText( + this.path, + JSON.stringify(this.data, null, 2), + ); + } +} + +export class AgentRecords extends Disposable implements IAgentRecords { + declare readonly _serviceBrand: undefined; + private readonly path: string; + + constructor( + @IAgentKaos private readonly agentKaos: IAgentKaos, + @ILogService _log: ILogService, + path: string = 'wire.jsonl', + ) { + super(); + this.path = path; + } + + async logRecord(record: AgentRecord): Promise { + const line = `${JSON.stringify(record)}\n`; + let existing = ''; + try { + existing = await this.agentKaos.kaos.readText(this.path); + } catch { + } + await this.agentKaos.kaos.writeText(this.path, existing + line); + } + + async *replay(): AsyncIterable { + let text: string; + try { + text = await this.agentKaos.kaos.readText(this.path); + } catch { + return; + } + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + yield JSON.parse(trimmed) as AgentRecord; + } + } + + restore(): Promise { + throw new Error('TODO: AgentRecords.restore'); + } +} + +registerScopedService(LifecycleScope.Core, ISessionStore, SessionStore, InstantiationType.Delayed, 'records'); +registerScopedService(LifecycleScope.Session, ISessionMetaStore, SessionMetaStore, InstantiationType.Delayed, 'records'); +registerScopedService(LifecycleScope.Agent, IAgentRecords, AgentRecords, InstantiationType.Delayed, 'records'); diff --git a/packages/agent-core-v2/src/session-activity/index.ts b/packages/agent-core-v2/src/session-activity/index.ts new file mode 100644 index 000000000..45331e0c4 --- /dev/null +++ b/packages/agent-core-v2/src/session-activity/index.ts @@ -0,0 +1,9 @@ +/** + * `session-activity` domain barrel — re-exports the session-activity contract + * (`sessionActivity`) and its scoped service (`sessionActivityService`). + * Importing this barrel registers the `ISessionActivity` binding into the scope + * registry. + */ + +export * from './sessionActivity'; +export * from './sessionActivityService'; diff --git a/packages/agent-core-v2/src/session-activity/sessionActivity.ts b/packages/agent-core-v2/src/session-activity/sessionActivity.ts new file mode 100644 index 000000000..62c43f8e4 --- /dev/null +++ b/packages/agent-core-v2/src/session-activity/sessionActivity.ts @@ -0,0 +1,17 @@ +/** + * `session-activity` domain (L6) — session-level idle predicate. + * + * Defines the public contract of session activity: the `ISessionActivity` used + * to query whether the session is idle. Session-scoped — one instance per + * session. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ISessionActivity { + readonly _serviceBrand: undefined; + isIdle(): boolean; +} + +export const ISessionActivity: ServiceIdentifier = + createDecorator('sessionActivity'); diff --git a/packages/agent-core-v2/src/session-activity/sessionActivityService.ts b/packages/agent-core-v2/src/session-activity/sessionActivityService.ts new file mode 100644 index 000000000..98f4c8a74 --- /dev/null +++ b/packages/agent-core-v2/src/session-activity/sessionActivityService.ts @@ -0,0 +1,30 @@ +/** + * `session-activity` domain (L6) — `ISessionActivity` implementation. + * + * Derives session idleness by checking each agent's turn; drives agent + * lifecycle through `agent-lifecycle` and observes turns through `turn`. Bound + * at Session scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle'; +import { ITurnService } from '#/turn/turn'; + +import { ISessionActivity } from './sessionActivity'; + +export class SessionActivity implements ISessionActivity { + declare readonly _serviceBrand: undefined; + + constructor(@IAgentLifecycleService private readonly agents: IAgentLifecycleService) {} + + isIdle(): boolean { + for (const handle of this.agents.list()) { + const turn = handle.accessor.get(ITurnService); + if (turn.hasActiveTurn) return false; + } + return true; + } +} + +registerScopedService(LifecycleScope.Session, ISessionActivity, SessionActivity, InstantiationType.Delayed, 'session-activity'); diff --git a/packages/agent-core-v2/src/session-context/index.ts b/packages/agent-core-v2/src/session-context/index.ts new file mode 100644 index 000000000..d1f572417 --- /dev/null +++ b/packages/agent-core-v2/src/session-context/index.ts @@ -0,0 +1,6 @@ +/** + * `session-context` domain barrel — re-exports the `ISessionContext` contract + * and its `sessionContextSeed` helper (`sessionContext`). + */ + +export * from './sessionContext'; diff --git a/packages/agent-core-v2/src/session-context/sessionContext.ts b/packages/agent-core-v2/src/session-context/sessionContext.ts new file mode 100644 index 000000000..1bf6fe239 --- /dev/null +++ b/packages/agent-core-v2/src/session-context/sessionContext.ts @@ -0,0 +1,31 @@ +/** + * `session-context` domain (L6) — seeded per-session context token. + * + * Defines the `ISessionContext` contract carrying the session id and the session + * `meta` store, and the `sessionContextSeed` helper that seeds it into a Session + * scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { ISessionMetaStore } from '#/records/records'; + +export interface ISessionContext { + readonly sessionId: string; + readonly meta: ISessionMetaStore; +} + +export const ISessionContext: ServiceIdentifier = + createDecorator('sessionContext'); + +export function sessionContextSeed( + sessionId: string, + meta: ISessionMetaStore, +): ScopeSeed { + return [ + [ + ISessionContext as ServiceIdentifier, + { sessionId, meta } satisfies ISessionContext, + ], + ]; +} diff --git a/packages/agent-core-v2/src/session/index.ts b/packages/agent-core-v2/src/session/index.ts new file mode 100644 index 000000000..0e261f943 --- /dev/null +++ b/packages/agent-core-v2/src/session/index.ts @@ -0,0 +1,8 @@ +/** + * `session` domain barrel — re-exports the session facade contract + * (`session`) and its scoped service (`sessionService`). Importing this + * barrel registers the `ISessionService` binding into the scope registry. + */ + +export * from './session'; +export * from './sessionService'; diff --git a/packages/agent-core-v2/src/session/session.ts b/packages/agent-core-v2/src/session/session.ts new file mode 100644 index 000000000..89643e7ab --- /dev/null +++ b/packages/agent-core-v2/src/session/session.ts @@ -0,0 +1,28 @@ +/** + * `session` domain (L6) — session facade. + * + * Defines the public contract of a session: the `SessionStatus` model and the + * `ISessionService` used by upper layers to query status, manage child agents + * (`fork` / `listChildren`), and run session operations (`compact` / `undo` / + * `archive`). Session-scoped — one instance per session. The agent loop itself + * is driven by `agent-lifecycle` and `turn`, not here. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IScopeHandle } from '#/_base/di/scope'; + +export type SessionStatus = 'running' | 'idle' | 'awaiting_approval'; + +export interface ISessionService { + readonly _serviceBrand: undefined; + status(): SessionStatus; + agents(): readonly IScopeHandle[]; + fork(): Promise; + listChildren(): readonly IScopeHandle[]; + compact(): Promise; + undo(): Promise; + archive(): Promise; +} + +export const ISessionService: ServiceIdentifier = + createDecorator('sessionService'); diff --git a/packages/agent-core-v2/src/session/sessionService.ts b/packages/agent-core-v2/src/session/sessionService.ts new file mode 100644 index 000000000..3438833ad --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionService.ts @@ -0,0 +1,54 @@ +/** + * `session` domain (L6) — `ISessionService` implementation. + * + * Owns the session's child-agent set and session-level operations; drives + * agent lifecycle through `agent-lifecycle`, broadcasts through `event`, + * persists session metadata through `records`, and records activity through + * `session-activity`. Bound at Session scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { type IScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle'; +import { IEventService } from '#/event/event'; +import { ISessionMetaStore } from '#/records/records'; +import { ISessionActivity } from '#/session-activity/sessionActivity'; + +import { type SessionStatus, ISessionService } from './session'; + +export class SessionService implements ISessionService { + declare readonly _serviceBrand: undefined; + + constructor( + @ISessionMetaStore _meta: ISessionMetaStore, + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, + @ISessionActivity private readonly activity: ISessionActivity, + @IEventService _event: IEventService, + ) {} + + status(): SessionStatus { + return this.activity.isIdle() ? 'idle' : 'running'; + } + + agents(): readonly IScopeHandle[] { + return this.agentLifecycle.list(); + } + + fork(): Promise { + throw new Error('TODO: SessionService.fork'); + } + listChildren(): readonly IScopeHandle[] { + return []; + } + compact(): Promise { + throw new Error('TODO: SessionService.compact'); + } + undo(): Promise { + throw new Error('TODO: SessionService.undo'); + } + archive(): Promise { + throw new Error('TODO: SessionService.archive'); + } +} + +registerScopedService(LifecycleScope.Session, ISessionService, SessionService, InstantiationType.Delayed, 'session'); diff --git a/packages/agent-core-v2/src/skill/index.ts b/packages/agent-core-v2/src/skill/index.ts new file mode 100644 index 000000000..c7fd77624 --- /dev/null +++ b/packages/agent-core-v2/src/skill/index.ts @@ -0,0 +1,8 @@ +/** + * `skill` domain barrel — re-exports the skill contract (`skill`) and its + * scoped services (`skillService`). Importing this barrel registers the + * `ISkillRegistry` and `ISkillService` bindings into the scope registry. + */ + +export * from './skill'; +export * from './skillService'; diff --git a/packages/agent-core-v2/src/skill/skill.ts b/packages/agent-core-v2/src/skill/skill.ts new file mode 100644 index 000000000..7bd590d25 --- /dev/null +++ b/packages/agent-core-v2/src/skill/skill.ts @@ -0,0 +1,35 @@ +/** + * `skill` domain (L3) — session skill registry and per-agent skill service. + * + * Defines the public contract for skills: the `SkillDefinition` model, the + * `ISkillRegistry` used to load roots and register skills, and the + * `ISkillService` used by agents to activate a skill. `ISkillRegistry` is + * Session-scoped (one registry per session); `ISkillService` is Agent-scoped + * (one per agent). + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface SkillDefinition { + readonly name: string; + readonly root: string; +} + +export interface ISkillRegistry { + readonly _serviceBrand: undefined; + loadRoots(roots: readonly string[]): Promise; + register(skill: SkillDefinition): void; + list(): readonly SkillDefinition[]; + get(name: string): SkillDefinition | undefined; +} + +export const ISkillRegistry: ServiceIdentifier = + createDecorator('skillRegistry'); + +export interface ISkillService { + readonly _serviceBrand: undefined; + activate(name: string): Promise; +} + +export const ISkillService: ServiceIdentifier = + createDecorator('skillService'); diff --git a/packages/agent-core-v2/src/skill/skillService.ts b/packages/agent-core-v2/src/skill/skillService.ts new file mode 100644 index 000000000..8beff6217 --- /dev/null +++ b/packages/agent-core-v2/src/skill/skillService.ts @@ -0,0 +1,70 @@ +/** + * `skill` domain (L3) — `ISkillRegistry` and `ISkillService` implementation. + * + * Owns the skill registry and per-agent skill activation; reads configuration + * through `config`, logs through `log`, persists records through `records`, and + * observes turns through `turn`. Registry bound at Session scope; service bound + * at Agent scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IConfigService } from '#/config/config'; +import { ILogService } from '#/log/log'; +import { IAgentRecords } from '#/records/records'; +import { ITurnService } from '#/turn/turn'; + +import { + type SkillDefinition, + ISkillRegistry, + ISkillService, +} from './skill'; + +export class SkillRegistry implements ISkillRegistry { + declare readonly _serviceBrand: undefined; + private readonly skills = new Map(); + private roots: readonly string[] = []; + + constructor( + @IConfigService _config: IConfigService, + @ILogService _log: ILogService, + ) {} + + loadRoots(roots: readonly string[]): Promise { + this.roots = roots; + return Promise.resolve(); + } + + register(skill: SkillDefinition): void { + this.skills.set(skill.name, skill); + } + + list(): readonly SkillDefinition[] { + return [...this.skills.values()]; + } + + get(name: string): SkillDefinition | undefined { + return this.skills.get(name); + } +} + +export class SkillService implements ISkillService { + declare readonly _serviceBrand: undefined; + + constructor( + @ISkillRegistry private readonly registry: ISkillRegistry, + @IAgentRecords _records: IAgentRecords, + @ITurnService private readonly turn: ITurnService, + ) {} + + async activate(name: string): Promise { + const skill = this.registry.get(name); + if (skill === undefined) { + throw new Error(`SkillService.activate: unknown skill '${name}'`); + } + return this.turn.prompt(`Activate skill: ${skill.name}`); + } +} + +registerScopedService(LifecycleScope.Session, ISkillRegistry, SkillRegistry, InstantiationType.Delayed, 'skill'); +registerScopedService(LifecycleScope.Agent, ISkillService, SkillService, InstantiationType.Delayed, 'skill'); diff --git a/packages/agent-core-v2/src/swarm/index.ts b/packages/agent-core-v2/src/swarm/index.ts new file mode 100644 index 000000000..c517f724c --- /dev/null +++ b/packages/agent-core-v2/src/swarm/index.ts @@ -0,0 +1,8 @@ +/** + * `swarm` domain barrel — re-exports the swarm contract (`swarm`) and its + * scoped service (`swarmService`). Importing this barrel registers the + * `ISwarmService` binding into the scope registry. + */ + +export * from './swarm'; +export * from './swarmService'; diff --git a/packages/agent-core-v2/src/swarm/swarm.ts b/packages/agent-core-v2/src/swarm/swarm.ts new file mode 100644 index 000000000..408c82e35 --- /dev/null +++ b/packages/agent-core-v2/src/swarm/swarm.ts @@ -0,0 +1,19 @@ +/** + * `swarm` domain (L4) — multi-agent swarm mode. + * + * Defines the public contract of swarm mode: the `ISwarmService` used to enter + * and exit swarm mode and to query whether it is active. Agent-scoped — one + * instance per agent. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ISwarmService { + readonly _serviceBrand: undefined; + readonly active: boolean; + enter(): Promise; + exit(): void; +} + +export const ISwarmService: ServiceIdentifier = + createDecorator('swarmService'); diff --git a/packages/agent-core-v2/src/swarm/swarmService.ts b/packages/agent-core-v2/src/swarm/swarmService.ts new file mode 100644 index 000000000..696713aa3 --- /dev/null +++ b/packages/agent-core-v2/src/swarm/swarmService.ts @@ -0,0 +1,43 @@ +/** + * `swarm` domain (L4) — `ISwarmService` implementation. + * + * Tracks whether swarm mode is active; drives agent lifecycle through + * `agent-lifecycle`, checks permissions through `permission`, and persists + * records through `records`. Bound at Agent scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle'; +import { IPermissionService } from '#/permission/permission'; +import { IAgentRecords } from '#/records/records'; + +import { ISwarmService } from './swarm'; + +export class SwarmService extends Disposable implements ISwarmService { + declare readonly _serviceBrand: undefined; + private isActive = false; + + constructor( + @IAgentRecords _records: IAgentRecords, + @IAgentLifecycleService _agentLifecycle: IAgentLifecycleService, + @IPermissionService _permission: IPermissionService, + ) { + super(); + } + + get active(): boolean { + return this.isActive; + } + + enter(): Promise { + this.isActive = true; + return Promise.resolve(); + } + exit(): void { + this.isActive = false; + } +} + +registerScopedService(LifecycleScope.Agent, ISwarmService, SwarmService, InstantiationType.Delayed, 'swarm'); diff --git a/packages/agent-core-v2/src/telemetry/index.ts b/packages/agent-core-v2/src/telemetry/index.ts new file mode 100644 index 000000000..39e02917d --- /dev/null +++ b/packages/agent-core-v2/src/telemetry/index.ts @@ -0,0 +1,8 @@ +/** + * `telemetry` domain barrel — re-exports the `telemetry` contract and its + * scoped service (`telemetryService`). Importing this barrel registers the + * `ITelemetryService` binding into the scope registry. + */ + +export * from './telemetry'; +export * from './telemetryService'; diff --git a/packages/agent-core-v2/src/telemetry/telemetry.ts b/packages/agent-core-v2/src/telemetry/telemetry.ts new file mode 100644 index 000000000..d99241416 --- /dev/null +++ b/packages/agent-core-v2/src/telemetry/telemetry.ts @@ -0,0 +1,37 @@ +/** + * `telemetry` domain (L1) — telemetry event tracking facade. + * + * Defines the public contract of telemetry: the `TelemetryContext` / + * `TelemetryProperties` model and the `ITelemetryService` used by other + * domains to record events, plus the `TelemetryClient` it delegates to. + * Core-scoped — one shared instance for the process. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export type TelemetryPropertyValue = boolean | number | string | undefined | null; + +export type TelemetryProperties = Readonly>; + +export interface TelemetryContext { + readonly sessionId?: string; + readonly agentId?: string; + readonly turnId?: string; +} + +export interface TelemetryClient { + track(event: string, properties?: TelemetryProperties): void; +} + +export const noopTelemetryClient: TelemetryClient = { + track: () => {}, +}; + +export interface ITelemetryService { + readonly _serviceBrand: undefined; + track(event: string, properties?: TelemetryProperties): void; + withContext(patch: TelemetryContext): ITelemetryService; +} + +export const ITelemetryService: ServiceIdentifier = + createDecorator('telemetryService'); diff --git a/packages/agent-core-v2/src/telemetry/telemetryService.ts b/packages/agent-core-v2/src/telemetry/telemetryService.ts new file mode 100644 index 000000000..dfa1ad213 --- /dev/null +++ b/packages/agent-core-v2/src/telemetry/telemetryService.ts @@ -0,0 +1,50 @@ +/** + * `telemetry` domain (L1) — `ITelemetryService` implementation. + * + * Merges the bound `TelemetryContext` into each event and forwards it to the + * configured `TelemetryClient`; supports child contexts via `withContext`. + * Bound at Core scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; + +import { + type TelemetryClient, + type TelemetryContext, + type TelemetryProperties, + ITelemetryService, + noopTelemetryClient, +} from './telemetry'; + +export class TelemetryService implements ITelemetryService { + declare readonly _serviceBrand: undefined; + private delegate: TelemetryClient; + + constructor(private readonly context: TelemetryContext = {}) { + this.delegate = noopTelemetryClient; + } + + setDelegate(client: TelemetryClient): void { + this.delegate = client; + } + + track(event: string, properties?: TelemetryProperties): void { + const merged: TelemetryProperties = { ...this.context, ...properties }; + this.delegate.track(event, merged); + } + + withContext(patch: TelemetryContext): ITelemetryService { + const child = new TelemetryService({ ...this.context, ...patch }); + child.delegate = this.delegate; + return child; + } +} + +registerScopedService( + LifecycleScope.Core, + ITelemetryService, + TelemetryService, + InstantiationType.Eager, + 'telemetry', +); diff --git a/packages/agent-core-v2/src/terminal/index.ts b/packages/agent-core-v2/src/terminal/index.ts new file mode 100644 index 000000000..b4de489fa --- /dev/null +++ b/packages/agent-core-v2/src/terminal/index.ts @@ -0,0 +1,8 @@ +/** + * `terminal` domain barrel — re-exports the terminal contract (`terminal`) and + * its scoped service (`terminalService`). Importing this barrel registers the + * `ITerminalService` binding into the scope registry. + */ + +export * from './terminal'; +export * from './terminalService'; diff --git a/packages/agent-core-v2/src/terminal/terminal.ts b/packages/agent-core-v2/src/terminal/terminal.ts new file mode 100644 index 000000000..179461dce --- /dev/null +++ b/packages/agent-core-v2/src/terminal/terminal.ts @@ -0,0 +1,23 @@ +/** + * `terminal` domain (cross-cutting) — session-scope terminal service. + * + * Defines the public contract of terminal management: the `TerminalHandle` + * model and the `ITerminalService` used to spawn processes, write to stdin, + * and kill terminals. Session-scoped — one service per session. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface TerminalHandle { + readonly id: string; +} + +export interface ITerminalService { + readonly _serviceBrand: undefined; + spawn(cmd: string, args: readonly string[]): Promise; + write(id: string, data: string): void; + kill(id: string): Promise; +} + +export const ITerminalService: ServiceIdentifier = + createDecorator('terminalService'); diff --git a/packages/agent-core-v2/src/terminal/terminalService.ts b/packages/agent-core-v2/src/terminal/terminalService.ts new file mode 100644 index 000000000..c3d9637d6 --- /dev/null +++ b/packages/agent-core-v2/src/terminal/terminalService.ts @@ -0,0 +1,48 @@ +/** + * `terminal` domain (cross-cutting) — `ITerminalService` implementation. + * + * Owns the spawned terminal processes and their lifecycle; runs processes + * through `kaos` and logs through `log`. Bound at Session scope. + */ + +import type { KaosProcess } from '@moonshot-ai/kaos'; + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { ISessionKaosService } from '#/kaos/kaos'; +import { ILogService } from '#/log/log'; + +import { type TerminalHandle, ITerminalService } from './terminal'; + +export class TerminalService extends Disposable implements ITerminalService { + declare readonly _serviceBrand: undefined; + private readonly processes = new Map(); + + constructor( + @ILogService _log: ILogService, + @ISessionKaosService private readonly sessionKaos: ISessionKaosService, + ) { + super(); + } + + async spawn(cmd: string, args: readonly string[]): Promise { + const proc = await this.sessionKaos.toolKaos.exec(cmd, ...args); + const id = String(proc.pid); + this.processes.set(id, proc); + return { id }; + } + + write(id: string, data: string): void { + this.processes.get(id)?.stdin.write(data); + } + + async kill(id: string): Promise { + const proc = this.processes.get(id); + if (proc === undefined) return; + await proc.kill(); + this.processes.delete(id); + } +} + +registerScopedService(LifecycleScope.Session, ITerminalService, TerminalService, InstantiationType.Delayed, 'terminal'); diff --git a/packages/agent-core-v2/src/tool/index.ts b/packages/agent-core-v2/src/tool/index.ts new file mode 100644 index 000000000..3e8bc8b40 --- /dev/null +++ b/packages/agent-core-v2/src/tool/index.ts @@ -0,0 +1,8 @@ +/** + * `tool` domain barrel — re-exports the tool contract (`tool`) and its + * scoped services (`toolService`). Importing this barrel registers the + * `IToolDefinitionRegistry` and `IToolService` bindings into the scope registry. + */ + +export * from './tool'; +export * from './toolService'; diff --git a/packages/agent-core-v2/src/tool/tool.ts b/packages/agent-core-v2/src/tool/tool.ts new file mode 100644 index 000000000..bd1655724 --- /dev/null +++ b/packages/agent-core-v2/src/tool/tool.ts @@ -0,0 +1,41 @@ +/** + * `tool` domain (L3) — tool-definition registry and per-agent tool service. + * + * Defines the public contract for tools: the `ToolDefinition` and + * `ToolCallResult` models, the `IToolDefinitionRegistry` used to register and + * look up tool definitions, and the `IToolService` used by agents to execute + * tools. `IToolDefinitionRegistry` is Core-scoped (one shared registry); + * `IToolService` is Agent-scoped (one per agent). + */ + +import { createDecorator, type ServiceIdentifier, type ServicesAccessor } from '#/_base/di/instantiation'; + +export interface ToolDefinition { + readonly name: string; + readonly factory: (accessor: ServicesAccessor) => unknown; +} + +export interface IToolDefinitionRegistry { + readonly _serviceBrand: undefined; + register(def: ToolDefinition): void; + get(name: string): ToolDefinition | undefined; + list(): readonly ToolDefinition[]; +} + +export const IToolDefinitionRegistry: ServiceIdentifier = + createDecorator('toolDefinitionRegistry'); + +export interface ToolCallResult { + readonly output: string; +} + +export interface IToolService { + readonly _serviceBrand: undefined; + execute(name: string, args: unknown): Promise; + list(): readonly ToolDefinition[]; + registerUserTool(def: ToolDefinition): void; + registerMcpTools(serverId: string, tools: readonly ToolDefinition[]): void; +} + +export const IToolService: ServiceIdentifier = + createDecorator('toolService'); diff --git a/packages/agent-core-v2/src/tool/toolService.ts b/packages/agent-core-v2/src/tool/toolService.ts new file mode 100644 index 000000000..5fb2943da --- /dev/null +++ b/packages/agent-core-v2/src/tool/toolService.ts @@ -0,0 +1,114 @@ +/** + * `tool` domain (L3) — `IToolDefinitionRegistry` and `IToolService` + * implementation. + * + * Owns the tool-definition registry and per-agent tool execution; reads + * configuration through `config`, runs processes through `kaos`, drives LLM + * generation through `kosong`, checks permissions through `permission`, and + * persists records through `records`. Registry bound at Core scope; service + * bound at Agent scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { + IInstantiationService, + type ServiceIdentifier, + type ServicesAccessor, +} from '#/_base/di/instantiation'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentConfigService } from '#/config/config'; +import { IAgentKaos } from '#/kaos/kaos'; +import { ILLMService } from '#/kosong/kosong'; +import { IPermissionService } from '#/permission/permission'; +import { IAgentRecords } from '#/records/records'; + +import { + type ToolCallResult, + type ToolDefinition, + IToolDefinitionRegistry, + IToolService, +} from './tool'; + +interface ExecutableTool { + execute(args: unknown): Promise; +} + +function asExecutable(instance: unknown): ExecutableTool { + if ( + typeof instance === 'object' && + instance !== null && + typeof (instance as { execute?: unknown }).execute === 'function' + ) { + return instance as ExecutableTool; + } + throw new Error('tool factory did not return an executable tool (missing execute)'); +} + +export class ToolDefinitionRegistry implements IToolDefinitionRegistry { + declare readonly _serviceBrand: undefined; + private readonly defs = new Map(); + + register(def: ToolDefinition): void { + this.defs.set(def.name, def); + } + get(name: string): ToolDefinition | undefined { + return this.defs.get(name); + } + list(): readonly ToolDefinition[] { + return [...this.defs.values()]; + } +} + +export class ToolService implements IToolService { + declare readonly _serviceBrand: undefined; + private readonly user = new Map(); + private readonly mcp = new Map(); + private readonly accessor: ServicesAccessor; + + constructor( + @IToolDefinitionRegistry private readonly registry: IToolDefinitionRegistry, + @IAgentConfigService _agentConfig: IAgentConfigService, + @IAgentRecords _records: IAgentRecords, + @IAgentKaos _agentKaos: IAgentKaos, + @IPermissionService _permission: IPermissionService, + @ILLMService _llm: ILLMService, + @IInstantiationService instantiation: IInstantiationService, + ) { + this.accessor = { + get: (id: ServiceIdentifier): T => instantiation.invokeFunction((a) => a.get(id)), + }; + } + + private build(def: ToolDefinition): ExecutableTool { + return asExecutable(def.factory(this.accessor)); + } + + private find(name: string): ToolDefinition | undefined { + return this.user.get(name) ?? this.mcp.get(name) ?? this.registry.get(name); + } + + async execute(name: string, args: unknown): Promise { + const def = this.find(name); + if (def === undefined) { + throw new Error(`ToolService.execute: unknown tool '${name}'`); + } + return this.build(def).execute(args); + } + + list(): readonly ToolDefinition[] { + return [...this.registry.list(), ...this.user.values(), ...this.mcp.values()]; + } + + registerUserTool(def: ToolDefinition): void { + this.user.set(def.name, def); + } + + registerMcpTools(serverId: string, tools: readonly ToolDefinition[]): void { + for (const def of tools) { + this.mcp.set(`${serverId}:${def.name}`, def); + } + } +} + +registerScopedService(LifecycleScope.Core, IToolDefinitionRegistry, ToolDefinitionRegistry, InstantiationType.Delayed, 'tool'); +registerScopedService(LifecycleScope.Agent, IToolService, ToolService, InstantiationType.Delayed, 'tool'); diff --git a/packages/agent-core-v2/src/tooldedup/index.ts b/packages/agent-core-v2/src/tooldedup/index.ts new file mode 100644 index 000000000..5e52130d3 --- /dev/null +++ b/packages/agent-core-v2/src/tooldedup/index.ts @@ -0,0 +1,8 @@ +/** + * `tooldedup` domain barrel — re-exports the tool-call deduplication + * contract (`tooldedup`) and its scoped service (`tooldedupService`). Importing + * this barrel registers the `IToolDedupService` binding into the scope registry. + */ + +export * from './tooldedup'; +export * from './tooldedupService'; diff --git a/packages/agent-core-v2/src/tooldedup/tooldedup.ts b/packages/agent-core-v2/src/tooldedup/tooldedup.ts new file mode 100644 index 000000000..2d1f4598e --- /dev/null +++ b/packages/agent-core-v2/src/tooldedup/tooldedup.ts @@ -0,0 +1,18 @@ +/** + * `tooldedup` domain (L4) — per-turn tool-call deduplication. + * + * Defines the public contract for tool-call deduplication: the + * `IToolDedupService` used within a turn to detect repeated calls in the same + * step and to finalize a call. Turn-scoped — one instance per turn. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IToolDedupService { + readonly _serviceBrand: undefined; + checkSameStep(toolCallId: string, args: unknown): boolean; + finalize(toolCallId: string): void; +} + +export const IToolDedupService: ServiceIdentifier = + createDecorator('toolDedupService'); diff --git a/packages/agent-core-v2/src/tooldedup/tooldedupService.ts b/packages/agent-core-v2/src/tooldedup/tooldedupService.ts new file mode 100644 index 000000000..755c4da6e --- /dev/null +++ b/packages/agent-core-v2/src/tooldedup/tooldedupService.ts @@ -0,0 +1,56 @@ +/** + * `tooldedup` domain (L4) — `IToolDedupService` implementation. + * + * Tracks tool calls within a turn to detect same-step repeats and consecutive + * streaks; reports telemetry through `telemetry` and observes turns through + * `turn`. Bound at Turn scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { ITelemetryService } from '#/telemetry/telemetry'; +import { ITurnContext } from '#/turn/turn'; + +import { IToolDedupService } from './tooldedup'; + +function fingerprint(args: unknown): string { + return JSON.stringify(args); +} + +export class ToolDedupService extends Disposable implements IToolDedupService { + declare readonly _serviceBrand: undefined; + private readonly seenThisStep = new Set(); + private lastFingerprint: string | undefined; + private streak = 0; + + constructor( + @ITelemetryService _telemetry: ITelemetryService, + @ITurnContext _turnContext: ITurnContext, + ) { + super(); + } + + checkSameStep(toolCallId: string, args: unknown): boolean { + const key = `${toolCallId}:${fingerprint(args)}`; + if (this.seenThisStep.has(key)) return true; + this.seenThisStep.add(key); + return false; + } + + finalize(toolCallId: string): void { + const fp = toolCallId; + if (fp === this.lastFingerprint) { + this.streak += 1; + } else { + this.lastFingerprint = fp; + this.streak = 1; + } + } + + get currentStreak(): number { + return this.streak; + } +} + +registerScopedService(LifecycleScope.Turn, IToolDedupService, ToolDedupService, InstantiationType.Delayed, 'tooldedup'); diff --git a/packages/agent-core-v2/src/turn/index.ts b/packages/agent-core-v2/src/turn/index.ts new file mode 100644 index 000000000..cb2583b07 --- /dev/null +++ b/packages/agent-core-v2/src/turn/index.ts @@ -0,0 +1,9 @@ +/** + * `turn` domain barrel — re-exports the turn contract (`turn`) and its scoped + * services (`turnService`, `loopRunner`). Importing this barrel registers the + * `ITurnService` and `ILoopRunner` bindings into the scope registry. + */ + +export * from './turn'; +export * from './turnService'; +export * from './loopRunner'; diff --git a/packages/agent-core-v2/src/turn/loopRunner.ts b/packages/agent-core-v2/src/turn/loopRunner.ts new file mode 100644 index 000000000..043f01a79 --- /dev/null +++ b/packages/agent-core-v2/src/turn/loopRunner.ts @@ -0,0 +1,19 @@ +/** + * `turn` domain (L4) — `ILoopRunner` implementation. + * + * Runs the per-turn loop. Bound at Turn scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; + +import { ILoopRunner } from './turn'; + +export class LoopRunner implements ILoopRunner { + declare readonly _serviceBrand: undefined; + run(): Promise { + return Promise.resolve(); + } +} + +registerScopedService(LifecycleScope.Turn, ILoopRunner, LoopRunner, InstantiationType.Delayed, 'turn'); diff --git a/packages/agent-core-v2/src/turn/turn.ts b/packages/agent-core-v2/src/turn/turn.ts new file mode 100644 index 000000000..a0427b7f2 --- /dev/null +++ b/packages/agent-core-v2/src/turn/turn.ts @@ -0,0 +1,61 @@ +/** + * `turn` domain (L4) — drives the turn lifecycle. + * + * Defines the public contract of a turn: the `ITurnService` used by upper layers + * to start, steer, retry, and cancel a turn and to observe its events, the + * per-turn `ITurnContext`, and the `ILoopRunner` that runs the turn loop. + * `ITurnService` is Agent-scoped; `ILoopRunner` is Turn-scoped. + */ + +import type { Event } from '#/_base/event'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface TurnStartEvent { + readonly turnId: string; +} +export interface TurnToolEvent { + readonly turnId: string; + readonly toolCallId: string; + readonly toolName: string; +} +export interface TurnStepEvent { + readonly turnId: string; + readonly step: number; +} +export interface TurnEndEvent { + readonly turnId: string; + readonly reason: string; +} + +export interface ITurnService { + readonly _serviceBrand: undefined; + readonly onWillStartTurn: Event; + readonly onWillExecuteTool: Event; + readonly onDidFinalizeTool: Event; + readonly onDidEndStep: Event; + readonly onDidEndTurn: Event; + readonly hasActiveTurn: boolean; + readonly currentId: string | undefined; + prompt(input: string): Promise; + steer(content: string, origin?: string): void; + retry(): Promise; + cancel(reason?: string): void; +} + +export const ITurnService: ServiceIdentifier = + createDecorator('turnService'); + +export interface ITurnContext { + readonly turnId: string; +} + +export const ITurnContext: ServiceIdentifier = + createDecorator('turnContext'); + +export interface ILoopRunner { + readonly _serviceBrand: undefined; + run(): Promise; +} + +export const ILoopRunner: ServiceIdentifier = + createDecorator('loopRunner'); diff --git a/packages/agent-core-v2/src/turn/turnService.ts b/packages/agent-core-v2/src/turn/turnService.ts new file mode 100644 index 000000000..61ddc3f41 --- /dev/null +++ b/packages/agent-core-v2/src/turn/turnService.ts @@ -0,0 +1,117 @@ +/** + * `turn` domain (L4) — `ITurnService` implementation. + * + * Drives the turn lifecycle and emits its events; runs the turn loop through + * `loopRunner`, drives agent lifecycle through `agent-lifecycle`, reads + * history through `context`, enqueues follow-up through `injection`, drives + * LLM generation through `kosong`, logs through `log`, checks permissions + * through `permission`, reports telemetry through `telemetry`, executes tools + * through `tool`, and checks usage through `usage`. Bound at Agent scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle'; +import { IContextService } from '#/context/context'; +import { IInjectionService } from '#/injection/injection'; +import { ILLMService } from '#/kosong/kosong'; +import { ILogService } from '#/log/log'; +import { IPermissionService } from '#/permission/permission'; +import { ITelemetryService } from '#/telemetry/telemetry'; +import { IToolService } from '#/tool/tool'; +import { IUsageService } from '#/usage/usage'; + +import { + type TurnEndEvent, + type TurnStartEvent, + type TurnStepEvent, + type TurnToolEvent, + ILoopRunner, + ITurnService, +} from './turn'; + +let nextTurnId = 0; + +export class TurnService extends Disposable implements ITurnService { + declare readonly _serviceBrand: undefined; + + private readonly _onWillStartTurn = this._register(new Emitter()); + readonly onWillStartTurn: Event = this._onWillStartTurn.event; + private readonly _onWillExecuteTool = this._register(new Emitter()); + readonly onWillExecuteTool: Event = this._onWillExecuteTool.event; + private readonly _onDidFinalizeTool = this._register(new Emitter()); + readonly onDidFinalizeTool: Event = this._onDidFinalizeTool.event; + private readonly _onDidEndStep = this._register(new Emitter()); + readonly onDidEndStep: Event = this._onDidEndStep.event; + private readonly _onDidEndTurn = this._register(new Emitter()); + readonly onDidEndTurn: Event = this._onDidEndTurn.event; + + private active: { readonly turnId: string; cancelled: boolean } | undefined; + private readonly steerBuffer: { content: string; origin?: string }[] = []; + + constructor( + @IContextService _context: IContextService, + @IToolService _tool: IToolService, + @IPermissionService _permission: IPermissionService, + @ILLMService _llm: ILLMService, + @IInjectionService _injection: IInjectionService, + @IUsageService _usage: IUsageService, + @ITelemetryService _telemetry: ITelemetryService, + @ILogService _log: ILogService, + @IAgentLifecycleService _agentLifecycle: IAgentLifecycleService, + @ILoopRunner private readonly loopRunner: ILoopRunner, + ) { + super(); + } + + get hasActiveTurn(): boolean { + return this.active !== undefined; + } + get currentId(): string | undefined { + return this.active?.turnId; + } + + async prompt(input: string): Promise { + if (this.active !== undefined) { + this.steer(input); + return; + } + await this.launch(input); + } + + steer(content: string, origin?: string): void { + this.steerBuffer.push({ content, origin }); + } + + retry(): Promise { + throw new Error('TODO: TurnService.retry'); + } + + cancel(reason?: string): void { + if (this.active === undefined) return; + this.active.cancelled = true; + const turnId = this.active.turnId; + this.active = undefined; + this._onDidEndTurn.fire({ turnId, reason: reason ?? 'cancelled' }); + } + + private async launch(input: string): Promise { + const turnId = `turn-${nextTurnId++}`; + this.active = { turnId, cancelled: false }; + this._onWillStartTurn.fire({ turnId }); + try { + await this.loopRunner.run(); + this._onDidEndStep.fire({ turnId, step: 0 }); + } finally { + if (this.active?.turnId === turnId) { + this.active = undefined; + this._onDidEndTurn.fire({ turnId, reason: 'completed' }); + } + } + void input; + } +} + +registerScopedService(LifecycleScope.Agent, ITurnService, TurnService, InstantiationType.Delayed, 'turn'); diff --git a/packages/agent-core-v2/src/usage/index.ts b/packages/agent-core-v2/src/usage/index.ts new file mode 100644 index 000000000..a59c03184 --- /dev/null +++ b/packages/agent-core-v2/src/usage/index.ts @@ -0,0 +1,8 @@ +/** + * `usage` domain barrel — re-exports the usage contract (`usage`) and its + * scoped service (`usageService`). Importing this barrel registers the + * `IUsageService` binding into the scope registry. + */ + +export * from './usage'; +export * from './usageService'; diff --git a/packages/agent-core-v2/src/usage/usage.ts b/packages/agent-core-v2/src/usage/usage.ts new file mode 100644 index 000000000..28150d995 --- /dev/null +++ b/packages/agent-core-v2/src/usage/usage.ts @@ -0,0 +1,22 @@ +/** + * `usage` domain (L4) — per-agent token and cost accounting. + * + * Defines the `UsageTotals` model and the `IUsageService` used to record and + * read token usage. Agent-scoped — one instance per agent. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface UsageTotals { + readonly inputTokens: number; + readonly outputTokens: number; +} + +export interface IUsageService { + readonly _serviceBrand: undefined; + readonly totals: UsageTotals; + record(inputTokens: number, outputTokens: number): void; +} + +export const IUsageService: ServiceIdentifier = + createDecorator('usageService'); diff --git a/packages/agent-core-v2/src/usage/usageService.ts b/packages/agent-core-v2/src/usage/usageService.ts new file mode 100644 index 000000000..a129aff05 --- /dev/null +++ b/packages/agent-core-v2/src/usage/usageService.ts @@ -0,0 +1,38 @@ +/** + * `usage` domain (L4) — `IUsageService` implementation. + * + * Accumulates per-agent token totals; records usage through `records` and + * reports through `telemetry`. Bound at Agent scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentRecords } from '#/records/records'; +import { ITelemetryService } from '#/telemetry/telemetry'; + +import { type UsageTotals, IUsageService } from './usage'; + +export class UsageService extends Disposable implements IUsageService { + declare readonly _serviceBrand: undefined; + private inputTokens = 0; + private outputTokens = 0; + + constructor( + @IAgentRecords _records: IAgentRecords, + @ITelemetryService _telemetry: ITelemetryService, + ) { + super(); + } + + get totals(): UsageTotals { + return { inputTokens: this.inputTokens, outputTokens: this.outputTokens }; + } + + record(inputTokens: number, outputTokens: number): void { + this.inputTokens += inputTokens; + this.outputTokens += outputTokens; + } +} + +registerScopedService(LifecycleScope.Agent, IUsageService, UsageService, InstantiationType.Delayed, 'usage'); diff --git a/packages/agent-core-v2/src/workspace/index.ts b/packages/agent-core-v2/src/workspace/index.ts new file mode 100644 index 000000000..5e20ff364 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/index.ts @@ -0,0 +1,9 @@ +/** + * `workspace` domain barrel — re-exports the workspace contract (`workspace`) + * and its scoped services (`workspaceService`). Importing this barrel registers + * the `IWorkspaceRegistry` and `IWorkspaceFsService` bindings into the scope + * registry. + */ + +export * from './workspace'; +export * from './workspaceService'; diff --git a/packages/agent-core-v2/src/workspace/workspace.ts b/packages/agent-core-v2/src/workspace/workspace.ts new file mode 100644 index 000000000..c467e6e44 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspace.ts @@ -0,0 +1,33 @@ +/** + * `workspace` domain (cross-cutting) — core-scope workspace registry + fs. + * + * Defines the public contracts of workspace management: the `WorkspaceInfo` + * model, the `IWorkspaceRegistry` used to register and look up workspaces, and + * the `IWorkspaceFsService` used to resolve paths within a workspace. + * Core-scoped — shared across the application. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface WorkspaceInfo { + readonly id: string; + readonly root: string; +} + +export interface IWorkspaceRegistry { + readonly _serviceBrand: undefined; + register(root: string): WorkspaceInfo; + get(id: string): WorkspaceInfo | undefined; + list(): readonly WorkspaceInfo[]; +} + +export const IWorkspaceRegistry: ServiceIdentifier = + createDecorator('workspaceRegistry'); + +export interface IWorkspaceFsService { + readonly _serviceBrand: undefined; + resolve(workspaceId: string, rel: string): string; +} + +export const IWorkspaceFsService: ServiceIdentifier = + createDecorator('workspaceFsService'); diff --git a/packages/agent-core-v2/src/workspace/workspaceService.ts b/packages/agent-core-v2/src/workspace/workspaceService.ts new file mode 100644 index 000000000..2a597df2f --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceService.ts @@ -0,0 +1,64 @@ +/** + * `workspace` domain (cross-cutting) — `IWorkspaceRegistry` / + * `IWorkspaceFsService` implementation. + * + * Owns the workspace registry and path resolution; resolves filesystem access + * through `kaos` and logs through `log`. Bound at Core scope. + */ + +import { join } from 'node:path'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IKaosFactory } from '#/kaos/kaos'; +import { ILogService } from '#/log/log'; + +import { + type WorkspaceInfo, + IWorkspaceFsService, + IWorkspaceRegistry, +} from './workspace'; + +let nextWorkspaceId = 0; + +export class WorkspaceRegistry implements IWorkspaceRegistry { + declare readonly _serviceBrand: undefined; + private readonly workspaces = new Map(); + + constructor( + @IKaosFactory _kaosFactory: IKaosFactory, + @ILogService _log: ILogService, + ) {} + + register(root: string): WorkspaceInfo { + const id = `ws-${nextWorkspaceId++}`; + const info: WorkspaceInfo = { id, root }; + this.workspaces.set(id, info); + return info; + } + get(id: string): WorkspaceInfo | undefined { + return this.workspaces.get(id); + } + list(): readonly WorkspaceInfo[] { + return [...this.workspaces.values()]; + } +} + +export class WorkspaceFsService implements IWorkspaceFsService { + declare readonly _serviceBrand: undefined; + + constructor( + @IKaosFactory _kaosFactory: IKaosFactory, + @ILogService _log: ILogService, + private readonly registry: WorkspaceRegistry = new WorkspaceRegistry(undefined as never, undefined as never), + ) {} + + resolve(workspaceId: string, rel: string): string { + const ws = this.registry.get(workspaceId); + if (ws === undefined) throw new Error(`unknown workspace '${workspaceId}'`); + return join(ws.root, rel); + } +} + +registerScopedService(LifecycleScope.Core, IWorkspaceRegistry, WorkspaceRegistry, InstantiationType.Delayed, 'workspace'); +registerScopedService(LifecycleScope.Core, IWorkspaceFsService, WorkspaceFsService, InstantiationType.Delayed, 'workspace'); diff --git a/packages/agent-core-v2/test/agent-lifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/agent-lifecycle/agentLifecycle.test.ts new file mode 100644 index 000000000..c3f389437 --- /dev/null +++ b/packages/agent-core-v2/test/agent-lifecycle/agentLifecycle.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { AgentLifecycleService } from '#/agent-lifecycle/agentLifecycleService'; +import { ISessionMetaStore } from '#/records/records'; +import { ISessionContext } from '#/session-context/sessionContext'; + +describe('AgentLifecycleService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(ISessionContext, {}); + ix.stub(ISessionMetaStore, {}); + }); + afterEach(() => disposables.dispose()); + + it('create / getHandle / list / remove', async () => { + const svc = disposables.add(ix.createInstance(AgentLifecycleService)); + const main = await svc.createMain(); + expect(main.id).toBe('main'); + expect(svc.getHandle('main')).toBe(main); + expect(svc.list()).toEqual([main]); + await svc.remove('main'); + expect(svc.getHandle('main')).toBeUndefined(); + }); + + it('create assigns sequential ids when unspecified', async () => { + const svc = disposables.add(ix.createInstance(AgentLifecycleService)); + const a = await svc.create({}); + const b = await svc.create({}); + expect(a.id).not.toBe(b.id); + }); +}); diff --git a/packages/agent-core-v2/test/approval/approval.test.ts b/packages/agent-core-v2/test/approval/approval.test.ts new file mode 100644 index 000000000..7444601b8 --- /dev/null +++ b/packages/agent-core-v2/test/approval/approval.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { ApprovalService } from '#/approval/approvalService'; + +describe('ApprovalService', () => { + it('request parks until decide resolves it', async () => { + const svc = new ApprovalService(); + const p = svc.request({ id: 'r1', toolName: 'bash' }); + expect(svc.listPending()).toEqual([{ id: 'r1', toolName: 'bash' }]); + svc.decide('r1', 'allow'); + await expect(p).resolves.toBe('allow'); + expect(svc.listPending()).toEqual([]); + }); + + it('decide on unknown id is a no-op', () => { + const svc = new ApprovalService(); + expect(() => svc.decide('missing', 'deny')).not.toThrow(); + }); +}); diff --git a/packages/agent-core-v2/test/auth/auth.test.ts b/packages/agent-core-v2/test/auth/auth.test.ts new file mode 100644 index 000000000..092cc6e8c --- /dev/null +++ b/packages/agent-core-v2/test/auth/auth.test.ts @@ -0,0 +1,32 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IConfigService } from '#/config/config'; +import { IEnvironmentService } from '#/environment/environment'; +import { ITelemetryService } from '#/telemetry/telemetry'; + +import { OAuthService } from '#/auth/authService'; + +describe('OAuthService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IConfigService, {}); + ix.stub(IEnvironmentService, {}); + ix.stub(ITelemetryService, {}); + }); + afterEach(() => disposables.dispose()); + + it('login / status / logout', async () => { + const svc = ix.createInstance(OAuthService); + expect(await svc.status()).toEqual({ loggedIn: false }); + await svc.login('kimi'); + expect(await svc.status()).toEqual({ loggedIn: true, provider: 'kimi' }); + await svc.logout('kimi'); + expect(await svc.status()).toEqual({ loggedIn: false }); + }); +}); diff --git a/packages/agent-core-v2/test/background/background.test.ts b/packages/agent-core-v2/test/background/background.test.ts new file mode 100644 index 000000000..670ad09b1 --- /dev/null +++ b/packages/agent-core-v2/test/background/background.test.ts @@ -0,0 +1,36 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle'; +import { IAgentKaos } from '#/kaos/kaos'; +import { ILogService } from '#/log/log'; +import { IAgentRecords } from '#/records/records'; +import { ITelemetryService } from '#/telemetry/telemetry'; + +import { BackgroundService } from '#/background/backgroundService'; + +describe('BackgroundService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IAgentKaos, {}); + ix.stub(IAgentRecords, {}); + ix.stub(ILogService, {}); + ix.stub(ITelemetryService, {}); + ix.stub(IAgentLifecycleService, {}); + }); + afterEach(() => disposables.dispose()); + + it('start / list / stop / getOutput', async () => { + const svc = disposables.add(ix.createInstance(BackgroundService)); + const id = await svc.start({ id: 'x', kind: 'process' }); + expect(svc.list()).toEqual([{ id: 'x', kind: 'process' }]); + expect(await svc.getOutput(id)).toBe(''); + await svc.stop(id); + svc.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/compaction/compaction.test.ts b/packages/agent-core-v2/test/compaction/compaction.test.ts new file mode 100644 index 000000000..92aedeefc --- /dev/null +++ b/packages/agent-core-v2/test/compaction/compaction.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { CompactionService } from '#/compaction/compactionService'; +import { ContextService } from '#/context/contextService'; +import { InjectionService } from '#/injection/injectionService'; +import { LoopRunner } from '#/turn/loopRunner'; +import { TurnService } from '#/turn/turnService'; + +function makeTurn(): TurnService { + return new TurnService( + undefined as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + new LoopRunner(), + ); +} + +describe('CompactionService', () => { + it('injects a compaction summary when token usage exceeds the threshold', async () => { + const ctx = new ContextService(undefined as never); + ctx.appendMessage({ role: 'user', content: 'x'.repeat(100) }); + const injection = new InjectionService(ctx); + const turn = makeTurn(); + const compaction = new CompactionService( + ctx, + undefined as never, + undefined as never, + undefined as never, + turn, + injection, + 10, + ); + await turn.prompt('go'); + expect(injection.flush()).toEqual([ + { kind: 'compaction_summary', content: 'context overflow — compact pending' }, + ]); + compaction.dispose(); + }); + + it('does nothing below the threshold', async () => { + const ctx = new ContextService(undefined as never); + ctx.appendMessage({ role: 'user', content: 'hi' }); + const injection = new InjectionService(ctx); + const turn = makeTurn(); + const compaction = new CompactionService( + ctx, + undefined as never, + undefined as never, + undefined as never, + turn, + injection, + 10_000, + ); + await turn.prompt('go'); + expect(injection.flush()).toEqual([]); + compaction.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/config/config.test.ts b/packages/agent-core-v2/test/config/config.test.ts new file mode 100644 index 000000000..476b5fdbe --- /dev/null +++ b/packages/agent-core-v2/test/config/config.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { IAgentKaos } from '#/kaos/kaos'; +import type { ILogger } from '#/log/log'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IEnvironmentService } from '#/environment/environment'; +import { ILogService } from '#/log/log'; +import { IAgentRecords } from '#/records/records'; +import { IConfigRegistry, IConfigService } from '#/config/config'; + +import { AgentConfigService, ConfigRegistry, ConfigService } from '#/config/configService'; + +const noopLogger: ILogger = { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, + child: () => noopLogger, +}; + +const noopLog: ILogService = { + ...noopLogger, + _serviceBrand: undefined, + level: 'info', + setLevel: () => {}, +}; + +const unusedEnv: IEnvironmentService = { + _serviceBrand: undefined, + homeDir: '', + configPath: '', + detect: () => Promise.reject(new Error('unused')), +}; + +const unusedRecords: IAgentRecords = { + _serviceBrand: undefined, + logRecord: () => Promise.resolve(), + // eslint-disable-next-line @typescript-eslint/require-await + replay: async function* () {}, + restore: () => Promise.resolve(), +}; + +describe('ConfigRegistry', () => { + it('registers and retrieves a section', () => { + const reg = new ConfigRegistry(); + const schema = { type: 'object' }; + reg.registerSection('permission', schema); + expect(reg.getSection('permission')).toEqual({ domain: 'permission', schema }); + expect(reg.getSection('missing')).toBeUndefined(); + }); + + it('throws when the same domain is registered twice', () => { + const reg = new ConfigRegistry(); + reg.registerSection('permission', { type: 'object' }); + expect(() => reg.registerSection('permission', { type: 'object' })).toThrow( + /already registered/, + ); + }); + + it('deep-merges patches', () => { + const reg = new ConfigRegistry(); + const merged = reg.merge({ a: 1, nested: { x: 1, y: 2 } }, { nested: { y: 3, z: 4 }, b: 2 }); + expect(merged).toEqual({ a: 1, b: 2, nested: { x: 1, y: 3, z: 4 } }); + }); +}); + +describe('ConfigService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IConfigRegistry, new ConfigRegistry()); + ix.stub(IEnvironmentService, unusedEnv); + ix.stub(ILogService, noopLog); + }); + afterEach(() => disposables.dispose()); + + it('set merges and get reads back', async () => { + const svc = disposables.add(ix.createInstance(ConfigService)); + await svc.set('agent', { modelAlias: 'k2', nested: { a: 1 } }); + await svc.set('agent', { nested: { b: 2 } }); + expect(svc.get('agent')).toEqual({ modelAlias: 'k2', nested: { a: 1, b: 2 } }); + }); + + it('fires onDidChange with the domain', async () => { + const svc = disposables.add(ix.createInstance(ConfigService)); + const fired: string[] = []; + disposables.add(svc.onDidChange((e) => fired.push(e.domain))); + await svc.set('agent', { modelAlias: 'k2' }); + await svc.set('tool', { x: 1 }); + expect(fired).toEqual(['agent', 'tool']); + }); +}); + +describe('AgentConfigService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let agentSection: Record; + + const agentKaos: IAgentKaos = { + _serviceBrand: undefined, + get kaos(): never { + throw new Error('unused'); + }, + cwd: '/repo', + chdir: () => Promise.resolve(), + }; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + agentSection = {}; + ix.stub(IConfigService, { get: () => agentSection as T }); + ix.stub(IAgentRecords, unusedRecords); + ix.stub(IAgentKaos, agentKaos); + }); + afterEach(() => disposables.dispose()); + + it('reads the agent section and cwd from kaos', () => { + agentSection = { modelAlias: 'k2', systemPrompt: 'hi', provider: 'p' }; + const view = ix.createInstance(AgentConfigService); + expect(view.modelAlias).toBe('k2'); + expect(view.systemPrompt).toBe('hi'); + expect(view.provider).toBe('p'); + expect(view.thinkingLevel).toBeUndefined(); + expect(view.cwd).toBe('/repo'); + }); + + it('setModel / setThinking update the view', async () => { + const view = ix.createInstance(AgentConfigService); + await view.setModel('k1'); + await view.setThinking('high'); + expect(view.modelAlias).toBe('k1'); + expect(view.thinkingLevel).toBe('high'); + }); +}); diff --git a/packages/agent-core-v2/test/context/context.test.ts b/packages/agent-core-v2/test/context/context.test.ts new file mode 100644 index 000000000..54e5699bd --- /dev/null +++ b/packages/agent-core-v2/test/context/context.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { ContextService } from '#/context/contextService'; +import { IAgentRecords } from '#/records/records'; + +describe('ContextService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IAgentRecords, { _serviceBrand: undefined }); + }); + afterEach(() => disposables.dispose()); + + it('appends messages and projects them in order', () => { + const ctx = ix.createInstance(ContextService); + ctx.appendMessage({ role: 'user', content: 'hi' }); + ctx.appendMessage({ role: 'assistant', content: 'hello' }); + ctx.appendSystemReminder('note'); + expect(ctx.project().map((m) => m.role)).toEqual(['user', 'assistant', 'system']); + }); + + it('tokenUsage estimates from content length', () => { + const ctx = ix.createInstance(ContextService); + ctx.appendMessage({ role: 'user', content: 'a'.repeat(40) }); + expect(ctx.tokenUsage()).toBe(10); + }); + + it('applyCompaction replaces history with a summary; undo restores', () => { + const ctx = ix.createInstance(ContextService); + ctx.appendMessage({ role: 'user', content: '1' }); + ctx.appendMessage({ role: 'assistant', content: '2' }); + ctx.applyCompaction('summary'); + expect(ctx.project()).toEqual([{ role: 'system', content: 'summary' }]); + ctx.undo(); + expect(ctx.project().map((m) => m.content)).toEqual(['1', '2']); + }); + + it('undo without snapshot pops the last message', () => { + const ctx = ix.createInstance(ContextService); + ctx.appendMessage({ role: 'user', content: '1' }); + ctx.appendMessage({ role: 'user', content: '2' }); + ctx.undo(); + expect(ctx.project().map((m) => m.content)).toEqual(['1']); + }); +}); diff --git a/packages/agent-core-v2/test/cron/cron.test.ts b/packages/agent-core-v2/test/cron/cron.test.ts new file mode 100644 index 000000000..7b85b24ab --- /dev/null +++ b/packages/agent-core-v2/test/cron/cron.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; + +import type { Event } from '#/_base/event'; +import type { ServicesAccessor } from '#/_base/di/instantiation'; +import type { IScopeHandle } from '#/_base/di/scope'; +import type { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle'; +import type { ISessionActivity } from '#/session-activity/sessionActivity'; +import type { + ITurnService, + TurnEndEvent, + TurnStartEvent, + TurnStepEvent, + TurnToolEvent, +} from '#/turn/turn'; + +import { CronFireCoordinator, CronService } from '#/cron/cronService'; + +const noneEvent = ((): Event => () => ({ dispose: () => {} }))(); + +class StubTurn implements ITurnService { + readonly _serviceBrand: undefined; + readonly onWillStartTurn = noneEvent as Event; + readonly onWillExecuteTool = noneEvent as Event; + readonly onDidFinalizeTool = noneEvent as Event; + readonly onDidEndStep = noneEvent as Event; + readonly onDidEndTurn = noneEvent as Event; + readonly steered: string[] = []; + get hasActiveTurn(): boolean { + return false; + } + get currentId(): string | undefined { + return undefined; + } + prompt(): Promise { + return Promise.resolve(); + } + steer(content: string): void { + this.steered.push(content); + } + retry(): Promise { + return Promise.resolve(); + } + cancel(): void {} +} + +function activity(idle: boolean): ISessionActivity { + return { _serviceBrand: undefined, isIdle: () => idle }; +} + +describe('CronService', () => { + it('create / list / delete', async () => { + const svc = new CronService( + undefined as never, + activity(true), + undefined as never, + undefined as never, + undefined as never, + undefined as never, + ); + const id = await svc.create({ id: '', cron: '1000', prompt: 'hi', recurring: false }); + expect(svc.list()).toHaveLength(1); + await svc.delete(id); + expect(svc.list()).toEqual([]); + svc.dispose(); + }); + + it('tick fires due tasks only when idle', async () => { + const svc = new CronService( + undefined as never, + activity(false), + undefined as never, + undefined as never, + undefined as never, + undefined as never, + ); + const fired: string[] = []; + svc.onDidFire((e) => fired.push(e.content)); + await svc.create({ id: 'a', cron: '1000', prompt: 'fire-me', recurring: false }); + svc.tick(Date.now() + 500); + expect(fired).toEqual([]); + (svc as unknown as { activity: ISessionActivity }).activity = activity(true); + svc.tick(Date.now() + 2000); + expect(fired).toEqual(['fire-me']); + svc.dispose(); + }); + + it('one-shot tasks are removed after firing', async () => { + const svc = new CronService( + undefined as never, + activity(true), + undefined as never, + undefined as never, + undefined as never, + undefined as never, + ); + await svc.create({ id: 'a', cron: '1000', prompt: 'x', recurring: false }); + svc.tick(Date.now() + 2000); + expect(svc.list()).toEqual([]); + svc.dispose(); + }); +}); + +describe('CronFireCoordinator', () => { + it('steers the main agent on fire', async () => { + const turn = new StubTurn(); + const handle: IScopeHandle = { + id: 'main', + kind: 2, + accessor: { get: () => turn } as ServicesAccessor, + }; + const agents: IAgentLifecycleService = { + _serviceBrand: undefined, + create: () => Promise.resolve(handle), + createMain: () => Promise.resolve(handle), + getHandle: (id) => (id === 'main' ? handle : undefined), + list: () => [handle], + remove: () => Promise.resolve(), + }; + const cron = new CronService( + undefined as never, + activity(true), + undefined as never, + undefined as never, + undefined as never, + undefined as never, + ); + const coord = new CronFireCoordinator(cron, agents); + await cron.create({ id: 'a', cron: '1000', prompt: 'steer-me', recurring: false }); + cron.tick(Date.now() + 2000); + expect(turn.steered).toEqual(['steer-me']); + coord.dispose(); + cron.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/di/auto-inject.test.ts b/packages/agent-core-v2/test/di/auto-inject.test.ts new file mode 100644 index 000000000..c8f2eca18 --- /dev/null +++ b/packages/agent-core-v2/test/di/auto-inject.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { CyclicDependencyError } from '#/_base/di/errors'; +import { IInstantiationService, createDecorator } from '#/_base/di/instantiation'; +import { InstantiationService } from '#/_base/di/instantiationService'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; + +describe('@IFoo auto-injection', () => { + it('pure-service ctor: both @IFoo params resolve from the container', () => { + interface IBar { + tag: 'bar'; + } + interface IBaz { + tag: 'baz'; + } + const IBar = createDecorator('p1.1-IBar-pure'); + const IBaz = createDecorator('p1.1-IBaz-pure'); + + class Bar implements IBar { + tag = 'bar' as const; + } + class Baz implements IBaz { + tag = 'baz' as const; + } + class Foo { + constructor( + @IBar public readonly bar: IBar, + @IBaz public readonly baz: IBaz, + ) {} + } + const IFoo = createDecorator('p1.1-IFoo-pure'); + + const ix = new InstantiationService( + new ServiceCollection( + [IBar, new SyncDescriptor(Bar)], + [IBaz, new SyncDescriptor(Baz)], + [IFoo, new SyncDescriptor(Foo)], + ), + ); + const foo = ix.invokeFunction((a) => a.get(IFoo)); + expect(foo).toBeInstanceOf(Foo); + expect(foo.bar).toBeInstanceOf(Bar); + expect(foo.baz).toBeInstanceOf(Baz); + }); + + it('mixed static prefix + service suffix via createInstance(ctor, ...rest)', () => { + interface IBaz { + tag: 'baz'; + } + const IBaz = createDecorator('p1.1-IBaz-mixed'); + class Baz implements IBaz { + tag = 'baz' as const; + } + class Bar { + constructor( + public readonly name: string, + @IBaz public readonly baz: IBaz, + ) {} + } + const ix = new InstantiationService( + new ServiceCollection([IBaz, new SyncDescriptor(Baz)]), + ); + const bar = ix.createInstance(Bar as new (name: string) => Bar, 'hello'); + expect(bar.name).toBe('hello'); + expect(bar.baz).toBeInstanceOf(Baz); + }); + + it('@IInstantiationService self-injection resolves to the OWNING container', () => { + class Widget { + constructor(public readonly label: string) {} + } + interface IFactoryHost { + makeWidget(): Widget; + } + const IFactoryHost = createDecorator('p1.1-IFactoryHost'); + class FactoryHost implements IFactoryHost { + constructor(@IInstantiationService private readonly ix: IInstantiationService) {} + makeWidget(): Widget { + return this.ix.createInstance(Widget, 'made-by-factory'); + } + } + const ix = new InstantiationService( + new ServiceCollection([IFactoryHost, new SyncDescriptor(FactoryHost)]), + ); + const host = ix.invokeFunction((a) => a.get(IFactoryHost)); + const w = host.makeWidget(); + expect(w).toBeInstanceOf(Widget); + expect(w.label).toBe('made-by-factory'); + }); + + it('Graph cycle: A.@IBar + B.@IA throws CyclicDependencyError before any ctor runs', () => { + interface IA { + tag: 'A'; + } + interface IB { + tag: 'B'; + } + const IA = createDecorator('p1.1-cycle-IA'); + const IB = createDecorator('p1.1-cycle-IB'); + + let aCtorRan = false; + let bCtorRan = false; + class AImpl implements IA { + tag = 'A' as const; + constructor(@IB _b: IB) { + aCtorRan = true; + } + } + class BImpl implements IB { + tag = 'B' as const; + constructor(@IA _a: IA) { + bCtorRan = true; + } + } + const ix = new InstantiationService( + new ServiceCollection( + [IA, new SyncDescriptor(AImpl)], + [IB, new SyncDescriptor(BImpl)], + ), + ); + + let captured: unknown; + try { + ix.invokeFunction((a) => a.get(IA)); + } catch (e) { + captured = e; + } + expect(captured).toBeInstanceOf(CyclicDependencyError); + expect((captured as CyclicDependencyError).message).toMatch( + /cyclic dependency between services/i, + ); + expect(aCtorRan).toBe(false); + expect(bCtorRan).toBe(false); + }); + + it('cross-container Graph cycle: parent A→@IB, child B→@IA throws Cyclic', () => { + interface IA { + tag: 'A'; + } + interface IB { + tag: 'B'; + } + const IA = createDecorator('p1.1-xcycle-IA'); + const IB = createDecorator('p1.1-xcycle-IB'); + + class AImpl implements IA { + tag = 'A' as const; + constructor(@IB _b: IB) {} + } + class BImpl implements IB { + tag = 'B' as const; + constructor(@IA _a: IA) {} + } + const parent = new InstantiationService( + new ServiceCollection([IA, new SyncDescriptor(AImpl)]), + ); + const child = parent.createChild( + new ServiceCollection([IB, new SyncDescriptor(BImpl)]), + ); + expect(() => + child.invokeFunction((a) => a.get(IA)), + ).toThrowError(CyclicDependencyError); + }); +}); diff --git a/packages/agent-core-v2/test/di/child.test.ts b/packages/agent-core-v2/test/di/child.test.ts new file mode 100644 index 000000000..7e5350807 --- /dev/null +++ b/packages/agent-core-v2/test/di/child.test.ts @@ -0,0 +1,380 @@ +import { describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { + IInstantiationService, + createDecorator, + type IInstantiationService as IInstantiationServiceType, +} from '#/_base/di/instantiation'; +import { InstantiationService } from '#/_base/di/instantiationService'; +import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; + +interface ILogger { + log(msg: string): void; + name: string; +} +const ILogger = createDecorator('logger'); + +class ConsoleLogger implements ILogger { + name = 'console'; + log(_m: string): void {} +} +class ChildLogger implements ILogger { + name = 'child'; + log(_m: string): void {} +} + +describe('InstantiationService.createChild', () => { + it('child inherits parent services', () => { + const parent = new InstantiationService( + new ServiceCollection([ILogger, new SyncDescriptor(ConsoleLogger)]), + ); + const child = parent.createChild(new ServiceCollection()); + const fromChild = child.invokeFunction((a) => a.get(ILogger)); + expect(fromChild).toBeInstanceOf(ConsoleLogger); + + const fromParent = parent.invokeFunction((a) => a.get(ILogger)); + expect(fromChild).toBe(fromParent); + }); + + it('child shadowing: child registration overrides parent', () => { + const parent = new InstantiationService( + new ServiceCollection([ILogger, new SyncDescriptor(ConsoleLogger)]), + ); + const child = parent.createChild( + new ServiceCollection([ILogger, new SyncDescriptor(ChildLogger)]), + ); + const fromChild = child.invokeFunction((a) => a.get(ILogger)); + const fromParent = parent.invokeFunction((a) => a.get(ILogger)); + expect(fromChild).toBeInstanceOf(ChildLogger); + expect(fromParent).toBeInstanceOf(ConsoleLogger); + expect(fromChild).not.toBe(fromParent); + }); + + it('constructs parent-owned descriptors in the parent scope when resolved from a child', () => { + interface IDep { + tag: string; + } + const IDep = createDecorator('owner-scope-dep'); + class ParentDep implements IDep { + tag = 'parent'; + } + class ChildDep implements IDep { + tag = 'child'; + } + class ParentOwned { + constructor(@IDep public readonly dep: IDep) {} + } + + const IParentOwned = createDecorator('owner-scope-parent-owned'); + + const parent = new InstantiationService( + new ServiceCollection( + [IDep, new SyncDescriptor(ParentDep)], + [IParentOwned, new SyncDescriptor(ParentOwned)], + ), + ); + const child = parent.createChild( + new ServiceCollection([IDep, new SyncDescriptor(ChildDep)]), + ); + + const fromChild = child.invokeFunction((a) => a.get(IParentOwned)); + const fromParent = parent.invokeFunction((a) => a.get(IParentOwned)); + expect(fromChild).toBe(fromParent); + expect(fromChild.dep).toBeInstanceOf(ParentDep); + expect(fromChild.dep.tag).toBe('parent'); + }); + + it('injects the parent instantiation service into parent-owned services resolved from a child', () => { + class ParentOwned { + constructor(@IInstantiationService public readonly ix: IInstantiationServiceType) {} + } + const IParentOwned = createDecorator('owner-scope-parent-ix'); + + const parent = new InstantiationService( + new ServiceCollection([IParentOwned, new SyncDescriptor(ParentOwned)]), + ); + const child = parent.createChild(new ServiceCollection()); + + const instance = child.invokeFunction((a) => a.get(IParentOwned)); + expect(instance.ix).toBe(parent); + expect(instance.ix).not.toBe(child); + }); + + it('sibling isolation: two children of the same parent do not share scoped services', () => { + interface IScoped { + tag: string; + } + const IScoped = createDecorator('scoped'); + class ScopedA implements IScoped { + tag = 'A'; + } + class ScopedB implements IScoped { + tag = 'B'; + } + + const parent = new InstantiationService(); + const childA = parent.createChild( + new ServiceCollection([IScoped, new SyncDescriptor(ScopedA)]), + ); + const childB = parent.createChild( + new ServiceCollection([IScoped, new SyncDescriptor(ScopedB)]), + ); + + expect(childA.invokeFunction((a) => a.get(IScoped).tag)).toBe('A'); + expect(childB.invokeFunction((a) => a.get(IScoped).tag)).toBe('B'); + + expect(parent.invokeFunction((a) => a.get(IScoped))).toBeUndefined(); + }); + + it('dispose order: A→B→C construction yields C→B→A teardown', () => { + const events: string[] = []; + interface ITagged { + tag: string; + } + const IA = createDecorator('A'); + const IB = createDecorator('B'); + const IC = createDecorator('C'); + class A implements ITagged, IDisposable { + tag = 'A'; + dispose(): void { + events.push('disposed A'); + } + } + class B implements ITagged, IDisposable { + tag = 'B'; + dispose(): void { + events.push('disposed B'); + } + } + class C implements ITagged, IDisposable { + tag = 'C'; + dispose(): void { + events.push('disposed C'); + } + } + const ix = new InstantiationService( + new ServiceCollection( + [IA, new SyncDescriptor(A)], + [IB, new SyncDescriptor(B)], + [IC, new SyncDescriptor(C)], + ), + ); + ix.invokeFunction((a) => { + a.get(IA); + a.get(IB); + a.get(IC); + }); + ix.dispose(); + expect(events).toEqual(['disposed C', 'disposed B', 'disposed A']); + }); + + it('does not dispose pre-built service instances from the ServiceCollection', () => { + const events: string[] = []; + interface IFoo { + tag: string; + } + const IFoo = createDecorator('prebuilt-not-disposed'); + class Foo implements IFoo, IDisposable { + tag = 'foo'; + dispose(): void { + events.push('disposed'); + } + } + const instance = new Foo(); + const ix = new InstantiationService(new ServiceCollection([IFoo, instance])); + expect(ix.invokeFunction((a) => a.get(IFoo))).toBe(instance); + ix.dispose(); + expect(events).toEqual([]); + }); + + it('idempotent dispose: second call is a no-op', () => { + const events: string[] = []; + interface IFoo { + tag: string; + } + const IFoo = createDecorator('foo'); + class Foo implements IFoo, IDisposable { + tag = 'foo'; + dispose(): void { + events.push('disposed'); + } + } + const ix = new InstantiationService( + new ServiceCollection([IFoo, new SyncDescriptor(Foo)]), + ); + ix.invokeFunction((a) => a.get(IFoo)); + ix.dispose(); + ix.dispose(); + expect(events).toEqual(['disposed']); + }); + + it('parent dispose propagates to children', () => { + const events: string[] = []; + interface IParentSvc { + tag: string; + } + interface IChildSvc { + tag: string; + } + const IParentSvc = createDecorator('parentSvc'); + const IChildSvc = createDecorator('childSvc'); + class ParentSvc implements IParentSvc, IDisposable { + tag = 'parent'; + dispose(): void { + events.push('disposed parent svc'); + } + } + class ChildSvc implements IChildSvc, IDisposable { + tag = 'child'; + dispose(): void { + events.push('disposed child svc'); + } + } + + const parent = new InstantiationService( + new ServiceCollection([IParentSvc, new SyncDescriptor(ParentSvc)]), + ); + const child = parent.createChild( + new ServiceCollection([IChildSvc, new SyncDescriptor(ChildSvc)]), + ); + + parent.invokeFunction((a) => a.get(IParentSvc)); + child.invokeFunction((a) => a.get(IChildSvc)); + + parent.dispose(); + + expect(events).toEqual(['disposed child svc', 'disposed parent svc']); + }); + + it('disposing a child clears it from parent so parent.dispose does not double-dispose', () => { + const events: string[] = []; + interface ISvc { + tag: string; + } + const ISvc = createDecorator('svc'); + class Svc implements ISvc, IDisposable { + tag = 'svc'; + dispose(): void { + events.push('disposed'); + } + } + + const parent = new InstantiationService(); + const child = parent.createChild( + new ServiceCollection([ISvc, new SyncDescriptor(Svc)]), + ); + child.invokeFunction((a) => a.get(ISvc)); + child.dispose(); + parent.dispose(); + expect(events).toEqual(['disposed']); + }); + + it('use-after-dispose: invokeFunction / createInstance / createChild throw', () => { + const ix = new InstantiationService(); + ix.dispose(); + expect(() => { + ix.invokeFunction((_a) => undefined); + }).toThrowError(/disposed/); + expect(() => { + ix.createInstance(class A { + value = 'a'; + }); + }).toThrowError(/disposed/); + expect(() => { + ix.createChild(new ServiceCollection()); + }).toThrowError(/disposed/); + }); +}); + +describe('Disposable base class', () => { + it('insertion order on dispose', () => { + const events: string[] = []; + class Child implements IDisposable { + constructor(public readonly label: string) {} + dispose(): void { + events.push(`disposed ${this.label}`); + } + } + class Owner extends Disposable { + constructor() { + super(); + this._register(new Child('first')); + this._register(new Child('second')); + this._register(new Child('third')); + } + } + const o = new Owner(); + o.dispose(); + expect(events).toEqual(['disposed first', 'disposed second', 'disposed third']); + }); + + it('idempotent dispose on the base class', () => { + const events: string[] = []; + class Child implements IDisposable { + dispose(): void { + events.push('disposed'); + } + } + class Owner extends Disposable { + constructor() { + super(); + this._register(new Child()); + } + } + const o = new Owner(); + o.dispose(); + o.dispose(); + expect(events).toEqual(['disposed']); + }); + + it('register-after-dispose: child is torn down immediately, not leaked', () => { + const events: string[] = []; + class Child implements IDisposable { + dispose(): void { + events.push('disposed'); + } + } + class Owner extends Disposable { + addLate(): void { + this._register(new Child()); + } + } + const o = new Owner(); + o.dispose(); + o.addLate(); + expect(events).toEqual(['disposed']); + }); + + it('continues teardown and rethrows if one child throws', () => { + const events: string[] = []; + class GoodChild implements IDisposable { + dispose(): void { + events.push('good'); + } + } + class BadChild implements IDisposable { + dispose(): void { + events.push('bad-attempted'); + throw new Error('boom'); + } + } + class TailChild implements IDisposable { + dispose(): void { + events.push('tail'); + } + } + class Owner extends Disposable { + constructor() { + super(); + this._register(new GoodChild()); + this._register(new BadChild()); + this._register(new TailChild()); + } + } + const o = new Owner(); + expect(() => { o.dispose(); }).toThrow('boom'); + expect(events).toEqual(['good', 'bad-attempted', 'tail']); + }); +}); diff --git a/packages/agent-core-v2/test/di/cyclic.test.ts b/packages/agent-core-v2/test/di/cyclic.test.ts new file mode 100644 index 000000000..23528435c --- /dev/null +++ b/packages/agent-core-v2/test/di/cyclic.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { CyclicDependencyError } from '#/_base/di/errors'; +import { + createDecorator, + IInstantiationService, + type IInstantiationService as IInstantiationServiceType, +} from '#/_base/di/instantiation'; +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 and VS Code's `ServiceLoop1`/`ServiceLoop2` + * do. The container detects the cycle while resolving the constructor graph. + */ + +describe('Cyclic dependency detection', () => { + it('direct self-cycle A → A throws CyclicDependencyError', () => { + interface IA { + tag: 'A'; + } + const IA = createDecorator('A'); + class A implements IA { + tag = 'A' as const; + constructor(@IA _self: IA) {} + } + const ix = new InstantiationService(new ServiceCollection([IA, new SyncDescriptor(A)])); + expect(() => ix.invokeFunction((a) => a.get(IA))).toThrowError(CyclicDependencyError); + }); + + it('indirect cycle A → B → A includes both names in `path` in construction order', () => { + interface IA { + tag: 'A'; + } + interface IB { + tag: 'B'; + } + const IA = createDecorator('A'); + const IB = createDecorator('B'); + class A implements IA { + tag = 'A' as const; + constructor(@IB _b: IB) {} + } + class B implements IB { + tag = 'B' as const; + constructor(@IA _a: IA) {} + } + const ix = new InstantiationService( + new ServiceCollection([IA, new SyncDescriptor(A)], [IB, new SyncDescriptor(B)]), + ); + + let captured: CyclicDependencyError | undefined; + try { + ix.invokeFunction((a) => a.get(IA)); + } catch (e) { + captured = e as CyclicDependencyError; + } + expect(captured).toBeInstanceOf(CyclicDependencyError); + expect(captured!.path).toEqual(['A', 'B', 'A']); + expect(captured!.message).toMatch(/cyclic dependency between services/i); + }); + + it('no-cycle chain A → B → C constructs cleanly', () => { + interface ITagged { + tag: string; + } + const IA = createDecorator('A'); + const IB = createDecorator('B'); + const IC = createDecorator('C'); + class C implements ITagged { + tag = 'C'; + } + class B implements ITagged { + tag = 'B'; + constructor(@IC _c: ITagged) {} + } + class A implements ITagged { + tag = 'A'; + constructor(@IB _b: ITagged) {} + } + const ix = new InstantiationService( + new ServiceCollection( + [IA, new SyncDescriptor(A)], + [IB, new SyncDescriptor(B)], + [IC, new SyncDescriptor(C)], + ), + ); + expect(() => ix.invokeFunction((a) => a.get(IA))).not.toThrow(); + }); + + it('cycle across parent/child boundary is detected', () => { + interface IA { + tag: 'A'; + } + interface IB { + tag: 'B'; + } + const IA = createDecorator('A'); + const IB = createDecorator('B'); + + class A implements IA { + tag = 'A' as const; + constructor(@IB _b: IB) {} + } + class B implements IB { + tag = 'B' as const; + constructor(@IA _a: IA) {} + } + + const parent = new InstantiationService( + new ServiceCollection([IA, new SyncDescriptor(A)]), + ); + const child = parent.createChild(new ServiceCollection([IB, new SyncDescriptor(B)])); + + let captured: CyclicDependencyError | undefined; + try { + child.invokeFunction((a) => a.get(IA)); + } catch (e) { + captured = e as CyclicDependencyError; + } + expect(captured).toBeInstanceOf(CyclicDependencyError); + expect(captured!.path).toEqual(['A', 'B', 'A']); + }); + + it('stack is unwound even when construction throws', () => { + interface ITagged { + tag: string; + } + const IBoom = createDecorator('Boom'); + const IFine = createDecorator('Fine'); + + class Boom implements ITagged { + tag = 'boom'; + constructor() { + throw new Error('intentional'); + } + } + class Fine implements ITagged { + tag = 'fine'; + } + + const ix = new InstantiationService( + new ServiceCollection([IBoom, new SyncDescriptor(Boom)], [IFine, new SyncDescriptor(Fine)]), + ); + + expect(() => ix.invokeFunction((a) => a.get(IBoom))).toThrowError(/intentional/); + expect(() => ix.invokeFunction((a) => a.get(IFine))).not.toThrow(); + }); +}); + +describe('Recursive instantiation regression (#105562)', () => { + it('recursive invokeFunction during construction does not double-create a dependency', () => { + interface IService1 { + tag: 's1'; + } + interface IService2 { + tag: 's2'; + } + interface IService21 { + readonly service1: IService1; + readonly service2: IService2; + } + const IService1 = createDecorator('reentrant-s1'); + const IService2 = createDecorator('reentrant-s2'); + const IService21 = createDecorator('reentrant-s21'); + + let service2CtorCount = 0; + + 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(); + } + } + class Service2Impl implements IService2 { + tag = 's2' as const; + constructor() { + service2CtorCount += 1; + } + } + class Service21Impl implements IService21 { + constructor( + @IService2 public readonly service2: IService2, + @IService1 public readonly service1: IService1, + ) {} + } + + const insta = new InstantiationService( + new ServiceCollection( + [IService1, new SyncDescriptor(Service1Impl)], + [IService2, new SyncDescriptor(Service2Impl)], + [IService21, new SyncDescriptor(Service21Impl)], + ), + ); + + const obj = insta.invokeFunction((accessor) => accessor.get(IService21)); + 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); + }); +}); + +describe('Sync/Async dependency loop', () => { + interface IA { + readonly _serviceBrand: undefined; + doIt(): boolean; + } + interface IB { + readonly _serviceBrand: undefined; + b(): boolean; + } + + it('sync re-entrant cycle (via createInstance in ctor) explodes with RECURSIVELY', () => { + const IA = createDecorator('loop-sync-A'); + const IB = createDecorator('loop-sync-B'); + + class BConsumer { + constructor(@IB private readonly b: IB) {} + doIt(): boolean { + return this.b.b(); + } + } + class AService implements IA { + readonly _serviceBrand: undefined; + private readonly prop: BConsumer; + constructor(@IInstantiationService insta: IInstantiationServiceType) { + this.prop = insta.createInstance(BConsumer); + } + doIt(): boolean { + return this.prop.doIt(); + } + } + class BService implements IB { + readonly _serviceBrand: undefined; + constructor(@IA _a: IA) {} + b(): boolean { + return true; + } + } + + const insta = new InstantiationService( + new ServiceCollection( + [IA, new SyncDescriptor(AService)], + [IB, new SyncDescriptor(BService)], + ), + true, + undefined, + true, + ); + + let captured: unknown; + try { + insta.invokeFunction((accessor) => accessor.get(IA)); + } catch (e) { + captured = e; + } + expect(captured).toBeInstanceOf(Error); + expect((captured as Error).message).toContain('RECURSIVELY'); + }); + + it('delayed A breaks the synchronous recursion but the cycle is still tracked in the global graph', () => { + const IA = createDecorator('loop-async-A'); + const IB = createDecorator('loop-async-B'); + + class BConsumer { + constructor(@IB private readonly b: IB) {} + doIt(): boolean { + return this.b.b(); + } + } + class AService implements IA { + readonly _serviceBrand: undefined; + private readonly prop: BConsumer; + constructor(@IInstantiationService insta: IInstantiationServiceType) { + this.prop = insta.createInstance(BConsumer); + } + doIt(): boolean { + return this.prop.doIt(); + } + } + class BService implements IB { + readonly _serviceBrand: undefined; + constructor(@IA _a: IA) {} + b(): boolean { + return true; + } + } + + const insta = new InstantiationService( + new ServiceCollection( + [IA, new SyncDescriptor(AService, [], true)], + [IB, new SyncDescriptor(BService, [])], + ), + true, + undefined, + true, + ); + + const a = insta.invokeFunction((accessor) => accessor.get(IA)); + expect(a.doIt()).toBe(true); + + const cycle = insta._globalGraph?.findCycleSlow(); + expect(cycle).toBe('loop-async-A -> loop-async-B -> loop-async-A'); + }); +}); diff --git a/packages/agent-core-v2/test/di/delayed.test.ts b/packages/agent-core-v2/test/di/delayed.test.ts new file mode 100644 index 000000000..91c24a32f --- /dev/null +++ b/packages/agent-core-v2/test/di/delayed.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; + +import { Emitter, type Event } from '#/_base/event'; +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { createDecorator } from '#/_base/di/instantiation'; +import { InstantiationService } from '#/_base/di/instantiationService'; +import { dispose } from '#/_base/di/lifecycle'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; + +/** + * Delayed-instantiation tests ported from VS Code's + * `instantiationService.test.ts` ("Delayed and events" family). + * + * A service registered with `SyncDescriptor(Ctor, [], true)` 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', () => { + interface IA { + readonly onDidDoIt: Event; + doIt(): void; + } + const IA = createDecorator('delayed-A-events'); + + let created = false; + class AImpl implements IA { + private _doIt = 0; + private readonly _onDidDoIt = new Emitter(); + readonly onDidDoIt: Event = this._onDidDoIt.event; + + constructor() { + created = true; + } + + doIt(): void { + this._doIt += 1; + this._onDidDoIt.fire(this); + } + } + + const insta = new InstantiationService( + new ServiceCollection([IA, new SyncDescriptor(AImpl, [], true)]), + true, + undefined, + true, + ); + + class Consumer { + constructor(@IA public readonly a: IA) {} + } + + const c = insta.createInstance(Consumer); + let eventCount = 0; + + const listener = (e: unknown) => { + expect(e).toBeInstanceOf(AImpl); + 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); + + const d3 = c.a.onDidDoIt(listener); + c.a.doIt(); + expect(eventCount).toBe(3); + + dispose([d1, d3]); + }); + + it('event reference captured before init still works after init', () => { + interface IA { + readonly onDidDoIt: Event; + doIt(): void; + noop(): void; + } + const IA = createDecorator('delayed-A-capture'); + + let created = false; + class AImpl implements IA { + private _doIt = 0; + private readonly _onDidDoIt = new Emitter(); + readonly onDidDoIt: Event = this._onDidDoIt.event; + + constructor() { + created = true; + } + + doIt(): void { + this._doIt += 1; + this._onDidDoIt.fire(this); + } + + noop(): void {} + } + + const insta = new InstantiationService( + new ServiceCollection([IA, new SyncDescriptor(AImpl, [], true)]), + true, + undefined, + true, + ); + + class Consumer { + constructor(@IA public readonly a: IA) {} + } + + const c = insta.createInstance(Consumer); + let eventCount = 0; + + const listener = (e: unknown) => { + expect(e).toBeInstanceOf(AImpl); + 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); + + dispose(d1); + }); + + it('disposing an early listener before/after init stops delivery', () => { + interface IA { + readonly onDidDoIt: Event; + doIt(): void; + } + const IA = createDecorator('delayed-A-dispose'); + + let created = false; + class AImpl implements IA { + private _doIt = 0; + private readonly _onDidDoIt = new Emitter(); + readonly onDidDoIt: Event = this._onDidDoIt.event; + + constructor() { + created = true; + } + + doIt(): void { + this._doIt += 1; + this._onDidDoIt.fire(this); + } + } + + const insta = new InstantiationService( + new ServiceCollection([IA, new SyncDescriptor(AImpl, [], true)]), + true, + undefined, + true, + ); + + class Consumer { + constructor(@IA public readonly a: IA) {} + } + + const c = insta.createInstance(Consumer); + let eventCount = 0; + + const listener = (e: unknown) => { + expect(e).toBeInstanceOf(AImpl); + eventCount++; + }; + + const d1 = c.a.onDidDoIt(listener); + expect(created).toBe(false); + expect(eventCount).toBe(0); + + c.a.doIt(); + expect(created).toBe(true); + expect(eventCount).toBe(1); + + dispose(d1); + + c.a.doIt(); + expect(eventCount).toBe(1); + }); +}); diff --git a/packages/agent-core-v2/test/di/scope-tree.test.ts b/packages/agent-core-v2/test/di/scope-tree.test.ts new file mode 100644 index 000000000..d91367dda --- /dev/null +++ b/packages/agent-core-v2/test/di/scope-tree.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IDisposable } from '#/_base/di/lifecycle'; +import { + LifecycleScope, + Scope, + _clearScopedRegistryForTests, + createCoreScope, + registerScopedService, +} from '#/_base/di/scope'; + +interface ICoreSvc { + tag: 'core'; +} +interface ISessionSvc { + core: ICoreSvc; + tag: 'session'; +} +interface IAgentSvc { + session: ISessionSvc; + core: ICoreSvc; + tag: 'agent'; +} + +const ICoreSvc = createDecorator('tree-core'); +const ISessionSvc = createDecorator('tree-session'); +const IAgentSvc = createDecorator('tree-agent'); + +class CoreSvc implements ICoreSvc { + tag = 'core' as const; +} +class SessionSvc implements ISessionSvc { + tag = 'session' as const; + constructor(@ICoreSvc public readonly core: ICoreSvc) {} +} +class AgentSvc implements IAgentSvc { + tag = 'agent' as const; + constructor( + @ISessionSvc public readonly session: ISessionSvc, + @ICoreSvc public readonly core: ICoreSvc, + ) {} +} + +describe('Scope tree', () => { + beforeEach(() => { + _clearScopedRegistryForTests(); + registerScopedService(LifecycleScope.Core, ICoreSvc, CoreSvc); + registerScopedService(LifecycleScope.Session, ISessionSvc, SessionSvc); + registerScopedService(LifecycleScope.Agent, IAgentSvc, AgentSvc); + }); + + function buildTree(): { core: Scope; session: Scope; agent: Scope } { + const core = createCoreScope(); + const session = core.createChild(LifecycleScope.Session, 's1'); + const agent = session.createChild(LifecycleScope.Agent, 'main'); + return { core, session, agent }; + } + + it('each scope resolves its own layer service', () => { + const { core, session, agent } = buildTree(); + expect(core.accessor.get(ICoreSvc).tag).toBe('core'); + expect(session.accessor.get(ISessionSvc).tag).toBe('session'); + expect(agent.accessor.get(IAgentSvc).tag).toBe('agent'); + core.dispose(); + }); + + it('child resolves ancestor services via createChild fallback', () => { + const { core, session, agent } = buildTree(); + const sessionSvc = session.accessor.get(ISessionSvc); + const agentSvc = agent.accessor.get(IAgentSvc); + expect(sessionSvc.core.tag).toBe('core'); + expect(agentSvc.session.tag).toBe('session'); + expect(agentSvc.core.tag).toBe('core'); + expect(agentSvc.core).toBe(core.accessor.get(ICoreSvc)); + core.dispose(); + }); + + it('parent cannot resolve a child-layer service', () => { + const { core, session } = buildTree(); + expect(() => core.accessor.get(ISessionSvc)).toThrow(); + expect(() => session.accessor.get(IAgentSvc)).toThrow(); + core.dispose(); + }); + + it('children map tracks created child scopes', () => { + const { core, session, agent } = buildTree(); + expect(core.children.get('s1')).toBe(session); + expect(session.children.get('main')).toBe(agent); + core.dispose(); + }); + + it('rejects a child whose kind is not strictly greater', () => { + const core = createCoreScope(); + const session = core.createChild(LifecycleScope.Session, 's1'); + expect(() => session.createChild(LifecycleScope.Session, 's2')).toThrow(/greater/); + expect(() => session.createChild(LifecycleScope.Core, 'c2')).toThrow(/greater/); + core.dispose(); + }); + + it('rejects duplicate child ids within a parent', () => { + const core = createCoreScope(); + core.createChild(LifecycleScope.Session, 's1'); + expect(() => core.createChild(LifecycleScope.Session, 's1')).toThrow(/already has a child/); + core.dispose(); + }); + + it('dispose tears down children before the parent (C→B→A)', () => { + const events: string[] = []; + interface ITagged extends IDisposable { + tag: string; + } + const IA = createDecorator('tree-dispose-A'); + const IB = createDecorator('tree-dispose-B'); + const IC = createDecorator('tree-dispose-C'); + _clearScopedRegistryForTests(); + class A implements ITagged { + tag = 'A'; + dispose(): void { events.push('A'); } + } + class B implements ITagged { + tag = 'B'; + dispose(): void { events.push('B'); } + } + class C implements ITagged { + tag = 'C'; + dispose(): void { events.push('C'); } + } + registerScopedService(LifecycleScope.Core, IA, A, InstantiationType.Eager); + registerScopedService(LifecycleScope.Session, IB, B, InstantiationType.Eager); + registerScopedService(LifecycleScope.Agent, IC, C, InstantiationType.Eager); + + const core = createCoreScope(); + const session = core.createChild(LifecycleScope.Session, 's1'); + const agent = session.createChild(LifecycleScope.Agent, 'main'); + core.accessor.get(IA); + session.accessor.get(IB); + agent.accessor.get(IC); + core.dispose(); + expect(events).toEqual(['C', 'B', 'A']); + }); + + it('disposing a child removes it from the parent children map', () => { + const { core, session, agent } = buildTree(); + agent.dispose(); + expect(session.children.has('main')).toBe(false); + session.dispose(); + expect(core.children.has('s1')).toBe(false); + core.dispose(); + }); + + it('toHandle exposes id/kind/accessor for parent-domain reach-in', () => { + const { core, session } = buildTree(); + const handle = session.toHandle(); + expect(handle.id).toBe('s1'); + expect(handle.kind).toBe(LifecycleScope.Session); + expect(handle.accessor.get(ISessionSvc).tag).toBe('session'); + core.dispose(); + }); + + it('extra seed injects a context token resolvable from that scope', () => { + interface ISessionContext { + sessionId: string; + } + const ISessionContext = createDecorator('tree-session-ctx'); + _clearScopedRegistryForTests(); + + const core = createCoreScope(); + const session = core.createChild(LifecycleScope.Session, 's1', { + extra: [[ISessionContext as ServiceIdentifier, { sessionId: 's1' }]], + }); + expect(session.accessor.get(ISessionContext).sessionId).toBe('s1'); + expect(() => core.accessor.get(ISessionContext)).toThrow(); + core.dispose(); + }); + + it('use-after-dispose throws on createChild', () => { + const core = createCoreScope(); + const session = core.createChild(LifecycleScope.Session, 's1'); + session.dispose(); + expect(() => session.createChild(LifecycleScope.Agent, 'a1')).toThrow(/disposed/); + core.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/di/scoped-register.test.ts b/packages/agent-core-v2/test/di/scoped-register.test.ts new file mode 100644 index 000000000..0ca8a092b --- /dev/null +++ b/packages/agent-core-v2/test/di/scoped-register.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { createDecorator } from '#/_base/di/instantiation'; +import { + LifecycleScope, + _clearScopedRegistryForTests, + getScopedServiceDescriptors, + registerScopedService, +} from '#/_base/di/scope'; + +interface ICore { + tag: 'core'; +} +interface ISession { + tag: 'session'; +} +interface IAgent { + tag: 'agent'; +} + +const ICore = createDecorator('scoped-core'); +const ISession = createDecorator('scoped-session'); +const IAgent = createDecorator('scoped-agent'); + +class CoreSvc implements ICore { + tag = 'core' as const; +} +class SessionSvc implements ISession { + tag = 'session' as const; +} +class AgentSvc implements IAgent { + tag = 'agent' as const; +} + +describe('registerScopedService / getScopedServiceDescriptors', () => { + beforeEach(() => { + _clearScopedRegistryForTests(); + }); + + it('filters registrations by scope layer', () => { + registerScopedService(LifecycleScope.Core, ICore, CoreSvc, InstantiationType.Delayed, 'core-domain'); + registerScopedService(LifecycleScope.Session, ISession, SessionSvc, InstantiationType.Delayed, 'session-domain'); + registerScopedService(LifecycleScope.Agent, IAgent, AgentSvc, InstantiationType.Eager, 'agent-domain'); + + expect(getScopedServiceDescriptors(LifecycleScope.Core).map((e) => e.id)).toEqual([ICore]); + expect(getScopedServiceDescriptors(LifecycleScope.Session).map((e) => e.id)).toEqual([ISession]); + expect(getScopedServiceDescriptors(LifecycleScope.Agent).map((e) => e.id)).toEqual([IAgent]); + }); + + it('records the domain and delayed-instantiation flag', () => { + registerScopedService(LifecycleScope.Session, ISession, SessionSvc, InstantiationType.Delayed, 'session-domain'); + registerScopedService(LifecycleScope.Agent, IAgent, AgentSvc, InstantiationType.Eager, 'agent-domain'); + + const [sessionEntry] = getScopedServiceDescriptors(LifecycleScope.Session); + const [agentEntry] = getScopedServiceDescriptors(LifecycleScope.Agent); + + expect(sessionEntry?.domain).toBe('session-domain'); + expect(sessionEntry?.descriptor.supportsDelayedInstantiation).toBe(true); + expect(agentEntry?.domain).toBe('agent-domain'); + expect(agentEntry?.descriptor.supportsDelayedInstantiation).toBe(false); + }); + + it('allows the same id to coexist at different scopes', () => { + interface IDual { + tag: string; + } + const IDual = createDecorator('scoped-dual'); + class CoreDual implements IDual { + tag = 'core'; + } + class SessionDual implements IDual { + tag = 'session'; + } + registerScopedService(LifecycleScope.Core, IDual, CoreDual); + registerScopedService(LifecycleScope.Session, IDual, SessionDual); + + expect(getScopedServiceDescriptors(LifecycleScope.Core)).toHaveLength(1); + expect(getScopedServiceDescriptors(LifecycleScope.Session)).toHaveLength(1); + expect(getScopedServiceDescriptors(LifecycleScope.Core)[0]?.id).toBe(IDual); + expect(getScopedServiceDescriptors(LifecycleScope.Session)[0]?.id).toBe(IDual); + }); +}); diff --git a/packages/agent-core-v2/test/di/scoped-test-container.test.ts b/packages/agent-core-v2/test/di/scoped-test-container.test.ts new file mode 100644 index 000000000..fc5dcaae1 --- /dev/null +++ b/packages/agent-core-v2/test/di/scoped-test-container.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { + LifecycleScope, + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; +import { createScopedTestHost, stubPair } from '#/_base/di/test'; + +interface IGreeter { + greet(): string; +} +interface IConsumer { + label(): string; +} + +const IGreeter = createDecorator('container-greeter'); +const IConsumer = createDecorator('container-consumer'); + +class Consumer implements IConsumer { + constructor(@IGreeter private readonly greeter: IGreeter) {} + label(): string { + return `consumed:${this.greeter.greet()}`; + } +} + +describe('scoped test container', () => { + beforeEach(() => { + _clearScopedRegistryForTests(); + registerScopedService(LifecycleScope.Session, IConsumer, Consumer); + }); + + it('injects a stubbed ancestor dependency into a child-layer service', () => { + const stubGreeter: IGreeter = { greet: () => 'hello-from-stub' }; + const host = createScopedTestHost([stubPair(IGreeter, stubGreeter)]); + const session = host.child(LifecycleScope.Session, 's1'); + + const consumer = session.accessor.get(IConsumer); + expect(consumer.label()).toBe('consumed:hello-from-stub'); + + host.dispose(); + }); + + it('stubs are isolated per scope (sibling scopes see different seeds)', () => { + const host = createScopedTestHost(); + const s1 = host.child(LifecycleScope.Session, 's1', [ + stubPair(IGreeter, { greet: () => 'one' }), + ]); + const s2 = host.child(LifecycleScope.Session, 's2', [ + stubPair(IGreeter, { greet: () => 'two' }), + ]); + + expect(s1.accessor.get(IGreeter).greet()).toBe('one'); + expect(s2.accessor.get(IGreeter).greet()).toBe('two'); + + host.dispose(); + }); + + it('childOf builds deeper (Agent) scopes under a given parent', () => { + const host = createScopedTestHost([stubPair(IGreeter, { greet: () => 'deep' })]); + const session = host.child(LifecycleScope.Session, 's1'); + const agent = host.childOf(session, LifecycleScope.Agent, 'main', [ + stubPair(IGreeter, { greet: () => 'agent-local' }), + ]); + + expect(agent.accessor.get(IGreeter).greet()).toBe('agent-local'); + expect(session.accessor.get(IGreeter).greet()).toBe('deep'); + + host.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/di/self-register.test.ts b/packages/agent-core-v2/test/di/self-register.test.ts new file mode 100644 index 000000000..2882f33f9 --- /dev/null +++ b/packages/agent-core-v2/test/di/self-register.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { IInstantiationService } from '#/_base/di/instantiation'; +import { InstantiationService } from '#/_base/di/instantiationService'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; + +describe('IInstantiationService self-registration', () => { + it('uses the VS Code diagnostic service id', () => { + expect(String(IInstantiationService)).toBe('instantiationService'); + }); + + it('root container exposes itself via accessor.get(IInstantiationService)', () => { + const ix = new InstantiationService(); + const resolved = ix.invokeFunction((a) => a.get(IInstantiationService)); + expect(resolved).toBe(ix); + }); + + it('child container resolves to ITSELF, not the parent', () => { + const parent = new InstantiationService(); + const child = parent.createChild(new ServiceCollection()); + const resolvedChild = child.invokeFunction((a) => a.get(IInstantiationService)); + const resolvedParent = parent.invokeFunction((a) => a.get(IInstantiationService)); + expect(resolvedChild).toBe(child); + expect(resolvedParent).toBe(parent); + expect(resolvedChild).not.toBe(resolvedParent); + }); + + it('multiple roots resolve to distinct instances', () => { + const a = new InstantiationService(); + const b = new InstantiationService(); + expect(a.invokeFunction((acc) => acc.get(IInstantiationService))).toBe(a); + expect(b.invokeFunction((acc) => acc.get(IInstantiationService))).toBe(b); + }); +}); diff --git a/packages/agent-core-v2/test/environment/environmentService.test.ts b/packages/agent-core-v2/test/environment/environmentService.test.ts new file mode 100644 index 000000000..fdff28035 --- /dev/null +++ b/packages/agent-core-v2/test/environment/environmentService.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, _clearScopedRegistryForTests, registerScopedService } from '#/_base/di/scope'; +import { createScopedTestHost } from '#/_base/di/test'; +import { + IEnvironmentService, + environmentSeed, +} from '#/environment/environment'; +import { EnvironmentService } from '#/environment/environmentService'; + +describe('EnvironmentService (scoped)', () => { + beforeEach(() => { + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.Core, + IEnvironmentService, + EnvironmentService, + InstantiationType.Eager, + 'environment', + ); + }); + + it('resolves homeDir/configPath from the seeded context token', () => { + const host = createScopedTestHost(environmentSeed('/tmp/kimi-home')); + const env = host.core.accessor.get(IEnvironmentService); + expect(env.homeDir).toBe('/tmp/kimi-home'); + expect(env.configPath).toBe('/tmp/kimi-home/config.toml'); + host.dispose(); + }); + + it('detect() returns a cached OS/shell probe', async () => { + const host = createScopedTestHost(environmentSeed('/tmp/kimi-home')); + const env = host.core.accessor.get(IEnvironmentService); + const a = await env.detect(); + const b = await env.detect(); + expect(a).toBe(b); + expect(typeof a.osKind).toBe('string'); + expect(typeof a.shellPath).toBe('string'); + host.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/event/event.test.ts b/packages/agent-core-v2/test/event/event.test.ts new file mode 100644 index 000000000..c6715ac12 --- /dev/null +++ b/packages/agent-core-v2/test/event/event.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { EventService } from '#/event/eventService'; + +describe('EventService', () => { + it('publish delivers to subscribers; unsubscribe stops delivery', () => { + const svc = new EventService(); + const received: string[] = []; + const sub = svc.subscribe((e) => received.push(e.type)); + svc.publish({ type: 'a', payload: null }); + svc.publish({ type: 'b', payload: null }); + sub.dispose(); + svc.publish({ type: 'c', payload: null }); + expect(received).toEqual(['a', 'b']); + }); +}); diff --git a/packages/agent-core-v2/test/filestore/filestore.test.ts b/packages/agent-core-v2/test/filestore/filestore.test.ts new file mode 100644 index 000000000..6b5bc1695 --- /dev/null +++ b/packages/agent-core-v2/test/filestore/filestore.test.ts @@ -0,0 +1,27 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { FileStore } from '#/filestore/fileStoreService'; +import { IKaosFactory } from '#/kaos/kaos'; + +describe('FileStore', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IKaosFactory, { _serviceBrand: undefined }); + }); + afterEach(() => disposables.dispose()); + + it('put / get / delete', async () => { + const store = ix.createInstance(FileStore); + const data = new TextEncoder().encode('hello'); + await store.put('k1', data); + expect(await store.get('k1')).toEqual(data); + await store.delete('k1'); + expect(await store.get('k1')).toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/test/flag/flag.test.ts b/packages/agent-core-v2/test/flag/flag.test.ts new file mode 100644 index 000000000..e0400300d --- /dev/null +++ b/packages/agent-core-v2/test/flag/flag.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; + +import { ConfigRegistry, ConfigService } from '#/config/configService'; +import type { ILogService, ILogger } from '#/log/log'; + +import { FlagRegistry } from '#/flag/registry'; +import { + EXPERIMENTAL_SECTION, + FlagService, + MASTER_ENV, +} from '#/flag/flagService'; + +const noopLogger: ILogger = { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, + child: () => noopLogger, +}; +const noopLog: ILogService = { + ...noopLogger, + _serviceBrand: undefined, + level: 'info', + setLevel: () => {}, +}; + +function makeConfigService(): { registry: ConfigRegistry; config: ConfigService } { + const registry = new ConfigRegistry(); + const config = new ConfigService( + registry, + undefined as never, + noopLog, + ); + return { registry, config }; +} + +function makeFlagService( + env: Readonly> = {}, +): { registry: ConfigRegistry; config: ConfigService; flags: FlagService } { + const { registry, config } = makeConfigService(); + const flags = new FlagService(registry, config, env); + return { registry, config, flags }; +} + +describe('FlagRegistry', () => { + it('lists registered definitions and resolves by id', () => { + const reg = new FlagRegistry(); + expect(reg.list().map((d) => d.id)).toEqual(['micro_compaction']); + expect(reg.get('micro_compaction')?.env).toBe('KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION'); + }); + + it('returns undefined for an unknown id', () => { + const reg = new FlagRegistry(); + // @ts-expect-error -- unknown id is not part of the FlagId union + expect(reg.get('does_not_exist')).toBeUndefined(); + }); +}); + +describe('FlagService', () => { + it('registers the experimental config section downward', () => { + const { registry } = makeFlagService(); + expect(registry.getSection(EXPERIMENTAL_SECTION)).toMatchObject({ + domain: EXPERIMENTAL_SECTION, + }); + expect(registry.getSection(EXPERIMENTAL_SECTION)?.schema).toBeDefined(); + }); + + it('resolves the registry default when nothing overrides it', () => { + const { flags } = makeFlagService(); + const state = flags.explain('micro_compaction'); + expect(state?.enabled).toBe(true); + expect(state?.source).toBe('default'); + expect(flags.enabled('micro_compaction')).toBe(true); + }); + + it('applies config overrides above the default', async () => { + const { config, flags } = makeFlagService(); + await config.set(EXPERIMENTAL_SECTION, { micro_compaction: false }); + const state = flags.explain('micro_compaction'); + expect(state?.enabled).toBe(false); + expect(state?.source).toBe('config'); + expect(state?.configValue).toBe(false); + }); + + it('lets per-feature env override config', async () => { + const { config, flags } = makeFlagService({ + KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION: 'true', + }); + await config.set(EXPERIMENTAL_SECTION, { micro_compaction: false }); + const state = flags.explain('micro_compaction'); + expect(state?.enabled).toBe(true); + expect(state?.source).toBe('env'); + expect(state?.configValue).toBe(false); + }); + + it('lets the master env switch force every flag on', async () => { + const { config, flags } = makeFlagService({ [MASTER_ENV]: '1' }); + await config.set(EXPERIMENTAL_SECTION, { micro_compaction: false }); + const state = flags.explain('micro_compaction'); + expect(state?.enabled).toBe(true); + expect(state?.source).toBe('master-env'); + }); + + it('refreshes overrides when the experimental config section changes', async () => { + const { config, flags } = makeFlagService(); + expect(flags.enabled('micro_compaction')).toBe(true); + await config.set(EXPERIMENTAL_SECTION, { micro_compaction: false }); + expect(flags.enabled('micro_compaction')).toBe(false); + await config.set(EXPERIMENTAL_SECTION, { micro_compaction: true }); + expect(flags.enabled('micro_compaction')).toBe(true); + }); + + it('ignores unrelated config section changes', async () => { + const { config, flags } = makeFlagService(); + await config.set('agent', { modelAlias: 'k2' }); + expect(flags.explain('micro_compaction')?.source).toBe('default'); + }); + + it('supports imperative setConfigOverrides', () => { + const { flags } = makeFlagService(); + flags.setConfigOverrides({ micro_compaction: false }); + expect(flags.enabled('micro_compaction')).toBe(false); + flags.setConfigOverrides(undefined); + expect(flags.enabled('micro_compaction')).toBe(true); + }); + + it('exposes snapshot / enabledIds / explainAll', () => { + const { flags } = makeFlagService(); + expect(flags.snapshot()).toEqual({ micro_compaction: true }); + expect(flags.enabledIds()).toEqual(['micro_compaction']); + expect(flags.explainAll().map((s) => s.id)).toEqual(['micro_compaction']); + }); + + it('treats env values case-insensitively and ignores garbage', () => { + const truthy = makeFlagService({ KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION: 'YES' }).flags; + expect(truthy.enabled('micro_compaction')).toBe(true); + const falsy = makeFlagService({ KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION: 'off' }).flags; + expect(falsy.enabled('micro_compaction')).toBe(false); + const garbage = makeFlagService({ KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION: 'maybe' }).flags; + expect(garbage.enabled('micro_compaction')).toBe(true); + }); +}); diff --git a/packages/agent-core-v2/test/fs/fs.test.ts b/packages/agent-core-v2/test/fs/fs.test.ts new file mode 100644 index 000000000..13cdc73a3 --- /dev/null +++ b/packages/agent-core-v2/test/fs/fs.test.ts @@ -0,0 +1,54 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { LocalKaos } from '@moonshot-ai/kaos'; + +import type { ILogService, ILogger } from '#/log/log'; + +import { SessionKaosService } from '#/kaos/sessionKaosService'; +import { FsService } from '#/fs/fsService'; + +const noopLogger: ILogger = { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, + child: () => noopLogger, +}; +const noopLog: ILogService = { + ...noopLogger, + _serviceBrand: undefined, + level: 'info', + setLevel: () => {}, +}; + +describe('FsService', () => { + let dir: string; + let fs: FsService; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'fs-test-')); + const base = await LocalKaos.create(); + const sessionKaos = new SessionKaosService(noopLog); + sessionKaos.setToolKaos(base.withCwd(dir)); + fs = new FsService(sessionKaos, noopLog); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('write then read round-trips', async () => { + await fs.write('hello.txt', 'world'); + expect(await fs.read('hello.txt')).toBe('world'); + }); + + it('mkdir creates a directory', async () => { + await fs.mkdir('sub/deep'); + const st = (await fs.stat('sub/deep')) as { isDirectory?: () => boolean }; + expect(typeof st).toBe('object'); + }); +}); diff --git a/packages/agent-core-v2/test/gateway/gateway.test.ts b/packages/agent-core-v2/test/gateway/gateway.test.ts new file mode 100644 index 000000000..859dbd3b4 --- /dev/null +++ b/packages/agent-core-v2/test/gateway/gateway.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { InstantiationService } from '#/_base/di/instantiationService'; +import type { Event } from '#/_base/event'; +import type { ServicesAccessor } from '#/_base/di/instantiation'; +import type { IScopeHandle } from '#/_base/di/scope'; +import type { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle'; +import type { + ITurnService, + TurnEndEvent, + TurnStartEvent, + TurnStepEvent, + TurnToolEvent, +} from '#/turn/turn'; + +import { RestGateway, ScopeRegistry } from '#/gateway/gatewayService'; + +const noneEvent = ((): Event => () => ({ dispose: () => {} }))(); + +describe('ScopeRegistry', () => { + it('createSession / get / close', async () => { + const reg = new ScopeRegistry(new InstantiationService()); + const h = await reg.createSession({ sessionId: 's1', workDir: '/tmp' }); + expect(h.id).toBe('s1'); + expect(reg.get('s1')).toBe(h); + await reg.close('s1'); + expect(reg.get('s1')).toBeUndefined(); + }); +}); + +describe('RestGateway', () => { + it('routes prompt to the agent turn service', async () => { + const prompts: string[] = []; + const turn: ITurnService = { + _serviceBrand: undefined, + onWillStartTurn: noneEvent as Event, + onWillExecuteTool: noneEvent as Event, + onDidFinalizeTool: noneEvent as Event, + onDidEndStep: noneEvent as Event, + onDidEndTurn: noneEvent as Event, + get hasActiveTurn() { + return false; + }, + get currentId() { + return undefined; + }, + prompt: (input: string) => { + prompts.push(input); + return Promise.resolve(); + }, + steer: () => {}, + retry: () => Promise.resolve(), + cancel: () => {}, + }; + const agentHandle: IScopeHandle = { id: 'main', kind: 2, accessor: { get: () => turn } as ServicesAccessor }; + const agents: IAgentLifecycleService = { + _serviceBrand: undefined, + create: () => Promise.resolve(agentHandle), + createMain: () => Promise.resolve(agentHandle), + getHandle: () => agentHandle, + list: () => [agentHandle], + remove: () => Promise.resolve(), + }; + const sessionHandle: IScopeHandle = { id: 's1', kind: 1, accessor: { get: () => agents } as ServicesAccessor }; + const scopes = { + _serviceBrand: undefined, + createSession: () => Promise.resolve(sessionHandle), + get: (id: string) => (id === 's1' ? sessionHandle : undefined), + close: () => Promise.resolve(), + }; + const gw = new RestGateway(scopes); + await gw.prompt('s1', 'main', 'hello'); + expect(prompts).toEqual(['hello']); + }); +}); diff --git a/packages/agent-core-v2/test/goal/goal.test.ts b/packages/agent-core-v2/test/goal/goal.test.ts new file mode 100644 index 000000000..f5ad219f0 --- /dev/null +++ b/packages/agent-core-v2/test/goal/goal.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IInjectionService } from '#/injection/injection'; +import { IAgentRecords } from '#/records/records'; +import { ITurnService } from '#/turn/turn'; +import { LoopRunner } from '#/turn/loopRunner'; +import { TurnService } from '#/turn/turnService'; + +import { GoalService } from '#/goal/goalService'; + +function makeTurn(): TurnService { + return new TurnService( + undefined as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + new LoopRunner(), + ); +} + +describe('GoalService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IAgentRecords, {}); + ix.set(ITurnService, makeTurn()); + ix.stub(IInjectionService, {}); + }); + afterEach(() => disposables.dispose()); + + it('create / update / clear track current goal', () => { + const goal = disposables.add(ix.createInstance(GoalService)); + expect(goal.current).toBeUndefined(); + goal.create('build it'); + expect(goal.current).toEqual({ objective: 'build it', status: 'active' }); + goal.update({ status: 'done' }); + expect(goal.current?.status).toBe('done'); + goal.clear(); + expect(goal.current).toBeUndefined(); + goal.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/hooks/hooks.test.ts b/packages/agent-core-v2/test/hooks/hooks.test.ts new file mode 100644 index 000000000..91a1d5822 --- /dev/null +++ b/packages/agent-core-v2/test/hooks/hooks.test.ts @@ -0,0 +1,27 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { IConfigService } from '#/config/config'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { HookEngine } from '#/hooks/hookEngine'; +import { ILogService } from '#/log/log'; + +describe('HookEngine', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IConfigService, { _serviceBrand: undefined }); + ix.stub(ILogService, { _serviceBrand: undefined }); + }); + afterEach(() => disposables.dispose()); + + it('passes through with continue: true by default', async () => { + const hooks = disposables.add(ix.createInstance(HookEngine)); + expect(await hooks.runUserPromptSubmit('hi')).toEqual({ continue: true }); + expect(await hooks.runPreToolCall('bash', {})).toEqual({ continue: true }); + await expect(hooks.runSessionStart()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/test/injection/injection.test.ts b/packages/agent-core-v2/test/injection/injection.test.ts new file mode 100644 index 000000000..f8ad18382 --- /dev/null +++ b/packages/agent-core-v2/test/injection/injection.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { IContextService } from '#/context/context'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { InjectionQueue, InjectionService } from '#/injection/injectionService'; + +describe('InjectionService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IContextService, { _serviceBrand: undefined }); + }); + afterEach(() => disposables.dispose()); + + it('push then flush drains in FIFO order', () => { + const svc = ix.createInstance(InjectionService); + svc.push({ kind: 'a', content: '1' }); + svc.push({ kind: 'b', content: '2' }); + expect(svc.flush()).toEqual([ + { kind: 'a', content: '1' }, + { kind: 'b', content: '2' }, + ]); + expect(svc.flush()).toEqual([]); + }); +}); + +describe('InjectionQueue', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + }); + afterEach(() => disposables.dispose()); + + it('is an independent per-turn queue', () => { + const q = ix.createInstance(InjectionQueue); + q.push({ kind: 'x', content: 'y' }); + expect(q.flush()).toEqual([{ kind: 'x', content: 'y' }]); + }); +}); diff --git a/packages/agent-core-v2/test/kaos/kaos.test.ts b/packages/agent-core-v2/test/kaos/kaos.test.ts new file mode 100644 index 000000000..6868d6dc6 --- /dev/null +++ b/packages/agent-core-v2/test/kaos/kaos.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { LocalKaos } from '@moonshot-ai/kaos'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IEnvironmentService } from '#/environment/environment'; +import { ILogService, type ILogger } from '#/log/log'; + +import { ISessionKaosService } from '#/kaos/kaos'; +import { AgentKaos } from '#/kaos/agentKaos'; +import { KaosFactory } from '#/kaos/kaosFactory'; +import { SessionKaosService } from '#/kaos/sessionKaosService'; + +const noopLogger: ILogger = { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, + child: () => noopLogger, +}; +const noopLog: ILogService = { + ...noopLogger, + _serviceBrand: undefined, + level: 'info', + setLevel: () => {}, +}; + +describe('KaosFactory', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IEnvironmentService, {}); + ix.stub(ILogService, noopLog); + }); + afterEach(() => disposables.dispose()); + + it('creates a local kaos', async () => { + const factory = ix.createInstance(KaosFactory); + const kaos = await factory.create({ kind: 'local' }); + expect(typeof kaos.getcwd()).toBe('string'); + }); + + it('pins to the requested cwd', async () => { + const factory = ix.createInstance(KaosFactory); + const base = await LocalKaos.create(); + const target = base.getcwd(); + const kaos = await factory.create({ kind: 'local', cwd: target }); + expect(kaos.getcwd()).toBe(target); + }); + + it('throws TODO for ssh', async () => { + const factory = ix.createInstance(KaosFactory); + await expect(factory.create({ kind: 'ssh', host: 'h' })).rejects.toThrow(/TODO/); + }); +}); + +describe('SessionKaosService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(ILogService, noopLog); + }); + afterEach(() => disposables.dispose()); + + async function make(): Promise<{ svc: SessionKaosService; kaos: LocalKaos }> { + const svc = disposables.add(ix.createInstance(SessionKaosService)); + const kaos = await LocalKaos.create(); + svc.setToolKaos(kaos); + return { svc, kaos }; + } + + it('throws before setToolKaos', () => { + const svc = disposables.add(ix.createInstance(SessionKaosService)); + expect(() => svc.toolKaos).toThrow(/before setToolKaos/); + }); + + it('persistenceKaos defaults to toolKaos', async () => { + const { svc, kaos } = await make(); + expect(svc.persistenceKaos).toBe(kaos); + }); + + it('setPersistenceKaos overrides the default', async () => { + const { svc } = await make(); + const other = await LocalKaos.create(); + svc.setPersistenceKaos(other); + expect(svc.persistenceKaos).toBe(other); + }); + + it('additionalDirs add / dedupe / remove', async () => { + const { svc } = await make(); + svc.addAdditionalDir('/a'); + svc.addAdditionalDir('/b'); + svc.addAdditionalDir('/a'); + expect(svc.additionalDirs).toEqual(['/a', '/b']); + svc.removeAdditionalDir('/a'); + expect(svc.additionalDirs).toEqual(['/b']); + }); + + it('systemContextKaos is pinned to the tool cwd', async () => { + const { svc } = await make(); + const sys = svc.systemContextKaos; + expect(sys.getcwd()).toBe(svc.toolKaos.getcwd()); + expect(sys).not.toBe(svc.toolKaos); + }); +}); + +describe('AgentKaos', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(ILogService, noopLog); + }); + afterEach(() => disposables.dispose()); + + it('derives cwd from the session kaos and isolates chdir', async () => { + const session = disposables.add(ix.createInstance(SessionKaosService)); + const base = await LocalKaos.create(); + session.setToolKaos(base); + ix.set(ISessionKaosService, session); + + const agent = ix.createInstance(AgentKaos); + expect(agent.cwd).toBe(base.getcwd()); + + const next = base.withCwd('/').getcwd(); + await agent.chdir('/'); + expect(agent.cwd).toBe(next); + expect(session.toolKaos.getcwd()).toBe(base.getcwd()); + }); +}); diff --git a/packages/agent-core-v2/test/kosong/kosong.test.ts b/packages/agent-core-v2/test/kosong/kosong.test.ts new file mode 100644 index 000000000..8d96f3a66 --- /dev/null +++ b/packages/agent-core-v2/test/kosong/kosong.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { ILogger } from '#/log/log'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IConfigRegistry, IConfigService } from '#/config/config'; +import { IEnvironmentService } from '#/environment/environment'; +import { IModelCatalogService } from '#/kosong/kosong'; +import { ILogService } from '#/log/log'; + +import { ConfigRegistry, ConfigService } from '#/config/configService'; +import { ModelCatalogService, ProviderManager } from '#/kosong/kosongService'; + +const noopLogger: ILogger = { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, + child: () => noopLogger, +}; +const noopLog: ILogService = { + ...noopLogger, + _serviceBrand: undefined, + level: 'info', + setLevel: () => {}, +}; + +const unusedEnv: IEnvironmentService = { + _serviceBrand: undefined, + homeDir: '', + configPath: '', + detect: () => Promise.reject(new Error('unused')), +}; + +describe('ModelCatalogService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let catalog: ModelCatalogService; + + beforeEach(async () => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IConfigRegistry, new ConfigRegistry()); + ix.stub(IEnvironmentService, unusedEnv); + ix.stub(ILogService, noopLog); + const config = disposables.add(ix.createInstance(ConfigService)); + ix.set(IConfigService, config); + await config.set('kosong', { + providers: [ + { id: 'kimi', name: 'Kimi' }, + { id: 'other', name: 'Other' }, + ], + models: [ + { id: 'k2', providerId: 'kimi' }, + { id: 'o1', providerId: 'other' }, + ], + defaultProviderId: 'kimi', + defaultModelId: 'k2', + }); + catalog = ix.createInstance(ModelCatalogService); + }); + afterEach(() => disposables.dispose()); + + it('lists providers from config', async () => { + expect(await catalog.listProviders()).toEqual([ + { id: 'kimi', name: 'Kimi' }, + { id: 'other', name: 'Other' }, + ]); + }); + + it('lists models, optionally filtered by provider', async () => { + expect(await catalog.listModels()).toHaveLength(2); + expect(await catalog.listModels('kimi')).toEqual([{ id: 'k2', providerId: 'kimi' }]); + }); +}); + +describe('ProviderManager', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let config: ConfigService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IConfigRegistry, new ConfigRegistry()); + ix.stub(IEnvironmentService, unusedEnv); + ix.stub(ILogService, noopLog); + config = disposables.add(ix.createInstance(ConfigService)); + ix.set(IConfigService, config); + ix.set(IModelCatalogService, new SyncDescriptor(ModelCatalogService)); + }); + afterEach(() => disposables.dispose()); + + async function make(): Promise { + await config.set('kosong', { + providers: [{ id: 'kimi', name: 'Kimi' }], + models: [{ id: 'k2', providerId: 'kimi' }], + defaultProviderId: 'kimi', + defaultModelId: 'k2', + }); + return ix.createInstance(ProviderManager); + } + + it('resolves defaults when no ids given', async () => { + const pm = await make(); + expect(await pm.resolve()).toEqual({ providerId: 'kimi', modelId: 'k2' }); + }); + + it('resolves explicit ids', async () => { + const pm = await make(); + expect(await pm.resolve('kimi', 'k2')).toEqual({ providerId: 'kimi', modelId: 'k2' }); + }); + + it('throws on unknown provider', async () => { + const pm = await make(); + await expect(pm.resolve('nope', 'k2')).rejects.toThrow(/unknown provider/); + }); + + it('throws when no defaults and no ids', async () => { + await config.set('kosong', { providers: [{ id: 'kimi', name: 'Kimi' }] }); + const pm = ix.createInstance(ProviderManager); + await expect(pm.resolve()).rejects.toThrow(/no defaults/); + }); +}); diff --git a/packages/agent-core-v2/test/lint/domain-layers.test.ts b/packages/agent-core-v2/test/lint/domain-layers.test.ts new file mode 100644 index 000000000..14b1de542 --- /dev/null +++ b/packages/agent-core-v2/test/lint/domain-layers.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { SRC_ROOT, checkSource } from '../../scripts/check-domain-layers.mjs'; + +const at = (domain: string, file: string): string => `${SRC_ROOT}/${domain}/${file}`; + +const V1 = ['@moonshot-ai', 'agent-core'].join('/'); + +describe('check-domain-layers', () => { + it('flags a direct import of v1 (@moonshot-ai/agent-core)', () => { + const violations = checkSource( + `import { KimiCore } from '${V1}';`, + at('turn', 'turn.ts'), + ); + expect(violations).toHaveLength(1); + expect(violations[0]?.message).toMatch(/v2 must not import v1/); + }); + + it('flags a v1 subpath import', () => { + const violations = checkSource( + `import { Session } from '${V1}/session';`, + at('turn', 'turn.ts'), + ); + expect(violations).toHaveLength(1); + expect(violations[0]?.message).toMatch(/v2 must not import v1/); + }); + + it('allows a domain to import a lower layer', () => { + const violations = checkSource( + `import { createDecorator } from '#/_base/di/instantiation';`, + at('turn', 'turn.ts'), + ); + expect(violations).toHaveLength(0); + }); + + it('flags a lower layer importing a higher layer', () => { + const violations = checkSource( + `import { ITurnService } from '#/turn/turn';`, + at('log', 'log.ts'), + ); + expect(violations).toHaveLength(1); + expect(violations[0]?.message).toMatch(/layer violation/); + expect(violations[0]?.message).toMatch(/log.*L1.*turn.*L4/s); + }); + + it('allows same-domain relative imports', () => { + const violations = checkSource( + `import { helper } from './helper';`, + at('turn', 'turn.ts'), + ); + expect(violations).toHaveLength(0); + }); + + it('allows sibling-package imports (out of scope for layering)', () => { + const violations = checkSource( + `import { something } from '@moonshot-ai/kaos';`, + at('log', 'log.ts'), + ); + expect(violations).toHaveLength(0); + }); + + it('exempts the top-level package barrel from layering', () => { + const violations = checkSource( + `export * from './_base/di/index';`, + `${SRC_ROOT}/index.ts`, + ); + expect(violations).toHaveLength(0); + }); +}); diff --git a/packages/agent-core-v2/test/log/logService.test.ts b/packages/agent-core-v2/test/log/logService.test.ts new file mode 100644 index 000000000..d0ae6a90e --- /dev/null +++ b/packages/agent-core-v2/test/log/logService.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, _clearScopedRegistryForTests } from '#/_base/di/scope'; +import { createScopedTestHost, stubPair } from '#/_base/di/test'; +import { + ConsoleLogSink, + ILogService, + ILogSink, + LogService, + MemoryLogSink, + levelEnabled, +} from '#/log/index'; +import { registerScopedService } from '#/_base/di/scope'; + +describe('LogService (unit)', () => { + it('emits entries to the sink at/above the configured level', () => { + const sink = new MemoryLogSink(); + const log = new LogService(sink, {}, 'info'); + log.debug('hidden'); + log.info('hello'); + log.warn('careful'); + expect(sink.entries.map((e) => e.msg)).toEqual(['hello', 'careful']); + expect(sink.entries.every((e) => typeof e.t === 'number')).toBe(true); + }); + + it('extracts Error payload onto entry.error', () => { + const sink = new MemoryLogSink(); + const log = new LogService(sink, {}, 'info'); + const err = new Error('boom'); + log.error('failed', err); + expect(sink.entries[0]?.error?.message).toBe('boom'); + expect(sink.entries[0]?.error?.stack).toContain('boom'); + }); + + it('merges object payload into ctx', () => { + const sink = new MemoryLogSink(); + const log = new LogService(sink, {}, 'debug'); + log.info('with ctx', { requestId: 'r1', count: 2 }); + expect(sink.entries[0]?.ctx).toEqual({ requestId: 'r1', count: 2 }); + }); + + it('child merges bound context and bound wins over payload', () => { + const sink = new MemoryLogSink(); + const parent = new LogService(sink, {}, 'debug'); + const child = parent.child({ sessionId: 's1', agentId: 'main' }); + child.info('evt', { sessionId: 'override', extra: 'x' }); + expect(sink.entries[0]?.ctx).toEqual({ + sessionId: 's1', + agentId: 'main', + extra: 'x', + }); + }); + + it('child chains accumulate context', () => { + const sink = new MemoryLogSink(); + const root = new LogService(sink, {}, 'debug'); + const leaf = root.child({ a: 1 }).child({ b: 2 }); + leaf.info('evt'); + expect(sink.entries[0]?.ctx).toEqual({ a: 1, b: 2 }); + }); + + it('setLevel changes filtering at runtime', () => { + const sink = new MemoryLogSink(); + const log = new LogService(sink, {}, 'error'); + log.info('hidden'); + log.setLevel('info'); + log.info('shown'); + expect(sink.entries.map((e) => e.msg)).toEqual(['shown']); + }); +}); + +describe('levelEnabled', () => { + it('respects ordering and off', () => { + expect(levelEnabled('error', 'info')).toBe(true); + expect(levelEnabled('debug', 'info')).toBe(false); + expect(levelEnabled('info', 'off')).toBe(false); + expect(levelEnabled('info', 'debug')).toBe(true); + }); +}); + +describe('ILogService (scoped)', () => { + beforeEach(() => { + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.Core, + ILogSink, + ConsoleLogSink, + InstantiationType.Eager, + 'log', + ); + registerScopedService( + LifecycleScope.Core, + ILogService, + LogService, + InstantiationType.Eager, + 'log', + ); + }); + + it('resolves ILogService from the Core scope with its sink injected', () => { + const sink = new MemoryLogSink(); + const host = createScopedTestHost([stubPair(ILogSink, sink)]); + const log = host.core.accessor.get(ILogService); + log.info('scoped-hello'); + expect(sink.entries.map((e) => e.msg)).toEqual(['scoped-hello']); + host.dispose(); + }); + + it('a scoped child logger bound to sessionId is resolvable downstream', () => { + const sink = new MemoryLogSink(); + const host = createScopedTestHost([stubPair(ILogSink, sink)]); + const root = host.core.accessor.get(ILogService); + const sessionLog = root.child({ sessionId: 's1' }); + sessionLog.warn('bound'); + expect(sink.entries[0]?.ctx).toEqual({ sessionId: 's1' }); + host.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/mcp/mcp.test.ts b/packages/agent-core-v2/test/mcp/mcp.test.ts new file mode 100644 index 000000000..11a84b214 --- /dev/null +++ b/packages/agent-core-v2/test/mcp/mcp.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IOAuthService } from '#/auth/auth'; +import { IConfigService } from '#/config/config'; +import { ILogService } from '#/log/log'; +import { ITelemetryService } from '#/telemetry/telemetry'; + +import { McpService } from '#/mcp/mcpService'; + +describe('McpService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IConfigService, {}); + ix.stub(ILogService, {}); + ix.stub(ITelemetryService, {}); + ix.stub(IOAuthService, {}); + }); + afterEach(() => disposables.dispose()); + + it('connect / disconnect / list + status events', async () => { + const svc = disposables.add(ix.createInstance(McpService)); + const statuses: string[] = []; + svc.onDidChangeServerStatus((e) => statuses.push(`${e.serverId}:${e.status}`)); + await svc.connect('s1'); + await svc.connect('s2'); + expect([...svc.list()].sort()).toEqual(['s1', 's2']); + await svc.disconnect('s1'); + expect(svc.list()).toEqual(['s2']); + expect(statuses).toEqual(['s1:connected', 's2:connected', 's1:disconnected']); + svc.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/message/message.test.ts b/packages/agent-core-v2/test/message/message.test.ts new file mode 100644 index 000000000..3e6aa36a8 --- /dev/null +++ b/packages/agent-core-v2/test/message/message.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IContextService } from '#/context/context'; +import { ContextService } from '#/context/contextService'; +import { IAgentRecords } from '#/records/records'; + +import { MessageService } from '#/message/messageService'; + +const unusedRecords: IAgentRecords = { + _serviceBrand: undefined, + logRecord: () => Promise.resolve(), + // eslint-disable-next-line @typescript-eslint/require-await + replay: async function* () { + /* no records in tests */ + }, + restore: () => Promise.resolve(), +}; + +describe('MessageService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IAgentRecords, unusedRecords); + ix.set(IContextService, new SyncDescriptor(ContextService)); + }); + afterEach(() => disposables.dispose()); + + it('projects context messages with stable derived ids', () => { + const ctx = ix.get(IContextService); + ctx.appendMessage({ role: 'user', content: 'a' }); + ctx.appendMessage({ role: 'assistant', content: 'b' }); + const msg = ix.createInstance(MessageService); + const list = msg.list(); + expect(list).toEqual([ + { id: 'msg-0', role: 'user', content: 'a' }, + { id: 'msg-1', role: 'assistant', content: 'b' }, + ]); + expect(msg.get('msg-1')).toEqual({ id: 'msg-1', role: 'assistant', content: 'b' }); + expect(msg.get('missing')).toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/test/permission/permission.test.ts b/packages/agent-core-v2/test/permission/permission.test.ts new file mode 100644 index 000000000..6c2809731 --- /dev/null +++ b/packages/agent-core-v2/test/permission/permission.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { ApprovalService } from '#/approval/approvalService'; +import { + PermissionPolicyRegistry, + PermissionService, +} from '#/permission/permissionService'; + +describe('PermissionPolicyRegistry', () => { + it('returns the first non-undefined decision', () => { + const reg = new PermissionPolicyRegistry(); + reg.register({ name: 'p1', evaluate: () => undefined }); + reg.register({ name: 'p2', evaluate: () => 'deny' }); + reg.register({ name: 'p3', evaluate: () => 'allow' }); + expect(reg.evaluate({ toolName: 'bash', args: {} })).toBe('deny'); + }); + + it('defaults to allow when no policy matches', () => { + const reg = new PermissionPolicyRegistry(); + expect(reg.evaluate({ toolName: 'bash', args: {} })).toBe('allow'); + }); +}); + +describe('PermissionService', () => { + function make(mode: 'yolo' | 'manual' | 'auto' = 'auto') { + const reg = new PermissionPolicyRegistry(); + const approval = new ApprovalService(); + const svc = new PermissionService( + reg, + undefined as never, + undefined as never, + approval, + undefined as never, + mode, + ); + return { svc, reg, approval }; + } + + it('yolo always allows', async () => { + const { svc, reg } = make('yolo'); + reg.register({ name: 'deny-all', evaluate: () => 'deny' }); + expect(await svc.beforeToolCall({ toolName: 'bash', args: {} })).toBe('allow'); + }); + + it('auto returns registry decision', async () => { + const { svc, reg } = make('auto'); + reg.register({ name: 'deny-bash', evaluate: (ctx) => (ctx.toolName === 'bash' ? 'deny' : undefined) }); + expect(await svc.beforeToolCall({ toolName: 'bash', args: {} })).toBe('deny'); + expect(await svc.beforeToolCall({ toolName: 'read', args: {} })).toBe('allow'); + }); + + it('auto routes ask through approval', async () => { + const { svc, reg, approval } = make('auto'); + reg.register({ name: 'ask-all', evaluate: () => 'ask' }); + const p = svc.beforeToolCall({ toolName: 'bash', args: {} }); + approval.decide('bash', 'allow'); + await expect(p).resolves.toBe('allow'); + }); + + it('manual always requests approval', async () => { + const { svc, approval } = make('manual'); + const p = svc.beforeToolCall({ toolName: 'bash', args: {} }); + approval.decide('bash', 'deny'); + await expect(p).resolves.toBe('deny'); + }); +}); diff --git a/packages/agent-core-v2/test/plan/plan.test.ts b/packages/agent-core-v2/test/plan/plan.test.ts new file mode 100644 index 000000000..728923e2d --- /dev/null +++ b/packages/agent-core-v2/test/plan/plan.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { ContextService } from '#/context/contextService'; +import { InjectionService } from '#/injection/injectionService'; +import { LoopRunner } from '#/turn/loopRunner'; +import { TurnService } from '#/turn/turnService'; + +import { PlanService } from '#/plan/planService'; + +function makeTurn(): TurnService { + return new TurnService( + undefined as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + new LoopRunner(), + ); +} + +describe('PlanService', () => { + it('enter sets active and pushes a plan injection', async () => { + const ctx = new ContextService(undefined as never); + const injection = new InjectionService(ctx); + const turn = makeTurn(); + const plan = new PlanService( + undefined as never, + undefined as never, + undefined as never, + injection, + turn, + ); + expect(plan.active).toBe(false); + await plan.enter(); + expect(plan.active).toBe(true); + expect(injection.flush()).toEqual([ + { kind: 'plan', content: 'Plan mode active — propose a plan before acting.' }, + ]); + plan.cancel(); + expect(plan.active).toBe(false); + plan.dispose(); + }); + + it('resets active on turn end', async () => { + const ctx = new ContextService(undefined as never); + const injection = new InjectionService(ctx); + const turn = makeTurn(); + const plan = new PlanService(undefined as never, undefined as never, undefined as never, injection, turn); + await plan.enter(); + await turn.prompt('go'); + expect(plan.active).toBe(false); + plan.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/question/question.test.ts b/packages/agent-core-v2/test/question/question.test.ts new file mode 100644 index 000000000..75a45d09b --- /dev/null +++ b/packages/agent-core-v2/test/question/question.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; + +import { QuestionService } from '#/question/questionService'; + +describe('QuestionService', () => { + it('request parks until answer resolves it', async () => { + const svc = new QuestionService(); + const p = svc.request({ id: 'q1', prompt: 'name?' }); + expect(svc.listPending()).toEqual([{ id: 'q1', prompt: 'name?' }]); + svc.answer('q1', 'kimi'); + await expect(p).resolves.toBe('kimi'); + expect(svc.listPending()).toEqual([]); + }); +}); diff --git a/packages/agent-core-v2/test/records/records.test.ts b/packages/agent-core-v2/test/records/records.test.ts new file mode 100644 index 000000000..c2f7a046b --- /dev/null +++ b/packages/agent-core-v2/test/records/records.test.ts @@ -0,0 +1,108 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { LocalKaos } from '@moonshot-ai/kaos'; + +import type { ILogService, ILogger } from '#/log/log'; + +import { AgentKaos } from '#/kaos/agentKaos'; +import { SessionKaosService } from '#/kaos/sessionKaosService'; +import { + AgentRecords, + SessionMetaStore, + encodeWorkDirKey, +} from '#/records/recordsService'; + +const noopLogger: ILogger = { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, + child: () => noopLogger, +}; +const noopLog: ILogService = { + ...noopLogger, + _serviceBrand: undefined, + level: 'info', + setLevel: () => {}, +}; + +describe('encodeWorkDirKey', () => { + it('is deterministic and path-sensitive', () => { + const a = encodeWorkDirKey('/home/user/repo'); + const b = encodeWorkDirKey('/home/user/repo'); + const c = encodeWorkDirKey('/home/user/other'); + expect(a).toBe(b); + expect(a).not.toBe(c); + expect(a.startsWith('wd_')).toBe(true); + }); +}); + +describe('SessionMetaStore', () => { + let dir: string; + let sessionKaos: SessionKaosService; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'records-test-')); + const base = await LocalKaos.create(); + sessionKaos = new SessionKaosService(noopLog); + sessionKaos.setToolKaos(base.withCwd(dir)); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('read returns {} when state.json is absent', async () => { + const meta = new SessionMetaStore(sessionKaos, noopLog); + expect(await meta.read()).toEqual({}); + }); + + it('write merges and persists; read round-trips', async () => { + const meta = new SessionMetaStore(sessionKaos, noopLog); + await meta.write({ title: 'hello' }); + await meta.write({ count: 1 }); + + const fresh = new SessionMetaStore(sessionKaos, noopLog); + expect(await fresh.read()).toEqual({ title: 'hello', count: 1 }); + }); +}); + +describe('AgentRecords', () => { + let dir: string; + let records: AgentRecords; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'records-test-')); + const base = await LocalKaos.create(); + const sessionKaos = new SessionKaosService(noopLog); + sessionKaos.setToolKaos(base.withCwd(dir)); + const agentKaos = new AgentKaos(sessionKaos); + records = new AgentRecords(agentKaos, noopLog); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('logRecord appends and replay yields records in order', async () => { + await records.logRecord({ kind: 'a', payload: 1 }); + await records.logRecord({ kind: 'b', payload: 2 }); + + const out = []; + for await (const r of records.replay()) out.push(r); + expect(out).toEqual([ + { kind: 'a', payload: 1 }, + { kind: 'b', payload: 2 }, + ]); + }); + + it('replay on empty store yields nothing', async () => { + const out = []; + for await (const r of records.replay()) out.push(r); + expect(out).toEqual([]); + }); +}); diff --git a/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts b/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts new file mode 100644 index 000000000..055eb2b49 --- /dev/null +++ b/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import type { Event } from '#/_base/event'; +import type { ServicesAccessor } from '#/_base/di/instantiation'; +import type { IScopeHandle } from '#/_base/di/scope'; +import type { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle'; +import type { + ITurnService, + TurnEndEvent, + TurnStartEvent, + TurnStepEvent, + TurnToolEvent, +} from '#/turn/turn'; + +import { SessionActivity } from '#/session-activity/sessionActivityService'; + +const noneEvent = ((): Event => () => ({ dispose: () => {} }))(); + +function stubTurn(active: boolean): ITurnService { + return { + _serviceBrand: undefined, + onWillStartTurn: noneEvent as Event, + onWillExecuteTool: noneEvent as Event, + onDidFinalizeTool: noneEvent as Event, + onDidEndStep: noneEvent as Event, + onDidEndTurn: noneEvent as Event, + get hasActiveTurn() { + return active; + }, + get currentId() { + return active ? 't' : undefined; + }, + prompt: () => Promise.resolve(), + steer: () => {}, + retry: () => Promise.resolve(), + cancel: () => {}, + }; +} + +function lifecycle(handles: readonly IScopeHandle[]): IAgentLifecycleService { + return { + _serviceBrand: undefined, + create: () => Promise.resolve(handles[0]!), + createMain: () => Promise.resolve(handles[0]!), + getHandle: () => undefined, + list: () => handles, + remove: () => Promise.resolve(), + }; +} + +describe('SessionActivity', () => { + it('idle when no agents', () => { + const a = new SessionActivity(lifecycle([])); + expect(a.isIdle()).toBe(true); + }); + + it('idle when all agents idle', () => { + const h: IScopeHandle = { id: 'a', kind: 2, accessor: { get: () => stubTurn(false) } as ServicesAccessor }; + expect(new SessionActivity(lifecycle([h])).isIdle()).toBe(true); + }); + + it('not idle when any agent has an active turn', () => { + const idle: IScopeHandle = { id: 'a', kind: 2, accessor: { get: () => stubTurn(false) } as ServicesAccessor }; + const busy: IScopeHandle = { id: 'b', kind: 2, accessor: { get: () => stubTurn(true) } as ServicesAccessor }; + expect(new SessionActivity(lifecycle([idle, busy])).isIdle()).toBe(false); + }); +}); diff --git a/packages/agent-core-v2/test/session/session.test.ts b/packages/agent-core-v2/test/session/session.test.ts new file mode 100644 index 000000000..13e4f8584 --- /dev/null +++ b/packages/agent-core-v2/test/session/session.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import type { ServicesAccessor } from '#/_base/di/instantiation'; +import type { IScopeHandle } from '#/_base/di/scope'; +import type { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle'; +import type { ISessionActivity } from '#/session-activity/sessionActivity'; + +import { SessionService } from '#/session/sessionService'; + +const handle: IScopeHandle = { id: 'main', kind: 2, accessor: { get: () => ({}) } as ServicesAccessor }; + +function make(idle: boolean): SessionService { + const agents: IAgentLifecycleService = { + _serviceBrand: undefined, + create: () => Promise.resolve(handle), + createMain: () => Promise.resolve(handle), + getHandle: () => handle, + list: () => [handle], + remove: () => Promise.resolve(), + }; + const activity: ISessionActivity = { _serviceBrand: undefined, isIdle: () => idle }; + return new SessionService(undefined as never, agents, activity, undefined as never); +} + +describe('SessionService', () => { + it('status reflects activity', () => { + expect(make(true).status()).toBe('idle'); + expect(make(false).status()).toBe('running'); + }); + + it('agents delegates to lifecycle', () => { + expect(make(true).agents()).toEqual([handle]); + }); +}); diff --git a/packages/agent-core-v2/test/skill/skill.test.ts b/packages/agent-core-v2/test/skill/skill.test.ts new file mode 100644 index 000000000..863c74ca2 --- /dev/null +++ b/packages/agent-core-v2/test/skill/skill.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { Event } from '#/_base/event'; +import { + ITurnService, + type TurnEndEvent, + type TurnStartEvent, + type TurnStepEvent, + type TurnToolEvent, +} from '#/turn/turn'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IConfigService } from '#/config/config'; +import { ILogService } from '#/log/log'; +import { IAgentRecords } from '#/records/records'; +import { ISkillRegistry } from '#/skill/skill'; + +import { SkillRegistry, SkillService } from '#/skill/skillService'; + +const noneEvent = ((): Event => () => ({ dispose: () => {} }))(); + +class StubTurn implements ITurnService { + readonly _serviceBrand: undefined; + readonly onWillStartTurn = noneEvent as Event; + readonly onWillExecuteTool = noneEvent as Event; + readonly onDidFinalizeTool = noneEvent as Event; + readonly onDidEndStep = noneEvent as Event; + readonly onDidEndTurn = noneEvent as Event; + readonly prompts: string[] = []; + get hasActiveTurn(): boolean { + return false; + } + get currentId(): string | undefined { + return undefined; + } + prompt(input: string): Promise { + this.prompts.push(input); + return Promise.resolve(); + } + steer(): void {} + retry(): Promise { + return Promise.resolve(); + } + cancel(): void {} +} + +describe('SkillRegistry', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IConfigService, {}); + ix.stub(ILogService, {}); + }); + afterEach(() => disposables.dispose()); + + it('register / get / list', async () => { + const reg = ix.createInstance(SkillRegistry); + reg.register({ name: 'commit', root: '/skills/commit' }); + expect(reg.get('commit')).toEqual({ name: 'commit', root: '/skills/commit' }); + expect(reg.list()).toHaveLength(1); + await reg.loadRoots(['/skills']); + }); +}); + +describe('SkillService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IConfigService, {}); + ix.stub(ILogService, {}); + ix.stub(IAgentRecords, {}); + }); + afterEach(() => disposables.dispose()); + + it('activate prompts the turn for a known skill', async () => { + const reg = ix.createInstance(SkillRegistry); + reg.register({ name: 'commit', root: '/skills/commit' }); + ix.set(ISkillRegistry, reg); + const turn = new StubTurn(); + ix.set(ITurnService, turn); + const svc = ix.createInstance(SkillService); + await svc.activate('commit'); + expect(turn.prompts).toEqual(['Activate skill: commit']); + }); + + it('activate throws for unknown skill', async () => { + const reg = ix.createInstance(SkillRegistry); + ix.set(ISkillRegistry, reg); + const turn = new StubTurn(); + ix.set(ITurnService, turn); + const svc = ix.createInstance(SkillService); + await expect(svc.activate('missing')).rejects.toThrow(/unknown skill/); + }); +}); diff --git a/packages/agent-core-v2/test/swarm/swarm.test.ts b/packages/agent-core-v2/test/swarm/swarm.test.ts new file mode 100644 index 000000000..76562717d --- /dev/null +++ b/packages/agent-core-v2/test/swarm/swarm.test.ts @@ -0,0 +1,31 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle'; +import { IPermissionService } from '#/permission/permission'; +import { IAgentRecords } from '#/records/records'; +import { SwarmService } from '#/swarm/swarmService'; + +describe('SwarmService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IAgentRecords, {}); + ix.stub(IAgentLifecycleService, {}); + ix.stub(IPermissionService, {}); + }); + afterEach(() => disposables.dispose()); + + it('enter / exit toggle active', async () => { + const swarm = disposables.add(ix.createInstance(SwarmService)); + expect(swarm.active).toBe(false); + await swarm.enter(); + expect(swarm.active).toBe(true); + swarm.exit(); + expect(swarm.active).toBe(false); + }); +}); diff --git a/packages/agent-core-v2/test/telemetry/telemetryService.test.ts b/packages/agent-core-v2/test/telemetry/telemetryService.test.ts new file mode 100644 index 000000000..ac1114490 --- /dev/null +++ b/packages/agent-core-v2/test/telemetry/telemetryService.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, _clearScopedRegistryForTests, registerScopedService } from '#/_base/di/scope'; +import { createScopedTestHost } from '#/_base/di/test'; +import { + type TelemetryClient, + type TelemetryProperties, + ITelemetryService, + TelemetryService, +} from '#/telemetry/index'; + +class CapturingClient implements TelemetryClient { + readonly events: { event: string; properties?: TelemetryProperties }[] = []; + track(event: string, properties?: TelemetryProperties): void { + this.events.push({ event, properties }); + } +} + +describe('TelemetryService (unit)', () => { + it('noop by default — does not throw', () => { + const svc = new TelemetryService(); + expect(() => svc.track('evt', { a: 1 })).not.toThrow(); + }); + + it('merges bound context into tracked properties', () => { + const client = new CapturingClient(); + const svc = new TelemetryService({ sessionId: 's1' }); + svc.setDelegate(client); + svc.track('turn.start', { agentId: 'main' }); + expect(client.events[0]).toEqual({ + event: 'turn.start', + properties: { sessionId: 's1', agentId: 'main' }, + }); + }); + + it('withContext merges context and shares the delegate', () => { + const client = new CapturingClient(); + const root = new TelemetryService({ sessionId: 's1' }); + root.setDelegate(client); + const child = root.withContext({ agentId: 'main', turnId: 't1' }); + child.track('tool.call', { name: 'bash' }); + expect(client.events[0]?.properties).toEqual({ + sessionId: 's1', + agentId: 'main', + turnId: 't1', + name: 'bash', + }); + }); + + it('per-call properties override bound context on key collision', () => { + const client = new CapturingClient(); + const svc = new TelemetryService({ sessionId: 's1' }); + svc.setDelegate(client); + svc.track('evt', { sessionId: 'override' }); + expect(client.events[0]?.properties?.['sessionId']).toBe('override'); + }); +}); + +describe('ITelemetryService (scoped)', () => { + beforeEach(() => { + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.Core, + ITelemetryService, + TelemetryService, + InstantiationType.Eager, + 'telemetry', + ); + }); + + it('resolves from the Core scope', () => { + const host = createScopedTestHost(); + const svc = host.core.accessor.get(ITelemetryService); + expect(() => svc.track('scoped')).not.toThrow(); + host.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/terminal/terminal.test.ts b/packages/agent-core-v2/test/terminal/terminal.test.ts new file mode 100644 index 000000000..abe19f095 --- /dev/null +++ b/packages/agent-core-v2/test/terminal/terminal.test.ts @@ -0,0 +1,49 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { LocalKaos } from '@moonshot-ai/kaos'; + +import type { ILogService, ILogger } from '#/log/log'; + +import { SessionKaosService } from '#/kaos/sessionKaosService'; +import { TerminalService } from '#/terminal/terminalService'; + +const noopLogger: ILogger = { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, + child: () => noopLogger, +}; +const noopLog: ILogService = { + ...noopLogger, + _serviceBrand: undefined, + level: 'info', + setLevel: () => {}, +}; + +describe('TerminalService', () => { + let dir: string; + let terminal: TerminalService; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'term-test-')); + const base = await LocalKaos.create(); + const sessionKaos = new SessionKaosService(noopLog); + sessionKaos.setToolKaos(base.withCwd(dir)); + terminal = new TerminalService(noopLog, sessionKaos); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('spawn returns a handle and kill terminates the process', async () => { + const handle = await terminal.spawn('sleep', ['10']); + expect(typeof handle.id).toBe('string'); + await terminal.kill(handle.id); + }); +}); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts new file mode 100644 index 000000000..fc4eb0aa6 --- /dev/null +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IAgentConfigService } from '#/config/config'; +import { IAgentKaos } from '#/kaos/kaos'; +import { ILLMService } from '#/kosong/kosong'; +import { IPermissionService } from '#/permission/permission'; +import { IAgentRecords } from '#/records/records'; +import { IToolDefinitionRegistry, type ToolCallResult, type ToolDefinition } from '#/tool/tool'; +import { ToolDefinitionRegistry, ToolService } from '#/tool/toolService'; + +const echoDef: ToolDefinition = { + name: 'echo', + factory: () => ({ + execute: (args: unknown): Promise => + Promise.resolve({ output: JSON.stringify(args) }), + }), +}; + +describe('ToolDefinitionRegistry', () => { + it('registers and retrieves definitions', () => { + const reg = new ToolDefinitionRegistry(); + reg.register(echoDef); + expect(reg.get('echo')).toBe(echoDef); + expect(reg.get('missing')).toBeUndefined(); + expect(reg.list()).toEqual([echoDef]); + }); +}); + +describe('ToolService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let reg: ToolDefinitionRegistry; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + reg = new ToolDefinitionRegistry(); + reg.register(echoDef); + ix.set(IToolDefinitionRegistry, reg); + ix.stub(IAgentConfigService, {}); + ix.stub(IAgentRecords, {}); + ix.stub(IAgentKaos, {}); + ix.stub(IPermissionService, {}); + ix.stub(ILLMService, {}); + }); + afterEach(() => disposables.dispose()); + + function make(): { svc: ToolService; reg: ToolDefinitionRegistry } { + const svc = ix.createInstance(ToolService); + return { svc, reg }; + } + + it('executes a builtin tool from the registry', async () => { + const { svc } = make(); + const result = await svc.execute('echo', { msg: 'hi' }); + expect(result).toEqual({ output: '{"msg":"hi"}' }); + }); + + it('routes a user-registered tool', async () => { + const { svc } = make(); + const userDef: ToolDefinition = { + name: 'user-tool', + factory: () => ({ execute: (): Promise => Promise.resolve({ output: 'user' }) }), + }; + svc.registerUserTool(userDef); + expect(await svc.execute('user-tool', {})).toEqual({ output: 'user' }); + }); + + it('throws on unknown tool', async () => { + const { svc } = make(); + await expect(svc.execute('nope', {})).rejects.toThrow(/unknown tool/); + }); + + it('list aggregates builtin + user + mcp', () => { + const { svc } = make(); + svc.registerUserTool({ name: 'u', factory: () => ({ execute: () => Promise.resolve({ output: '' }) }) }); + svc.registerMcpTools('srv', [{ name: 'm', factory: () => ({ execute: () => Promise.resolve({ output: '' }) }) }]); + expect(svc.list().map((d) => d.name).sort()).toEqual(['echo', 'm', 'u']); + }); +}); diff --git a/packages/agent-core-v2/test/tooldedup/tooldedup.test.ts b/packages/agent-core-v2/test/tooldedup/tooldedup.test.ts new file mode 100644 index 000000000..0427d1778 --- /dev/null +++ b/packages/agent-core-v2/test/tooldedup/tooldedup.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { ITelemetryService } from '#/telemetry/telemetry'; +import { ITurnContext } from '#/turn/turn'; + +import { ToolDedupService } from '#/tooldedup/tooldedupService'; + +describe('ToolDedupService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(ITelemetryService, {}); + ix.stub(ITurnContext, {}); + }); + afterEach(() => disposables.dispose()); + + it('detects same-step duplicates', () => { + const d = disposables.add(ix.createInstance(ToolDedupService)); + expect(d.checkSameStep('c1', { a: 1 })).toBe(false); + expect(d.checkSameStep('c1', { a: 1 })).toBe(true); + expect(d.checkSameStep('c1', { a: 2 })).toBe(false); + d.dispose(); + }); + + it('tracks cross-step streak via finalize', () => { + const d = disposables.add(ix.createInstance(ToolDedupService)); + d.finalize('same'); + d.finalize('same'); + d.finalize('same'); + expect(d.currentStreak).toBe(3); + d.finalize('other'); + expect(d.currentStreak).toBe(1); + d.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/turn/turn.test.ts b/packages/agent-core-v2/test/turn/turn.test.ts new file mode 100644 index 000000000..91fef3c94 --- /dev/null +++ b/packages/agent-core-v2/test/turn/turn.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle'; +import { IContextService } from '#/context/context'; +import { IInjectionService } from '#/injection/injection'; +import { ILLMService } from '#/kosong/kosong'; +import { ILogService } from '#/log/log'; +import { IPermissionService } from '#/permission/permission'; +import { ITelemetryService } from '#/telemetry/telemetry'; +import { IToolService } from '#/tool/tool'; +import { ILoopRunner } from '#/turn/turn'; +import { IUsageService } from '#/usage/usage'; + +import { LoopRunner } from '#/turn/loopRunner'; +import { TurnService } from '#/turn/turnService'; + +describe('TurnService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IContextService, {}); + ix.stub(IToolService, {}); + ix.stub(IPermissionService, {}); + ix.stub(ILLMService, {}); + ix.stub(IInjectionService, {}); + ix.stub(IUsageService, {}); + ix.stub(ITelemetryService, {}); + ix.stub(ILogService, {}); + ix.stub(IAgentLifecycleService, {}); + ix.set(ILoopRunner, new LoopRunner()); + }); + afterEach(() => disposables.dispose()); + + function make(): TurnService { + return disposables.add(ix.createInstance(TurnService)); + } + + it('launch emits start → step → end and tracks active state', async () => { + const svc = make(); + const events: string[] = []; + svc.onWillStartTurn((e) => events.push(`start:${e.turnId}`)); + svc.onDidEndStep((e) => events.push(`step:${e.step}`)); + svc.onDidEndTurn((e) => events.push(`end:${e.reason}`)); + + expect(svc.hasActiveTurn).toBe(false); + await svc.prompt('hello'); + expect(svc.hasActiveTurn).toBe(false); + expect(events).toEqual(['start:turn-0', 'step:0', 'end:completed']); + }); + + it('steer buffers input', () => { + const svc = make(); + svc.steer('a'); + svc.steer('b', 'user'); + expect(svc.hasActiveTurn).toBe(false); + }); + + it('cancel fires onDidEndTurn with cancelled reason', async () => { + const svc = make(); + const ends: string[] = []; + svc.onDidEndTurn((e) => ends.push(e.reason)); + const slow = new (class extends LoopRunner { + override run(): Promise { + return new Promise((resolve) => setTimeout(resolve, 10)); + } + })(); + ix.set(ILoopRunner, slow); + const svc2 = disposables.add(ix.createInstance(TurnService)); + svc2.onDidEndTurn((e) => ends.push(e.reason)); + const p = svc2.prompt('hello'); + expect(svc2.hasActiveTurn).toBe(true); + svc2.cancel('user'); + await p; + expect(ends).toContain('user'); + }); +}); diff --git a/packages/agent-core-v2/test/usage/usage.test.ts b/packages/agent-core-v2/test/usage/usage.test.ts new file mode 100644 index 000000000..8b4eee863 --- /dev/null +++ b/packages/agent-core-v2/test/usage/usage.test.ts @@ -0,0 +1,27 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IAgentRecords } from '#/records/records'; +import { ITelemetryService } from '#/telemetry/telemetry'; +import { UsageService } from '#/usage/usageService'; + +describe('UsageService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IAgentRecords, { _serviceBrand: undefined }); + ix.stub(ITelemetryService, { _serviceBrand: undefined }); + }); + afterEach(() => disposables.dispose()); + + it('accumulates input/output tokens', () => { + const svc = disposables.add(ix.createInstance(UsageService)); + svc.record(10, 5); + svc.record(3, 2); + expect(svc.totals).toEqual({ inputTokens: 13, outputTokens: 7 }); + }); +}); diff --git a/packages/agent-core-v2/test/workspace/workspace.test.ts b/packages/agent-core-v2/test/workspace/workspace.test.ts new file mode 100644 index 000000000..b9d698f5a --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspace.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { WorkspaceFsService, WorkspaceRegistry } from '#/workspace/workspaceService'; + +describe('WorkspaceRegistry', () => { + it('register / get / list', () => { + const reg = new WorkspaceRegistry(undefined as never, undefined as never); + const ws = reg.register('/repo'); + expect(ws.root).toBe('/repo'); + expect(reg.get(ws.id)).toEqual(ws); + expect(reg.list()).toEqual([ws]); + }); +}); + +describe('WorkspaceFsService', () => { + it('resolves a relative path against a registered workspace', () => { + const reg = new WorkspaceRegistry(undefined as never, undefined as never); + const ws = reg.register('/repo'); + const fs = new WorkspaceFsService(undefined as never, undefined as never, reg); + expect(fs.resolve(ws.id, 'src/index.ts')).toBe('/repo/src/index.ts'); + }); + + it('throws for unknown workspace', () => { + const fs = new WorkspaceFsService(undefined as never, undefined as never); + expect(() => fs.resolve('nope', 'x')).toThrow(/unknown workspace/); + }); +}); diff --git a/packages/agent-core-v2/tsconfig.json b/packages/agent-core-v2/tsconfig.json new file mode 100644 index 000000000..760fb974a --- /dev/null +++ b/packages/agent-core-v2/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "experimentalDecorators": true + }, + "include": ["src", "test"] +} diff --git a/packages/agent-core-v2/tsdown.config.ts b/packages/agent-core-v2/tsdown.config.ts new file mode 100644 index 000000000..cb1685162 --- /dev/null +++ b/packages/agent-core-v2/tsdown.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'tsdown'; + +import { rawTextPlugin } from '../../build/raw-text-plugin.mjs'; + +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + dts: true, + outDir: 'dist', + clean: true, + plugins: [rawTextPlugin()], + deps: { + neverBundle: [ + '@moonshot-ai/kosong', + '@moonshot-ai/kaos', + '@moonshot-ai/kimi-code-oauth', + '@moonshot-ai/kimi-telemetry', + ], + }, +}); diff --git a/packages/agent-core-v2/vitest.config.ts b/packages/agent-core-v2/vitest.config.ts new file mode 100644 index 000000000..9e1a49878 --- /dev/null +++ b/packages/agent-core-v2/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + name: 'agent-core-v2', + include: ['test/**/*.{test,e2e,integration}.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9d2dff47..8cb1ed258 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -430,6 +430,52 @@ importers: specifier: ^3.3.1 version: 3.3.1 + packages/agent-core-v2: + dependencies: + '@moonshot-ai/kaos': + specifier: workspace:^ + version: link:../kaos + '@moonshot-ai/kimi-code-oauth': + specifier: workspace:^ + version: link:../oauth + '@moonshot-ai/kimi-telemetry': + specifier: workspace:^ + version: link:../telemetry + '@moonshot-ai/kosong': + specifier: workspace:^ + version: link:../kosong + '@moonshot-ai/protocol': + specifier: workspace:^ + version: link:../protocol + nunjucks: + specifier: ^3.2.4 + version: 3.2.4(chokidar@4.0.3) + pathe: + specifier: ^2.0.3 + version: 2.0.3 + smol-toml: + specifier: ^1.6.1 + version: 1.6.1 + socks: + specifier: ^2.8.9 + version: 2.8.9 + undici: + specifier: ^7.27.1 + version: 7.27.1 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@types/nunjucks': + specifier: ^3.2.6 + version: 3.2.6 + '@types/sinon': + specifier: ^21.0.1 + version: 21.0.1 + sinon: + specifier: ^22.0.0 + version: 22.0.0 + packages/kaos: dependencies: pathe: @@ -8278,7 +8324,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.4': dependencies: