diff --git a/.changeset/custom-agent-identity.md b/.changeset/custom-agent-identity.md new file mode 100644 index 0000000000..b008816ef4 --- /dev/null +++ b/.changeset/custom-agent-identity.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a custom agent identity, plus a switch for the built-in skills that document Kimi Code itself. Set `[identity] name` in `config.toml` (or `KIMI_CODE_IDENTITY_NAME`) to change the name the agent uses for itself and the identifier it presents to third-party providers and MCP servers; set `builtin_product_skills = false` to drop the product-documentation skills. diff --git a/apps/kimi-web/src/components/chat/ChatPane.vue b/apps/kimi-web/src/components/chat/ChatPane.vue index 79ad6c5d19..5e53008683 100644 --- a/apps/kimi-web/src/components/chat/ChatPane.vue +++ b/apps/kimi-web/src/components/chat/ChatPane.vue @@ -1124,7 +1124,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): padding: 10px 0; } -/* Skill activation card (replaces raw XML) */ +/* Skill activation card (replaces raw XML) */ .skill-act { display: flex; flex-direction: column; diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 350bdf9c4e..2713119774 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -103,6 +103,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d | `merge_all_available_skills` | `boolean` | `true` | Whether to merge Agent Skills from all available directories | | `extra_skill_dirs` | `array` | — | Extra skill search directories, layered on top of the default directories | | `extra_agent_dirs` | `array` | — | Extra custom agent search directories, layered on top of the default directories | +| `builtin_product_skills` | `boolean` | `true` | Whether the built-in skills that document Kimi Code itself are offered to the model: `update-config`, `custom-theme`, `mcp-config`, `check-kimi-code-docs`, and `import-from-cc-codex`. Turning them off trims their names and descriptions from the system prompt, at the cost of the guided flows for those tasks. Read by the `agent-core-v2` engine (`kimi web` and the `KIMI_CODE_EXPERIMENTAL_FLAG` paths); ignored on the default engine | | `telemetry` | `boolean` | `true` | Whether anonymous telemetry is enabled; disabled only when explicitly set to `false` | | `providers` | `table` | `{}` | API provider table → [`providers`](#providers) | | `models` | `table` | — | Model alias table → [`models`](#models) | @@ -114,6 +115,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d | `services` | `table` | — | Built-in external service configuration → [`services`](#services) | | `permission` | `table` | — | Initial permission rules → [`permission`](#permission) | | `hooks` | `array` | — | Lifecycle hooks; see [Hooks](../customization/hooks.md) | +| `identity` | `table` | — | Custom agent identity → [`identity`](#identity) | The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `tools`, `image`, `services`, and `permission`. @@ -297,6 +299,29 @@ In print mode (`kimi -p ""`), Kimi Code stays alive after the main agent `startup_timeout_ms` and `tool_timeout_ms` can be overridden by the `KIMI_MCP_STARTUP_TIMEOUT_MS` and `KIMI_MCP_TOOL_TIMEOUT_MS` environment variables respectively, which take higher priority than `config.toml`. See [MCP](../customization/mcp.md) for the full MCP server configuration. +## `identity` + +Customizes how the agent identifies itself. Leave it unset and nothing changes. + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `string` | — | Display name the agent calls itself in the system prompt (fills the `${product_name}` slot, including in your own `SYSTEM.md` and agent files) | +| `slug` | `string` | derived from `name` | Machine identifier used in protocol fields: the `User-Agent` product token sent to third-party providers, and the client name announced to MCP servers. Derived from `name` when omitted: lowercased, with every run of non-alphanumeric characters folded to `-` | + +```toml +[identity] +name = "Acme Dev Agent" +slug = "acme-dev" # optional +``` + +Both fields can be set through the `KIMI_CODE_IDENTITY_NAME` and `KIMI_CODE_IDENTITY_SLUG` environment variables, which take higher priority than `config.toml` and are never written back to it — convenient for containers and CI, where writing a config file is awkward. + +A name that contains no ASCII letters or digits (for example a purely Chinese name) leaves nothing to derive a slug from and falls back to `agent`; write `slug` explicitly if you need a specific protocol token. + +The identity is resolved once at startup and holds for the life of the process — it is announced to MCP servers and providers when connections are made, so it cannot change midway. Edits to this section take effect on the next start, for new sessions: a resumed session keeps the system prompt it was recorded with, since its past turns already speak under that identity. Likewise, an MCP OAuth authorization keeps the client registration it was granted under; reset that server's authentication to register under the new identity. + +This section is read by the `agent-core-v2` engine, which currently backs `kimi web` and the `KIMI_CODE_EXPERIMENTAL_FLAG` paths. On the default `kimi` / `kimi -p` engine it is ignored. + ## `tools` `tools` is the global tool switch: it applies to every agent in all sessions and intersects with each agent's own `tools` / `disallowedTools` policy. diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index cdbadfca8f..031d36a258 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -128,6 +128,9 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins`; useful for dev loopback servers, staging CDN files, or alternate marketplace directories | `https://code.kimi.com/kimi-code/plugins/marketplace.json`; also accepts `http://`, `file://` URLs, and local paths | | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast | | `KIMI_SUBAGENT_TIMEOUT_MS` | Maximum wall-clock time (ms) a single subagent (`Agent` / `AgentSwarm`) may run; takes higher priority than `[subagent] timeout_ms` in `config.toml` (default `7200000`, i.e. 2 hours) | Positive integer; invalid values fall back to the config or default | +| `KIMI_CODE_IDENTITY_NAME` | Display name the agent calls itself in the system prompt; takes higher priority than `[identity] name` in `config.toml` and is never written back to it | Any non-empty string; blank values read as unset | +| `KIMI_CODE_IDENTITY_SLUG` | Protocol identifier for the `User-Agent` product token sent to third-party providers and the MCP client name; takes higher priority than `[identity] slug`. Derived from the name when unset | Any non-empty string; normalized to lowercase with non-alphanumeric runs folded to `-` | +| `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | Whether the built-in skills documenting Kimi Code itself are offered to the model; takes higher priority than `builtin_product_skills` in `config.toml` (default enabled) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | Enable the experimental secondary-model feature in every launch mode, including the interactive TUI; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_SECONDARY_MODEL` | Secondary model; takes higher priority than [`[secondary_model] model`](./config-files.md#secondary-model) in `config.toml`. When the secondary-model experiment is enabled, newly spawned subagents (`Agent` / `AgentSwarm`) bind to it by default instead of inheriting the main agent's model | The alias of a configured `[models]` entry, e.g. `kimi-code/kimi-k2.5`; blank values are ignored | | `KIMI_SECONDARY_EFFORT` | Thinking effort for the secondary model; takes higher priority than `[secondary_model] default_effort` in `config.toml` and applies only when both the model and its experiment are enabled | An effort value, e.g. `low`; blank values are ignored | @@ -150,6 +153,8 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_NO_AUTO_UPDATE` | Fully disable the update preflight — no check, background install, or prompt. Legacy alias `KIMI_CLI_NO_AUTO_UPDATE` is also honored | Truthy: `1`/`true`/`yes`/`on` | | `KIMI_DISABLE_CRON` | Disable the scheduled-task tool (`CronCreate` rejects new schedules; existing tasks do not fire) | `1` to disable | +The three `KIMI_CODE_IDENTITY_*` / `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` variables are read by the `agent-core-v2` engine, which currently backs `kimi web` and the `KIMI_CODE_EXPERIMENTAL_FLAG` paths; the default `kimi` / `kimi -p` engine ignores them. + ## Diagnostic logs These variables control log level and file rotation, read once at process startup: diff --git a/docs/en/customization/skills.md b/docs/en/customization/skills.md index 70b36e1e8d..b905e98180 100644 --- a/docs/en/customization/skills.md +++ b/docs/en/customization/skills.md @@ -81,7 +81,7 @@ The Kimi-specific user Skill directory moves with `KIMI_CODE_HOME`, so isolated extra_skill_dirs = ["~/team-skills", ".agents/team-skills"] ``` -**Built-in Skills** are distributed with the CLI and have the lowest priority. They provide out-of-the-box workflows for common tasks — for example, configuring MCP servers, customizing the TUI theme, and editing config files. See [Built-in skill commands](../reference/slash-commands.md#built-in-skill-commands) for the full list. +**Built-in Skills** are distributed with the CLI and have the lowest priority. They provide out-of-the-box workflows for common tasks — for example, configuring MCP servers, customizing the TUI theme, and editing config files. See [Built-in skill commands](../reference/slash-commands.md#built-in-skill-commands) for the full list. Those describing Kimi Code itself can be turned off with the top-level [`builtin_product_skills`](../configuration/config-files.md#top-level-fields) field. ## Invoking a Skill diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 8f963a7ab1..f26368d028 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -103,6 +103,7 @@ timeout = 5 | `merge_all_available_skills` | `boolean` | `true` | 是否合并所有目录中的 Agent Skills | | `extra_skill_dirs` | `array` | — | 额外 Skill 搜索目录,叠加到默认目录之上 | | `extra_agent_dirs` | `array` | — | 额外自定义 Agent 搜索目录,叠加到默认目录之上 | +| `builtin_product_skills` | `boolean` | `true` | 是否向模型提供介绍 Kimi Code 自身的内置 Skills:`update-config`、`custom-theme`、`mcp-config`、`check-kimi-code-docs`、`import-from-cc-codex`。关闭后它们的名称和描述不再进入系统提示词,代价是失去这些任务的引导流程。本字段由 `agent-core-v2` 引擎读取(`kimi web` 和开启 `KIMI_CODE_EXPERIMENTAL_FLAG` 的路径),默认引擎会忽略 | | `telemetry` | `boolean` | `true` | 是否启用匿名遥测;显式设为 `false` 时关闭 | | `providers` | `table` | `{}` | API 供应商表 → [`providers`](#providers) | | `models` | `table` | — | 模型别名表 → [`models`](#models) | @@ -114,6 +115,7 @@ timeout = 5 | `services` | `table` | — | 内置外部服务配置 → [`services`](#services) | | `permission` | `table` | — | 初始权限规则 → [`permission`](#permission) | | `hooks` | `array
` | — | 生命周期 hook,详见 [Hooks](../customization/hooks.md) | +| `identity` | `table` | — | 自定义 Agent 身份 → [`identity`](#identity) | 以下各节对 `providers`、`models`、`thinking`、`loop_control`、`background`、`image`、`services`、`permission` 等嵌套表逐一展开。 @@ -297,6 +299,29 @@ max_output_size = 8192 `startup_timeout_ms` 和 `tool_timeout_ms` 可分别被环境变量 `KIMI_MCP_STARTUP_TIMEOUT_MS` 和 `KIMI_MCP_TOOL_TIMEOUT_MS` 覆盖,优先级高于配置文件。MCP server 的完整配置方式见 [MCP](../customization/mcp.md)。 +## `identity` + +自定义 Agent 的身份标识。不设置时行为完全不变。 + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `name` | `string` | — | Agent 在系统提示词中的自称(填充 `${product_name}` 变量,你自己的 `SYSTEM.md` 和 agent 文件同样适用) | +| `slug` | `string` | 由 `name` 派生 | 协议字段中使用的机器标识:发给第三方 provider 的 `User-Agent` 产品名,以及连接 MCP 服务器时声明的客户端名。省略时由 `name` 派生:转小写,连续的非字母数字字符折叠为 `-` | + +```toml +[identity] +name = "Acme Dev Agent" +slug = "acme-dev" # 可选 +``` + +两个字段都可以通过 `KIMI_CODE_IDENTITY_NAME` 和 `KIMI_CODE_IDENTITY_SLUG` 环境变量设置,优先级高于 `config.toml`,且不会被写回配置文件——适合不便写配置文件的容器和 CI 场景。 + +如果名称中不含任何 ASCII 字母或数字(例如纯中文名称),就无法派生出 slug,此时回退为 `agent`;需要特定协议标识请显式填写 `slug`。 + +身份在启动时解析一次,进程生命周期内保持不变——建立连接时它已宣告给 MCP 服务器和 provider,中途无法更换。修改本节配置在下次启动时对新会话生效;resume 的会话保留录制时的系统提示词,因为其历史轮次本就以原身份自称。同理,已完成的 MCP OAuth 授权保留其授予时的客户端注册;重置该服务器的认证即可在新身份下重新注册。 + +本节由 `agent-core-v2` 引擎读取,目前 `kimi web` 和开启 `KIMI_CODE_EXPERIMENTAL_FLAG` 的路径使用该引擎。默认的 `kimi` / `kimi -p` 引擎会忽略此配置。 + ## `tools` `tools` 设置全局工具开关,对所有会话中的每个 Agent 生效,并在 Agent 自身的 `tools` / `disallowedTools` 策略之上再取一次交集。 diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 3b29de8a10..c8848ff515 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -128,6 +128,9 @@ kimi | `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 覆盖 `/plugins` 加载的 plugin marketplace JSON,适合 dev loopback server、测试 CDN 文件或替换 marketplace 目录 | `https://code.kimi.com/kimi-code/plugins/marketplace.json`;也接受 `http://`、`file://` URL 和本地路径 | | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 | | `KIMI_SUBAGENT_TIMEOUT_MS` | 单个子 Agent(`Agent` / `AgentSwarm`)可运行的最长时间(毫秒);优先级高于 `config.toml` 的 `[subagent] timeout_ms`(默认 `7200000`,即 2 小时) | 正整数;非法值回退到配置或默认值 | +| `KIMI_CODE_IDENTITY_NAME` | Agent 在系统提示词中的自称,优先级高于 `config.toml` 的 `[identity] name`,且不会被写回配置文件 | 任意非空字符串;空值视为未设置 | +| `KIMI_CODE_IDENTITY_SLUG` | 协议标识,用于发给第三方 provider 的 `User-Agent` 产品名和 MCP 客户端名,优先级高于 `[identity] slug`。未设置时由名称派生 | 任意非空字符串;会转小写并将连续非字母数字字符折叠为 `-` | +| `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | 是否向模型提供介绍 Kimi Code 自身的内置 Skills,优先级高于 `config.toml` 的 `builtin_product_skills`(默认开启) | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | 在包括交互式 TUI 在内的所有启动方式下启用实验性的次主力模型功能;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_SECONDARY_MODEL` | 次主力模型;优先级高于 `config.toml` 的 [`[secondary_model] model`](./config-files.md#secondary-model)。次主力模型实验功能启用后,新派生的子 Agent 默认绑定该模型,而不再继承主 Agent 的模型 | `[models]` 中已配置条目的别名,如 `kimi-code/kimi-k2.5`;空白值被忽略 | | `KIMI_SECONDARY_EFFORT` | 次主力模型的 thinking effort;优先级高于 `config.toml` 的 `[secondary_model] default_effort`,仅在次主力模型及其实验功能均启用时生效 | effort 取值,如 `low`;空白值被忽略 | @@ -150,6 +153,8 @@ kimi | `KIMI_CODE_NO_AUTO_UPDATE` | 完全禁用更新预检——不检查、不后台安装、不提示。同时兼容旧名 `KIMI_CLI_NO_AUTO_UPDATE` | 真值:`1`/`true`/`yes`/`on` | | `KIMI_DISABLE_CRON` | 禁用定时任务工具(`CronCreate` 拒绝新计划,已有任务不触发) | `1` 表示禁用 | +`KIMI_CODE_IDENTITY_*` 和 `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` 这三个变量由 `agent-core-v2` 引擎读取,目前 `kimi web` 和开启 `KIMI_CODE_EXPERIMENTAL_FLAG` 的路径使用该引擎;默认的 `kimi` / `kimi -p` 引擎会忽略它们。 + ## 诊断日志 这组变量控制日志级别和文件滚动,进程启动时读取一次: diff --git a/docs/zh/customization/skills.md b/docs/zh/customization/skills.md index 0ee0ce4449..8fd45fa178 100644 --- a/docs/zh/customization/skills.md +++ b/docs/zh/customization/skills.md @@ -81,7 +81,7 @@ Kimi 专属用户级 Skill 目录会随 `KIMI_CODE_HOME` 移动,因此隔离 extra_skill_dirs = ["~/team-skills", ".agents/team-skills"] ``` -**内置 Skills** 随 CLI 一起分发,优先级最低。它们为常见任务提供开箱即用的工作流,例如配置 MCP server、定制 TUI 主题和编辑配置文件。完整列表详见[内置 Skill 命令](../reference/slash-commands.md#内置-skill-命令)。 +**内置 Skills** 随 CLI 一起分发,优先级最低。它们为常见任务提供开箱即用的工作流,例如配置 MCP server、定制 TUI 主题和编辑配置文件。完整列表详见[内置 Skill 命令](../reference/slash-commands.md#内置-skill-命令)。其中介绍 Kimi Code 自身的部分可以通过顶层 [`builtin_product_skills`](../configuration/config-files.md#顶层字段) 字段关闭。 ## 调用 Skill diff --git a/packages/acp-server/test/skills.test.ts b/packages/acp-server/test/skills.test.ts index 66e42eac75..3b26cd1b0d 100644 --- a/packages/acp-server/test/skills.test.ts +++ b/packages/acp-server/test/skills.test.ts @@ -192,7 +192,7 @@ describe('acp-server skills / available commands', () => { // The model received the rendered skill activation (content + args), not // the raw slash text. const history = JSON.stringify(scripted!.callHistory()[0]); - expect(history).toContain('kimi-skill-loaded'); + expect(history).toContain('skill-loaded'); expect(history).toContain('Always answer with the word FIXTURE'); expect(history).toContain('ARGUMENTS: some args'); expect(history).not.toContain('/skill:acp-fixture'); @@ -211,7 +211,7 @@ describe('acp-server skills / available commands', () => { expect(scripted!.callCount()).toBe(1); const history = JSON.stringify(scripted!.callHistory()[0]); - expect(history).toContain('kimi-skill-loaded'); + expect(history).toContain('skill-loaded'); expect(history).toContain('write-goal'); }, 30_000); diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index 41378493a6..f7ea27b4dd 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -8,8 +8,9 @@ # commented "# field: type" lines describe the remaining schema fields. # Values resolve as: default -> config.toml -> env overlay -> memory. -# Index (23 sections · 3 overlay(s)) +# Index (25 sections · 3 overlay(s)) # background src/agent/task/configSection.ts +# builtinProductSkills src/app/skillCatalog/configSection.ts # cron src/app/cron/configSection.ts # defaultPermissionMode src/agent/permissionMode/configSection.ts # defaultPlanMode src/agent/plan/configSection.ts @@ -17,6 +18,7 @@ # extraAgentDirs src/workspace/workspaceAgentProfileLoader/configSection.ts # extraSkillDirs src/app/skillCatalog/configSection.ts # hooks src/agent/externalHooks/configSection.ts +# identity src/app/agentIdentity/configSection.ts # image src/agent/media/configSection.ts # loopControl src/agent/loop/configSection.ts # mcp src/app/mcpConfig/configSection.ts @@ -56,6 +58,17 @@ # print_background_mode: "exit" | "drain" | "steer" # print_max_turns: integer +# ########################################################################## +# builtinProductSkills (config.toml: builtin_product_skills) +# owner: src/app/skillCatalog/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# <- KIMI_CODE_BUILTIN_PRODUCT_SKILLS (custom parse) +# ########################################################################## + +builtin_product_skills = true + # ########################################################################## # cron # owner: src/app/cron/configSection.ts @@ -135,6 +148,20 @@ extra_skill_dirs = [] # command: string # timeout: integer +# ########################################################################## +# identity +# owner: src/app/agentIdentity/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# name <- KIMI_CODE_IDENTITY_NAME (custom parse) +# slug <- KIMI_CODE_IDENTITY_SLUG (custom parse) +# ########################################################################## + +[identity] +# name: string +# slug: string + # ########################################################################## # image # owner: src/agent/media/configSection.ts diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 8df72905d7..9dddfee7b1 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -167,6 +167,7 @@ export interface WorkspaceStateSnapshot { }; readonly mermaid?: string; readonly d2?: string; + readonly productSpecific?: boolean; }[]; readonly skipped?: readonly /* SkippedSkill — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { readonly path: string; @@ -202,6 +203,7 @@ export interface WorkspaceStateSnapshot { }; readonly mermaid?: string; readonly d2?: string; + readonly productSpecific?: boolean; }) => void; register: (skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { readonly name: string; @@ -227,6 +229,7 @@ export interface WorkspaceStateSnapshot { }; readonly mermaid?: string; readonly d2?: string; + readonly productSpecific?: boolean; }, options?: { readonly replace?: boolean; }) => void; @@ -260,6 +263,7 @@ export interface WorkspaceStateSnapshot { }; readonly mermaid?: string; readonly d2?: string; + readonly productSpecific?: boolean; } | undefined; getPluginSkill: (pluginId: string, name: string) => /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { readonly name: string; @@ -285,6 +289,7 @@ export interface WorkspaceStateSnapshot { }; readonly mermaid?: string; readonly d2?: string; + readonly productSpecific?: boolean; } | undefined; renderSkillPrompt: (skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { readonly name: string; @@ -310,6 +315,7 @@ export interface WorkspaceStateSnapshot { }; readonly mermaid?: string; readonly d2?: string; + readonly productSpecific?: boolean; }, rawArgs: string, context?: { readonly sessionId?: string; }) => string; @@ -337,6 +343,7 @@ export interface WorkspaceStateSnapshot { }; readonly mermaid?: string; readonly d2?: string; + readonly productSpecific?: boolean; }[]; listInvocableSkills: () => readonly /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { readonly name: string; @@ -362,6 +369,7 @@ export interface WorkspaceStateSnapshot { }; readonly mermaid?: string; readonly d2?: string; + readonly productSpecific?: boolean; }[]; getSkillRoots: () => readonly string[]; getSkippedByPolicy: () => readonly /* SkippedSkill — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { @@ -485,6 +493,7 @@ export interface SessionStateSnapshot { }; readonly mermaid?: string; readonly d2?: string; + readonly productSpecific?: boolean; }[]; readonly skipped?: readonly /* SkippedSkill — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { readonly path: string; @@ -520,6 +529,7 @@ export interface SessionStateSnapshot { }; readonly mermaid?: string; readonly d2?: string; + readonly productSpecific?: boolean; }) => void; register: (skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { readonly name: string; @@ -545,6 +555,7 @@ export interface SessionStateSnapshot { }; readonly mermaid?: string; readonly d2?: string; + readonly productSpecific?: boolean; }, options?: { readonly replace?: boolean; }) => void; @@ -578,6 +589,7 @@ export interface SessionStateSnapshot { }; readonly mermaid?: string; readonly d2?: string; + readonly productSpecific?: boolean; } | undefined; getPluginSkill: (pluginId: string, name: string) => /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { readonly name: string; @@ -603,6 +615,7 @@ export interface SessionStateSnapshot { }; readonly mermaid?: string; readonly d2?: string; + readonly productSpecific?: boolean; } | undefined; renderSkillPrompt: (skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { readonly name: string; @@ -628,6 +641,7 @@ export interface SessionStateSnapshot { }; readonly mermaid?: string; readonly d2?: string; + readonly productSpecific?: boolean; }, rawArgs: string, context?: { readonly sessionId?: string; }) => string; @@ -655,6 +669,7 @@ export interface SessionStateSnapshot { }; readonly mermaid?: string; readonly d2?: string; + readonly productSpecific?: boolean; }[]; listInvocableSkills: () => readonly /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { readonly name: string; @@ -680,6 +695,7 @@ export interface SessionStateSnapshot { }; readonly mermaid?: string; readonly d2?: string; + readonly productSpecific?: boolean; }[]; getSkillRoots: () => readonly string[]; getSkippedByPolicy: () => readonly /* SkippedSkill — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { diff --git a/packages/agent-core-v2/src/agent/mcp/tools/auth.ts b/packages/agent-core-v2/src/agent/mcp/tools/auth.ts index 97eea0af04..4464a90a38 100644 --- a/packages/agent-core-v2/src/agent/mcp/tools/auth.ts +++ b/packages/agent-core-v2/src/agent/mcp/tools/auth.ts @@ -55,10 +55,10 @@ This server requires an OAuth login that has not yet been completed. ` + 1. The tool prints an authorization URL. 2. **You must show that URL to the user verbatim** and ask them to open it - in a browser, sign in, and approve the kimi-code client. + in a browser, sign in, and approve the client. 3. The tool blocks (up to 15 minutes) until the browser redirects back to the local callback listener. - 4. On success, kimi-code reconnects the MCP server and the real tools + 4. On success, the client reconnects the MCP server and the real tools replace this synthetic tool. Take no arguments. Treat the URL as sensitive — do not modify it or strip diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 0b4f095b36..a533a824c3 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -41,7 +41,12 @@ * plugin changes reach the prompt when the skill catalog re-pulls its plugin * source on explicit plugin reload (the Workspace-scope catalog forwards the * plugin source's change through the session seed) — the same point where - * plugin skills take effect. `refreshSystemPrompt` never rejects: a + * plugin skills take effect. The builtin source is refreshed on the same + * signal: it changes only when its config switch is toggled, so it costs what + * a config edit costs, unlike the file-backed sources whose fs watches would + * rebuild every agent's prompt on each edit. Subscribing to the catalog rather + * than to the config section matters — the catalog fires after the + * contribution is replaced, so the rebuilt prompt cannot read the old listing. `refreshSystemPrompt` never rejects: a * failed context build keeps the current prompt and surfaces a warning, * because the `[tools]` config watcher fires it voided (an unhandled * rejection would crash kap-server) and the Session tool-policy fan-out @@ -61,8 +66,13 @@ * fields because the container only holds pure data structures. After every * successful bind / apply / refresh (never before the new prompt commits, * so a failed build cannot poison the set), the injected AGENTS.md paths are - * seeded into `agentsMdReminder`'s known-set with the effective cwd. Bound at - * Agent scope. + * seeded into `agentsMdReminder`'s known-set with the effective cwd. Fills the + * prompt's product-name slot from the `agentIdentity` snapshot — frozen for + * the process, so no `[identity]` subscription belongs here; the template's + * own default applies when nothing is configured. `bind` gates on the freeze + * before materializing the model, whose resolution reads the identity through + * the host-headers port — a fast bootstrap must wait, not trip the pre-freeze + * guard. Bound at Agent scope. */ import { Disposable } from '#/_base/di/lifecycle'; @@ -88,6 +98,7 @@ import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; import { ErrorCodes, Error2 } from "#/errors"; +import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import type { LoopControl } from '#/agent/loop/configSection'; @@ -99,7 +110,10 @@ import type { ToolSource } from '#/tool/toolContract'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; -import { PLUGIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource'; +import { + BUILTIN_SKILL_SOURCE_ID, + PLUGIN_SKILL_SOURCE_ID, +} from '#/app/skillCatalog/skillSource'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; @@ -231,6 +245,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ @IBuiltinAgentProfileLoader private readonly builtinProfiles: IBuiltinAgentProfileLoader, @IAgentStateService private readonly states: IAgentStateService, @IPluginService private readonly plugins: IPluginService, + @IAgentIdentity private readonly identity: IAgentIdentity, @IAgentAgentsMdReminderService private readonly agentsMdReminder: IAgentAgentsMdReminderService, ) { super(); @@ -260,7 +275,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ ); this._register( this.skillCatalog.onDidChange((sourceId) => { - if (sourceId === PLUGIN_SKILL_SOURCE_ID) { + if (sourceId === PLUGIN_SKILL_SOURCE_ID || sourceId === BUILTIN_SKILL_SOURCE_ID) { void this.refreshSystemPrompt(); } }), @@ -351,6 +366,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ async bind(input: BindAgentInput): Promise { await this.catalog.ready; + await this.identity.resolved(); this.assertBindable(input.profile); const profile = this.catalog.get(input.profile); if (profile === undefined) { @@ -925,7 +941,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ skills, pluginSections, skillActive: this.isToolActiveForProfile(profile, 'Skill'), - productName: this.bootstrap.args.displayName, + productName: (await this.identity.resolved()).displayName, replyStyleGuide: this.bootstrap.args.replyStyleGuide, }; } diff --git a/packages/agent-core-v2/src/agent/skill/prompt.ts b/packages/agent-core-v2/src/agent/skill/prompt.ts index f4bf30a869..1cbb50362d 100644 --- a/packages/agent-core-v2/src/agent/skill/prompt.ts +++ b/packages/agent-core-v2/src/agent/skill/prompt.ts @@ -37,9 +37,9 @@ export function renderModelToolSkillPrompt(input: RenderModelToolSkillPromptInpu export function renderSkillLoadedBlock(input: RenderSkillLoadedBlockInput): string { return [ - ``, + ``, input.skillContent, - '', + '', ].join('\n'); } diff --git a/packages/agent-core-v2/src/agent/tools/cron/cron-create/cron-create.md b/packages/agent-core-v2/src/agent/tools/cron/cron-create/cron-create.md index 04384b165d..8448968186 100644 --- a/packages/agent-core-v2/src/agent/tools/cron/cron-create/cron-create.md +++ b/packages/agent-core-v2/src/agent/tools/cron/cron-create/cron-create.md @@ -66,7 +66,7 @@ Use `recurring: false` for "remind me at X" style requests, single deadlines, "i ## Session lifetime -Cron tasks live in the current kimi CLI session. When you exit, they +Cron tasks live in the current session. When you exit, they are persisted under the session homedir; resuming the same session reloads them and the scheduler resumes from each task's `createdAt`. Fire times that fell during the offline window are collapsed into a single delivery via `coalescedCount` (and recurring diff --git a/packages/agent-core-v2/src/agent/tools/fetch-url/fetchUrlTool.ts b/packages/agent-core-v2/src/agent/tools/fetch-url/fetchUrlTool.ts index 2fa30e5522..865dae6717 100644 --- a/packages/agent-core-v2/src/agent/tools/fetch-url/fetchUrlTool.ts +++ b/packages/agent-core-v2/src/agent/tools/fetch-url/fetchUrlTool.ts @@ -1,11 +1,14 @@ /** * `tools` domain — `FetchURLTool` implementation. * - * Receives the App-scope `IWebFetchService` via DI and fetches through its - * host-injected `UrlFetcher`. The default service falls back to the - * built-in `LocalFetchURLProvider`, so `FetchURL` is always available without - * OAuth. Bound at Agent scope; self-registers via `registerAgentToolService(...)` at - * module load. + * Receives the App-scope `IWebFetchService` via DI and resolves its + * host-injected `UrlFetcher` per invocation — the service re-reads config and + * login state on each `getUrlFetcher()` call, and composing the fetcher at + * tool construction would both pin that state for the agent's lifetime and + * race the identity freeze during a fast bootstrap. The default service falls + * back to the built-in `LocalFetchURLProvider`, so `FetchURL` is always + * available without OAuth. Bound at Agent scope; self-registers via + * `registerAgentToolService(...)` at module load. */ import { toInputJsonSchema } from '#/tool/input-schema'; @@ -20,7 +23,7 @@ import { ToolResultBuilder } from '#/tool/result-builder'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { IWebFetchService } from '#/app/web/web'; -import { HttpFetchError, type UrlFetcher } from '#/app/web/tools/fetch-url-types'; +import { HttpFetchError } from '#/app/web/tools/fetch-url-types'; import { FetchURLInputSchema, IFetchURLTool, type FetchURLInput } from './fetch-url'; import DESCRIPTION from './fetch-url.md?raw'; @@ -30,11 +33,7 @@ export class FetchURLTool implements IFetchURLTool { readonly description: string = DESCRIPTION; readonly parameters: Record = toInputJsonSchema(FetchURLInputSchema); - private readonly fetcher: UrlFetcher; - - constructor(@IWebFetchService webFetch: IWebFetchService) { - this.fetcher = webFetch.getUrlFetcher(); - } + constructor(@IWebFetchService private readonly webFetch: IWebFetchService) {} resolveExecution(args: FetchURLInput): ToolExecution { const preview = args.url.length > 50 ? `${args.url.slice(0, 50)}…` : args.url; @@ -53,7 +52,9 @@ export class FetchURLTool implements IFetchURLTool { { toolCallId, signal }: ExecutableToolContext, ): Promise { try { - const { content, kind } = await this.fetcher.fetch(args.url, { toolCallId, signal }); + const { content, kind } = await this.webFetch + .getUrlFetcher() + .fetch(args.url, { toolCallId, signal }); if (!content) { return { diff --git a/packages/agent-core-v2/src/agent/tools/skill/skill.md b/packages/agent-core-v2/src/agent/tools/skill/skill.md index 8d05c6faec..a1a66c8ac8 100644 --- a/packages/agent-core-v2/src/agent/tools/skill/skill.md +++ b/packages/agent-core-v2/src/agent/tools/skill/skill.md @@ -1 +1 @@ -Invoke a registered skill from the current skill listing. BLOCKING REQUIREMENT: when a skill from the listing matches the user's request, you MUST call this tool (not free-form text). Do not re-invoke a skill to repeat work already done: if a `` block for it with the same `args` is already present in the conversation, follow those instructions directly instead of calling the tool again. Do call the tool again when you need the skill with different arguments — the loaded block was expanded with the earlier `args` and will not reflect new inputs. \ No newline at end of file +Invoke a registered skill from the current skill listing. BLOCKING REQUIREMENT: when a skill from the listing matches the user's request, you MUST call this tool (not free-form text). Do not re-invoke a skill to repeat work already done: if a `` block for it with the same `args` is already present in the conversation, follow those instructions directly instead of calling the tool again. Do call the tool again when you need the skill with different arguments — the loaded block was expanded with the earlier `args` and will not reflect new inputs. \ No newline at end of file diff --git a/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts b/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts index c0e9e0d9c2..9f720adcbb 100644 --- a/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts +++ b/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts @@ -2,10 +2,14 @@ * `tools` domain — `WebSearchTool` implementation (the `WebSearch` tool). * * Resolves the host-injected `WebSearchProvider` from the App-scope - * `IWebSearchProviderService` (`auth` domain) at construction — the tool only - * activates when a provider is configured, because there is no local search - * backend — renders the results through `ToolResultBuilder`, and classifies - * provider errors into model-readable output. + * `IWebSearchProviderService` (`auth` domain) per invocation — the activation + * gate checks presence alone, and the provider (which embeds the frozen + * identity headers) only composes once a call needs it, so tool construction + * during a fast bootstrap cannot race the identity freeze and a mid-session + * login or config edit reaches the next call. The tool only activates when a + * provider is configured, because there is no local search backend; results + * render through `ToolResultBuilder`, and provider errors classify into + * model-readable output. * * Registered via the module-level `registerAgentToolService(IWebSearchTool, * WebSearchTool)` at the bottom of this file — the same "import = register" @@ -23,13 +27,11 @@ import { import { ToolResultBuilder } from '#/tool/result-builder'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { IWebSearchProviderService } from '#/app/auth/webSearch/webSearch'; -import { Error2, ErrorCodes } from '#/errors'; import { IWebSearchTool, WebSearchInputSchema, type WebSearchInput, - type WebSearchProvider, } from './web-search'; import DESCRIPTION from './web-search.md?raw'; @@ -40,17 +42,9 @@ export class WebSearchTool implements IWebSearchTool { readonly description: string = DESCRIPTION; readonly parameters: Record = toInputJsonSchema(WebSearchInputSchema); - private readonly provider: WebSearchProvider; - constructor( - @IWebSearchProviderService providerService: IWebSearchProviderService, - ) { - const provider = providerService.getWebSearchProvider(); - if (provider === undefined) { - throw new Error2(ErrorCodes.INTERNAL, 'WebSearchProviderService returned no provider during tool activation.'); - } - this.provider = provider; - } + @IWebSearchProviderService private readonly providerService: IWebSearchProviderService, + ) {} resolveExecution(args: WebSearchInput): ToolExecution { const preview = args.query.length > 40 ? `${args.query.slice(0, 40)}…` : args.query; @@ -68,8 +62,15 @@ export class WebSearchTool implements IWebSearchTool { args: WebSearchInput, { toolCallId, signal }: ExecutableToolContext, ): Promise { + const provider = this.providerService.getWebSearchProvider(); + if (provider === undefined) { + return { + isError: true, + output: 'Web search is no longer configured; the provider was removed after this session started.', + }; + } try { - const results = await this.provider.search(args.query, { toolCallId, signal }); + const results = await provider.search(args.query, { toolCallId, signal }); const builder = new ToolResultBuilder({ maxLineLength: null }); if (results.length === 0) { @@ -133,5 +134,5 @@ function classifySearchError(error: unknown): string { registerAgentToolService(IWebSearchTool, WebSearchTool, { name: 'WebSearch', domain: 'auth', - when: (accessor) => accessor.get(IWebSearchProviderService).getWebSearchProvider() !== undefined, + when: (accessor) => accessor.get(IWebSearchProviderService).hasWebSearchProvider(), }); diff --git a/packages/agent-core-v2/src/app/agentIdentity/agentIdentity.ts b/packages/agent-core-v2/src/app/agentIdentity/agentIdentity.ts new file mode 100644 index 0000000000..08b25260ef --- /dev/null +++ b/packages/agent-core-v2/src/app/agentIdentity/agentIdentity.ts @@ -0,0 +1,94 @@ +/** + * `agentIdentity` domain — resolved identity contract. + * + * The identity the agent uses for itself, resolved from the `[identity]` + * config section over the host's declared display name and frozen for the + * life of the process: the identity is announced outward (MCP initialize, + * OAuth registration, provider request logs) and cannot be re-announced, so + * restart-to-change is the one coherent semantic — and consumers may bake the + * snapshot into caches, prompts, and connections with no invalidation + * obligations. `resolved()` awaits the freeze; `current()` throws before it, + * so an early materialization fails loudly instead of caching a pre-config + * value. Bound at App scope. + * + * The snapshot carries finished products, never raw material for call sites + * to compose: the prompt display name, the protocol slug (`undefined` on + * either means no custom identity — consumers keep their built-in behavior), + * and the outbound `User-Agent` projections, which rewrite only the product + * token of what the host already sends (the key located case-insensitively, + * the host's spelling kept) — except toward directories this process chooses + * to call, where a header is always presented. + */ + +import { replaceUserAgentProduct } from '@moonshot-ai/kimi-code-oauth'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export const DEFAULT_IDENTITY_SLUG = 'agent'; + +export interface AgentIdentitySnapshot { + readonly displayName: string | undefined; + readonly slug: string | undefined; + readonly outboundUserAgent: string; + readonly thirdPartyUserAgent: string | undefined; + readonly requestHeaders: Readonly>; +} + +export interface IAgentIdentity { + readonly _serviceBrand: undefined; + + resolved(): Promise; + current(): AgentIdentitySnapshot; +} + +export const IAgentIdentity: ServiceIdentifier = + createDecorator('agentIdentity'); + +export function normalizeIdentitySlug(raw: string): string { + const folded = raw + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '-') + .replaceAll(/^-+|-+$/g, ''); + return folded.length > 0 ? folded : DEFAULT_IDENTITY_SLUG; +} + +export interface AgentIdentityInput { + readonly name?: string; + readonly slug?: string; + readonly hostDisplayName?: string; + readonly hostRequestHeaders: Readonly>; +} + +export function buildAgentIdentitySnapshot(input: AgentIdentityInput): AgentIdentitySnapshot { + const name = declared(input.name); + const rawSlug = declared(input.slug) ?? name; + const slug = rawSlug === undefined ? undefined : normalizeIdentitySlug(rawSlug); + const userAgentKeys = Object.keys(input.hostRequestHeaders).filter( + (key) => key.toLowerCase() === 'user-agent', + ); + const hostUserAgent = + userAgentKeys[0] === undefined ? undefined : input.hostRequestHeaders[userAgentKeys[0]]; + const thirdPartyUserAgent = + hostUserAgent === undefined || slug === undefined + ? hostUserAgent + : replaceUserAgentProduct(hostUserAgent, slug); + const requestHeaders: Record = { ...input.hostRequestHeaders }; + if (slug !== undefined) { + for (const key of userAgentKeys) { + const value = requestHeaders[key]; + if (value !== undefined) requestHeaders[key] = replaceUserAgentProduct(value, slug); + } + } + return { + displayName: name ?? declared(input.hostDisplayName), + slug, + outboundUserAgent: thirdPartyUserAgent ?? slug ?? DEFAULT_IDENTITY_SLUG, + thirdPartyUserAgent, + requestHeaders, + }; +} + +function declared(raw: string | undefined): string | undefined { + const trimmed = raw?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +} diff --git a/packages/agent-core-v2/src/app/agentIdentity/agentIdentityService.ts b/packages/agent-core-v2/src/app/agentIdentity/agentIdentityService.ts new file mode 100644 index 0000000000..7a87be577c --- /dev/null +++ b/packages/agent-core-v2/src/app/agentIdentity/agentIdentityService.ts @@ -0,0 +1,72 @@ +/** + * `agentIdentity` domain — `IAgentIdentity` implementation. + * + * Builds the process-lifetime snapshot from the `[identity]` config section + * (which already layers `env > config.toml`) and the host's declared display + * name and request headers in `IBootstrapService.args`, once config has first + * loaded; later `[identity]` edits take effect on the next start. Bound at + * App scope, activated eagerly so the freeze is armed before any consumer can + * observe config readiness — a config load failure still freezes, from + * whatever the config service then serves, matching what every other section + * consumer would read. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { CoreErrors } from '#/_base/errors/codes'; +import { Error2 } from '#/_base/errors/errors'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; + +import { + buildAgentIdentitySnapshot, + IAgentIdentity, + type AgentIdentitySnapshot, +} from './agentIdentity'; +import { IDENTITY_SECTION, type IdentityConfig } from './configSection'; + +export class AgentIdentityService implements IAgentIdentity { + declare readonly _serviceBrand: undefined; + + private snapshot: AgentIdentitySnapshot | undefined; + private readonly frozen: Promise; + + constructor( + @IConfigService config: IConfigService, + @IBootstrapService bootstrap: IBootstrapService, + ) { + this.frozen = config.ready + .catch(() => undefined) + .then(() => { + const section = config.get(IDENTITY_SECTION) ?? {}; + this.snapshot = buildAgentIdentitySnapshot({ + name: section.name, + slug: section.slug, + hostDisplayName: bootstrap.args.displayName, + hostRequestHeaders: bootstrap.args.requestHeaders, + }); + return this.snapshot; + }); + } + + resolved(): Promise { + return this.frozen; + } + + current(): AgentIdentitySnapshot { + if (this.snapshot === undefined) { + throw new Error2( + CoreErrors.codes.INTERNAL, + 'agent identity read before config load completed', + ); + } + return this.snapshot; + } +} + +registerScopedService( + LifecycleScope.App, + IAgentIdentity, + AgentIdentityService, + ScopeActivation.OnScopeCreated, + 'agentIdentity', +); diff --git a/packages/agent-core-v2/src/app/agentIdentity/configSection.ts b/packages/agent-core-v2/src/app/agentIdentity/configSection.ts new file mode 100644 index 0000000000..83f6aca206 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentIdentity/configSection.ts @@ -0,0 +1,58 @@ +/** + * `agentIdentity` domain — the `[identity]` config section. + * + * Owns the user-facing custom-identity preference: `name`, the display name in + * the system prompt, and the optional `slug` that goes into protocol fields. + * Both bind to `KIMI_CODE_IDENTITY_NAME` / `KIMI_CODE_IDENTITY_SLUG` so a + * container or CI run can state an identity without writing `config.toml`; an + * env override never persists back into the file. Leaving the section unset + * means no custom identity, and every consumer keeps its current behavior. + * + * Unlike most sections this one is read exactly once: `agentIdentity` freezes + * its snapshot when config first loads, so edits apply on the next start — + * see the domain contract for why mid-process changes cannot be honored. + * + * Self-registered at module load via `registerConfigSection`. + */ + +import { z } from 'zod'; + +import { + type EnvBindings, + envBindings, + stripEnvBoundFields, +} from '#/app/config/config'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; + +export const IDENTITY_SECTION = 'identity'; + +export const IdentityConfigSchema = z.object({ + name: z.string().optional(), + slug: z.string().optional(), +}); + +export type IdentityConfig = z.infer; + +export const IDENTITY_NAME_ENV = 'KIMI_CODE_IDENTITY_NAME'; +export const IDENTITY_SLUG_ENV = 'KIMI_CODE_IDENTITY_SLUG'; + +function parseIdentityEnv(raw: string): string | undefined { + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export const identityEnvBindings: EnvBindings = envBindings( + IdentityConfigSchema, + { + name: { env: IDENTITY_NAME_ENV, parse: parseIdentityEnv }, + slug: { env: IDENTITY_SLUG_ENV, parse: parseIdentityEnv }, + }, +); + +export const stripIdentityEnv = stripEnvBoundFields(identityEnvBindings); + +registerConfigSection(IDENTITY_SECTION, IdentityConfigSchema, { + defaultValue: {}, + env: identityEnvBindings, + stripEnv: stripIdentityEnv, +}); diff --git a/packages/agent-core-v2/src/app/auth/webSearch/webSearch.ts b/packages/agent-core-v2/src/app/auth/webSearch/webSearch.ts index e357576fbb..73dbdf500f 100644 --- a/packages/agent-core-v2/src/app/auth/webSearch/webSearch.ts +++ b/packages/agent-core-v2/src/app/auth/webSearch/webSearch.ts @@ -4,7 +4,9 @@ * Owns the seam for the `WebSearch` backend, which needs an authenticated * Moonshot search provider. `IWebSearchProviderService` exposes the * configured `WebSearchProvider` (or `undefined` when search is not - * configured). Tests and hosts that need a custom backend bind + * configured), and `hasWebSearchProvider` answers presence alone — for tool + * activation gates, which may run before the identity snapshot the composed + * provider embeds has frozen. Tests and hosts that need a custom backend bind * `IWebSearchProviderService` directly. Bound at App scope. */ @@ -18,6 +20,7 @@ export interface IWebSearchProviderService { readonly _serviceBrand: undefined; getWebSearchProvider(): WebSearchProvider | undefined; + hasWebSearchProvider(): boolean; } export const IWebSearchProviderService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts b/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts index 93dcb0140f..3ddbe07639 100644 --- a/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts +++ b/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts @@ -9,23 +9,30 @@ * state after a successful Kimi login), whose bearer token comes from * `IOAuthService.resolveTokenProvider(...)` and whose base URL is derived from * the provider's `baseUrl`. The explicit config wins over the managed - * derivation. Both use the host's Kimi identity headers - * (`IBootstrapService.args.requestHeaders`) as default headers. When neither - * source is configured it yields `undefined`. + * derivation. When neither source is configured it yields `undefined`. * Tests and hosts that need a custom backend bind `IWebSearchProviderService` * directly. Bound at App scope. + * + * Default headers split by who chose the endpoint: a `[services]` entry names + * its own, so that path sends `agentIdentity`'s frozen `requestHeaders` — the + * host header set with the `User-Agent` product token rewritten to the + * configured identity — while the managed OAuth path sends the host's own + * headers (`IBootstrapService.args.requestHeaders`) verbatim, being the + * endpoint the session authenticated against. */ import { KIMI_CODE_PROVIDER_NAME, kimiCodeBaseUrl, + type BearerTokenProvider, } from '@moonshot-ai/kimi-code-oauth'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IOAuthService } from '#/app/auth/auth'; +import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; -import { IProviderService } from '#/kosong/provider/provider'; +import { IProviderService, type ProviderConfig } from '#/kosong/provider/provider'; import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition'; import { SERVICES_SECTION, type ServicesConfig } from '../configSection'; @@ -41,17 +48,41 @@ export class WebSearchProviderService implements IWebSearchProviderService { @IOAuthService private readonly oauth: IOAuthService, @IBootstrapService private readonly bootstrap: IBootstrapService, @IConfigService private readonly config: IConfigService, + @IAgentIdentity private readonly identity: IAgentIdentity, ) {} getWebSearchProvider(): WebSearchProvider | undefined { return this.fromServicesConfig() ?? this.fromManagedOAuth(); } - private fromServicesConfig(): WebSearchProvider | undefined { + hasWebSearchProvider(): boolean { + return this.configuredSearch() !== undefined || this.managedTokenProvider() !== undefined; + } + + private configuredSearch(): (ServicesConfig['moonshotSearch'] & { baseUrl: string }) | undefined { const search = this.config.get(SERVICES_SECTION)?.moonshotSearch; - if (search?.baseUrl === undefined) { + if (search?.baseUrl === undefined) return undefined; + return search as ServicesConfig['moonshotSearch'] & { baseUrl: string }; + } + + private managedTokenProvider(): + | { provider: ProviderConfig; tokenProvider: BearerTokenProvider } + | undefined { + const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME); + if (provider === undefined || !isOAuthCatalogVendor(provider.type) || provider.oauth === undefined) { return undefined; } + const tokenProvider = this.oauth.resolveTokenProvider( + KIMI_CODE_PROVIDER_NAME, + provider.oauth, + ); + if (tokenProvider === undefined) return undefined; + return { provider, tokenProvider }; + } + + private fromServicesConfig(): WebSearchProvider | undefined { + const search = this.configuredSearch(); + if (search === undefined) return undefined; const tokenProvider = search.oauth === undefined ? undefined @@ -60,23 +91,15 @@ export class WebSearchProviderService implements IWebSearchProviderService { baseUrl: search.baseUrl, tokenProvider, apiKey: nonEmptyString(search.apiKey), - defaultHeaders: { ...this.bootstrap.args.requestHeaders }, + defaultHeaders: { ...this.identity.current().requestHeaders }, customHeaders: search.customHeaders, }); } private fromManagedOAuth(): WebSearchProvider | undefined { - const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME); - if (provider === undefined || !isOAuthCatalogVendor(provider.type) || provider.oauth === undefined) { - return undefined; - } - const tokenProvider = this.oauth.resolveTokenProvider( - KIMI_CODE_PROVIDER_NAME, - provider.oauth, - ); - if (tokenProvider === undefined) { - return undefined; - } + const managed = this.managedTokenProvider(); + if (managed === undefined) return undefined; + const { provider, tokenProvider } = managed; const baseUrl = `${(provider.baseUrl ?? kimiCodeBaseUrl()).replace(/\/+$/, '')}/search`; return new MoonshotWebSearchProvider({ baseUrl, diff --git a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts index fc7a77a08f..1b61b8a26b 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts @@ -8,6 +8,10 @@ * kosong's in-memory registries), and publishes `event.model_catalog.changed` * on change. Bound at App scope. * + * Custom registries are third-party endpoints, so the refresh User-Agent + * carries the configured custom identity's product token, matching what chat + * requests send. + * * `modelSource: 'static'` short-circuits refresh: a provider whose effective * model source is `static` (config-declared, or declared by its vendor * definition) serves its models from the static `[models.*]` section, so @@ -47,7 +51,7 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/ import { Error2 } from '#/_base/errors/errors'; import { IOAuthService } from '#/app/auth/auth'; import { AuthErrors } from '#/app/auth/errors'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IConfigService } from '#/app/config/config'; import { IEventService } from '#/app/event/event'; import { ModelCatalogErrors } from '#/kosong/model/errors'; @@ -91,7 +95,7 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { @IConfigService private readonly config: IConfigService, @IOAuthService private readonly oauth: IOAuthService, @IEventService private readonly events: IEventService, - @IBootstrapService private readonly bootstrap: IBootstrapService, + @IAgentIdentity private readonly identity: IAgentIdentity, ) {} refreshProviderModels( @@ -123,7 +127,8 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { } const exclusion = this.computeStaticExclusion(); - const result = await refreshProviderModels(this.buildRefreshHost(exclusion), { + const { outboundUserAgent } = await this.identity.resolved(); + const result = await refreshProviderModels(this.buildRefreshHost(exclusion, outboundUserAgent), { scope: options.scope, providerId: options.providerId, }); @@ -176,13 +181,13 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { }; } - private buildRefreshHost(exclusion: StaticExclusion): RefreshProviderHost { + private buildRefreshHost(exclusion: StaticExclusion, userAgent: string): RefreshProviderHost { return { getConfig: async () => this.readUserConfigShape(exclusion), removeProvider: (providerId) => this.shapeWithoutProvider(providerId), setConfig: (patch) => this.applyRefreshPatch(patch, exclusion), resolveOAuthToken: (providerName, oauthRef) => this.resolveOAuthToken(providerName, oauthRef), - userAgent: this.bootstrap.args.requestHeaders['User-Agent'], + userAgent, }; } diff --git a/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts b/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts index ae8eae75b5..d3b6bf9343 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts @@ -1,23 +1,42 @@ /** * `kosongConfig` domain — `IHostRequestHeaders` implementation. * - * Bridges kosong's host-headers port to the host invocation args: the headers - * are the ones the host stated in `BootstrapInput.args.requestHeaders` - * (usually built through `createKimiDefaultHeaders`), exposed through - * `IBootstrapService.args`. kosong's model catalog only sees the port. Bound - * at App scope. + * Bridges kosong's host-headers port to the host invocation args: `headers` + * is what the host stated in `BootstrapInput.args.requestHeaders` (usually + * built through `createKimiDefaultHeaders`), verbatim; `thirdPartyHeaders` is + * the `User-Agent`-only layer with the product token taken from the frozen + * identity snapshot. kosong's model catalog only sees the port. Bound at App + * scope. + * + * The third-party layer reads `agentIdentity.current()`, which throws until + * config has first loaded — so a model materialized too early fails loudly + * instead of caching headers that misstate the configured identity. Vendors + * on the full-headers path never touch it. */ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; export class HostRequestHeadersAdapter implements IHostRequestHeaders { readonly headers: Readonly>; - constructor(@IBootstrapService bootstrap: IBootstrapService) { + constructor( + @IBootstrapService bootstrap: IBootstrapService, + @IAgentIdentity private readonly identity: IAgentIdentity, + ) { this.headers = bootstrap.args.requestHeaders; } + + get thirdPartyHeaders(): Readonly> { + const userAgent = this.identity.current().thirdPartyUserAgent; + return userAgent === undefined ? {} : { 'User-Agent': userAgent }; + } + + get identitySlug(): string | undefined { + return this.identity.current().slug; + } } registerScopedService( diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts index 683f8097ca..b3587cadbb 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts @@ -25,6 +25,11 @@ * passes (drop, then re-add onto clean slots). The kosong persistence * bridge then pushes the change into the registries, which is also what * invalidates the runtime model catalog. + * + * Both third-party fetches — the models.dev directory and the custom-registry + * import — send the identity snapshot's `outboundUserAgent`, matching what + * the scheduled refresh of the same registry sends: these are directories + * this service chooses to call, so a header is always sent. */ import { @@ -38,6 +43,7 @@ import { import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Error2 } from '#/_base/errors/errors'; +import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IConfigService } from '#/app/config/config'; import { IModelCatalog } from '#/kosong/model/catalog'; import { type ModelsSection } from '#/kosong/model/model'; @@ -76,15 +82,20 @@ export class ModelsDevImportService implements IModelsDevImportService { @IConfigService private readonly config: IConfigService, @IKosongConfigService private readonly kosongConfig: IKosongConfigService, @IModelCatalog private readonly modelCatalog: IModelCatalog, + @IAgentIdentity private readonly identity: IAgentIdentity, ) {} + private async outboundUserAgent(): Promise { + return (await this.identity.resolved()).outboundUserAgent; + } + async listModelsDevProviders(): Promise { - const catalog = await getModelsDevCatalog(); + const catalog = await getModelsDevCatalog(await this.outboundUserAgent()); return Object.entries(catalog).map(([id, entry]) => toModelsDevProviderItem(id, entry)); } async getModelsDevProvider(catalogId: string): Promise { - const catalog = await getModelsDevCatalog(); + const catalog = await getModelsDevCatalog(await this.outboundUserAgent()); const entry = modelsDevEntry(catalog, catalogId); if (entry === undefined) { throw new Error2( @@ -126,7 +137,7 @@ export class ModelsDevImportService implements IModelsDevImportService { options: ImportModelsDevProviderOptions, ): Promise { const { catalogId } = options; - const catalog = await getModelsDevCatalog(); + const catalog = await getModelsDevCatalog(await this.outboundUserAgent()); const entry = modelsDevEntry(catalog, catalogId); if (entry === undefined) { throw new Error2( @@ -216,7 +227,7 @@ export class ModelsDevImportService implements IModelsDevImportService { try { entries = await fetchCustomRegistry(source, { fetchImpl: upstreamFetch(), - userAgent: 'kimi-code-kap-server', + userAgent: await this.outboundUserAgent(), signal: AbortSignal.timeout(UPSTREAM_FETCH_TIMEOUT_MS), }); } catch (err) { diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts index 9ec40db4c3..1cb9a5e8bc 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts @@ -2,6 +2,12 @@ * `kosongConfig` domain — models.dev upstream: fetch the third-party * directory, in-memory cache, built-in snapshot fallback, and the pruned * item mapping behind the import service's browse methods. + * + * The caller states the outbound `User-Agent`: this module is plain + * module-level state with no container access, and the value depends on the + * host and the configured identity, which only the calling service can see. + * The cached catalog does not vary by caller, so a later call with a different + * value still reuses it. */ import { CoreErrors } from '#/_base/errors/codes'; @@ -64,20 +70,20 @@ export function upstreamFetch(): typeof fetch { return fetchImpl; } -export async function getModelsDevCatalog(): Promise { +export async function getModelsDevCatalog(userAgent: string): Promise { const now = nowImpl(); if (cache !== undefined && now - cache.fetchedAt < CACHE_TTL_MS) return cache.catalog; - inFlight ??= fetchAndCache().finally(() => { + inFlight ??= fetchAndCache(userAgent).finally(() => { inFlight = undefined; }); return inFlight; } -async function fetchAndCache(): Promise { +async function fetchAndCache(userAgent: string): Promise { const now = nowImpl(); try { const res = await fetchImpl(MODELS_DEV_URL, { - headers: { Accept: 'application/json', 'User-Agent': 'kimi-code-kap-server' }, + headers: { Accept: 'application/json', 'User-Agent': userAgent }, signal: AbortSignal.timeout(UPSTREAM_FETCH_TIMEOUT_MS), }); if (!res.ok) { diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtin/builtin.ts b/packages/agent-core-v2/src/app/skillCatalog/builtin/builtin.ts index a0f68d31cc..dbbb9dad49 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/builtin/builtin.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/builtin/builtin.ts @@ -3,11 +3,15 @@ * * Code-defined builtin skills are constants (not discovered from storage), so * they bypass `ISkillDiscovery`: `BUILTIN_SKILLS` feeds the builtin - * `ISkillSource`, and `registerBuiltinSkills` stamps them into an in-memory - * catalog for edge composition without a Session. + * `ISkillSource`. + * + * `visibleBuiltinSkills` is the one place that decides which of them the + * `builtin_product_skills` switch excludes. Every consumer goes through it — the + * session-scoped source and the session-less workspace listings alike — so a + * skill marked `productSpecific` cannot stay advertised on one surface while + * being filtered on another. */ -import type { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; import type { SkillDefinition } from '#/app/skillCatalog/types'; import { CHECK_KIMI_CODE_DOCS_SKILL } from './check-kimi-code-docs'; import { CUSTOM_THEME_SKILL } from './custom-theme'; @@ -33,10 +37,9 @@ export const BUILTIN_SKILLS: readonly SkillDefinition[] = [ SUB_SKILL_CONSOLIDATE, ]; -export function registerBuiltinSkills(registry: InMemorySkillCatalog): void { - for (const skill of BUILTIN_SKILLS) { - registry.registerBuiltinSkill(skill); - } +export function visibleBuiltinSkills(productSkillsEnabled: boolean): readonly SkillDefinition[] { + if (productSkillsEnabled) return BUILTIN_SKILLS; + return BUILTIN_SKILLS.filter((skill) => skill.productSpecific !== true); } export { diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtin/check-kimi-code-docs.ts b/packages/agent-core-v2/src/app/skillCatalog/builtin/check-kimi-code-docs.ts index fe1009793d..6d66d8ebf5 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/builtin/check-kimi-code-docs.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/builtin/check-kimi-code-docs.ts @@ -23,4 +23,5 @@ export const CHECK_KIMI_CODE_DOCS_SKILL: SkillDefinition = { ...parsed.metadata, type: parsed.metadata.type ?? 'inline', }, + productSpecific: true, }; diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtin/custom-theme.ts b/packages/agent-core-v2/src/app/skillCatalog/builtin/custom-theme.ts index 15f0291a29..566e71188b 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/builtin/custom-theme.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/builtin/custom-theme.ts @@ -24,4 +24,5 @@ export const CUSTOM_THEME_SKILL: SkillDefinition = { type: parsed.metadata.type ?? 'inline', disableModelInvocation: true, }, + productSpecific: true, }; diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtin/import-from-cc-codex.ts b/packages/agent-core-v2/src/app/skillCatalog/builtin/import-from-cc-codex.ts index 5722e2eb2c..58d6d90efd 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/builtin/import-from-cc-codex.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/builtin/import-from-cc-codex.ts @@ -24,4 +24,5 @@ export const IMPORT_FROM_CC_CODEX_SKILL: SkillDefinition = { type: parsed.metadata.type ?? 'inline', disableModelInvocation: true, }, + productSpecific: true, }; diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtin/mcp-config.ts b/packages/agent-core-v2/src/app/skillCatalog/builtin/mcp-config.ts index 0eae44ebef..7b2f77f67a 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/builtin/mcp-config.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/builtin/mcp-config.ts @@ -24,4 +24,5 @@ export const MCP_CONFIG_SKILL: SkillDefinition = { type: parsed.metadata.type ?? 'inline', disableModelInvocation: true, }, + productSpecific: true, }; diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.ts b/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.ts index 1964ed3ba9..00d0dbec9b 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.ts @@ -23,4 +23,5 @@ export const UPDATE_CONFIG_SKILL: SkillDefinition = { ...parsed.metadata, type: parsed.metadata.type ?? 'inline', }, + productSpecific: true, }; diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtinSkillSource.ts b/packages/agent-core-v2/src/app/skillCatalog/builtinSkillSource.ts index 7035493da5..c56ff7680a 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/builtinSkillSource.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/builtinSkillSource.ts @@ -2,15 +2,33 @@ * `skillCatalog` domain — builtin `ISkillSource` producer. * * Yields the code-defined `BUILTIN_SKILLS` as the lowest-priority contribution - * (`builtin`, priority 0) so extra / user / workspace / plugin skills override it on - * name collision. Bound at App scope. + * (`builtin`, priority 0) so extra / user / workspace / plugin skills override + * it on name collision. Bound at App scope. + * + * Product-documentation skills are filtered here rather than downstream: their + * names sit in the system prompt for the whole session, and being the + * lowest-priority source this one loads first and is kept for the life of the + * handler — hence the wait for config readiness, and the change event that + * lets the catalog reload it when the switch is toggled. */ +import { Emitter, type Event } from '#/_base/event'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IConfigService } from '#/app/config/config'; -import { BUILTIN_SKILLS } from './builtin/builtin'; -import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from './skillSource'; +import { visibleBuiltinSkills } from './builtin/builtin'; +import { + BUILTIN_PRODUCT_SKILLS_SECTION, + builtinProductSkillsEnabled, +} from './configSection'; +import { + BUILTIN_SKILL_SOURCE_ID, + SKILL_SOURCE_PRIORITY, + type ISkillSource, + type SkillContribution, +} from './skillSource'; export interface IBuiltinSkillSource extends ISkillSource { readonly _serviceBrand: undefined; @@ -19,14 +37,26 @@ export interface IBuiltinSkillSource extends ISkillSource { export const IBuiltinSkillSource: ServiceIdentifier = createDecorator('builtinSkillSource'); -export class BuiltinSkillSource implements IBuiltinSkillSource { +export class BuiltinSkillSource extends Disposable implements IBuiltinSkillSource { declare readonly _serviceBrand: undefined; - readonly id = 'builtin'; + readonly id = BUILTIN_SKILL_SOURCE_ID; readonly priority = SKILL_SOURCE_PRIORITY.builtin; + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + + constructor(@IConfigService private readonly config: IConfigService) { + super(); + this._register( + this.config.onDidSectionChange((event) => { + if (event.domain === BUILTIN_PRODUCT_SKILLS_SECTION) this.onDidChangeEmitter.fire(); + }), + ); + } async load(): Promise { - return { skills: BUILTIN_SKILLS }; + await this.config.ready; + return { skills: visibleBuiltinSkills(builtinProductSkillsEnabled(this.config)) }; } } diff --git a/packages/agent-core-v2/src/app/skillCatalog/configSection.ts b/packages/agent-core-v2/src/app/skillCatalog/configSection.ts index 4cf8ed4be3..a9948829fb 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/configSection.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/configSection.ts @@ -2,12 +2,37 @@ * `skillCatalog` domain — skill config sections. * * Registers the v1-compatible top-level config domains `extraSkillDirs` and - * `mergeAllAvailableSkills`. Values stay camelCase in memory; TOML uses the - * snake_case keys `extra_skill_dirs` and `merge_all_available_skills`. + * `mergeAllAvailableSkills`, plus `builtinProductSkills`. Values stay camelCase + * in memory; TOML uses the snake_case keys `extra_skill_dirs`, + * `merge_all_available_skills`, and `builtin_product_skills`. + * + * `builtinProductSkills` decides whether the builtin skills documenting this + * CLI itself — its `config.toml` / `tui.toml` settings, custom themes, MCP + * setup, the official docs lookup, and the Claude Code / Codex import — are + * offered to the model. On by default; turning it off trims their names and + * descriptions from the system prompt, where they otherwise sit on every turn, + * at the cost of the guided flows for those tasks. Useful for unattended runs, + * or deployments where nobody reconfigures the CLI mid-task. + * + * That section is a whole-section scalar rather than an object of fields, so + * the env binding covers it directly and it needs its own strip: + * `stripEnvBoundFields` only walks object fields, so an env override would + * otherwise be written back into `config.toml`. The strip restores the + * env-free file value while the env var resolves, and drops the field when the + * file held anything but a boolean. `builtinProductSkillsEnabled` reads the + * resolved switch; only an explicit opt-out disables, so a missing or + * not-yet-registered section behaves like the shipped default. */ import { z } from 'zod'; +import { parseBooleanEnv } from '#/_base/utils/env'; +import { + type ConfigStripEnv, + type EnvBindings, + envBindings, + type IConfigService, +} from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; export const EXTRA_SKILL_DIRS_SECTION = 'extraSkillDirs'; @@ -25,3 +50,35 @@ export type MergeAllAvailableSkillsConfig = z.infer; + +export const BUILTIN_PRODUCT_SKILLS_ENV = 'KIMI_CODE_BUILTIN_PRODUCT_SKILLS'; + +export const builtinProductSkillsEnvBindings: EnvBindings = + envBindings(BuiltinProductSkillsConfigSchema, { + env: BUILTIN_PRODUCT_SKILLS_ENV, + parse: parseBooleanEnv, + }); + +export const stripBuiltinProductSkillsEnv: ConfigStripEnv = ( + value, + raw, + getEnv, +) => { + if (getEnv === undefined) return value; + if (parseBooleanEnv(getEnv(BUILTIN_PRODUCT_SKILLS_ENV)) === undefined) return value; + return typeof raw === 'boolean' ? raw : undefined; +}; + +registerConfigSection(BUILTIN_PRODUCT_SKILLS_SECTION, BuiltinProductSkillsConfigSchema, { + defaultValue: true, + env: builtinProductSkillsEnvBindings, + stripEnv: stripBuiltinProductSkillsEnv, +}); + +export function builtinProductSkillsEnabled(config: IConfigService): boolean { + return config.get(BUILTIN_PRODUCT_SKILLS_SECTION) !== false; +} diff --git a/packages/agent-core-v2/src/app/skillCatalog/registry.ts b/packages/agent-core-v2/src/app/skillCatalog/registry.ts index 8ac3791d1d..19386a84a1 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/registry.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/registry.ts @@ -82,9 +82,9 @@ export class InMemorySkillCatalog implements SkillCatalog { const instructions = plugin.instructions; if (instructions === undefined || instructions.trim().length === 0) return content; return ( - `\n` + + `\n` + `${instructions}\n` + - `\n\n${content}` + `\n\n${content}` ); } diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts b/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts index dd95dca7cf..5388ce3849 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts @@ -31,6 +31,7 @@ export const SKILL_SOURCE_PRIORITY = { } as const; export const PLUGIN_SKILL_SOURCE_ID = 'plugin'; +export const BUILTIN_SKILL_SOURCE_ID = 'builtin'; export interface ISkillSource { readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/app/skillCatalog/types.ts b/packages/agent-core-v2/src/app/skillCatalog/types.ts index 210cfe62d0..9ee2a86a18 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/types.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/types.ts @@ -1,3 +1,13 @@ +/** + * `skillCatalog` domain — skill data types. + * + * The shapes every skill source produces and the catalog stores. A definition + * marked `productSpecific` documents this CLI itself — its configuration, + * themes, MCP setup — rather than a capability the agent applies to the user's + * work, which is what the `builtin_product_skills` switch excludes; those + * names and descriptions otherwise sit in the system prompt every turn. + */ + export type SkillSource = 'project' | 'user' | 'extra' | 'builtin'; export interface SkillMetadata { @@ -23,6 +33,7 @@ export interface SkillDefinition { readonly plugin?: SkillPluginContext; readonly mermaid?: string | undefined; readonly d2?: string; + readonly productSpecific?: boolean; } export interface SkillSummary { diff --git a/packages/agent-core-v2/src/app/web/webService.ts b/packages/agent-core-v2/src/app/web/webService.ts index e036461160..af458bda8b 100644 --- a/packages/agent-core-v2/src/app/web/webService.ts +++ b/packages/agent-core-v2/src/app/web/webService.ts @@ -8,11 +8,17 @@ * Kimi OAuth provider when it carries an `oauth` ref (the state after a * successful Kimi login), routing fetches through the Moonshot fetch service * (`${provider.baseUrl}/fetch`); and (3) the built-in `LocalFetchURLProvider`, - * so `FetchURL` keeps working without any configuration. The first two use the - * host's Kimi identity headers (`IBootstrapService.args.requestHeaders`) and - * fall back to the local fetcher on failure. Reads config and the managed - * provider lazily on each `getUrlFetcher()` call so it tracks edits and login - * state. Bound at App scope. + * so `FetchURL` keeps working without any configuration. The first two fall + * back to the local fetcher on failure. Reads config and the managed provider + * lazily on each `getUrlFetcher()` call so it tracks edits and login state. + * Bound at App scope. + * + * Default headers split by who chose the endpoint: a `[services]` entry names + * its own, so that path sends `agentIdentity`'s frozen `requestHeaders` — the + * host header set with the `User-Agent` product token rewritten to the + * configured identity — while the managed OAuth path sends the host's own + * headers (`IBootstrapService.args.requestHeaders`) verbatim, being the + * endpoint the session authenticated against. */ import { @@ -23,6 +29,7 @@ import { import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IOAuthService } from '#/app/auth/auth'; import { SERVICES_SECTION, type ServicesConfig } from '#/app/auth/configSection'; +import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IProviderService } from '#/kosong/provider/provider'; @@ -42,6 +49,7 @@ export class WebFetchService implements IWebFetchService { @IOAuthService private readonly oauth: IOAuthService, @IBootstrapService private readonly bootstrap: IBootstrapService, @IConfigService private readonly config: IConfigService, + @IAgentIdentity private readonly identity: IAgentIdentity, ) { this.localFetcher = new LocalFetchURLProvider(); } @@ -63,7 +71,7 @@ export class WebFetchService implements IWebFetchService { baseUrl: fetchConfig.baseUrl, tokenProvider, apiKey: nonEmptyString(fetchConfig.apiKey), - defaultHeaders: { ...this.bootstrap.args.requestHeaders }, + defaultHeaders: { ...this.identity.current().requestHeaders }, customHeaders: fetchConfig.customHeaders, localFallback: this.localFetcher, }); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 24c61c7f74..2d0233d232 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -107,6 +107,10 @@ export * from '#/kosong/provider/providerService'; export * from '#/kosong/provider/providerDefinition'; export * from '#/kosong/provider/protocolAdapterRegistry'; import '#/app/skillCatalog/configSection'; +import '#/app/agentIdentity/configSection'; +export * from '#/app/agentIdentity/configSection'; +export * from '#/app/agentIdentity/agentIdentity'; +export * from '#/app/agentIdentity/agentIdentityService'; import '#/kosong/protocol/errors'; export * from '#/kosong/protocol/errors'; export * from '#/kosong/protocol/protocol'; diff --git a/packages/agent-core-v2/src/kosong/model/catalogService.ts b/packages/agent-core-v2/src/kosong/model/catalogService.ts index c0b1c1ae9c..e73f214f38 100644 --- a/packages/agent-core-v2/src/kosong/model/catalogService.ts +++ b/packages/agent-core-v2/src/kosong/model/catalogService.ts @@ -28,7 +28,9 @@ * model/provider config-change events. Tests that mutate config * behind the services' backs (bypassing those events) must call * `notifyConfigChanged()` to drop the cache — otherwise `get` keeps serving - * the previous generation's Model. + * the previous generation's Model. The host-header layers baked into an + * entry need no invalidation: both are frozen for the process (bootstrap + * args, and the identity snapshot behind the third-party layer). * * Inspection: every assembly also captures a `ResolutionTraceCollector` * (provenance records + intermediate artifacts, reference-only) alongside the @@ -42,6 +44,14 @@ * provider registry plus credential state. `setDefaultModel` writes the * global default-model pointer (through `IModelService`) after a * materialization gate — the catalog's only write. + * + * Outbound headers: vendors declaring `hostHeaders: 'full'` receive the host + * headers port's complete set and stay consistent with it — that set is the + * host's to define, and backends key on the product token it carries (log + * filtering, rollout gating). Everyone else receives the port's third-party + * layer, already finished on the app side (at most a `User-Agent`, product + * token per the configured identity) — this catalog picks a layer, it never + * edits one. */ import { parseKimiCodeCustomHeaders } from '@moonshot-ai/kimi-code-oauth'; @@ -387,6 +397,8 @@ export class ModelCatalog extends Disposable implements IModelCatalog { const declared = new Set((model.capabilities ?? []).map((c) => c.trim().toLowerCase())); trace.capture(TRACE.hostHeaders, this.hostRequestHeaders.headers); + trace.capture(TRACE.thirdPartyHeaders, this.hostRequestHeaders.thirdPartyHeaders); + trace.capture(TRACE.identitySlug, this.hostRequestHeaders.identitySlug); return { id, name: wireName, @@ -396,7 +408,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { headers: resolveOutboundHeaders( providerConfig?.type, providerConfig?.customHeaders, - this.hostRequestHeaders.headers, + this.hostRequestHeaders, ), capabilities, maxContextSize: model.maxContextSize, @@ -558,20 +570,15 @@ export class ModelCatalog extends Disposable implements IModelCatalog { export function resolveOutboundHeaders( providerType: string | undefined, customHeaders: Readonly> | undefined, - hostHeaders: Readonly>, + host: Pick, ): Readonly> { const forwardsAll = providerType !== undefined && getProviderDefinition(providerType)?.hostHeaders === 'full'; - const hostLayer = forwardsAll ? hostHeaders : userAgentOnly(hostHeaders); + const hostLayer = forwardsAll ? host.headers : host.thirdPartyHeaders; return { ...parseKimiCodeCustomHeaders(), ...hostLayer, ...customHeaders }; } -function userAgentOnly(headers: Readonly>): Record { - const userAgent = headers['User-Agent']; - return userAgent === undefined ? {} : { 'User-Agent': userAgent }; -} - function resolveModelCapabilities( declaredCapabilities: readonly string[] | undefined, detected: ModelCapability, diff --git a/packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts b/packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts index 53b3bd0da8..e334943cec 100644 --- a/packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts +++ b/packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts @@ -7,15 +7,24 @@ * `BootstrapInput.args.requestHeaders`; the app-side adapter * (`app/kosongConfig/hostRequestHeadersAdapter`) bridges * `IBootstrapService.args` to this port so kosong stays a pure abstraction - * layer. `ModelCatalog` merges them per vendor — the full set for vendors - * whose definition declares `hostHeaders: 'full'`, only the `User-Agent` for - * everyone else (so device identity never leaks to third-party endpoints). + * layer. The port carries two finished layers and `ModelCatalog` picks one + * per vendor — `headers`, the full verbatim set, for vendors whose definition + * declares `hostHeaders: 'full'`; `thirdPartyHeaders`, at most the + * `User-Agent`, for everyone else (so device identity never leaks to + * third-party endpoints). Any custom-identity rewriting happens on the app + * side before the layers reach this port; kosong applies them as given. + * + * `identitySlug` is provenance metadata only — the configured custom + * identity's token, surfaced by `inspect` to label where the third-party + * `User-Agent`'s product token came from. No resolution logic reads it. */ import { createDecorator } from '#/_base/di/instantiation'; export interface IHostRequestHeaders { readonly headers: Readonly>; + readonly thirdPartyHeaders: Readonly>; + readonly identitySlug?: string; } export const IHostRequestHeaders = createDecorator('hostRequestHeaders'); diff --git a/packages/agent-core-v2/src/kosong/model/inspection.ts b/packages/agent-core-v2/src/kosong/model/inspection.ts index bf94aae227..e2d6c29d70 100644 --- a/packages/agent-core-v2/src/kosong/model/inspection.ts +++ b/packages/agent-core-v2/src/kosong/model/inspection.ts @@ -95,6 +95,8 @@ export const TRACE = { detectedCapability: 'detectedCapability', capabilitySource: 'capabilitySource', hostHeaders: 'hostHeaders', + thirdPartyHeaders: 'thirdPartyHeaders', + identitySlug: 'identitySlug', } as const; export class ResolutionTraceCollector implements ResolutionTrace { @@ -468,6 +470,17 @@ function attributeCapabilities( ); } +function hostHeaderDetail( + forwardsAll: boolean, + key: string, + identitySlug: string | undefined, +): string { + if (forwardsAll) return "host request headers (hostHeaders: 'full')"; + return identitySlug !== undefined && key === 'User-Agent' + ? `host User-Agent, product token from [identity] (${identitySlug})` + : 'host User-Agent'; +} + function attributeHeaders( sources: Map, model: ResolvedModelLike, @@ -476,14 +489,13 @@ function attributeHeaders( ): void { const envLayer = parseKimiCodeCustomHeaders(); const rawHost = trace.captured>>(TRACE.hostHeaders) ?? {}; + const identitySlug = trace.captured(TRACE.identitySlug); const forwardsAll = providerConfig?.type !== undefined && getProviderDefinition(providerConfig.type)?.hostHeaders === 'full'; const hostLayer: Readonly> = forwardsAll ? rawHost - : rawHost['User-Agent'] === undefined - ? {} - : { 'User-Agent': rawHost['User-Agent'] }; + : trace.captured>>(TRACE.thirdPartyHeaders) ?? {}; const customLayer = providerConfig?.customHeaders ?? {}; for (const key of Object.keys(model.headers)) { const path = `resolved.headers.${key}`; @@ -492,7 +504,7 @@ function attributeHeaders( } else if (key in hostLayer) { sources.set(path, { kind: 'builtin', - detail: forwardsAll ? "host request headers (hostHeaders: 'full')" : 'host User-Agent', + detail: hostHeaderDetail(forwardsAll, key, identitySlug), }); } else if (key in envLayer) { sources.set(path, { kind: 'env', detail: 'KIMI_CODE_CUSTOM_HEADERS' }); diff --git a/packages/agent-core-v2/src/mcpCore/connection-manager.ts b/packages/agent-core-v2/src/mcpCore/connection-manager.ts index f206380efc..01dd9ba5c9 100644 --- a/packages/agent-core-v2/src/mcpCore/connection-manager.ts +++ b/packages/agent-core-v2/src/mcpCore/connection-manager.ts @@ -7,6 +7,11 @@ * provider when tokens are present, flips failing servers into `needs-auth` * on 401, and reconnects after authentication. Applies per-server settings * over the configured defaults and emits status changes to subscribers. + * + * `resolveClientName` supplies the name announced to servers during initialize + * (and the OAuth dynamic-registration label), consulted per connection so an + * identity configured after construction still applies; omitted, or resolving + * to `undefined`, keeps the built-in name. */ import { ErrorCodes, Error2 } from '#/errors'; @@ -97,6 +102,7 @@ export interface McpConnectionManagerOptions { readonly oauthService?: McpOAuthService; readonly log?: Logger; readonly resolveDefaultTimeouts?: () => McpDefaultTimeouts; + readonly resolveClientName?: () => string | undefined; } export class McpConnectionManager implements McpConnectionView { @@ -371,11 +377,13 @@ export class McpConnectionManager implements McpConnectionView { ): Promise { const toolCallTimeoutMs = config.toolTimeoutMs ?? this.options.resolveDefaultTimeouts?.().toolTimeoutMs; + const clientName = this.options.resolveClientName?.(); if (config.transport === 'stdio') { return new StdioMcpClient(config, { startupTimeoutMs, toolCallTimeoutMs, defaultCwd: this.options.stdioCwd, + clientName, }); } if (config.transport === 'sse') { @@ -384,6 +392,7 @@ export class McpConnectionManager implements McpConnectionView { toolCallTimeoutMs, envLookup: this.options.envLookup, oauthProvider: await this.resolveOAuthProvider(config, name), + clientName, }); } return new HttpMcpClient(config, { @@ -391,6 +400,7 @@ export class McpConnectionManager implements McpConnectionView { toolCallTimeoutMs, envLookup: this.options.envLookup, oauthProvider: await this.resolveOAuthProvider(config, name), + clientName, }); } diff --git a/packages/agent-core-v2/src/mcpCore/oauth/callback-server.ts b/packages/agent-core-v2/src/mcpCore/oauth/callback-server.ts index 3a8b3b71ea..5cbcf14677 100644 --- a/packages/agent-core-v2/src/mcpCore/oauth/callback-server.ts +++ b/packages/agent-core-v2/src/mcpCore/oauth/callback-server.ts @@ -27,14 +27,14 @@ const SUCCESS_HTML = 'Authorized' + '' + '

Sign-in complete

' + - '

You can close this tab and return to kimi-code.

' + + '

You can close this tab and return to the application.

' + ''; const ERROR_HTML = 'OAuth error' + '' + '

Sign-in failed

' + - '

The authorization server reported an error. Return to kimi-code for details.

' + + '

The authorization server reported an error. Return to the application for details.

' + ''; export async function startCallbackServer(): Promise { diff --git a/packages/agent-core-v2/src/mcpCore/oauth/provider.ts b/packages/agent-core-v2/src/mcpCore/oauth/provider.ts index 545b7dfa58..20c63cfb05 100644 --- a/packages/agent-core-v2/src/mcpCore/oauth/provider.ts +++ b/packages/agent-core-v2/src/mcpCore/oauth/provider.ts @@ -13,6 +13,10 @@ * blocking, while the data methods `await ready` before reading or writing. * The provider does not open browsers or run servers — it is the * persistence + flow-state shim. + * + * `clientName` is the product token for the default label + * (` ()`), carrying the configured custom identity; it + * is ignored when `clientLabel` states the whole label explicitly. */ import { randomBytes } from 'node:crypto'; @@ -30,6 +34,7 @@ import type { OAuthTokens, } from '@modelcontextprotocol/sdk/shared/auth.js'; +import { KIMI_MCP_CLIENT_NAME } from '../client-shared'; import { canonicalMcpOAuthResource, mcpOAuthStoreKey, type McpOAuthStore } from './store'; const TOKENS_SUFFIX = '-tokens.json'; @@ -42,6 +47,7 @@ export interface McpOAuthProviderOptions { readonly serverUrl: string | URL; readonly store: McpOAuthStore; readonly clientLabel?: string; + readonly clientName?: string; } export class McpOAuthClientProvider implements OAuthClientProvider { @@ -63,7 +69,9 @@ export class McpOAuthClientProvider implements OAuthClientProvider { this.serverUrl = canonicalMcpOAuthResource(options.serverUrl); this.storeKey = mcpOAuthStoreKey(options.serverName, this.serverUrl); this.store = options.store; - this.clientLabel = options.clientLabel ?? `kimi-code (${options.serverName})`; + this.clientLabel = + options.clientLabel ?? + `${options.clientName ?? KIMI_MCP_CLIENT_NAME} (${options.serverName})`; this.ready = this.load(); } diff --git a/packages/agent-core-v2/src/mcpCore/oauth/service.ts b/packages/agent-core-v2/src/mcpCore/oauth/service.ts index b4a25d9d57..c2972609fd 100644 --- a/packages/agent-core-v2/src/mcpCore/oauth/service.ts +++ b/packages/agent-core-v2/src/mcpCore/oauth/service.ts @@ -20,6 +20,10 @@ * 3. After `complete()` resolves successfully the provider has tokens on * disk; the caller (the synthetic tool) drives a manager-level * `reconnect` to swap the synthetic tool out for the real MCP tools. + * + * `resolveClientName` supplies the product token for provider default labels, + * consulted per provider so an identity configured after this service is + * constructed still applies. */ import { auth, type OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; @@ -33,6 +37,7 @@ import { mcpOAuthStoreKey, type McpOAuthStore } from './store'; export interface McpOAuthServiceOptions { readonly store: McpOAuthStore; readonly clientLabel?: string; + readonly resolveClientName?: () => string | undefined; } export interface BeginAuthorizationOptions { @@ -48,11 +53,13 @@ export interface BeginAuthorizationResult { export class McpOAuthService { private readonly store: McpOAuthStore; private readonly clientLabel: string | undefined; + private readonly resolveClientName: (() => string | undefined) | undefined; private readonly providers = new Map(); constructor(options: McpOAuthServiceOptions) { this.store = options.store; this.clientLabel = options.clientLabel; + this.resolveClientName = options.resolveClientName; } getProvider(serverName: string, serverUrl: string | URL): McpOAuthClientProvider { @@ -64,6 +71,7 @@ export class McpOAuthService { serverUrl, store: this.store, clientLabel: this.clientLabel, + clientName: this.resolveClientName?.(), }); this.providers.set(provider.storeKey, provider); } @@ -86,6 +94,7 @@ export class McpOAuthService { serverUrl, store: this.store, clientLabel: options.clientLabel, + clientName: this.resolveClientName?.(), }); if (options.clientLabel !== undefined) { this.providers.set(provider.storeKey, provider); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts index ae6a54a2ac..04b898f498 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts @@ -99,7 +99,7 @@ const DEFAULT_SUMMARY_POLICY = { registerAgentProfile({ name: 'agent', - description: 'Default Kimi Code agent', + description: 'Default agent', tools: AGENT_TOOLS, renderSystemPrompt: (context) => renderSystemPromptResult('', context, { skillActive: skillActiveFor(AGENT_TOOLS) }), diff --git a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts index e16c88d4b6..77c46e2c8a 100644 --- a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts @@ -17,6 +17,14 @@ * whose cwd is the handler root) lives as long as the handler — i.e. the * process — so a stateful stdio server is shared by concurrent sessions of * the workspace rather than owned by one session. Bound at Workspace scope. + * + * The client name announced to MCP servers — on initialize and on OAuth + * dynamic registration — is the identity snapshot's slug. Every manager it + * builds, the shared one and each session overlay, gates its connects on + * `identity.resolved()`, so the callback handed to the managers always reads + * the frozen snapshot: a connection (and the OAuth provider a remote server + * materializes, cached on the shared service) can never carry a pre-config + * name. */ import { Disposable } from '#/_base/di/lifecycle'; @@ -26,6 +34,7 @@ import { ILogService } from '#/_base/log/log'; import { McpConnectionManager } from '#/mcpCore/connection-manager'; import type { McpServerConfig } from '#/mcpCore/config-schema'; import { McpOAuthService } from '#/mcpCore/oauth/service'; +import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IMcpOAuthStore } from '#/app/mcpConfig/oauthStore'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { MergedMcpConnectionView } from '#/session/mcp/mergedConnectionView'; @@ -50,6 +59,7 @@ export class WorkspaceMcpService extends Disposable implements IWorkspaceMcpServ private readonly stdioCwd: string; readonly ready: Promise; private mutationTail: Promise = Promise.resolve(); + private readonly resolveClientName = (): string | undefined => this.identity.current().slug; constructor( @IWorkspaceContext workspace: IWorkspaceContext, @@ -57,15 +67,20 @@ export class WorkspaceMcpService extends Disposable implements IWorkspaceMcpServ @IMcpOAuthStore oauthStore: IMcpOAuthStore, @ILogService private readonly log: ILogService, @ITelemetryService private readonly telemetry: ITelemetryService, + @IAgentIdentity private readonly identity: IAgentIdentity, ) { super(); this.stdioCwd = workspace.cwd; - this.oauthService = new McpOAuthService({ store: oauthStore }); + this.oauthService = new McpOAuthService({ + store: oauthStore, + resolveClientName: this.resolveClientName, + }); this.manager = new McpConnectionManager({ log: this.log, oauthService: this.oauthService, stdioCwd: this.stdioCwd, resolveDefaultTimeouts: () => this.mcpConfig.tunables(), + resolveClientName: this.resolveClientName, }); this._register({ dispose: () => void this.manager.shutdown() }); this._register( @@ -99,10 +114,13 @@ export class WorkspaceMcpService extends Disposable implements IWorkspaceMcpServ oauthService: this.oauthService, stdioCwd: opts?.stdioCwd ?? this.stdioCwd, resolveDefaultTimeouts: () => this.mcpConfig.tunables(), + resolveClientName: this.resolveClientName, }); - const connect = sessionManager.connectAll({ ...servers }).catch((error: unknown) => { - this.log.error('session mcp overlay initial load failed', { error }); - }); + const connect = Promise.all([this.mcpConfig.ready, this.identity.resolved()]) + .then(() => sessionManager.connectAll({ ...servers })) + .catch((error: unknown) => { + this.log.error('session mcp overlay initial load failed', { error }); + }); const view = new MergedMcpConnectionView( this.manager, sessionManager, @@ -126,6 +144,7 @@ export class WorkspaceMcpService extends Disposable implements IWorkspaceMcpServ private async initialize(): Promise { await this.mcpConfig.ready; + await this.identity.resolved(); const servers = this.mcpConfig.servers(); if (Object.keys(servers).length === 0) return; await this.manager.connectAll(servers); diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 493e579781..f9d1416c44 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -121,8 +121,8 @@ describe('Agent loop', () => { [emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "" } [emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "