From b4ea5f018ef3ef2f6a1d7cdaeebf2eb8a8bfa927 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Thu, 29 Oct 2026 21:00:47 +0800 Subject: [PATCH 1/9] feat: add experimental multi-environment tool routing --- .changeset/multi-environment-routing.md | 5 + .../src/tui/reverse-rpc/approval/handler.ts | 18 +- .../test/tui/reverse-rpc/approval.test.ts | 16 ++ docs/en/guides/remote-environment.md | 15 ++ docs/zh/guides/remote-environment.md | 15 ++ .../agent-core-v2/docs/state-manifest.d.ts | 9 + .../agent-core-v2/docs/wire-manifest.d.ts | 1 + .../agentsMdReminderService.ts | 2 +- .../environmentBinding/agentEnvironment.ts | 86 ++++++-- .../environmentBinding/environmentRoster.ts | 69 ++++++ .../src/agent/environmentBinding/flag.ts | 16 ++ .../src/agent/media/registerMediaTools.ts | 1 + .../policies/environment-lease.ts | 3 +- .../policies/git-control-path-access-ask.ts | 5 +- .../policies/git-cwd-write-approve.ts | 8 +- .../policies/session-approval-history.ts | 5 +- .../agent/permissionRules/permissionRules.ts | 2 + .../permissionRules/permissionRulesOps.ts | 17 +- .../permissionRules/permissionRulesService.ts | 4 + .../src/agent/replayBuilder/fold.ts | 1 + .../toolActivation/toolActivationService.ts | 1 + .../agent/toolApproval/toolApprovalService.ts | 1 + .../toolExecutor/beforeToolExecuteEvent.ts | 2 + .../agent/toolExecutor/toolExecutorService.ts | 36 ++- .../src/agent/toolExecutor/toolHooks.ts | 2 + .../agent/toolRegistry/toolContribution.ts | 1 + .../src/agent/toolRegistry/toolRegistry.ts | 10 + .../agent/toolRegistry/toolRegistryService.ts | 52 ++++- .../src/agent/tools/edit/editTool.ts | 15 +- .../src/agent/tools/os/bash/bashTool.ts | 32 ++- .../src/agent/tools/os/glob/globTool.ts | 13 +- .../src/agent/tools/os/grep/grepTool.ts | 13 +- .../src/agent/tools/os/read/readTool.ts | 23 +- .../src/agent/tools/os/write/writeTool.ts | 15 +- .../read-media-file/readMediaFileTool.ts | 14 +- .../environmentDeclaration.ts | 1 + .../environmentDeclarationService.ts | 4 + .../src/features/fileHistory/fileHistory.ts | 2 +- .../fileHistory/fileHistoryService.ts | 34 ++- .../agentLifecycle/agentLifecycleService.ts | 1 + .../agent-core-v2/src/tool/toolContract.ts | 6 +- .../src/tool/toolInputDisplay.ts | 2 + .../agentsMdReminder/agentsMdReminder.test.ts | 14 ++ .../changeNotifier/changeNotifier.test.ts | 1 + .../environmentBindingService.test.ts | 176 ++++++++++++++- .../test/agent/media/tools/read-media.test.ts | 1 + .../permissionPolicyService.test.ts | 68 +++++- .../permissionRules/permissionRules.test.ts | 11 + .../test/agent/permissionRules/stubs.ts | 1 + .../agent/shellCommand/shellCommand.test.ts | 1 + .../toolActivationService.test.ts | 1 + .../agent/toolApproval/toolApproval.test.ts | 1 + .../agent/toolExecutor/toolExecutor.test.ts | 206 +++++++++++++++++- .../os/environmentGenerationGuard.test.ts | 94 ++++++++ .../agent-core-v2/test/environment/stubs.ts | 1 + .../features/fileHistory/fileHistory.test.ts | 66 +++++- packages/agent-core-v2/test/harness/agent.ts | 2 + .../os/backends/node-local/tools/read.test.ts | 1 + 58 files changed, 1102 insertions(+), 121 deletions(-) create mode 100644 .changeset/multi-environment-routing.md create mode 100644 packages/agent-core-v2/src/agent/environmentBinding/environmentRoster.ts create mode 100644 packages/agent-core-v2/src/agent/environmentBinding/flag.ts diff --git a/.changeset/multi-environment-routing.md b/.changeset/multi-environment-routing.md new file mode 100644 index 00000000000..1519e722bcf --- /dev/null +++ b/.changeset/multi-environment-routing.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add experimental per-call environment routing for file and shell tools. Enable it with `multi_environment = true` under `[experimental]` or `KIMI_CODE_EXPERIMENTAL_MULTI_ENVIRONMENT`. diff --git a/apps/kimi-code/src/tui/reverse-rpc/approval/handler.ts b/apps/kimi-code/src/tui/reverse-rpc/approval/handler.ts index 04b57892093..08e23df947c 100644 --- a/apps/kimi-code/src/tui/reverse-rpc/approval/handler.ts +++ b/apps/kimi-code/src/tui/reverse-rpc/approval/handler.ts @@ -19,12 +19,18 @@ export function createApprovalRequestHandler( try { const data = adaptApprovalRequest(event); // A badge lookup failure must never cancel the approval itself — it - // degrades to no badge. - let environment: string | undefined; - try { - environment = resolveEnvironment?.(); - } catch { - environment = undefined; + // degrades to no badge. A routed call's own environment takes precedence + // over the session's primary environment. + let environment = + event.display.kind === 'command' || event.display.kind === 'file_io' + ? event.display.environment + : undefined; + if (environment === undefined) { + try { + environment = resolveEnvironment?.(); + } catch { + environment = undefined; + } } const response = await controller.show( environment === undefined ? data : { ...data, environment }, diff --git a/apps/kimi-code/test/tui/reverse-rpc/approval.test.ts b/apps/kimi-code/test/tui/reverse-rpc/approval.test.ts index b741da7b020..5ea3d25cabf 100644 --- a/apps/kimi-code/test/tui/reverse-rpc/approval.test.ts +++ b/apps/kimi-code/test/tui/reverse-rpc/approval.test.ts @@ -157,6 +157,22 @@ describe('approval reverse-rpc', () => { ); }); + it('labels a routed call with its own environment instead of the session environment', async () => { + const controller = new ApprovalController(); + const show = vi.spyOn(controller, 'show').mockResolvedValue({ decision: 'approved' }); + const handler = createApprovalRequestHandler(controller, undefined, () => 'ssh:dev'); + + await handler(approvalEvent({ + display: { kind: 'command', command: 'pwd', cwd: '/work', environment: 'gpu' }, + })); + await handler(approvalEvent({ + display: { kind: 'file_io', operation: 'write', path: '/tmp/a.txt', environment: 'local' }, + })); + await handler(approvalEvent({ display: { kind: 'command', command: 'pwd' } })); + + expect(show.mock.calls.map(([data]) => data.environment)).toEqual(['gpu', 'local', 'ssh:dev']); + }); + it('tracks the requesting agent until approval settles or fails', async () => { const controller = new ApprovalController(); const show = vi.spyOn(controller, 'show').mockResolvedValue({ decision: 'approved' }); diff --git a/docs/en/guides/remote-environment.md b/docs/en/guides/remote-environment.md index dd18e596243..7175657005a 100644 --- a/docs/en/guides/remote-environment.md +++ b/docs/en/guides/remote-environment.md @@ -81,6 +81,21 @@ Resuming reads the persisted environment id and working directory and reconnects The footer shows the active environment's identity before the working directory; `local` renders nothing. A disconnected environment is shown with its failure reason, and the local git status slot is hidden for remote sessions. Approval prompts display the target environment next to the command or path, and `@` file completion queries the target filesystem. +## Multiple environments in one session + +The experimental multi-environment feature lets `Bash`, `Read`, `Write`, `Edit`, `Glob`, `Grep`, and `ReadMediaFile` choose a configured environment for each call. It is disabled by default. Enable it in `config.toml` alongside your [environment declarations](#declaring-environments): + +```toml +[experimental] +multi_environment = true +``` + +You can instead set `KIMI_CODE_EXPERIMENTAL_MULTI_ENVIRONMENT=true` for one process. When at least two environments are available, these tools gain an optional `environment` argument. Omitting it uses the session's primary environment; an unknown id fails the call rather than running it elsewhere. This enables the model to select environments — it does not add a manual environment selector to the TUI. + +All configured environments are candidates. The first call to another environment connects it lazily and uses its `defaultCwd`, or the working directory reported by the target when `defaultCwd` is unset. The primary keeps its existing working directory and additional directories. For example, a local session can inspect a remote checkout with `Read` using `environment = "dev-box"`, then run its tests with `Bash` in the same environment. + +Approval prompts show the environment for each call, and "allow for this session" approvals are remembered separately per environment. Tools outside the list above, skills, and project instructions (`AGENTS.md`) continue to use the primary environment or the local host as described elsewhere on this page. The model is asked to read a secondary project's `AGENTS.md` before modifying files there. + ## Disconnects and reconnecting Each environment id keeps one connection for the whole process. Workspaces bound to that id share it; a different id, including another id on the same machine, keeps its own. Two layers decide when the connection is dead: the SSH launcher's transport keepalive (`ServerAliveInterval`), and a protocol-level liveness probe — after about 30 seconds of idle time the client sends a `ping` to the executor, and no response within 10 seconds means the connection is dead. Any inbound traffic resets the idle clock so active transfers are never probed, and executors too old to know `ping` disable the probe automatically. A closed local pipe is also a disconnect signal. When the connection drops — network loss, a stopped container, or the executor exiting — every workspace using that id goes `disconnected` at once, and the processes it started on the target end with the connection. diff --git a/docs/zh/guides/remote-environment.md b/docs/zh/guides/remote-environment.md index afaae2e583a..8be8a870f08 100644 --- a/docs/zh/guides/remote-environment.md +++ b/docs/zh/guides/remote-environment.md @@ -81,6 +81,21 @@ kimi --environment dev-box --environment-cwd /home/me/projects -p "Run the test 底部状态栏会在工作目录前显示当前环境标识;`local` 不渲染。环境断开时显示失败原因,远程会话中本机 git 状态槽会隐藏。审批面板会在命令或路径旁显示目标环境标识,`@` 文件补全查询目标文件系统。 +## 在一个会话中使用多个环境 + +实验性的多环境功能允许 `Bash`、`Read`、`Write`、`Edit`、`Glob`、`Grep` 和 `ReadMediaFile` 在每次调用时选择一个已配置的环境。该功能默认关闭,在 `config.toml` 中与[环境声明](#声明环境)一起启用: + +```toml +[experimental] +multi_environment = true +``` + +也可以仅为当前进程设置 `KIMI_CODE_EXPERIMENTAL_MULTI_ENVIRONMENT=true`。至少有两个可用环境时,这些工具会增加可选的 `environment` 参数;省略时使用会话的主环境,传入未知 id 会让调用失败,不会改到别处执行。模型可以通过这个参数选择环境;TUI 目前没有提供手动选择环境的界面。 + +所有已配置的环境都是候选项。首次使用另一个环境时才建立连接,工作目录使用它的 `defaultCwd`;未设置时使用目标环境报告的工作目录。主环境保留原有的工作目录和附加目录。例如,本地会话可以让 `Read` 通过 `environment = "dev-box"` 查看远端代码,再让 `Bash` 在同一环境运行测试。 + +审批面板会显示每次调用所在的环境,「本会话内允许」的审批按环境分别记忆。上述列表之外的工具、skills 和项目说明(`AGENTS.md`)仍按本页其他章节的规则使用主环境或本机。模型会先读取次环境项目的 `AGENTS.md`,再修改其中的文件。 + ## 断线与重连 每个环境 id 在整个进程里只有一条连接。绑到这个 id 的工作区共用它;另一个 id,哪怕在同一台机器上,仍是各自的连接。连接是否已死由两层机制判断:SSH 启动器带 `ServerAliveInterval` 的传输层保活,以及协议层的存活探测——连接空闲约 30 秒时本机向执行器发送 `ping`,10 秒内未收到响应即判定断线;任何入站流量都会重置空闲计时,活跃传输不会被误判,不认识 `ping` 的旧版本执行器会自动停用该探测。本地管道关闭也是断线信号。连接断开时——网络中断、容器停止、执行器退出——使用这个 id 的所有工作区同时进入 `disconnected`,它在目标上启动的进程随连接结束。 diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 35b743a587d..89cacfff882 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -879,6 +879,7 @@ export interface AgentStateSnapshot { cwd?: string; description?: string; language?: 'bash'; + environment?: string; } | { kind: 'file_io'; operation: 'read' | 'write' | 'edit' | 'glob' | 'grep'; @@ -887,6 +888,7 @@ export interface AgentStateSnapshot { content?: string; before?: string; after?: string; + environment?: string; } | { kind: 'diff'; path: string; @@ -1092,6 +1094,7 @@ export interface AgentStateSnapshot { cwd?: string; description?: string; language?: 'bash'; + environment?: string; } | { kind: 'file_io'; operation: 'read' | 'write' | 'edit' | 'glob' | 'grep'; @@ -1100,6 +1103,7 @@ export interface AgentStateSnapshot { content?: string; before?: string; after?: string; + environment?: string; } | { kind: 'diff'; path: string; @@ -1314,6 +1318,7 @@ export interface AgentStateSnapshot { cwd?: string; description?: string; language?: 'bash'; + environment?: string; } | { kind: 'file_io'; operation: 'read' | 'write' | 'edit' | 'glob' | 'grep'; @@ -1322,6 +1327,7 @@ export interface AgentStateSnapshot { content?: string; before?: string; after?: string; + environment?: string; } | { kind: 'diff'; path: string; @@ -1528,6 +1534,7 @@ export interface AgentStateSnapshot { cwd?: string; description?: string; language?: 'bash'; + environment?: string; } | { kind: 'file_io'; operation: 'read' | 'write' | 'edit' | 'glob' | 'grep'; @@ -1536,6 +1543,7 @@ export interface AgentStateSnapshot { content?: string; before?: string; after?: string; + environment?: string; } | { kind: 'diff'; path: string; @@ -1790,6 +1798,7 @@ export interface AgentStateSnapshot { readonly reason?: string; }[]; readonly sessionApprovalRulePatterns: readonly string[]; + readonly environmentSessionApprovalRulePatterns?: Readonly>; }; // src/agent/plugin/agentPluginOps.ts // replayable · durable — folds: PluginSessionStartEvent diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 05d372bef8f..3f150eab3de 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -498,6 +498,7 @@ interface PermissionRecordApprovalResultPayload { toolName: string; action: string; sessionApprovalRule?: string; + environmentId?: string; result: PermissionApprovalResultRecord['result']; } diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts index 6bdca1e2306..3c88c1ebef7 100644 --- a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -108,7 +108,7 @@ export class AgentAgentsMdReminderService }), ); const handler = async (ctx: ToolDidExecuteContext, next: () => Promise): Promise => { - await this.probeAndRemind(ctx); + if (ctx.environmentId === undefined) await this.probeAndRemind(ctx); await next(); }; this._register(toolExecutor.hooks.onDidExecuteTool.register('agentsMdReminder', handler)); diff --git a/packages/agent-core-v2/src/agent/environmentBinding/agentEnvironment.ts b/packages/agent-core-v2/src/agent/environmentBinding/agentEnvironment.ts index e7541315058..e020f1917fe 100644 --- a/packages/agent-core-v2/src/agent/environmentBinding/agentEnvironment.ts +++ b/packages/agent-core-v2/src/agent/environmentBinding/agentEnvironment.ts @@ -3,11 +3,14 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; import type { IDisposable } from '#/_base/di/lifecycle'; import { ISessionEventBus } from '#/app/event/eventBus'; +import { IEnvironmentDeclarationService } from '#/app/environmentDeclaration/environmentDeclaration'; +import { IFlagService } from '#/app/flag/flag'; import { LifecycleScope } from '#/app/scopes'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import type { Environment, EnvironmentBinding, EnvironmentCapability, EnvironmentLease, EnvironmentPath, EnvironmentWorkspaceRoots } from '#/environment/environment'; import { LOCAL_ENVIRONMENT_ID } from '#/environment/environment'; -import { environmentIsReady, type EnvironmentGenerationSnapshot, type EnvironmentRegistryChange } from '#/environment/environmentRegistry'; +import { normalizeRemoteCwd } from '#/environment/environmentDefaults'; +import { EnvironmentError, environmentIsReady, type EnvironmentGenerationSnapshot, type EnvironmentRegistryChange } from '#/environment/environmentRegistry'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -19,6 +22,7 @@ import { IEnvironmentService } from '#/app/environment/environment'; import { IAgentEnvironmentBindingService } from './environmentBinding'; import { EnvironmentStatusChanged } from './environmentEvents'; +import { MULTI_ENVIRONMENT_FLAG_ID } from './flag'; export interface AgentEnvironmentBindingSnapshot { readonly binding: EnvironmentBinding; @@ -26,14 +30,21 @@ export interface AgentEnvironmentBindingSnapshot { readonly environment?: EnvironmentGenerationSnapshot; } +export interface AgentEnvironmentTarget { + readonly environmentId: string; + readonly primary: boolean; + readonly cwd?: string; +} + export interface IAgentEnvironmentService { readonly _serviceBrand: undefined; readonly onDidChange: Event; - inspect(): Environment; - isAvailable(required?: readonly EnvironmentCapability[]): boolean; - acquire(required?: readonly EnvironmentCapability[]): EnvironmentLease; - acquireWhenReady(required?: readonly EnvironmentCapability[]): Promise; - workspaceRoots(): EnvironmentWorkspaceRoots; + targets(): readonly AgentEnvironmentTarget[]; + inspect(environmentId?: string): Environment; + isAvailable(required?: readonly EnvironmentCapability[], environmentId?: string): boolean; + acquire(required?: readonly EnvironmentCapability[], environmentId?: string): EnvironmentLease; + acquireWhenReady(required?: readonly EnvironmentCapability[], environmentId?: string): Promise; + workspaceRoots(environmentId?: string): EnvironmentWorkspaceRoots; } export const IAgentEnvironmentService: ServiceIdentifier = @@ -42,9 +53,10 @@ export const IAgentEnvironmentService: ServiceIdentifier { - if (service.isAvailable(required)) return service.acquire(required); - return service.acquireWhenReady(required); + if (service.isAvailable(required, environmentId)) return service.acquire(required, environmentId); + return service.acquireWhenReady(required, environmentId); } export function pinnedGeneration(environment: Environment): string | undefined { @@ -119,6 +131,8 @@ export class AgentEnvironmentService implements IAgentEnvironmentService { @ISessionEventBus private readonly eventBus: ISessionEventBus, @ISessionContext private readonly session: ISessionContext, @ISessionStateService private readonly sessionState: ISessionStateService, + @IFlagService private readonly flags?: IFlagService, + @IEnvironmentDeclarationService private readonly declarations?: IEnvironmentDeclarationService, ) { this.bindingSubscription = this.binding.onDidChange(() => this.changeEmitter.fire()); this.registrySubscription = this.resolver.onDidChange((change) => { @@ -129,12 +143,31 @@ export class AgentEnvironmentService implements IAgentEnvironmentService { }); } - inspect(): Environment { - return this.resolver.inspect(this.binding.current); + targets(): readonly AgentEnvironmentTarget[] { + const current = this.binding.current; + const primary: AgentEnvironmentTarget = { environmentId: current.environmentId, primary: true, cwd: current.cwd }; + if (this.declarations === undefined || this.flags?.enabled(MULTI_ENVIRONMENT_FLAG_ID) !== true) return [primary]; + const declared = new Map( + this.declarations.snapshot().entries.map((declaration) => [declaration.id, declaration.entry.defaultCwd]), + ); + if (current.environmentId !== LOCAL_ENVIRONMENT_ID && !declared.has(current.environmentId)) return [primary]; + const secondary = [LOCAL_ENVIRONMENT_ID, ...declared.keys()] + .filter((environmentId) => environmentId !== current.environmentId && this.resolver.current(environmentId) !== undefined) + .toSorted((left, right) => left.localeCompare(right)) + .map((environmentId): AgentEnvironmentTarget => ({ + environmentId, + primary: false, + cwd: environmentId === LOCAL_ENVIRONMENT_ID ? this.session.cwd : declared.get(environmentId), + })); + return [primary, ...secondary]; + } + + inspect(environmentId?: string): Environment { + return this.resolver.inspect(this.bindingFor(environmentId)); } - workspaceRoots(): EnvironmentWorkspaceRoots { - const binding = this.binding.current; + workspaceRoots(environmentId?: string): EnvironmentWorkspaceRoots { + const binding = this.bindingFor(environmentId); let workDir = binding.cwd; if (workDir === undefined && binding.environmentId !== LOCAL_ENVIRONMENT_ID) { try { @@ -144,27 +177,44 @@ export class AgentEnvironmentService implements IAgentEnvironmentService { workDir = undefined; } } + if (binding.environmentId !== this.binding.current.environmentId) { + if (workDir === undefined) { + throw new EnvironmentError('environment.unavailable', `environment ${binding.environmentId} has no working directory until it connects`); + } + return { workDir, additionalDirs: [] }; + } return { workDir: workDir ?? this.session.cwd, additionalDirs: this.sessionState.get(workspaceContextAdditionalDirsKey), }; } - isAvailable(required: readonly EnvironmentCapability[] = []): boolean { + isAvailable(required: readonly EnvironmentCapability[] = [], environmentId?: string): boolean { try { - const environment = this.inspect(); + const environment = this.inspect(environmentId); return environmentIsReady(environment) && required.every((capability) => environment.capabilities.has(capability)); } catch { return false; } } - acquire(required: readonly EnvironmentCapability[] = []): EnvironmentLease { - return this.resolver.acquire(this.binding.current, required); + acquire(required: readonly EnvironmentCapability[] = [], environmentId?: string): EnvironmentLease { + return this.resolver.acquire(this.bindingFor(environmentId), required); } - async acquireWhenReady(required: readonly EnvironmentCapability[] = []): Promise { - return this.resolver.acquireWhenReady(this.binding.current, required); + async acquireWhenReady(required: readonly EnvironmentCapability[] = [], environmentId?: string): Promise { + return this.resolver.acquireWhenReady(this.bindingFor(environmentId), required); + } + + private bindingFor(environmentId: string | undefined): EnvironmentBinding { + const current = this.binding.current; + if (environmentId === undefined || environmentId === current.environmentId) return current; + const target = this.targets().find((candidate) => candidate.environmentId === environmentId); + if (target === undefined) { + throw new EnvironmentError('environment.not_found', `environment ${environmentId} is not available to this agent`); + } + if (target.cwd === undefined || environmentId === LOCAL_ENVIRONMENT_ID) return { environmentId, cwd: target.cwd }; + return { environmentId, cwd: normalizeRemoteCwd(target.cwd) }; } dispose(): void { diff --git a/packages/agent-core-v2/src/agent/environmentBinding/environmentRoster.ts b/packages/agent-core-v2/src/agent/environmentBinding/environmentRoster.ts new file mode 100644 index 00000000000..046b4e437ae --- /dev/null +++ b/packages/agent-core-v2/src/agent/environmentBinding/environmentRoster.ts @@ -0,0 +1,69 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { Disposable } from '#/_base/di/lifecycle'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { IAgentReminderService } from '#/features/reminder/reminderService'; + +import { IAgentEnvironmentService } from './agentEnvironment'; + +export const ENVIRONMENT_ROSTER_REMINDER_VARIANT = 'environment_roster'; + +const SINGLE_ENVIRONMENT_DISCLOSURE = 'single'; + +const ROSTER_GUIDANCE = + 'Tools with an `environment` argument run in the environment you name; omit it to use the primary environment. ' + + 'Each environment has its own files, shell, and installed programs, and relative paths resolve against its working directory. ' + + 'A remote environment connects on its first use; if it cannot connect, the call fails instead of running elsewhere. ' + + "The system prompt's working directory, AGENTS.md instructions, and skills describe the primary environment only; " + + "read a project's AGENTS.md before changing files in another environment."; + +export interface IAgentEnvironmentRosterService { + readonly _serviceBrand: undefined; +} + +export const IAgentEnvironmentRosterService: ServiceIdentifier = + createDecorator('agentEnvironmentRosterService'); + +export class AgentEnvironmentRosterService extends Disposable implements IAgentEnvironmentRosterService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentReminderService reminder: IAgentReminderService, + @IAgentEnvironmentService environment: IAgentEnvironmentService, + ) { + super(); + this._register( + reminder.register(ENVIRONMENT_ROSTER_REMINDER_VARIANT, ({ lastInjection, lastDisclosure }) => { + const targets = environment.targets(); + if (targets.length < 2) { + if (lastInjection === undefined || lastDisclosure === SINGLE_ENVIRONMENT_DISCLOSURE) return undefined; + return { + content: `Only the primary environment \`${targets[0]!.environmentId}\` is available now. Tools no longer take an \`environment\` argument.`, + disclosure: SINGLE_ENVIRONMENT_DISCLOSURE, + }; + } + const lines = targets.map((target) => { + const label = target.primary ? `\`${target.environmentId}\` (primary)` : `\`${target.environmentId}\``; + const cwd = target.primary ? environment.workspaceRoots().workDir : target.cwd; + return cwd === undefined + ? `- ${label}: working directory resolved on first use` + : `- ${label}: working directory ${cwd}`; + }); + const disclosure = lines.join('\n'); + if (lastInjection !== undefined && lastDisclosure === disclosure) return undefined; + return { + content: ['Execution environments available in this session:', ...lines, ROSTER_GUIDANCE].join('\n'), + disclosure, + }; + }), + ); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentEnvironmentRosterService, + AgentEnvironmentRosterService, + ScopeActivation.OnScopeCreated, + 'agentEnvironmentBinding', +); diff --git a/packages/agent-core-v2/src/agent/environmentBinding/flag.ts b/packages/agent-core-v2/src/agent/environmentBinding/flag.ts new file mode 100644 index 00000000000..7ef038358ae --- /dev/null +++ b/packages/agent-core-v2/src/agent/environmentBinding/flag.ts @@ -0,0 +1,16 @@ +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const MULTI_ENVIRONMENT_FLAG_ID = 'multi_environment'; +export const MULTI_ENVIRONMENT_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_MULTI_ENVIRONMENT'; + +export const multiEnvironmentFlag: FlagDefinitionInput = { + id: MULTI_ENVIRONMENT_FLAG_ID, + title: 'Multi-environment sessions', + description: + 'Let file and shell tools target any registered environment per call through an environment argument; remote environments connect on first use.', + env: MULTI_ENVIRONMENT_FLAG_ENV, + default: false, + surface: 'core', +}; + +registerFlagDefinition(multiEnvironmentFlag); diff --git a/packages/agent-core-v2/src/agent/media/registerMediaTools.ts b/packages/agent-core-v2/src/agent/media/registerMediaTools.ts index d159518910c..bc65c9704e8 100644 --- a/packages/agent-core-v2/src/agent/media/registerMediaTools.ts +++ b/packages/agent-core-v2/src/agent/media/registerMediaTools.ts @@ -43,6 +43,7 @@ export function registerMediaTools( deps.providerType, deps.attachmentStore, ), + { environmentScoped: true }, ); } diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/environment-lease.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/environment-lease.ts index 8ff3b0b6092..534c20c20c8 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/environment-lease.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/environment-lease.ts @@ -5,9 +5,10 @@ import type { IAgentEnvironmentService } from '#/agent/environmentBinding/agentE export function acquireEnvironmentLease( environment: IAgentEnvironmentService, required: readonly EnvironmentCapability[] = [], + environmentId?: string, ): EnvironmentLease | undefined { try { - return environment.acquire(required); + return environment.acquire(required, environmentId); } catch (error) { if (error instanceof EnvironmentError) return undefined; throw error; diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts index 454638bf2e5..1217f6be963 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts @@ -29,9 +29,10 @@ export class GitControlPathAccessAskPermissionPolicyService implements Permissio context: ResolvedToolExecutionHookContext, ): Promise { if (!isDangerousCommandGuardEnabled(this.config)) return undefined; - const cwd = this.workspace.workDir; + const environmentId = context.environmentId; + const cwd = environmentId === undefined ? this.workspace.workDir : this.environment.workspaceRoots(environmentId).workDir; if (cwd.length === 0) return undefined; - const lease = acquireEnvironmentLease(this.environment); + const lease = acquireEnvironmentLease(this.environment, [], environmentId); if (lease === undefined) return undefined; try { const pathClass = lease.environment.host?.pathClass; diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts index b744d7a21f9..51fa422aea8 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts @@ -24,7 +24,8 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli ): Promise { const toolName = context.toolCall.name; if (toolName !== 'Write' && toolName !== 'Edit') return undefined; - const lease = acquireEnvironmentLease(this.environment); + const environmentId = context.environmentId; + const lease = acquireEnvironmentLease(this.environment, [], environmentId); if (lease === undefined) return undefined; try { const pathClass = lease.environment.host?.pathClass; @@ -32,7 +33,8 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli const fs = lease.environment.fs; if (fs === undefined) return undefined; - const cwd = this.workspace.workDir; + const roots = environmentId === undefined ? this.workspace : this.environment.workspaceRoots(environmentId); + const cwd = roots.workDir; if (cwd.length === 0) return undefined; const writeAccesses = writeFileAccesses(context); @@ -41,7 +43,7 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli !writeAccesses.every((access) => isWithinWorkspace( access.path, - { workspaceDir: cwd, additionalDirs: this.workspace.additionalDirs }, + { workspaceDir: cwd, additionalDirs: roots.additionalDirs ?? [] }, 'posix', ), ) diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/session-approval-history.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/session-approval-history.ts index c299c3895d7..6c5628191d6 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/session-approval-history.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/session-approval-history.ts @@ -14,7 +14,10 @@ export class SessionApprovalHistoryPermissionPolicyService implements Permission ) {} evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { - for (const pattern of this.rulesService.sessionApprovalRulePatterns) { + const patterns = context.environmentId === undefined + ? this.rulesService.sessionApprovalRulePatterns + : this.rulesService.environmentSessionApprovalRulePatterns(context.environmentId); + for (const pattern of patterns) { const match = matchPermissionRule({ rule: { decision: 'allow', diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts index f524b5b4166..faa563b0568 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts @@ -7,6 +7,7 @@ export interface PermissionApprovalResultRecord { readonly toolName: string; readonly action: string; readonly sessionApprovalRule?: string; + readonly environmentId?: string; readonly result: ApprovalResponse; } @@ -26,6 +27,7 @@ export interface IAgentPermissionRulesService { readonly rules: readonly PermissionRule[]; readonly sessionApprovalRulePatterns: readonly string[]; + environmentSessionApprovalRulePatterns(environmentId: string): readonly string[]; addRules(rules: readonly PermissionRule[]): void; recordApprovalResult(record: PermissionApprovalResultRecord): void; } diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts index bf32582e78e..b7285688f80 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts @@ -9,6 +9,7 @@ import type { PermissionApprovalResultRecord, PermissionRule } from './permissio export interface PermissionRulesModelState { readonly rules: readonly PermissionRule[]; readonly sessionApprovalRulePatterns: readonly string[]; + readonly environmentSessionApprovalRulePatterns?: Readonly>; } const permissionRulesAddSchema = z.object({ @@ -31,6 +32,7 @@ const permissionRecordApprovalResultSchema = z.object({ toolName: z.string(), action: z.string(), sessionApprovalRule: z.string().optional(), + environmentId: z.string().optional(), result: z.custom(), }); @@ -58,13 +60,14 @@ export const permissionRulesKey = defineState( }) .on(PermissionRecordApprovalResult, (s, e) => { const pattern = e.sessionApprovalRule; - if ( - e.result.decision !== 'approved' || - e.result.scope !== 'session' || - pattern === undefined || - s.sessionApprovalRulePatterns.includes(pattern) - ) { + if (e.result.decision !== 'approved' || e.result.scope !== 'session' || pattern === undefined) return; + if (e.environmentId === undefined) { + if (s.sessionApprovalRulePatterns.includes(pattern)) return; + s.sessionApprovalRulePatterns = [...s.sessionApprovalRulePatterns, pattern]; return; } - s.sessionApprovalRulePatterns = [...s.sessionApprovalRulePatterns, pattern]; + const byEnvironment = s.environmentSessionApprovalRulePatterns ?? {}; + const patterns = byEnvironment[e.environmentId] ?? []; + if (patterns.includes(pattern)) return; + s.environmentSessionApprovalRulePatterns = { ...byEnvironment, [e.environmentId]: [...patterns, pattern] }; }); diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts index 046ed8c9674..84a8d5741fa 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts @@ -35,6 +35,10 @@ export class AgentPermissionRulesService implements IAgentPermissionRulesService return [...this.agentState.get(permissionRulesKey).sessionApprovalRulePatterns]; } + environmentSessionApprovalRulePatterns(environmentId: string): readonly string[] { + return [...(this.agentState.get(permissionRulesKey).environmentSessionApprovalRulePatterns?.[environmentId] ?? [])]; + } + addRules(rules: readonly PermissionRule[]): void { if (rules.length === 0) return; void this.dispatcher.dispatch( diff --git a/packages/agent-core-v2/src/agent/replayBuilder/fold.ts b/packages/agent-core-v2/src/agent/replayBuilder/fold.ts index faf14c7aa7b..4ddef958d7e 100644 --- a/packages/agent-core-v2/src/agent/replayBuilder/fold.ts +++ b/packages/agent-core-v2/src/agent/replayBuilder/fold.ts @@ -481,6 +481,7 @@ class WireReplayFoldState { toolName: record['toolName'] as string, action: record['action'] as string, sessionApprovalRule: readString(record, 'sessionApprovalRule'), + environmentId: readString(record, 'environmentId'), result: record['result'] as PermissionApprovalResultRecord['result'], }; this.push({ type: 'approval_result', record: approval }, time); diff --git a/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts b/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts index bd3d2ea76cf..143c7349a96 100644 --- a/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts +++ b/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts @@ -75,6 +75,7 @@ export class AgentToolActivationService extends Service implements IAgentToolAct const registration = this.toolRegistry.register(tool, { source: options.source, disclosure: options.disclosure, + environmentScoped: options.environmentScoped, }); this.registrations.set(record, registration); this._register(registration); diff --git a/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts b/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts index 30d6037807b..23b07cf261a 100644 --- a/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts +++ b/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts @@ -211,6 +211,7 @@ export class AgentToolApprovalService extends Service implements IAgentToolAppro toolName: name, action, sessionApprovalRule, + environmentId: context.environmentId, result: response, }); this.telemetry.track2('permission_approval_result', { diff --git a/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts b/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts index 0ccc3e02373..deaa082fc52 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts @@ -28,6 +28,7 @@ export class BeforeToolExecuteEventImpl implements BeforeToolExecuteEvent { readonly toolCalls: readonly ToolCall[]; readonly tool?: ExecutableTool | undefined; readonly args: unknown; + readonly environmentId?: string; readonly execution: RunnableToolExecution; private _vetoResult: ExecutableToolResult | undefined; @@ -44,6 +45,7 @@ export class BeforeToolExecuteEventImpl implements BeforeToolExecuteEvent { this.toolCalls = context.toolCalls; this.tool = context.tool; this.args = context.args; + this.environmentId = context.environmentId; this.execution = context.execution; } diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index e3411505a3c..be0e298c049 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -388,7 +388,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { let execution: ToolExecution; try { - execution = await call.tool.resolveExecution(call.args); + execution = await call.tool.resolveExecution(call.input, { environmentId: call.environmentId }); } catch (error) { const output = error instanceof PathSecurityError @@ -396,6 +396,13 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { : `Tool "${call.toolName}" failed to resolve execution: ${errorMessage(error)}`; return settleError(call.args, output, 'resolution-failed'); } + if ( + call.environmentId !== undefined && + execution.isError !== true && + (execution.display?.kind === 'command' || execution.display?.kind === 'file_io') + ) { + execution = { ...execution, display: { ...execution.display, environment: call.environmentId } }; + } const displayFields = toolCallDisplayFieldsFromExecution(execution); @@ -427,6 +434,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { toolCall: call.toolCall, execution, args: call.args, + environmentId: call.environmentId, }, options.signal, ); @@ -640,6 +648,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { toolCalls: [call.toolCall], tool: call.kind === 'runnable' ? call.tool : undefined, args: call.args, + environmentId: call.kind === 'runnable' ? call.environmentId : undefined, outcome, accesses: resolvedAccesses, result: result as ExecutableToolResult, @@ -689,6 +698,8 @@ interface RunnableToolCall { readonly toolName: string; readonly tool: ExecutableTool; readonly args: unknown; + readonly input: unknown; + readonly environmentId?: string; } interface RejectedToolCall { @@ -725,6 +736,7 @@ function buildBeforeExecuteContext( toolCalls: allCalls, tool: call.tool, args: call.args, + environmentId: call.environmentId, execution, }; } @@ -778,7 +790,17 @@ function preflightToolCall( output: unavailable, }; } - const validationError = validateExecutableToolArgs(tool, parsedArgs.data); + const route = toolRegistry.routeEnvironment(toolName, parsedArgs.data); + if (route.error !== undefined) { + return { + kind: 'rejected', + toolCall, + toolName, + args: parsedArgs.data, + output: route.error, + }; + } + const validationError = validateExecutableToolArgs(tool, route.args); if (validationError !== null) { return { kind: 'rejected', @@ -788,7 +810,15 @@ function preflightToolCall( output: `Invalid args for tool "${toolName}": ${validationError}`, }; } - return { kind: 'runnable', toolCall, toolName, tool, args: parsedArgs.data }; + return { + kind: 'runnable', + toolCall, + toolName, + tool, + args: parsedArgs.data, + input: route.args, + environmentId: route.environmentId, + }; } function validateExecutableToolArgs(tool: ExecutableTool, args: unknown): string | null { diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts index 113ede36e57..7d768f37b81 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts @@ -17,6 +17,7 @@ export interface ToolExecutionHookContext { readonly toolCalls: readonly ToolCall[]; readonly tool?: ExecutableTool | undefined; readonly args: unknown; + readonly environmentId?: string; } export interface ResolvedToolExecutionHookContext extends ToolExecutionHookContext { @@ -40,6 +41,7 @@ export interface WillExecuteToolEvent extends IWaitUntil { readonly toolCall: ToolCall; readonly execution: RunnableToolExecution; readonly args: unknown; + readonly environmentId?: string; } export type ToolExecutionOutcome = diff --git a/packages/agent-core-v2/src/agent/toolRegistry/toolContribution.ts b/packages/agent-core-v2/src/agent/toolRegistry/toolContribution.ts index d14ce59c5eb..d1fc3846486 100644 --- a/packages/agent-core-v2/src/agent/toolRegistry/toolContribution.ts +++ b/packages/agent-core-v2/src/agent/toolRegistry/toolContribution.ts @@ -19,6 +19,7 @@ export interface AgentToolContributionOptions { readonly disclosure?: ToolDisclosure; readonly when?: (accessor: ServicesAccessor) => boolean; readonly requiredEnvironmentCapabilities?: readonly EnvironmentCapability[]; + readonly environmentScoped?: boolean; readonly domain?: string; } diff --git a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistry.ts b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistry.ts index 10276a7859d..c06e8ccaa2f 100644 --- a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistry.ts +++ b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistry.ts @@ -10,6 +10,7 @@ import type { export interface ToolRegistrationOptions { readonly source?: ToolSource; readonly disclosure?: ToolDisclosure; + readonly environmentScoped?: boolean; } export interface ToolReference { @@ -17,6 +18,14 @@ export interface ToolReference { readonly source: ToolSource; } +export interface ToolEnvironmentRoute { + readonly args: unknown; + readonly environmentId?: string; + readonly error?: string; +} + +export const ENVIRONMENT_ARGUMENT = 'environment'; + export interface IAgentToolRegistryService { readonly _serviceBrand: undefined; @@ -24,6 +33,7 @@ export interface IAgentToolRegistryService { list(): readonly ToolInfo[]; listReferences(): readonly ToolReference[]; resolve(name: string): ExecutableTool | undefined; + routeEnvironment(name: string, args: unknown): ToolEnvironmentRoute; } export const IAgentToolRegistryService = createDecorator('agentToolRegistryService'); diff --git a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts index a643d9921ab..b450297ac95 100644 --- a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts +++ b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts @@ -1,6 +1,9 @@ +import { ref, type LiveRef } from '#/_base/di/instantiation'; import { toDisposable, type IDisposable } from "#/_base/di/lifecycle"; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { isPlainRecord } from '#/_base/utils/canonical-args'; +import { IAgentEnvironmentService } from '#/agent/environmentBinding/agentEnvironment'; import type { ExecutableTool, ToolDisclosure, @@ -8,7 +11,9 @@ import type { ToolSource, } from '#/tool/toolContract'; import { + ENVIRONMENT_ARGUMENT, IAgentToolRegistryService, + type ToolEnvironmentRoute, type ToolReference, type ToolRegistrationOptions, } from './toolRegistry'; @@ -19,15 +24,25 @@ interface ToolEntry { readonly tool: ExecutableTool; readonly source: ToolSource; readonly disclosure?: ToolDisclosure; + readonly environmentScoped: boolean; } export class AgentToolRegistryService implements IAgentToolRegistryService { declare readonly _serviceBrand: undefined; private readonly tools = new Map(); + constructor( + @ref(IAgentEnvironmentService) private readonly environment?: LiveRef, + ) {} + register(tool: ExecutableTool, options: ToolRegistrationOptions = {}): IDisposable { const source = options.source ?? 'builtin'; - const entry: ToolEntry = { tool, source, disclosure: options.disclosure }; + const entry: ToolEntry = { + tool, + source, + disclosure: options.disclosure, + environmentScoped: options.environmentScoped === true, + }; this.unregisterTool(tool.name); this.tools.set(tool.name, entry); @@ -39,11 +54,25 @@ export class AgentToolRegistryService implements IAgentToolRegistryService { } list(): readonly ToolInfo[] { + const targets = this.environment?.current?.targets() ?? []; + const environmentSchema = targets.length < 2 + ? undefined + : { + type: 'string', + enum: targets.map((target) => target.environmentId), + description: 'Environment to run this call in. Omit to use the primary environment.', + }; return [...this.tools.values()] - .map(({ tool, source, disclosure }) => ({ + .map(({ tool, source, disclosure, environmentScoped }) => ({ name: tool.name, description: tool.description, - parameters: tool.parameters, + parameters: + environmentScoped && environmentSchema !== undefined && isPlainRecord(tool.parameters['properties']) + ? { + ...tool.parameters, + properties: { ...tool.parameters['properties'], [ENVIRONMENT_ARGUMENT]: environmentSchema }, + } + : tool.parameters, source, disclosure, })) @@ -60,6 +89,23 @@ export class AgentToolRegistryService implements IAgentToolRegistryService { return this.tools.get(name)?.tool; } + routeEnvironment(name: string, args: unknown): ToolEnvironmentRoute { + if (this.tools.get(name)?.environmentScoped !== true || !isPlainRecord(args) || !Object.hasOwn(args, ENVIRONMENT_ARGUMENT)) { + return { args }; + } + const targets = this.environment?.current?.targets() ?? []; + if (targets.length < 2) return { args }; + const { [ENVIRONMENT_ARGUMENT]: requested, ...rest } = args; + const target = targets.find((candidate) => candidate.environmentId === requested); + if (target === undefined) { + const available = targets + .map((candidate) => (candidate.primary ? `${candidate.environmentId} (primary)` : candidate.environmentId)) + .join(', '); + return { args, error: `Unknown environment ${JSON.stringify(requested)}. Available environments: ${available}.` }; + } + return { args: rest, environmentId: target.primary ? undefined : target.environmentId }; + } + private unregisterTool(name: string): ToolEntry | undefined { const entry = this.tools.get(name); if (entry === undefined) return undefined; diff --git a/packages/agent-core-v2/src/agent/tools/edit/editTool.ts b/packages/agent-core-v2/src/agent/tools/edit/editTool.ts index a51c9e5e858..52abf9fa370 100644 --- a/packages/agent-core-v2/src/agent/tools/edit/editTool.ts +++ b/packages/agent-core-v2/src/agent/tools/edit/editTool.ts @@ -15,6 +15,7 @@ import { ToolAccesses, type ExecutableToolResult, type ToolExecution, + type ToolResolutionContext, } from '#/tool/toolContract'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; @@ -39,8 +40,9 @@ export class EditTool implements IEditTool { return { workspaceDir: view.workDir, additionalDirs: view.additionalDirs }; } - async resolveExecution(args: EditInput): Promise { - const planTarget = await this.planService?.resolveFile(args.path); + async resolveExecution(args: EditInput, context?: ToolResolutionContext): Promise { + const environmentId = context?.environmentId; + const planTarget = environmentId === undefined ? await this.planService?.resolveFile(args.path) : undefined; if (planTarget !== undefined) { const path = planTarget.path; return { @@ -58,17 +60,17 @@ export class EditTool implements IEditTool { }, }; } - const resolutionLease = await acquireOrWhenReady(this.environment, ['fs']); + const resolutionLease = await acquireOrWhenReady(this.environment, ['fs'], environmentId); try { const inspected = resolutionLease.environment; const expectedGeneration = pinnedGeneration(inspected); - const view = new EnvironmentWorkspaceView(inspected, { + const view = new EnvironmentWorkspaceView(inspected, environmentId === undefined ? { workDir: this.workspaceCtx.workDir, additionalDirs: [ ...this.workspaceCtx.additionalDirs, ...(this.skillCatalog?.catalog.getSkillRoots() ?? []), ], - }); + } : this.environment.workspaceRoots(environmentId)); const env = view.host; const workspace = this.workspaceConfig(view); const path = resolvePathAccessPath(args.path, { @@ -94,7 +96,7 @@ export class EditTool implements IEditTool { homeDir: env.homeDir, }), execute: async () => { - const lease = await acquireOrWhenReady(this.environment, ['fs']); + const lease = await acquireOrWhenReady(this.environment, ['fs'], environmentId); try { if (expectedGeneration !== undefined && lease.environment.identity.generation !== expectedGeneration) { return { isError: true, output: 'Environment changed before execution. Retry the tool call.' }; @@ -141,4 +143,5 @@ registerAgentToolService(IEditTool, EditTool, { name: 'Edit', domain: 'edit', requiredEnvironmentCapabilities: ['fs'], + environmentScoped: true, }); diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts index 2d773ea2db6..26d90c9eacb 100644 --- a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts @@ -13,6 +13,7 @@ import { DEFAULT_TOOL_RESULT_MAX_CHARS, type ExecutableToolResult, type ToolExecution, + type ToolResolutionContext, type ToolUpdate, } from '#/tool/toolContract'; import { @@ -134,8 +135,9 @@ export class BashTool implements IBashTool { return renderedDescription; } - resolveExecution(args: BashInput): ToolExecution { - const expectedGeneration = this.reviewedGeneration(); + resolveExecution(args: BashInput, context?: ToolResolutionContext): ToolExecution { + const environmentId = context?.environmentId; + const expectedGeneration = this.reviewedGeneration(environmentId); const preview = args.command.length > 50 ? `${args.command.slice(0, 50)}…` : args.command; return { description: args.run_in_background @@ -144,31 +146,34 @@ export class BashTool implements IBashTool { display: { kind: 'command', command: args.command, - cwd: this.executionCwd(args), + cwd: this.executionCwd(args, environmentId), description: args.description, language: 'bash', }, approvalRule: literalRulePattern(this.name, args.command), matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.command), execute: ({ signal, onUpdate, onForegroundTaskStart, toolCallId }) => - this.execution(args, signal, toolCallId, onUpdate, onForegroundTaskStart, expectedGeneration), + this.execution(args, signal, toolCallId, onUpdate, onForegroundTaskStart, expectedGeneration, environmentId), }; } - private reviewedGeneration(): string | undefined { + private reviewedGeneration(environmentId: string | undefined): string | undefined { try { - return pinnedGeneration(this.environment.inspect()); + return pinnedGeneration(this.environment.inspect(environmentId)); } catch { return undefined; } } - private executionCwd(args: BashInput): string { + private executionCwd(args: BashInput, environmentId: string | undefined): string | undefined { try { - const view = new EnvironmentWorkspaceView(this.environment.inspect(), this.workspaceCtx); + const view = new EnvironmentWorkspaceView( + this.environment.inspect(environmentId), + environmentId === undefined ? this.workspaceCtx : this.environment.workspaceRoots(environmentId), + ); return view.resolve(args.cwd ?? view.workDir); } catch { - return args.cwd ?? this.ctx.cwd; + return environmentId === undefined ? args.cwd ?? this.ctx.cwd : args.cwd; } } @@ -197,18 +202,22 @@ export class BashTool implements IBashTool { onUpdate?: (update: ToolUpdate) => void, onForegroundTaskStart?: (taskId: string) => void, expectedGeneration?: string, + environmentId?: string, ): Promise { const validationError = this.validateRunRequest(args, signal); if (validationError !== undefined) return validationError; const startsInBackground = args.run_in_background === true; const foregroundTimeoutMs = normalizeTimeoutMs(args.timeout, false); - const lease = await acquireOrWhenReady(this.environment, ['process']); + const lease = await acquireOrWhenReady(this.environment, ['process'], environmentId); if (expectedGeneration !== undefined && lease.environment.identity.generation !== expectedGeneration) { lease.dispose(); return { isError: true, output: 'Environment changed before execution. Retry the tool call.' }; } - const view = new EnvironmentWorkspaceView(lease.environment, this.workspaceCtx); + const view = new EnvironmentWorkspaceView( + lease.environment, + environmentId === undefined ? this.workspaceCtx : this.environment.workspaceRoots(environmentId), + ); const env = view.host; const command = env.osKind === 'Windows' ? rewriteWindowsNullRedirect(args.command) : args.command; const effectiveCwd = view.resolve(args.cwd ?? view.workDir); @@ -463,6 +472,7 @@ registerAgentToolService(IBashTool, BashTool, { name: 'Bash', domain: 'os/backends', requiredEnvironmentCapabilities: ['process'], + environmentScoped: true, }); function formatTimeoutLabel(timeoutMs: number): string { diff --git a/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts b/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts index d34c9e7017d..89a0566c229 100644 --- a/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts @@ -23,6 +23,7 @@ import { ToolAccesses, type ExecutableToolResult, type ToolExecution, + type ToolResolutionContext, } from '#/tool/toolContract'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { @@ -82,18 +83,19 @@ export class GlobTool implements IGlobTool { return { workspaceDir: view.workDir, additionalDirs: view.additionalDirs }; } - async resolveExecution(args: GlobInput): Promise { - const resolutionLease = await acquireOrWhenReady(this.environment, ['fs']); + async resolveExecution(args: GlobInput, context?: ToolResolutionContext): Promise { + const environmentId = context?.environmentId; + const resolutionLease = await acquireOrWhenReady(this.environment, ['fs'], environmentId); try { const inspected = resolutionLease.environment; const expectedGeneration = pinnedGeneration(inspected); - const view = new EnvironmentWorkspaceView(inspected, { + const view = new EnvironmentWorkspaceView(inspected, environmentId === undefined ? { workDir: this.workspaceCtx.workDir, additionalDirs: [ ...this.workspaceCtx.additionalDirs, ...(this.skillCatalog?.catalog.getSkillRoots() ?? []), ], - }); + } : this.environment.workspaceRoots(environmentId)); const env = { _serviceBrand: undefined, ...view.host, ready: Promise.resolve() }; const workspace = this.workspaceConfig(view); let path: string | undefined; @@ -127,7 +129,7 @@ export class GlobTool implements IGlobTool { approvalRule: literalRulePattern(this.name, args.pattern), matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.pattern), execute: async ({ signal }) => { - const lease = await acquireOrWhenReady(this.environment, ['fs', 'process']); + const lease = await acquireOrWhenReady(this.environment, ['fs', 'process'], environmentId); try { if (expectedGeneration !== undefined && lease.environment.identity.generation !== expectedGeneration) { return { isError: true, output: 'Environment changed before execution. Retry the tool call.' }; @@ -343,6 +345,7 @@ registerAgentToolService(IGlobTool, GlobTool, { name: 'Glob', domain: 'os/backends', requiredEnvironmentCapabilities: ['fs', 'process'], + environmentScoped: true, }); function createRgProbe(processService: IHostProcessService, cwd: string): RgProbe { diff --git a/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts b/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts index 503ddff6c29..42944173c21 100644 --- a/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts @@ -5,6 +5,7 @@ import { ToolAccesses, type ExecutableToolResult, type ToolExecution, + type ToolResolutionContext, } from '#/tool/toolContract'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IOAuthService } from '#/app/auth/auth'; @@ -81,18 +82,19 @@ export class GrepTool implements IGrepTool { return { workspaceDir: view.workDir, additionalDirs: view.additionalDirs }; } - async resolveExecution(args: GrepInput): Promise { - const resolutionLease = await acquireOrWhenReady(this.environment, ['fs']); + async resolveExecution(args: GrepInput, context?: ToolResolutionContext): Promise { + const environmentId = context?.environmentId; + const resolutionLease = await acquireOrWhenReady(this.environment, ['fs'], environmentId); try { const inspected = resolutionLease.environment; const expectedGeneration = pinnedGeneration(inspected); - const view = new EnvironmentWorkspaceView(inspected, { + const view = new EnvironmentWorkspaceView(inspected, environmentId === undefined ? { workDir: this.workspaceCtx.workDir, additionalDirs: [ ...this.workspaceCtx.additionalDirs, ...(this.skillCatalog?.catalog.getSkillRoots() ?? []), ], - }); + } : this.environment.workspaceRoots(environmentId)); const env = { _serviceBrand: undefined, ...view.host, ready: Promise.resolve() }; const workspace = this.workspace(view); let path: string | undefined; @@ -113,7 +115,7 @@ export class GrepTool implements IGrepTool { approvalRule: literalRulePattern(this.name, args.pattern), matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.pattern), execute: async ({ signal }) => { - const lease = await acquireOrWhenReady(this.environment, ['fs', 'process']); + const lease = await acquireOrWhenReady(this.environment, ['fs', 'process'], environmentId); try { if (expectedGeneration !== undefined && lease.environment.identity.generation !== expectedGeneration) { return { isError: true, output: 'Environment changed before execution. Retry the tool call.' }; @@ -361,6 +363,7 @@ registerAgentToolService(IGrepTool, GrepTool, { name: 'Grep', domain: 'os/backends', requiredEnvironmentCapabilities: ['fs', 'process'], + environmentScoped: true, }); function formatSpawnError(error: unknown, environment: Environment): string { diff --git a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts index a37c494bf98..16f5b6d0ddf 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts @@ -25,6 +25,7 @@ import { ToolAccesses, type ExecutableToolResult, type ToolExecution, + type ToolResolutionContext, } from '#/tool/toolContract'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { @@ -211,23 +212,26 @@ export class ReadTool implements IReadTool { return { workspaceDir: view.workDir, additionalDirs: view.additionalDirs }; } - async resolveExecution(args: ReadInput): Promise { + async resolveExecution(args: ReadInput, context?: ToolResolutionContext): Promise { + const environmentId = context?.environmentId; if (args.column_offset !== undefined && (args.line_offset ?? 1) < 0) { return { isError: true, output: 'column_offset is only supported for forward reads. Use a positive line_offset or the forward Next Read arguments.' }; } if (isDaemonFileUrl(args.path)) return this.attachmentExecution(args); - const planTarget = await this.planService?.resolveFile(args.path, 'read'); - if (planTarget !== undefined) return this.localArtifactExecution(args, planTarget, true); - const artifactTarget = this.localOutputTarget(args.path); - if (artifactTarget !== undefined) return this.localArtifactExecution(args, artifactTarget, false); - const resolutionLease = await acquireOrWhenReady(this.environment, ['fs']); + if (environmentId === undefined) { + const planTarget = await this.planService?.resolveFile(args.path, 'read'); + if (planTarget !== undefined) return this.localArtifactExecution(args, planTarget, true); + const artifactTarget = this.localOutputTarget(args.path); + if (artifactTarget !== undefined) return this.localArtifactExecution(args, artifactTarget, false); + } + const resolutionLease = await acquireOrWhenReady(this.environment, ['fs'], environmentId); try { const inspected = resolutionLease.environment; const expectedGeneration = pinnedGeneration(inspected); - const view = new EnvironmentWorkspaceView(inspected, { + const view = new EnvironmentWorkspaceView(inspected, environmentId === undefined ? { workDir: this.workspaceCtx.workDir, additionalDirs: [...this.workspaceCtx.additionalDirs, ...this.skillCatalog.catalog.getSkillRoots()], - }); + } : this.environment.workspaceRoots(environmentId)); const env = { _serviceBrand: undefined, ...view.host, ready: Promise.resolve() }; const workspace = this.workspaceConfig(view); const path = resolvePathAccessPath(args.path, { @@ -247,7 +251,7 @@ export class ReadTool implements IReadTool { homeDir: env.homeDir, }), execute: async () => { - const lease = await acquireOrWhenReady(this.environment, ['fs']); + const lease = await acquireOrWhenReady(this.environment, ['fs'], environmentId); try { if (expectedGeneration !== undefined && lease.environment.identity.generation !== expectedGeneration) { return { isError: true, output: 'Environment changed before execution. Retry the tool call.' }; @@ -672,4 +676,5 @@ export class ReadTool implements IReadTool { registerAgentToolService(IReadTool, ReadTool, { name: 'Read', domain: 'os/backends', + environmentScoped: true, }); diff --git a/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts b/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts index 972419a4aac..e6906eeeb58 100644 --- a/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts @@ -11,6 +11,7 @@ import { ToolAccesses, type ExecutableToolResult, type ToolExecution, + type ToolResolutionContext, } from '#/tool/toolContract'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { @@ -39,8 +40,9 @@ export class WriteTool implements IWriteTool { return { workspaceDir: view.workDir, additionalDirs: view.additionalDirs }; } - async resolveExecution(args: WriteInput): Promise { - const planTarget = await this.planService?.resolveFile(args.path); + async resolveExecution(args: WriteInput, context?: ToolResolutionContext): Promise { + const environmentId = context?.environmentId; + const planTarget = environmentId === undefined ? await this.planService?.resolveFile(args.path) : undefined; if (planTarget !== undefined) { const path = planTarget.path; return { @@ -58,17 +60,17 @@ export class WriteTool implements IWriteTool { }, }; } - const resolutionLease = await acquireOrWhenReady(this.environment, ['fs']); + const resolutionLease = await acquireOrWhenReady(this.environment, ['fs'], environmentId); try { const inspected = resolutionLease.environment; const expectedGeneration = pinnedGeneration(inspected); - const view = new EnvironmentWorkspaceView(inspected, { + const view = new EnvironmentWorkspaceView(inspected, environmentId === undefined ? { workDir: this.workspaceCtx.workDir, additionalDirs: [ ...this.workspaceCtx.additionalDirs, ...(this.skillCatalog?.catalog.getSkillRoots() ?? []), ], - }); + } : this.environment.workspaceRoots(environmentId)); const env = { _serviceBrand: undefined, ...view.host, ready: Promise.resolve() }; const workspace = this.workspaceConfig(view); const path = resolvePathAccessPath(args.path, { @@ -88,7 +90,7 @@ export class WriteTool implements IWriteTool { homeDir: env.homeDir, }), execute: async () => { - const lease = await acquireOrWhenReady(this.environment, ['fs']); + const lease = await acquireOrWhenReady(this.environment, ['fs'], environmentId); try { if (expectedGeneration !== undefined && lease.environment.identity.generation !== expectedGeneration) { return { isError: true, output: 'Environment changed before execution. Retry the tool call.' }; @@ -161,4 +163,5 @@ registerAgentToolService(IWriteTool, WriteTool, { name: 'Write', domain: 'os/backends', requiredEnvironmentCapabilities: ['fs'], + environmentScoped: true, }); diff --git a/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts b/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts index 6c4b54421df..97f6ed96976 100644 --- a/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts +++ b/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts @@ -15,6 +15,7 @@ import { type AgentTool, type ExecutableToolResult, type ToolExecution, + type ToolResolutionContext, } from '#/tool/toolContract'; import { resolvePathAccessPath, type WorkspaceConfig } from '#/tool/path-access'; import { @@ -227,21 +228,22 @@ export class ReadMediaFileTool implements AgentTool { return inlineVideoPart(data, mimeType); } - async resolveExecution(args: ReadMediaFileInput): Promise { + async resolveExecution(args: ReadMediaFileInput, context?: ToolResolutionContext): Promise { + const environmentId = context?.environmentId; if (!args.path) { return { isError: true, output: 'File path cannot be empty.' }; } if (isDaemonFileUrl(args.path)) { return this.attachmentExecution(args); } - const resolutionLease = await acquireOrWhenReady(this.environment, ['fs']); + const resolutionLease = await acquireOrWhenReady(this.environment, ['fs'], environmentId); try { const inspected = resolutionLease.environment; const expectedGeneration = pinnedGeneration(inspected); - const view = new EnvironmentWorkspaceView(inspected, { + const view = new EnvironmentWorkspaceView(inspected, environmentId === undefined ? { workDir: this.workspace.workspaceDir, additionalDirs: this.workspace.additionalDirs, - }); + } : this.environment.workspaceRoots(environmentId)); const env = view.host; const workspace = { workspaceDir: view.workDir, additionalDirs: view.additionalDirs }; const path = resolvePathAccessPath(args.path, { @@ -256,12 +258,12 @@ export class ReadMediaFileTool implements AgentTool { approvalRule: literalRulePattern(this.name, path), matchesRule: (ruleArgs) => matchesPathRuleSubject(ruleArgs, path, { - cwd: this.workspace.workspaceDir, + cwd: environmentId === undefined ? this.workspace.workspaceDir : workspace.workspaceDir, pathClass: env.pathClass, homeDir: env.homeDir, }), execute: async ({ signal }) => { - const lease = await acquireOrWhenReady(this.environment, ['fs']); + const lease = await acquireOrWhenReady(this.environment, ['fs'], environmentId); try { if (expectedGeneration !== undefined && lease.environment.identity.generation !== expectedGeneration) { return { isError: true, output: 'Environment changed before execution. Retry the tool call.' }; diff --git a/packages/agent-core-v2/src/app/environmentDeclaration/environmentDeclaration.ts b/packages/agent-core-v2/src/app/environmentDeclaration/environmentDeclaration.ts index 50f68107ae7..1ed88fa8473 100644 --- a/packages/agent-core-v2/src/app/environmentDeclaration/environmentDeclaration.ts +++ b/packages/agent-core-v2/src/app/environmentDeclaration/environmentDeclaration.ts @@ -6,6 +6,7 @@ export interface IEnvironmentDeclarationService { readonly _serviceBrand: undefined; readonly onDidChange: Event; declarations(): Promise; + snapshot(): EnvironmentDeclarationSet; registrationError(environmentId: string): Error | undefined; reportRegistration(environmentId: string, error?: Error): void; } diff --git a/packages/agent-core-v2/src/app/environmentDeclaration/environmentDeclarationService.ts b/packages/agent-core-v2/src/app/environmentDeclaration/environmentDeclarationService.ts index e8e0b9735c8..18f099ba463 100644 --- a/packages/agent-core-v2/src/app/environmentDeclaration/environmentDeclarationService.ts +++ b/packages/agent-core-v2/src/app/environmentDeclaration/environmentDeclarationService.ts @@ -40,6 +40,10 @@ export class EnvironmentDeclarationService implements IEnvironmentDeclarationSer return this.current; } + snapshot(): EnvironmentDeclarationSet { + return this.current; + } + registrationError(environmentId: string): Error | undefined { return this.registrationErrors.get(environmentId); } diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts index e0d0c86ffb6..2d0abb6288d 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts @@ -45,7 +45,7 @@ export interface IAgentFileHistoryService { history(): FileHistoryState; settled(): Promise; - captureForActiveTurn(path: string): Promise; + captureForActiveTurn(path: string, environmentId?: string): Promise; changes(turnId: number): Promise; turnRecorded(turnId: number): Promise; contentAt( diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts index d343b45b504..4ad4d7ff35b 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -178,7 +178,7 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor afterBytes = await this.entryBytes(after); } } else { - const current = await this.readCurrent(path); + const current = await this.readCurrent(path, before?.environmentId); if (current === 'unreadable') continue; if (current instanceof Uint8Array) afterBytes = current; else if (current === 'missing') liveMissing = true; @@ -259,7 +259,7 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor private onWillExecuteTool(event: WillExecuteToolEvent): void { const path = editTargetPath(event.execution.display); if (path === undefined) return; - event.waitUntil(this.enqueue(() => this.capture(path, event.turnId))); + event.waitUntil(this.enqueue(() => this.capture(path, event.turnId, event.environmentId))); } private onSubagentWillExecuteTool(event: WillExecuteToolEvent): void { @@ -267,13 +267,13 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor if (path === undefined) return; const main = this.agentLifecycle.handleOf(MAIN_AGENT_ID); if (main === undefined) return; - event.waitUntil(main.accessor.get(IAgentFileHistoryService).captureForActiveTurn(path)); + event.waitUntil(main.accessor.get(IAgentFileHistoryService).captureForActiveTurn(path, event.environmentId)); } - captureForActiveTurn(path: string): Promise { + captureForActiveTurn(path: string, environmentId?: string): Promise { const turnId = this.activeTurnId; if (turnId === undefined) return Promise.resolve(); - return this.enqueue(() => this.capture(path, turnId)); + return this.enqueue(() => this.capture(path, turnId, environmentId)); } private enqueue(op: () => Promise): Promise { @@ -291,15 +291,15 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor return run; } - private async capture(path: string, turnId: number): Promise { - const pathKey = this.pathKey(path); + private async capture(path: string, turnId: number, environmentId?: string): Promise { + const pathKey = environmentId === undefined ? this.pathKey(path) : `${environmentId}:${path}`; const state = this.history(); const startCheckpoint = state.checkpoints.find( (c) => c.turnId === turnId && checkpointPhaseOf(c) === 'start', ); if (startCheckpoint !== undefined && Object.hasOwn(startCheckpoint.entries, pathKey)) return; - const current = await this.readCurrent(pathKey); + const current = await this.readCurrent(pathKey, environmentId); if (current === 'unreadable') return; const latest = latestEntry(state.checkpoints, pathKey); const nextVersion = maxVersion(state.checkpoints, pathKey) + 1; @@ -330,7 +330,12 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor }; } await this.dispatcher.dispatch( - new FileHistoryTracked({ agentId: this.agentCtx.agentId, turnId, path: pathKey, entry }), + new FileHistoryTracked({ + agentId: this.agentCtx.agentId, + turnId, + path: pathKey, + entry: environmentId === undefined ? entry : { ...entry, environmentId }, + }), ); } @@ -358,7 +363,7 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor >; for (const [pathKey, before] of Object.entries(start.entries)) { const nextVersion = maxVersion(state.checkpoints, pathKey) + 1; - const current = await this.readCurrent(pathKey); + const current = await this.readCurrent(pathKey, before.environmentId); if (current === 'unreadable') continue; if (current === 'missing') { if (before.key !== null || before.oversize === true) { @@ -466,12 +471,15 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor private async readCurrent( pathKey: string, + environmentId?: string, ): Promise< Uint8Array | 'missing' | 'unreadable' | { oversizeBytes: number; mtimeMs?: number } > { let lease: EnvironmentLease; try { - lease = this.environment.acquire(['fs']); + lease = environmentId === undefined + ? this.environment.acquire(['fs']) + : await this.environment.acquireWhenReady(['fs'], environmentId); } catch (error) { if (error instanceof EnvironmentError) return 'unreadable'; throw error; @@ -481,7 +489,9 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor if (fs === undefined) return 'unreadable'; const path = lease.environment.path; if (path === undefined) return 'unreadable'; - const absolute = resolveWorkspacePath(path, this.workspaceCtx.workDir, pathKey); + const absolute = environmentId === undefined + ? resolveWorkspacePath(path, this.workspaceCtx.workDir, pathKey) + : pathKey.slice(environmentId.length + 1); let info; try { info = await fs.stat(absolute); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 2458e3217ab..11a90ef1049 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -52,6 +52,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import { closeTrailingOpenToolExchange } from '#/agent/contextMemory/openToolExchange'; import { IAgentEnvironmentBindingSeed, IAgentEnvironmentBindingService } from '#/agent/environmentBinding/environmentBinding'; import '#/agent/environmentBinding/environmentBindingService'; +import '#/agent/environmentBinding/environmentRoster'; import { EnvironmentSetBinding } from '#/agent/environmentBinding/environmentBindingOps'; import { LOCAL_ENVIRONMENT_ID } from '#/environment/environment'; import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index e319c4fa1c8..5f04ae70d50 100644 --- a/packages/agent-core-v2/src/tool/toolContract.ts +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -89,8 +89,12 @@ export interface RunnableToolExecution { export type ToolExecution = RunnableToolExecution | ExecutableToolErrorResult; +export interface ToolResolutionContext { + readonly environmentId?: string; +} + export interface ExecutableTool extends Tool { - resolveExecution(input: Input): ToolExecution | Promise; + resolveExecution(input: Input, context?: ToolResolutionContext): ToolExecution | Promise; } export type ToolSource = 'builtin' | 'user' | 'mcp'; diff --git a/packages/agent-core-v2/src/tool/toolInputDisplay.ts b/packages/agent-core-v2/src/tool/toolInputDisplay.ts index 70263161b77..dac3514f46d 100644 --- a/packages/agent-core-v2/src/tool/toolInputDisplay.ts +++ b/packages/agent-core-v2/src/tool/toolInputDisplay.ts @@ -5,6 +5,7 @@ export type ToolInputDisplay = cwd?: string | undefined; description?: string | undefined; language?: 'bash' | undefined; + environment?: string | undefined; } | { kind: 'file_io'; @@ -14,6 +15,7 @@ export type ToolInputDisplay = content?: string | undefined; before?: string | undefined; after?: string | undefined; + environment?: string | undefined; } | { kind: 'diff'; diff --git a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts index 6ec86916a55..226df92a613 100644 --- a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts +++ b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts @@ -273,6 +273,7 @@ function didCtx( readonly result?: ExecutableToolResult; readonly preflightRejected?: boolean; readonly accesses?: ToolAccessesType; + readonly environmentId?: string; } = {}, ): ToolDidExecuteContext { const toolCall: ToolCall = { @@ -287,6 +288,7 @@ function didCtx( toolCall, toolCalls: [toolCall], args, + environmentId: options.environmentId, tool: options.preflightRejected === true ? undefined : ({} as ExecutableTool), outcome: options.preflightRejected === true ? 'preflight-rejected' : 'executed', accesses: @@ -1554,6 +1556,18 @@ describe('agentsMdReminder environment degradation', () => { expect(h.reminders).toHaveLength(0); }); + it('does not inspect the primary environment for calls routed elsewhere', async () => { + const h = createHarness(); + const environment = h.ix.get(IAgentEnvironmentService); + environment.acquire = () => { + throw new Error('primary environment must not be read'); + }; + + await fire(h, didCtx('Read', { path: '/srv/app/AGENTS.md' }, { environmentId: 'gpu' })); + + expect(agentsMdMessages(h)).toHaveLength(0); + expect(h.reminders).toHaveLength(0); + }); }); describe('extractBashTargetDirs', () => { diff --git a/packages/agent-core-v2/test/agent/changeNotifier/changeNotifier.test.ts b/packages/agent-core-v2/test/agent/changeNotifier/changeNotifier.test.ts index b8b0b5e6f1c..d4d2bc44684 100644 --- a/packages/agent-core-v2/test/agent/changeNotifier/changeNotifier.test.ts +++ b/packages/agent-core-v2/test/agent/changeNotifier/changeNotifier.test.ts @@ -267,6 +267,7 @@ function createHarness(options: { reg.defineInstance(IAgentEnvironmentService, { _serviceBrand: undefined, onDidChange: () => ({ dispose: () => {} }), + targets: () => [{ environmentId: 'local', primary: true }], isAvailable: () => true, inspect() { return this.acquire().environment; diff --git a/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts b/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts index e1850541e45..3ccab479dd3 100644 --- a/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts +++ b/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts @@ -25,6 +25,12 @@ import { workspaceContextWorkDirKey, } from '#/session/workspaceContext/workspaceContextService'; import { IEnvironmentService, type EnvironmentResolver } from '#/app/environment/environment'; +import type { IEnvironmentDeclarationService } from '#/app/environmentDeclaration/environmentDeclaration'; +import type { IFlagService } from '#/app/flag/flag'; +import { MULTI_ENVIRONMENT_FLAG_ID } from '#/agent/environmentBinding/flag'; +import { AgentEnvironmentRosterService, ENVIRONMENT_ROSTER_REMINDER_VARIANT } from '#/agent/environmentBinding/environmentRoster'; +import type { ContextInjectionContext, ContextInjectionProvider, ContextInjectionResult } from '#/features/reminder/types'; +import type { RemoteEnvironmentEntry } from '#/remote/declaration/remoteEnvironmentDeclaration'; import { stubAgentContext } from '../agentContext/stubs'; import { noopLogger } from '../../wire/stubs'; import { fakeEnvironment } from '../../environment/stubs'; @@ -123,10 +129,34 @@ interface RestoreHook { (ctx: unknown, next: () => Promise): Promise; } +interface MultiEnvironmentOptions { + readonly enabled?: boolean; + readonly declarations: readonly { readonly id: string; readonly defaultCwd?: string }[]; +} + +function multiEnvironmentServices(options: MultiEnvironmentOptions | undefined): { + readonly flags?: IFlagService; + readonly declarations?: IEnvironmentDeclarationService; +} { + if (options === undefined) return {}; + return { + flags: { enabled: (id: string) => id === MULTI_ENVIRONMENT_FLAG_ID && options.enabled !== false } as unknown as IFlagService, + declarations: { + snapshot: () => ({ + entries: options.declarations.map((declaration) => ({ + id: declaration.id, + entry: { type: 'ssh', host: declaration.id, defaultCwd: declaration.defaultCwd } as RemoteEnvironmentEntry, + })), + }), + } as unknown as IEnvironmentDeclarationService, + }; +} + function setup(options: { agentId?: string; sessionCwd?: string; seedBinding?: EnvironmentBinding; + multiEnvironment?: MultiEnvironmentOptions; } = {}) { const registry = new EnvironmentRegistry(); const local = environment('local', 'local-one', 'ready', ['fs', 'process'], LOCAL_HOST); @@ -198,9 +228,19 @@ function setup(options: { noopLogger, stubBootstrap(), ); + const multi = multiEnvironmentServices(options.multiEnvironment); return { binding: agentBinding, - agentEnvironment: new AgentEnvironmentService(agentScopeContext, agentBinding, registry as unknown as IEnvironmentService, eventBus, session, sessionState), + agentEnvironment: new AgentEnvironmentService( + agentScopeContext, + agentBinding, + registry as unknown as IEnvironmentService, + eventBus, + session, + sessionState, + multi.flags, + multi.declarations, + ), state: agentState, }; }; @@ -495,3 +535,137 @@ describe('AgentEnvironmentService on-demand connect', () => { lease.dispose(); }); }); + +describe('AgentEnvironmentService multi-environment targets', () => { + it('exposes only the primary binding while the flag is off', () => { + const { agentEnvironment } = setup({ + multiEnvironment: { enabled: false, declarations: [{ id: 'remote', defaultCwd: '/srv/app' }] }, + }); + + expect(agentEnvironment.targets()).toEqual([{ environmentId: 'local', primary: true, cwd: undefined }]); + expect(() => agentEnvironment.acquire([], 'remote')).toThrowError( + expect.objectContaining>({ code: 'environment.not_found' }), + ); + }); + + it('routes declared environments to their own environment and default working directory', () => { + const { agentEnvironment, registry, sessionState } = setup({ + multiEnvironment: { declarations: [{ id: 'remote', defaultCwd: '/srv/app/' }] }, + }); + sessionState.set(workspaceContextAdditionalDirsKey, ['/workspace-extra']); + registry.register(environment('acp:other-session', 'acp-one', 'ready', ['fs', 'process'])); + + expect(agentEnvironment.targets()).toEqual([ + { environmentId: 'local', primary: true, cwd: undefined }, + { environmentId: 'remote', primary: false, cwd: '/srv/app/' }, + ]); + const lease = agentEnvironment.acquire(['process'], 'remote'); + expect(lease.environment.identity.environmentId).toBe('remote'); + lease.dispose(); + expect(agentEnvironment.workspaceRoots('remote')).toEqual({ workDir: '/srv/app', additionalDirs: [] }); + expect(agentEnvironment.workspaceRoots()).toEqual({ workDir: '/workspace', additionalDirs: ['/workspace-extra'] }); + expect(() => agentEnvironment.acquire([], 'acp:other-session')).toThrowError( + expect.objectContaining>({ code: 'environment.not_found' }), + ); + }); + + it('offers local beside a declared remote primary and resolves it to the session directory', () => { + const { agentEnvironment } = setup({ + seedBinding: { environmentId: 'remote', cwd: '/srv/app' }, + multiEnvironment: { declarations: [{ id: 'remote', defaultCwd: '/srv/app' }] }, + }); + + expect(agentEnvironment.targets()).toEqual([ + { environmentId: 'remote', primary: true, cwd: '/srv/app' }, + { environmentId: 'local', primary: false, cwd: '/workspace' }, + ]); + expect(agentEnvironment.workspaceRoots('local')).toEqual({ workDir: '/workspace', additionalDirs: [] }); + }); + + it('keeps a session-private primary isolated from other environments', () => { + const { agentEnvironment, registry } = setup({ + seedBinding: { environmentId: 'acp:session', cwd: '/workspace' }, + multiEnvironment: { declarations: [{ id: 'remote', defaultCwd: '/srv/app' }] }, + }); + registry.register(environment('acp:session', 'acp-session-one', 'ready', ['fs', 'process'])); + + expect(agentEnvironment.targets()).toEqual([{ environmentId: 'acp:session', primary: true, cwd: '/workspace' }]); + }); + + it('rejects a relative default working directory instead of guessing one', () => { + const { agentEnvironment } = setup({ + multiEnvironment: { declarations: [{ id: 'remote', defaultCwd: 'relative/dir' }] }, + }); + + expect(() => agentEnvironment.acquire([], 'remote')).toThrowError( + expect.objectContaining>({ code: 'environment.invalid_cwd' }), + ); + }); + + it('falls back to the connected host directory when no default working directory is declared', () => { + const { agentEnvironment } = setup({ multiEnvironment: { declarations: [{ id: 'remote' }] } }); + + expect(agentEnvironment.workspaceRoots('remote').workDir).toBe('/home/fake'); + }); +}); + +describe('AgentEnvironmentRosterService', () => { + function rosterProvider(agentEnvironment: ConstructorParameters[1]) { + let provider: ContextInjectionProvider | undefined; + const reminder = { + _serviceBrand: undefined, + register: (variant: string, registered: ContextInjectionProvider) => { + expect(variant).toBe(ENVIRONMENT_ROSTER_REMINDER_VARIANT); + provider = registered; + return { dispose: () => {} }; + }, + } as unknown as IAgentReminderService; + const roster = new AgentEnvironmentRosterService(reminder, agentEnvironment); + const provide = (context: Partial>) => + provider!({ injectedPositions: [], lastInjectedAt: null, isNewTurn: true, ...context }) as + | ContextInjectionResult + | undefined; + return { roster, provide }; + } + + it('lists every environment once and repeats only after the roster changes or leaves the context', () => { + const { agentEnvironment, registry } = setup({ + multiEnvironment: { declarations: [{ id: 'remote', defaultCwd: '/srv/app' }, { id: 'staging' }, { id: 'unregistered' }] }, + }); + registry.register(environment('staging', 'staging-one', 'pending')); + const { provide } = rosterProvider(agentEnvironment); + + const first = provide({}); + expect(first?.content).toBe( + [ + 'Execution environments available in this session:', + '- `local` (primary): working directory /workspace', + '- `remote`: working directory /srv/app', + '- `staging`: working directory resolved on first use', + 'Tools with an `environment` argument run in the environment you name; omit it to use the primary environment. ' + + 'Each environment has its own files, shell, and installed programs, and relative paths resolve against its working directory. ' + + 'A remote environment connects on its first use; if it cannot connect, the call fails instead of running elsewhere. ' + + "The system prompt's working directory, AGENTS.md instructions, and skills describe the primary environment only; " + + "read a project's AGENTS.md before changing files in another environment.", + ].join('\n'), + ); + const injected = { role: 'user' as const, content: [] }; + expect(provide({ lastInjection: injected, lastDisclosure: first?.disclosure })).toBeUndefined(); + expect(provide({ lastDisclosure: first?.disclosure })?.content).toBe(first?.content); + }); + + it('withdraws the roster once only the primary environment remains', () => { + const { agentEnvironment } = setup({ + multiEnvironment: { enabled: false, declarations: [{ id: 'remote', defaultCwd: '/srv/app' }] }, + }); + const { provide } = rosterProvider(agentEnvironment); + const injected = { role: 'user' as const, content: [] }; + + expect(provide({})).toBeUndefined(); + expect(provide({ lastInjection: injected, lastDisclosure: '- `local` (primary): working directory /workspace' })).toEqual({ + content: 'Only the primary environment `local` is available now. Tools no longer take an `environment` argument.', + disclosure: 'single', + }); + expect(provide({ lastInjection: injected, lastDisclosure: 'single' })).toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts b/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts index 379da618504..90ffed45661 100644 --- a/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts +++ b/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts @@ -943,6 +943,7 @@ describe('AgentMediaToolsRegistrar', () => { const environment: IAgentEnvironmentService = { _serviceBrand: undefined, onDidChange: environmentChanges.event, + targets: () => [{ environmentId: 'local', primary: true }], isAvailable: (required = []) => environmentAvailable && baseEnvironment.isAvailable(required), inspect: () => { if (!environmentAvailable) throw new Error('environment unavailable'); diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts index 1c0e85e4ffd..77b92684527 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts @@ -52,6 +52,7 @@ describe('AgentPermissionPolicyService chain', () => { let mode: PermissionMode; let rules: PermissionRule[]; let sessionApprovalRulePatterns: string[]; + let environmentSessionApprovalRulePatterns: Record; let workspace: ReturnType; let hostArgs: HostArgs; let dangerousCommandGuardEnabled: boolean; @@ -61,6 +62,7 @@ describe('AgentPermissionPolicyService chain', () => { mode = 'manual'; rules = []; sessionApprovalRulePatterns = []; + environmentSessionApprovalRulePatterns = {}; workspace = workspaceStub('/workspace'); hostArgs = { requestHeaders: {}, nonInteractive: false }; dangerousCommandGuardEnabled = true; @@ -86,12 +88,15 @@ describe('AgentPermissionPolicyService chain', () => { reg.definePartialInstance(IAgentPermissionRulesService, permissionRulesStub({ rules: () => rules, sessionApprovalRulePatterns: () => sessionApprovalRulePatterns, + environmentSessionApprovalRulePatterns: (environmentId) => + environmentSessionApprovalRulePatterns[environmentId] ?? [], })); reg.defineInstance(ISessionWorkspaceContext, workspace.stub); reg.defineInstance(IHostEnvironment, kaosStub()); reg.defineInstance(IAgentEnvironmentService, { _serviceBrand: undefined, onDidChange: () => ({ dispose: () => {} }), + targets: () => [{ environmentId: 'local', primary: true }], isAvailable: () => true, inspect() { return (this as IAgentEnvironmentService).acquire().environment; }, acquire: () => ({ @@ -223,6 +228,34 @@ describe('AgentPermissionPolicyService chain', () => { }); }); + it('keeps approve-for-session decisions inside the environment they were granted for', async () => { + rules.push({ + decision: 'ask', + scope: 'user', + pattern: 'Bash', + }); + sessionApprovalRulePatterns.push('Bash(printf first)'); + const routed = { + toolName: 'Bash', + args: { command: 'printf first', timeout: 60 }, + environmentId: 'gpu', + }; + + await expect(evaluate(routed)).resolves.toMatchObject({ + policyName: 'user-configured-ask', + result: { kind: 'ask' }, + }); + + environmentSessionApprovalRulePatterns['gpu'] = ['Bash(printf first)']; + await expect(evaluate(routed)).resolves.toMatchObject({ + policyName: 'session-approval-history', + result: { kind: 'approve' }, + }); + await expect(evaluate({ ...routed, environmentId: 'staging' })).resolves.toMatchObject({ + policyName: 'user-configured-ask', + }); + }); + it.each(['manual', 'yolo'] as const)( 'asks for shutdown in %s mode', async (currentMode) => { @@ -499,12 +532,14 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { let cleanupDirs: string[]; let environmentAcquireError: (() => Error) | undefined; let environmentFsAvailable: boolean; + let acquiredEnvironments: (string | undefined)[]; beforeEach(async () => { disposables = new DisposableStore(); mode = 'manual'; environmentAcquireError = undefined; environmentFsAvailable = true; + acquiredEnvironments = []; workspaceDir = await mkdtemp(join(tmpdir(), 'kimi-permission-git-')); cleanupDirs = [workspaceDir]; await mkdir(join(workspaceDir, '.git'), { recursive: true }); @@ -529,14 +564,16 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { reg.defineInstance(IAgentEnvironmentService, { _serviceBrand: undefined, onDidChange: () => ({ dispose: () => {} }), + targets: () => [{ environmentId: 'local', primary: true }], isAvailable: () => true, inspect() { return (this as IAgentEnvironmentService).acquire().environment; }, - acquire: () => { + acquire: (_required, environmentId) => { if (environmentAcquireError !== undefined) throw environmentAcquireError(); + acquiredEnvironments.push(environmentId); return { track: (resource) => resource, environment: { - identity: { environmentId: 'local', generation: 'test' }, + identity: { environmentId: environmentId ?? 'local', generation: 'test' }, capabilities: new Set(), status: 'ready', onDidChangeStatus: () => ({ dispose: () => {} }), @@ -553,13 +590,18 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { dirname: (path: string) => dirname(path), }, workspace: { mapRoots: (roots) => roots }, - fs: environmentFsAvailable ? hostFs : undefined, + fs: environmentId !== undefined + ? ({ stat: async () => { throw new Error('missing'); } } as unknown as typeof hostFs) + : environmentFsAvailable ? hostFs : undefined, }, dispose: () => {}, }; }, acquireWhenReady() { return Promise.resolve((this as IAgentEnvironmentService).acquire()); }, - workspaceRoots: () => ({ workDir: '/workspace', additionalDirs: [] }), + workspaceRoots: (environmentId) => ({ + workDir: environmentId === undefined ? '/workspace' : workspaceDir, + additionalDirs: [], + }), }); reg.defineInstance(ITelemetryService, recordingTelemetry([])); reg.definePartialInstance(IGitService, { @@ -605,6 +647,20 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { }); }); + it('judges a routed Write by the target environment instead of the primary git checkout', async () => { + await expect(evaluate({ + toolName: 'Write', + args: { path: 'src/a.ts', content: 'x' }, + accesses: ToolAccesses.writeFile(join(workspaceDir, 'src/a.ts')), + environmentId: 'gpu', + })).resolves.toMatchObject({ + policyName: 'fallback-ask', + result: { kind: 'ask' }, + }); + expect(acquiredEnvironments).toContain('gpu'); + expect(acquiredEnvironments).not.toContain(undefined); + }); + it('approves Edit on an additionalDir path in manual mode', async () => { const extraDir = await mkdtemp(join(tmpdir(), 'kimi-permission-extra-')); cleanupDirs.push(extraDir); @@ -721,6 +777,7 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { interface MutablePermissionRulesStubOptions { readonly rules?: () => readonly PermissionRule[]; readonly sessionApprovalRulePatterns?: () => readonly string[]; + readonly environmentSessionApprovalRulePatterns?: (environmentId: string) => readonly string[]; } function permissionRulesStub( @@ -735,6 +792,7 @@ function permissionRulesStub( get sessionApprovalRulePatterns() { return sessionApprovalRulePatterns(); }, + environmentSessionApprovalRulePatterns: options.environmentSessionApprovalRulePatterns ?? (() => []), addRules: () => {}, recordApprovalResult: () => {}, }; @@ -745,6 +803,7 @@ interface PolicyContextInput { readonly toolName: string; readonly args: Record; readonly accesses?: ToolAccessList; + readonly environmentId?: string; } function policyContext(input: PolicyContextInput): ResolvedToolExecutionHookContext { @@ -756,6 +815,7 @@ function policyContext(input: PolicyContextInput): ResolvedToolExecutionHookCont toolCall, toolCalls: [toolCall], args: input.args, + environmentId: input.environmentId, execution: { description: description(input.toolName), display: display(input.toolName, input.args), diff --git a/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts b/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts index 7e1d2f6b3c8..f5b53ac8e93 100644 --- a/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts +++ b/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts @@ -90,6 +90,15 @@ describe('AgentPermissionRulesService (wire-backed)', () => { expect(svc.sessionApprovalRulePatterns).toEqual(['Bash(rm *)']); }); + it('keeps session approvals for another environment out of the primary pattern set', () => { + svc.recordApprovalResult({ ...sessionApproval('Bash(rm *)'), environmentId: 'gpu' }); + svc.recordApprovalResult({ ...sessionApproval('Bash(rm *)'), environmentId: 'gpu' }); + + expect(svc.sessionApprovalRulePatterns).toEqual([]); + expect(svc.environmentSessionApprovalRulePatterns('gpu')).toEqual(['Bash(rm *)']); + expect(svc.environmentSessionApprovalRulePatterns('staging')).toEqual([]); + }); + it('ignores non-session approvals for the pattern set', () => { const oneTime: PermissionApprovalResultRecord = { turnId: 2, @@ -126,6 +135,7 @@ describe('AgentPermissionRulesService (wire-backed)', () => { it('replay rebuilds session approval patterns only (rules are not persisted)', async () => { svc.addRules([allowRule, denyRule]); svc.recordApprovalResult(sessionApproval('Bash(rm *)')); + svc.recordApprovalResult({ ...sessionApproval('Bash(rm *)'), environmentId: 'gpu' }); const records = await readRecords(); const ix2 = disposables.add(new TestInstantiationService()); @@ -149,6 +159,7 @@ describe('AgentPermissionRulesService (wire-backed)', () => { expect(freshState.get(permissionRulesKey)).toEqual({ rules: [], sessionApprovalRulePatterns: ['Bash(rm *)'], + environmentSessionApprovalRulePatterns: { gpu: ['Bash(rm *)'] }, }); const written: WireRecord[] = []; for await (const record of log2.read(testWireScope(SCOPE, 'permission-rules-replay'), AGENT_WIRE_RECORD_KEY)) { diff --git a/packages/agent-core-v2/test/agent/permissionRules/stubs.ts b/packages/agent-core-v2/test/agent/permissionRules/stubs.ts index 075c771ed18..9f4ee00cbd1 100644 --- a/packages/agent-core-v2/test/agent/permissionRules/stubs.ts +++ b/packages/agent-core-v2/test/agent/permissionRules/stubs.ts @@ -12,6 +12,7 @@ export function stubPermissionRulesService( return rules(); }, sessionApprovalRulePatterns: [], + environmentSessionApprovalRulePatterns: () => [], addRules: () => {}, recordApprovalResult: () => {}, }; diff --git a/packages/agent-core-v2/test/agent/shellCommand/shellCommand.test.ts b/packages/agent-core-v2/test/agent/shellCommand/shellCommand.test.ts index 6e6189bed23..566d89dc5d2 100644 --- a/packages/agent-core-v2/test/agent/shellCommand/shellCommand.test.ts +++ b/packages/agent-core-v2/test/agent/shellCommand/shellCommand.test.ts @@ -173,6 +173,7 @@ describe('AgentShellCommandService', () => { list: () => [], listReferences: () => [], resolve: () => undefined, + routeEnvironment: (_name, args) => ({ args }), }; ctx = createTestAgent(agentService(IAgentToolRegistryService, emptyRegistry)); context = ctx.get(IAgentContextMemoryService); diff --git a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts index 05f8ae471fe..044bdaff0a8 100644 --- a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts +++ b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts @@ -159,6 +159,7 @@ describe('AgentToolActivationService', () => { }; const environmentStub = { onDidChange: environmentChangeEmitter.event, + targets: () => [{ environmentId: 'local', primary: true }], isAvailable: (required: readonly EnvironmentCapability[] = []) => environmentData.available && required.every((capability) => environmentData.capabilities.has(capability)), inspect: () => { diff --git a/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts b/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts index db0484e3095..80dd3b730b3 100644 --- a/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts +++ b/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts @@ -117,6 +117,7 @@ describe('AgentToolApprovalService', () => { _serviceBrand: undefined, rules: [], sessionApprovalRulePatterns: [], + environmentSessionApprovalRulePatterns: () => [], addRules: () => {}, recordApprovalResult: (record) => { recorded.push(record); diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index f28f4ae62d3..89a6b205fda 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -1,7 +1,8 @@ import { readFileSync } from 'node:fs'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; +import { createRequire } from 'node:module'; import { PassThrough, Readable } from 'node:stream'; import { Jimp } from 'jimp'; @@ -18,6 +19,7 @@ import { type ExecutableToolContext, type ExecutableToolResult, type ToolExecution, + type ToolResolutionContext, type ToolResult, type ToolUpdate, } from '#/tool/toolContract'; @@ -51,7 +53,7 @@ import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { HostProcessService } from '#/os/backends/node-local/hostProcessService'; import { FakeEnvironment } from '#/environment/fakeEnvironment'; -import type { IAgentEnvironmentService } from '#/agent/environmentBinding/agentEnvironment'; +import { IAgentEnvironmentService, type AgentEnvironmentTarget } from '#/agent/environmentBinding/agentEnvironment'; import type { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; import { stubWorkspaceContext } from '../../session/workspaceContext/stub-workspace-context'; import { stubAgentEnvironment } from '../../environment/stubs'; @@ -74,6 +76,10 @@ import { stubBootstrap } from '../../app/bootstrap/stubs'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; import { registerStateServices } from '../../state/stubs'; import { registerTestAgentWireServices } from '../../wire/stubs'; +import { stubConfigService } from '../../app/config/stubs'; +import { stubToolResultTruncationService } from '../toolResultTruncation/stubs'; + +import { RemoteSession } from '#/remote/client/remoteSession'; type ToolExecutorEvent = | { readonly type: 'tool.result'; readonly toolCallId: string; readonly result: ToolResult }; @@ -88,6 +94,7 @@ let events: ToolExecutorEvent[]; let protocolEvents: ProtocolEvent[]; let telemetryEvents: TelemetryRecord[]; let truncateForModel: IAgentToolResultTruncationService['truncateForModel']; +let environmentTargets: AgentEnvironmentTarget[]; let durations: number[]; @@ -98,6 +105,7 @@ beforeEach(() => { protocolEvents = []; telemetryEvents = []; truncateForModel = async (input) => input.result; + environmentTargets = [{ environmentId: 'local', primary: true }]; ix = createServices(disposables, { additionalServices: (reg) => { registerStateServices(reg); @@ -105,6 +113,9 @@ beforeEach(() => { reg.define(IAgentToolRegistryService, AgentToolRegistryService); reg.define(IAgentToolExecutorService, AgentToolExecutorService); reg.defineInstance(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' })); + reg.defineInstance(IAgentEnvironmentService, { + targets: () => environmentTargets, + } as unknown as IAgentEnvironmentService); reg.defineInstance(ITelemetryService, recordingTelemetry(telemetryEvents)); reg.defineInstance(IAgentToolResultTruncationService, { _serviceBrand: undefined, @@ -1599,6 +1610,197 @@ describe('truncation pipeline', () => { }); }); +describe('AgentToolExecutorService environment routing', () => { + const textSchema = { + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], + additionalProperties: false, + }; + + beforeEach(() => { + environmentTargets = [ + { environmentId: 'local', primary: true }, + { environmentId: 'gpu', primary: false, cwd: '/work' }, + ]; + }); + + it('adds the environment argument to environment-scoped tools only', () => { + registry.register(new RoutedTool('scoped', textSchema), { environmentScoped: true }); + registry.register(new RoutedTool('plain', textSchema)); + + const parameters = Object.fromEntries(registry.list().map((info) => [info.name, info.parameters])); + expect(parameters['scoped']).toEqual({ + ...textSchema, + properties: { + text: { type: 'string' }, + environment: { + type: 'string', + enum: ['local', 'gpu'], + description: 'Environment to run this call in. Omit to use the primary environment.', + }, + }, + }); + expect(parameters['plain']).toBe(textSchema); + }); + + it('routes a call to the named environment, hands the tool its own arguments, and stamps the display', async () => { + const tool = new RoutedTool('scoped', textSchema); + registry.register(tool, { environmentScoped: true }); + const observed: { readonly environmentId: string | undefined; readonly args: unknown }[] = []; + executor.onBeforeExecuteTool((event) => { + observed.push({ environmentId: event.environmentId, args: event.args }); + }); + + await execute([ + toolCall('call_gpu', 'scoped', { text: 'a', environment: 'gpu' }), + toolCall('call_primary', 'scoped', { text: 'b', environment: 'local' }), + toolCall('call_default', 'scoped', { text: 'c' }), + ]); + + expect(tool.resolutions).toEqual([ + { args: { text: 'a' }, environmentId: 'gpu' }, + { args: { text: 'b' }, environmentId: undefined }, + { args: { text: 'c' }, environmentId: undefined }, + ]); + expect(observed).toEqual([ + { environmentId: 'gpu', args: { text: 'a', environment: 'gpu' } }, + { environmentId: undefined, args: { text: 'b', environment: 'local' } }, + { environmentId: undefined, args: { text: 'c' } }, + ]); + const displays = protocolEvents + .filter((event): event is ToolCallStarted => event.type === 'tool.call.started') + .map((event) => event.display); + expect(displays).toEqual([ + { kind: 'command', command: 'a', environment: 'gpu' }, + { kind: 'command', command: 'b' }, + { kind: 'command', command: 'c' }, + ]); + }); + + it('rejects an unknown environment without resolving the tool', async () => { + const tool = new RoutedTool('scoped', textSchema); + registry.register(tool, { environmentScoped: true }); + + const results = await execute([toolCall('call_bad', 'scoped', { text: 'a', environment: 'prod' })]); + + expect(results).toEqual([ + expect.objectContaining({ + isError: true, + output: 'Unknown environment "prod". Available environments: local (primary), gpu.', + }), + ]); + expect(tool.resolutions).toEqual([]); + }); + + it('keeps rejecting the environment argument while only the primary environment is available', async () => { + environmentTargets = [{ environmentId: 'local', primary: true }]; + const tool = new RoutedTool('scoped', textSchema); + registry.register(tool, { environmentScoped: true }); + + const results = await execute([toolCall('call_single', 'scoped', { text: 'a', environment: 'local' })]); + + expect(results[0]).toMatchObject({ + isError: true, + output: expect.stringContaining('Invalid args for tool "scoped"'), + }); + expect(tool.resolutions).toEqual([]); + expect(registry.list().find((info) => info.name === 'scoped')?.parameters).toBe(textSchema); + }); + + it('routes Read through the registry to a lazily connected exec-server', async () => { + const remoteDir = await mkdtemp(join(tmpdir(), 'multi-env-read-')); + const require = createRequire(import.meta.url); + const tsxCli = join(dirname(require.resolve('tsx/package.json')), 'dist', 'cli.mjs'); + const child = join(import.meta.dirname, '../../remote/fixtures/exec-server-child.ts'); + let session: RemoteSession | undefined; + const environment = { + identity: { environmentId: 'gpu', generation: 'gpu-one' }, + capabilities: new Set(['fs', 'process'] as const), + status: 'pending' as const, + onDidChangeStatus: () => ({ dispose: () => {} }), + connect: async () => { + session = await RemoteSession.connect({ + environmentId: 'gpu', + launcher: { type: 'command', program: process.execPath, args: [tsxCli, child], env: { EXEC_SERVER_VERSION: '9.9.9-test' } }, + }); + Object.assign(environment, { + host: session.host, + fs: session.fs, + process: session.process, + status: 'ready', + }); + }, + dispose: async () => { + await session?.dispose(); + }, + }; + try { + await writeFile(join(remoteDir, 'notes.txt'), 'from remote exec-server\n'); + ix.stub(IAgentEnvironmentService, { + targets: () => [ + { environmentId: 'local', primary: true, cwd: '/workspace' }, + { environmentId: 'gpu', primary: false, cwd: remoteDir }, + ], + inspect: () => environment, + isAvailable: () => false, + acquireWhenReady: async () => { + await environment.connect(); + return { environment, dispose: () => {} }; + }, + workspaceRoots: () => ({ workDir: remoteDir, additionalDirs: [] }), + } as unknown as IAgentEnvironmentService); + registry.register( + new ReadTool( + ix.get(IAgentEnvironmentService), + stubWorkspaceContext('/workspace'), + { catalog: { getSkillRoots: () => [] } } as unknown as ISessionSkillCatalog, + stubToolResultTruncationService(), + stubConfigService(), + { getModelCapabilities: () => ({ image_in: true, video_in: true }) } as unknown as IAgentProfileService, + { isToolActive: () => true } as unknown as IAgentToolPolicyService, + registry, + ), + { environmentScoped: true }, + ); + + const results = await execute([ + toolCall('remote-read', 'Read', { path: 'notes.txt', environment: 'gpu' }), + ]); + + expect(results).toEqual([ + expect.objectContaining({ + output: '1\tfrom remote exec-server', + display: expect.objectContaining({ path: join(remoteDir, 'notes.txt'), environment: 'gpu' }), + }), + ]); + expect(results[0]?.isError).not.toBe(true); + } finally { + await session?.dispose(); + await rm(remoteDir, { recursive: true, force: true }); + } + }); +}); + +class RoutedTool implements ExecutableTool> { + readonly description = 'Routed tool.'; + readonly resolutions: Array<{ readonly args: Record; readonly environmentId: string | undefined }> = []; + + constructor( + readonly name: string, + readonly parameters: Record, + ) {} + + resolveExecution(args: Record, context?: ToolResolutionContext): ToolExecution { + this.resolutions.push({ args, environmentId: context?.environmentId }); + return { + approvalRule: this.name, + display: { kind: 'command', command: String(args['text']) }, + execute: async () => ({ output: 'ok' }), + }; + } +} + function renderedOutputPath(output: string): string { const match = /^output_path: (.+)$/m.exec(output); if (match === null) throw new Error('expected tool output to include output_path'); diff --git a/packages/agent-core-v2/test/agent/tools/os/environmentGenerationGuard.test.ts b/packages/agent-core-v2/test/agent/tools/os/environmentGenerationGuard.test.ts index 760c4d33f55..91c2fdfae64 100644 --- a/packages/agent-core-v2/test/agent/tools/os/environmentGenerationGuard.test.ts +++ b/packages/agent-core-v2/test/agent/tools/os/environmentGenerationGuard.test.ts @@ -64,6 +64,7 @@ function registryBackedService(registry: EnvironmentRegistry): IAgentEnvironment return { _serviceBrand: undefined, onDidChange: (listener) => registry.onDidChange(() => listener()), + targets: () => [{ environmentId: 'local', primary: true }], isAvailable: (required: readonly EnvironmentCapability[] = []) => { try { const environment = registry.inspect(BINDING); @@ -418,3 +419,96 @@ describe('Bash environment generation guard', () => { expect(outputText(result)).not.toContain(ENVIRONMENT_CHANGED_OUTPUT); }); }); + +describe('tool environment routing', () => { + function routedHarness() { + const registry = new EnvironmentRegistry(); + const spawns: { readonly environmentId: string; readonly cwd: string | undefined }[] = []; + for (const [environmentId, content] of [['local', 'local content'], ['gpu', 'gpu content']] as const) { + registry.register(Object.assign( + new FakeEnvironment( + { environmentId, generation: `${environmentId}-one` }, + { status: 'ready', capabilities: ['fs', 'process'] }, + ), + { + fs: textFs(content), + process: { + spawn: async (_command: string, _args: readonly string[], options?: { readonly cwd?: string }) => { + spawns.push({ environmentId, cwd: options?.cwd }); + throw new Error(`spawned on ${environmentId}`); + }, + }, + }, + )); + } + const binding = (environmentId?: string): EnvironmentBinding => ({ environmentId: environmentId ?? 'local' }); + const service: IAgentEnvironmentService = { + _serviceBrand: undefined, + onDidChange: () => ({ dispose: () => {} }), + targets: () => [ + { environmentId: 'local', primary: true }, + { environmentId: 'gpu', primary: false, cwd: '/gpu' }, + ], + isAvailable: (required = [], environmentId) => { + const environment = registry.inspect(binding(environmentId)); + return environmentIsReady(environment) && required.every((capability) => environment.capabilities.has(capability)); + }, + inspect: (environmentId) => registry.inspect(binding(environmentId)), + acquire: (required = [], environmentId) => registry.acquire(binding(environmentId), required), + acquireWhenReady: (required = [], environmentId) => registry.acquireWhenReady(binding(environmentId), required), + workspaceRoots: (environmentId) => ({ + workDir: environmentId === undefined ? '/workspace' : '/gpu', + additionalDirs: [], + }), + }; + return { service, spawns }; + } + + it('reads from the requested environment and resolves relative paths against its working directory', async () => { + const { service } = routedHarness(); + const read = (environmentId?: string) => + new ReadTool( + service, + stubWorkspaceContext('/workspace'), + skillCatalog, + stubToolResultTruncationService(), + stubConfigService(), + profile, + toolPolicy, + toolRegistry, + ).resolveExecution({ path: 'notes.txt' }, { environmentId }); + + const routed = await read('gpu'); + expect(routed).toMatchObject({ display: { kind: 'file_io', operation: 'read', path: '/gpu/notes.txt' } }); + expect(outputText(await finish(routed))).toContain('gpu content'); + + const primary = await read(); + expect(primary).toMatchObject({ display: { path: '/workspace/notes.txt' } }); + expect(outputText(await finish(primary))).toContain('local content'); + }); + + it('runs Bash on the requested environment in its working directory', async () => { + const { service, spawns } = routedHarness(); + const bash = new BashTool( + service, + makeSessionContext({ + sessionId: 'ses', + workspaceId: 'ws', + sessionDir: '/tmp/ses', + sessionScope: 'ses', + cwd: '/workspace', + }), + stubWorkspaceContext('/workspace'), + {} as never, + toolPolicy, + stubConfigService(), + ); + + const resolved = bash.resolveExecution({ command: 'pwd' }, { environmentId: 'gpu' }); + expect(resolved).toMatchObject({ display: { kind: 'command', cwd: '/gpu' } }); + const result = await finish(resolved); + + expect(spawns).toEqual([{ environmentId: 'gpu', cwd: '/gpu' }]); + expect(outputText(result)).toContain('spawned on gpu'); + }); +}); diff --git a/packages/agent-core-v2/test/environment/stubs.ts b/packages/agent-core-v2/test/environment/stubs.ts index d390a78003a..a978b24232e 100644 --- a/packages/agent-core-v2/test/environment/stubs.ts +++ b/packages/agent-core-v2/test/environment/stubs.ts @@ -25,6 +25,7 @@ export function stubAgentEnvironment( return { _serviceBrand: undefined, onDidChange: options.onDidChange ?? (() => ({ dispose: () => {} })), + targets: () => [{ environmentId: resolve().identity.environmentId, primary: true, cwd: options.workDir }], isAvailable: options.isAvailable ?? (() => true), inspect: resolve, acquire: lease, diff --git a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts index 3ff45c97299..1343b064b60 100644 --- a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts +++ b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts @@ -63,6 +63,7 @@ describe('AgentFileHistoryService', () => { let blobs: IBlobStore; let scopeCtx: IAgentScopeContext; let files: Map; + let gpuFiles: Map; beforeEach(() => { disposables = new DisposableStore(); @@ -85,6 +86,7 @@ describe('AgentFileHistoryService', () => { executorEvents = stubToolExecutorEvents(); blobs = new BlobStoreService(new InMemoryStorageService()); files = new Map(); + gpuFiles = new Map(); }); afterEach(() => { @@ -97,16 +99,21 @@ describe('AgentFileHistoryService', () => { path: posix, workspace: { mapRoots: (roots: unknown) => roots }, }; + const gpu = { ...environment, fs: hostFs(false, gpuFiles) }; return { inspect: () => environment, - acquire: () => ({ - environment, + acquire: (_required: unknown, environmentId?: string) => ({ + environment: environmentId === 'gpu' ? gpu : environment, + dispose: () => {}, + }), + acquireWhenReady: async (_required: unknown, environmentId?: string) => ({ + environment: environmentId === 'gpu' ? gpu : environment, dispose: () => {}, }), } as unknown as IAgentEnvironmentService; } - function hostFs(remoteShape = false): IHostFileSystem { + function hostFs(remoteShape = false, store: Map = files): IHostFileSystem { const missing = (path: string): Error => remoteShape ? new HostFsError('os.fs.not_found', 'stat failed: path does not exist', { @@ -115,12 +122,12 @@ describe('AgentFileHistoryService', () => { : Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); return createFakeHostFs({ stat: async (path: string) => { - const content = files.get(path); + const content = store.get(path); if (content === undefined) throw missing(path); return { isFile: true, isDirectory: false, size: content.byteLength }; }, readBytes: async (path: string) => { - const content = files.get(path); + const content = store.get(path); if (content === undefined) throw missing(path); return content; }, @@ -183,7 +190,12 @@ describe('AgentFileHistoryService', () => { files.set(path, encoder.encode(content)); } - async function fireEdit(service: AgentFileHistoryService, path: string, turnId: number): Promise { + async function fireEdit( + service: AgentFileHistoryService, + path: string, + turnId: number, + environmentId?: string, + ): Promise { const toolCall: ToolCall = { type: 'function', id: `call-${String(turnId)}`, name: 'Edit', arguments: null }; const execution: RunnableToolExecution = { approvalRule: 'Edit', @@ -191,7 +203,7 @@ describe('AgentFileHistoryService', () => { execute: async () => ({ output: '' }), }; await executorEvents.fireWillExecute( - { turnId, toolCall, execution, args: {} }, + { turnId, toolCall, execution, args: {}, environmentId }, new AbortController().signal, ); await service.settled(); @@ -539,6 +551,46 @@ describe('AgentFileHistoryService', () => { expect(await blobs.list(scopeCtx.scope(), 'file-history/')).toEqual([]); }); + it('tracks an edit routed to another environment under an environment-qualified path', async () => { + const service = createService(); + setFile('/srv/app/a.txt', 'primary copy\n'); + gpuFiles.set('/srv/app/a.txt', encoder.encode('one\ntwo\n')); + + startTurn(1); + await fireEdit(service, '/srv/app/a.txt', 1, 'gpu'); + gpuFiles.set('/srv/app/a.txt', encoder.encode('one\nTWO\n')); + expect(await service.changes(1)).toEqual([ + { path: 'gpu:/srv/app/a.txt', status: 'modified', additions: 1, deletions: 1 }, + ]); + endTurn(1); + startTurn(2); + await service.settled(); + + expect(service.history().tracked).toEqual(['gpu:/srv/app/a.txt']); + expect(await service.changes(1)).toEqual([ + { path: 'gpu:/srv/app/a.txt', status: 'modified', additions: 1, deletions: 1 }, + ]); + expect((await service.contentAt(1, 'gpu:/srv/app/a.txt'))?.content).toBe('one\ntwo\n'); + expect((await service.contentAt(1, 'gpu:/srv/app/a.txt', 'end'))?.content).toBe('one\nTWO\n'); + }); + + it('keeps the environment on a reused backup so an unchanged routed file is not read from the primary', async () => { + const service = createService(); + gpuFiles.set('/srv/app/a.txt', encoder.encode('one\n')); + + startTurn(1); + await fireEdit(service, '/srv/app/a.txt', 1, 'gpu'); + gpuFiles.set('/srv/app/a.txt', encoder.encode('two\n')); + endTurn(1); + startTurn(2); + await fireEdit(service, '/srv/app/a.txt', 2, 'gpu'); + endTurn(2); + startTurn(3); + await service.settled(); + + expect(await service.changes(2)).toEqual([]); + }); + it('keeps files outside the workspace keyed by absolute path', async () => { const service = createService(); setFile('/elsewhere/notes.md', 'note\n'); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 399a6bb3cab..0dd38afd648 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -1383,6 +1383,7 @@ export class AgentTestContext { reg.defineInstance(IAgentEnvironmentService, { _serviceBrand: undefined, onDidChange: () => ({ dispose: () => {} }), + targets: () => [{ environmentId: environment.identity.environmentId, primary: true }], isAvailable: (required = []) => required.every((capability) => environment.capabilities.has(capability)), inspect: () => environment, acquire: (required = []): EnvironmentLease => { @@ -2406,6 +2407,7 @@ function createPermissionRulesStub( get sessionApprovalRulePatterns() { return []; }, + environmentSessionApprovalRulePatterns: () => [], addRules: (nextRules) => { rules = [...rules, ...nextRules]; }, diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts index 87c84919874..6a0bfe29fde 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts @@ -193,6 +193,7 @@ function createRegistryBackedTool(environmentValue: FakeEnvironment) { const environment: IAgentEnvironmentService = { _serviceBrand: undefined, onDidChange: (listener) => registry.onDidChange(() => listener()), + targets: () => [{ environmentId: 'local', primary: true }], isAvailable: (required = []) => { try { const lease = registry.acquire(binding, required); From 38202e18ab9b98be0c6683d931ccc22c6b6b5044 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Fri, 30 Oct 2026 12:42:36 +0800 Subject: [PATCH 2/9] refactor: route every environment-scoped call to an explicit target Model the primary as a full environment target: tools and git policies read workspace roots from the environment service for every target, and only primary-only features check the target role. Persisted records keep omitting the environment id for the primary. File history now exposes the environment as a structured field on its REST routes, judges routed captures against its own primary, and never connects an environment while recording; an unreachable environment only skips its own files. --- .../agentsMdReminderService.ts | 2 +- .../environmentBinding/agentEnvironment.ts | 16 ++-- .../policies/git-control-path-access-ask.ts | 9 +- .../policies/git-cwd-write-approve.ts | 7 +- .../policies/session-approval-history.ts | 5 +- .../agent/permissionRules/permissionRules.ts | 4 +- .../permissionRules/permissionRulesService.ts | 11 ++- .../agent/toolApproval/toolApprovalService.ts | 2 +- .../toolExecutor/beforeToolExecuteEvent.ts | 5 +- .../agent/toolExecutor/toolExecutorService.ts | 17 ++-- .../src/agent/toolExecutor/toolHooks.ts | 5 +- .../src/agent/toolRegistry/toolRegistry.ts | 3 +- .../agent/toolRegistry/toolRegistryService.ts | 10 +-- .../src/agent/tools/edit/editTool.ts | 18 ++-- .../src/agent/tools/os/bash/bashTool.ts | 24 +++--- .../src/agent/tools/os/glob/globTool.ts | 16 ++-- .../src/agent/tools/os/grep/grepTool.ts | 16 ++-- .../src/agent/tools/os/read/readTool.ts | 16 ++-- .../src/agent/tools/os/write/writeTool.ts | 18 ++-- .../read-media-file/readMediaFileTool.ts | 14 +-- .../src/environment/environment.ts | 4 + .../src/features/fileHistory/fileHistory.ts | 2 + .../fileHistory/fileHistoryService.ts | 58 ++++++++----- .../agent-core-v2/src/tool/toolContract.ts | 3 +- .../agentsMdReminder/agentsMdReminder.test.ts | 22 ++++- .../permissionPolicyService.test.ts | 50 ++++++----- .../permissionRules/permissionRules.test.ts | 13 +-- .../test/agent/permissionRules/stubs.ts | 3 +- .../agent/toolApproval/toolApproval.test.ts | 3 +- .../agent/toolExecutor/toolExecutor.test.ts | 33 ++++--- .../os/environmentGenerationGuard.test.ts | 34 ++++---- .../tools/os/rgUnavailableGuidance.test.ts | 3 - .../test/app/edit/tools/edit.test.ts | 4 +- .../features/fileHistory/fileHistory.test.ts | 86 +++++++++++++++---- packages/agent-core-v2/test/harness/agent.ts | 5 +- .../os/backends/node-local/tools/bash.test.ts | 33 ++----- .../os/backends/node-local/tools/glob.test.ts | 16 ++-- .../os/backends/node-local/tools/grep.test.ts | 6 +- .../os/backends/node-local/tools/read.test.ts | 5 +- .../backends/node-local/tools/write.test.ts | 8 +- .../src/protocol/rest-file-history.ts | 2 + packages/kap-server/src/routes/fileHistory.ts | 12 ++- 42 files changed, 343 insertions(+), 280 deletions(-) diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts index 3c88c1ebef7..1b935d769fc 100644 --- a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -108,7 +108,7 @@ export class AgentAgentsMdReminderService }), ); const handler = async (ctx: ToolDidExecuteContext, next: () => Promise): Promise => { - if (ctx.environmentId === undefined) await this.probeAndRemind(ctx); + if (ctx.environment?.primary !== false) await this.probeAndRemind(ctx); await next(); }; this._register(toolExecutor.hooks.onDidExecuteTool.register('agentsMdReminder', handler)); diff --git a/packages/agent-core-v2/src/agent/environmentBinding/agentEnvironment.ts b/packages/agent-core-v2/src/agent/environmentBinding/agentEnvironment.ts index e020f1917fe..155b5ce8c12 100644 --- a/packages/agent-core-v2/src/agent/environmentBinding/agentEnvironment.ts +++ b/packages/agent-core-v2/src/agent/environmentBinding/agentEnvironment.ts @@ -7,7 +7,7 @@ import { IEnvironmentDeclarationService } from '#/app/environmentDeclaration/env import { IFlagService } from '#/app/flag/flag'; import { LifecycleScope } from '#/app/scopes'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import type { Environment, EnvironmentBinding, EnvironmentCapability, EnvironmentLease, EnvironmentPath, EnvironmentWorkspaceRoots } from '#/environment/environment'; +import type { Environment, EnvironmentBinding, EnvironmentCapability, EnvironmentLease, EnvironmentPath, EnvironmentTarget, EnvironmentWorkspaceRoots } from '#/environment/environment'; import { LOCAL_ENVIRONMENT_ID } from '#/environment/environment'; import { normalizeRemoteCwd } from '#/environment/environmentDefaults'; import { EnvironmentError, environmentIsReady, type EnvironmentGenerationSnapshot, type EnvironmentRegistryChange } from '#/environment/environmentRegistry'; @@ -30,16 +30,10 @@ export interface AgentEnvironmentBindingSnapshot { readonly environment?: EnvironmentGenerationSnapshot; } -export interface AgentEnvironmentTarget { - readonly environmentId: string; - readonly primary: boolean; - readonly cwd?: string; -} - export interface IAgentEnvironmentService { readonly _serviceBrand: undefined; readonly onDidChange: Event; - targets(): readonly AgentEnvironmentTarget[]; + targets(): readonly EnvironmentTarget[]; inspect(environmentId?: string): Environment; isAvailable(required?: readonly EnvironmentCapability[], environmentId?: string): boolean; acquire(required?: readonly EnvironmentCapability[], environmentId?: string): EnvironmentLease; @@ -143,9 +137,9 @@ export class AgentEnvironmentService implements IAgentEnvironmentService { }); } - targets(): readonly AgentEnvironmentTarget[] { + targets(): readonly EnvironmentTarget[] { const current = this.binding.current; - const primary: AgentEnvironmentTarget = { environmentId: current.environmentId, primary: true, cwd: current.cwd }; + const primary: EnvironmentTarget = { environmentId: current.environmentId, primary: true, cwd: current.cwd }; if (this.declarations === undefined || this.flags?.enabled(MULTI_ENVIRONMENT_FLAG_ID) !== true) return [primary]; const declared = new Map( this.declarations.snapshot().entries.map((declaration) => [declaration.id, declaration.entry.defaultCwd]), @@ -154,7 +148,7 @@ export class AgentEnvironmentService implements IAgentEnvironmentService { const secondary = [LOCAL_ENVIRONMENT_ID, ...declared.keys()] .filter((environmentId) => environmentId !== current.environmentId && this.resolver.current(environmentId) !== undefined) .toSorted((left, right) => left.localeCompare(right)) - .map((environmentId): AgentEnvironmentTarget => ({ + .map((environmentId): EnvironmentTarget => ({ environmentId, primary: false, cwd: environmentId === LOCAL_ENVIRONMENT_ID ? this.session.cwd : declared.get(environmentId), diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts index 1217f6be963..d8c6237e59b 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts @@ -3,8 +3,6 @@ import { IConfigService } from '#/app/config/config'; import { findGitWorkTree } from '#/app/git/workTree'; import { IAgentEnvironmentService } from '#/agent/environmentBinding/agentEnvironment'; import { isDangerousCommandGuardEnabled } from '#/agent/permissionRules/configSection'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import type { ISessionWorkspaceContext as WorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import type { PermissionPolicy, PermissionPolicyResult, @@ -21,7 +19,6 @@ export class GitControlPathAccessAskPermissionPolicyService implements Permissio constructor( @IAgentEnvironmentService private readonly environment: IAgentEnvironmentService, - @ISessionWorkspaceContext private readonly workspace: WorkspaceContext, @IConfigService private readonly config: IConfigService, ) {} @@ -29,12 +26,12 @@ export class GitControlPathAccessAskPermissionPolicyService implements Permissio context: ResolvedToolExecutionHookContext, ): Promise { if (!isDangerousCommandGuardEnabled(this.config)) return undefined; - const environmentId = context.environmentId; - const cwd = environmentId === undefined ? this.workspace.workDir : this.environment.workspaceRoots(environmentId).workDir; - if (cwd.length === 0) return undefined; + const environmentId = context.environment?.environmentId; const lease = acquireEnvironmentLease(this.environment, [], environmentId); if (lease === undefined) return undefined; try { + const cwd = this.environment.workspaceRoots(environmentId).workDir; + if (cwd.length === 0) return undefined; const pathClass = lease.environment.host?.pathClass; if (pathClass === undefined) return undefined; const fs = lease.environment.fs; diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts index 51fa422aea8..95bb5e17d07 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts @@ -2,8 +2,6 @@ import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/tool import { isWithinWorkspace } from '#/tool/path-access'; import { findGitWorkTree } from '#/app/git/workTree'; import { IAgentEnvironmentService } from '#/agent/environmentBinding/agentEnvironment'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import type { ISessionWorkspaceContext as WorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import type { PermissionPolicy, PermissionPolicyResult, @@ -16,7 +14,6 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli constructor( @IAgentEnvironmentService private readonly environment: IAgentEnvironmentService, - @ISessionWorkspaceContext private readonly workspace: WorkspaceContext, ) {} async evaluate( @@ -24,7 +21,7 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli ): Promise { const toolName = context.toolCall.name; if (toolName !== 'Write' && toolName !== 'Edit') return undefined; - const environmentId = context.environmentId; + const environmentId = context.environment?.environmentId; const lease = acquireEnvironmentLease(this.environment, [], environmentId); if (lease === undefined) return undefined; try { @@ -33,7 +30,7 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli const fs = lease.environment.fs; if (fs === undefined) return undefined; - const roots = environmentId === undefined ? this.workspace : this.environment.workspaceRoots(environmentId); + const roots = this.environment.workspaceRoots(environmentId); const cwd = roots.workDir; if (cwd.length === 0) return undefined; diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/session-approval-history.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/session-approval-history.ts index 6c5628191d6..73c2449f55e 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/session-approval-history.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/session-approval-history.ts @@ -14,10 +14,7 @@ export class SessionApprovalHistoryPermissionPolicyService implements Permission ) {} evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { - const patterns = context.environmentId === undefined - ? this.rulesService.sessionApprovalRulePatterns - : this.rulesService.environmentSessionApprovalRulePatterns(context.environmentId); - for (const pattern of patterns) { + for (const pattern of this.rulesService.sessionApprovalRulePatterns(context.environment)) { const match = matchPermissionRule({ rule: { decision: 'allow', diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts index faa563b0568..2706f00eac5 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts @@ -1,5 +1,6 @@ import { createDecorator } from "#/_base/di/instantiation"; import type { ApprovalResponse } from "#/agent/interaction/approval"; +import type { EnvironmentTarget } from "#/environment/environment"; export interface PermissionApprovalResultRecord { readonly turnId: number; @@ -26,8 +27,7 @@ export interface IAgentPermissionRulesService { readonly _serviceBrand: undefined; readonly rules: readonly PermissionRule[]; - readonly sessionApprovalRulePatterns: readonly string[]; - environmentSessionApprovalRulePatterns(environmentId: string): readonly string[]; + sessionApprovalRulePatterns(environment?: EnvironmentTarget): readonly string[]; addRules(rules: readonly PermissionRule[]): void; recordApprovalResult(record: PermissionApprovalResultRecord): void; } diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts index 84a8d5741fa..f53cf481ee6 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts @@ -4,6 +4,7 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; +import type { EnvironmentTarget } from '#/environment/environment'; import { IEventDispatcher } from '#/state/eventDispatcher'; import { IAgentPermissionRulesService, @@ -31,12 +32,10 @@ export class AgentPermissionRulesService implements IAgentPermissionRulesService return [...this.agentState.get(permissionRulesKey).rules]; } - get sessionApprovalRulePatterns(): readonly string[] { - return [...this.agentState.get(permissionRulesKey).sessionApprovalRulePatterns]; - } - - environmentSessionApprovalRulePatterns(environmentId: string): readonly string[] { - return [...(this.agentState.get(permissionRulesKey).environmentSessionApprovalRulePatterns?.[environmentId] ?? [])]; + sessionApprovalRulePatterns(environment?: EnvironmentTarget): readonly string[] { + const state = this.agentState.get(permissionRulesKey); + if (environment?.primary !== false) return [...state.sessionApprovalRulePatterns]; + return [...(state.environmentSessionApprovalRulePatterns?.[environment.environmentId] ?? [])]; } addRules(rules: readonly PermissionRule[]): void { diff --git a/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts b/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts index 23b07cf261a..fb2b1b29158 100644 --- a/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts +++ b/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts @@ -211,7 +211,7 @@ export class AgentToolApprovalService extends Service implements IAgentToolAppro toolName: name, action, sessionApprovalRule, - environmentId: context.environmentId, + environmentId: context.environment?.primary === false ? context.environment.environmentId : undefined, result: response, }); this.telemetry.track2('permission_approval_result', { diff --git a/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts b/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts index deaa082fc52..82e4a310015 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts @@ -2,6 +2,7 @@ import { Emitter } from '#/_base/event'; import { BugIndicatingError } from '#/errors'; import type { ToolCall } from '#human/llm/message'; import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import type { EnvironmentTarget } from '#/environment/environment'; import type { ExecutableTool, ExecutableToolResult, @@ -28,7 +29,7 @@ export class BeforeToolExecuteEventImpl implements BeforeToolExecuteEvent { readonly toolCalls: readonly ToolCall[]; readonly tool?: ExecutableTool | undefined; readonly args: unknown; - readonly environmentId?: string; + readonly environment?: EnvironmentTarget; readonly execution: RunnableToolExecution; private _vetoResult: ExecutableToolResult | undefined; @@ -45,7 +46,7 @@ export class BeforeToolExecuteEventImpl implements BeforeToolExecuteEvent { this.toolCalls = context.toolCalls; this.tool = context.tool; this.args = context.args; - this.environmentId = context.environmentId; + this.environment = context.environment; this.execution = context.execution; } diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index be0e298c049..364c58034b1 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -5,6 +5,7 @@ import { AsyncEmitter, type Event } from '#/_base/event'; import { defineState } from '#/state/state'; import type { ContentPart, ToolCall } from '#human/llm/message'; import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; +import type { EnvironmentTarget } from '#/environment/environment'; import { compileToolArgsValidator, @@ -388,7 +389,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { let execution: ToolExecution; try { - execution = await call.tool.resolveExecution(call.input, { environmentId: call.environmentId }); + execution = await call.tool.resolveExecution(call.input, { environment: call.environment }); } catch (error) { const output = error instanceof PathSecurityError @@ -397,11 +398,11 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { return settleError(call.args, output, 'resolution-failed'); } if ( - call.environmentId !== undefined && + call.environment?.primary === false && execution.isError !== true && (execution.display?.kind === 'command' || execution.display?.kind === 'file_io') ) { - execution = { ...execution, display: { ...execution.display, environment: call.environmentId } }; + execution = { ...execution, display: { ...execution.display, environment: call.environment.environmentId } }; } const displayFields = toolCallDisplayFieldsFromExecution(execution); @@ -434,7 +435,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { toolCall: call.toolCall, execution, args: call.args, - environmentId: call.environmentId, + environment: call.environment, }, options.signal, ); @@ -648,7 +649,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { toolCalls: [call.toolCall], tool: call.kind === 'runnable' ? call.tool : undefined, args: call.args, - environmentId: call.kind === 'runnable' ? call.environmentId : undefined, + environment: call.kind === 'runnable' ? call.environment : undefined, outcome, accesses: resolvedAccesses, result: result as ExecutableToolResult, @@ -699,7 +700,7 @@ interface RunnableToolCall { readonly tool: ExecutableTool; readonly args: unknown; readonly input: unknown; - readonly environmentId?: string; + readonly environment?: EnvironmentTarget; } interface RejectedToolCall { @@ -736,7 +737,7 @@ function buildBeforeExecuteContext( toolCalls: allCalls, tool: call.tool, args: call.args, - environmentId: call.environmentId, + environment: call.environment, execution, }; } @@ -817,7 +818,7 @@ function preflightToolCall( tool, args: parsedArgs.data, input: route.args, - environmentId: route.environmentId, + environment: route.environment, }; } diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts index 7d768f37b81..1a9e689bc28 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts @@ -1,6 +1,7 @@ import type { IWaitUntil } from '#/_base/event'; import type { ToolCall } from '#human/llm/message'; import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import type { EnvironmentTarget } from '#/environment/environment'; import type { ExecutableTool, @@ -17,7 +18,7 @@ export interface ToolExecutionHookContext { readonly toolCalls: readonly ToolCall[]; readonly tool?: ExecutableTool | undefined; readonly args: unknown; - readonly environmentId?: string; + readonly environment?: EnvironmentTarget; } export interface ResolvedToolExecutionHookContext extends ToolExecutionHookContext { @@ -41,7 +42,7 @@ export interface WillExecuteToolEvent extends IWaitUntil { readonly toolCall: ToolCall; readonly execution: RunnableToolExecution; readonly args: unknown; - readonly environmentId?: string; + readonly environment?: EnvironmentTarget; } export type ToolExecutionOutcome = diff --git a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistry.ts b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistry.ts index c06e8ccaa2f..22e71c4a15b 100644 --- a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistry.ts +++ b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistry.ts @@ -1,5 +1,6 @@ import { createDecorator } from '#/_base/di/instantiation'; import { type IDisposable } from '#/_base/di/lifecycle'; +import type { EnvironmentTarget } from '#/environment/environment'; import type { ExecutableTool, ToolDisclosure, @@ -20,7 +21,7 @@ export interface ToolReference { export interface ToolEnvironmentRoute { readonly args: unknown; - readonly environmentId?: string; + readonly environment?: EnvironmentTarget; readonly error?: string; } diff --git a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts index b450297ac95..c599d0bc494 100644 --- a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts +++ b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts @@ -90,11 +90,11 @@ export class AgentToolRegistryService implements IAgentToolRegistryService { } routeEnvironment(name: string, args: unknown): ToolEnvironmentRoute { - if (this.tools.get(name)?.environmentScoped !== true || !isPlainRecord(args) || !Object.hasOwn(args, ENVIRONMENT_ARGUMENT)) { - return { args }; - } + if (this.tools.get(name)?.environmentScoped !== true) return { args }; const targets = this.environment?.current?.targets() ?? []; - if (targets.length < 2) return { args }; + if (targets.length < 2 || !isPlainRecord(args) || !Object.hasOwn(args, ENVIRONMENT_ARGUMENT)) { + return { args, environment: targets[0] }; + } const { [ENVIRONMENT_ARGUMENT]: requested, ...rest } = args; const target = targets.find((candidate) => candidate.environmentId === requested); if (target === undefined) { @@ -103,7 +103,7 @@ export class AgentToolRegistryService implements IAgentToolRegistryService { .join(', '); return { args, error: `Unknown environment ${JSON.stringify(requested)}. Available environments: ${available}.` }; } - return { args: rest, environmentId: target.primary ? undefined : target.environmentId }; + return { args: rest, environment: target }; } private unregisterTool(name: string): ToolEntry | undefined { diff --git a/packages/agent-core-v2/src/agent/tools/edit/editTool.ts b/packages/agent-core-v2/src/agent/tools/edit/editTool.ts index 52abf9fa370..4fe6c36d7ef 100644 --- a/packages/agent-core-v2/src/agent/tools/edit/editTool.ts +++ b/packages/agent-core-v2/src/agent/tools/edit/editTool.ts @@ -10,7 +10,6 @@ import { EnvironmentWorkspaceView } from '#/environment/environmentWorkspaceView import { IAgentPlanService } from '#/features/plan/plan'; import { acquireOrWhenReady, IAgentEnvironmentService, pinnedGeneration } from '#/agent/environmentBinding/agentEnvironment'; import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { ToolAccesses, type ExecutableToolResult, @@ -31,7 +30,6 @@ export class EditTool implements IEditTool { constructor( @IFileEditService private readonly editor: IFileEditService, @IAgentEnvironmentService private readonly environment: IAgentEnvironmentService, - @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, @IAgentPlanService private readonly planService?: IAgentPlanService, ) {} @@ -41,8 +39,9 @@ export class EditTool implements IEditTool { } async resolveExecution(args: EditInput, context?: ToolResolutionContext): Promise { - const environmentId = context?.environmentId; - const planTarget = environmentId === undefined ? await this.planService?.resolveFile(args.path) : undefined; + const environmentId = context?.environment?.environmentId; + const primary = context?.environment?.primary !== false; + const planTarget = primary ? await this.planService?.resolveFile(args.path) : undefined; if (planTarget !== undefined) { const path = planTarget.path; return { @@ -64,13 +63,14 @@ export class EditTool implements IEditTool { try { const inspected = resolutionLease.environment; const expectedGeneration = pinnedGeneration(inspected); - const view = new EnvironmentWorkspaceView(inspected, environmentId === undefined ? { - workDir: this.workspaceCtx.workDir, + const roots = this.environment.workspaceRoots(environmentId); + const view = new EnvironmentWorkspaceView(inspected, { + workDir: roots.workDir, additionalDirs: [ - ...this.workspaceCtx.additionalDirs, - ...(this.skillCatalog?.catalog.getSkillRoots() ?? []), + ...(roots.additionalDirs ?? []), + ...(primary ? this.skillCatalog?.catalog.getSkillRoots() ?? [] : []), ], - } : this.environment.workspaceRoots(environmentId)); + }); const env = view.host; const workspace = this.workspaceConfig(view); const path = resolvePathAccessPath(args.path, { diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts index 26d90c9eacb..6bd0dd413cf 100644 --- a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts @@ -4,8 +4,8 @@ import { IConfigService } from '#/app/config/config'; import type { HostEnvironmentInfo } from '#/os/interface/hostEnvironment'; import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { acquireOrWhenReady, pinnedGeneration, IAgentEnvironmentService } from '#/agent/environmentBinding/agentEnvironment'; +import type { EnvironmentTarget } from '#/environment/environment'; import { EnvironmentWorkspaceView } from '#/environment/environmentWorkspaceView'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { getShellPathBridge } from '#/_base/execEnv/shellPathBridge'; @@ -102,7 +102,6 @@ export class BashTool implements IBashTool { constructor( @IAgentEnvironmentService private readonly environment: IAgentEnvironmentService, @ISessionContext private readonly ctx: ISessionContext, - @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, @IAgentTaskService private readonly tasks: IAgentTaskService, @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @IConfigService private readonly config: IConfigService, @@ -136,8 +135,8 @@ export class BashTool implements IBashTool { } resolveExecution(args: BashInput, context?: ToolResolutionContext): ToolExecution { - const environmentId = context?.environmentId; - const expectedGeneration = this.reviewedGeneration(environmentId); + const environment = context?.environment; + const expectedGeneration = this.reviewedGeneration(environment?.environmentId); const preview = args.command.length > 50 ? `${args.command.slice(0, 50)}…` : args.command; return { description: args.run_in_background @@ -146,14 +145,14 @@ export class BashTool implements IBashTool { display: { kind: 'command', command: args.command, - cwd: this.executionCwd(args, environmentId), + cwd: this.executionCwd(args, environment), description: args.description, language: 'bash', }, approvalRule: literalRulePattern(this.name, args.command), matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.command), execute: ({ signal, onUpdate, onForegroundTaskStart, toolCallId }) => - this.execution(args, signal, toolCallId, onUpdate, onForegroundTaskStart, expectedGeneration, environmentId), + this.execution(args, signal, toolCallId, onUpdate, onForegroundTaskStart, expectedGeneration, environment?.environmentId), }; } @@ -165,15 +164,15 @@ export class BashTool implements IBashTool { } } - private executionCwd(args: BashInput, environmentId: string | undefined): string | undefined { + private executionCwd(args: BashInput, environment: EnvironmentTarget | undefined): string | undefined { try { const view = new EnvironmentWorkspaceView( - this.environment.inspect(environmentId), - environmentId === undefined ? this.workspaceCtx : this.environment.workspaceRoots(environmentId), + this.environment.inspect(environment?.environmentId), + this.environment.workspaceRoots(environment?.environmentId), ); return view.resolve(args.cwd ?? view.workDir); } catch { - return environmentId === undefined ? args.cwd ?? this.ctx.cwd : args.cwd; + return args.cwd ?? (environment?.primary === false ? undefined : this.ctx.cwd); } } @@ -214,10 +213,7 @@ export class BashTool implements IBashTool { lease.dispose(); return { isError: true, output: 'Environment changed before execution. Retry the tool call.' }; } - const view = new EnvironmentWorkspaceView( - lease.environment, - environmentId === undefined ? this.workspaceCtx : this.environment.workspaceRoots(environmentId), - ); + const view = new EnvironmentWorkspaceView(lease.environment, this.environment.workspaceRoots(environmentId)); const env = view.host; const command = env.osKind === 'Windows' ? rewriteWindowsNullRedirect(args.command) : args.command; const effectiveCwd = view.resolve(args.cwd ?? view.workDir); diff --git a/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts b/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts index 89a0566c229..8cad880fa7e 100644 --- a/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts @@ -15,7 +15,6 @@ import type { IHostProcessService } from '#/os/interface/hostProcess'; import { acquireOrWhenReady, IAgentEnvironmentService, pinnedGeneration } from '#/agent/environmentBinding/agentEnvironment'; import { EnvironmentWorkspaceView } from '#/environment/environmentWorkspaceView'; import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IOAuthService } from '#/app/auth/auth'; import { @@ -67,7 +66,6 @@ export class GlobTool implements IGlobTool { readonly parameters: Record = toInputJsonSchema(GlobInputSchema); constructor( @IAgentEnvironmentService private readonly environment: IAgentEnvironmentService, - @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, @ITelemetryService private readonly telemetry: ITelemetryService, @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, @IOAuthService private readonly auth?: IOAuthService, @@ -84,18 +82,20 @@ export class GlobTool implements IGlobTool { } async resolveExecution(args: GlobInput, context?: ToolResolutionContext): Promise { - const environmentId = context?.environmentId; + const environmentId = context?.environment?.environmentId; + const primary = context?.environment?.primary !== false; const resolutionLease = await acquireOrWhenReady(this.environment, ['fs'], environmentId); try { const inspected = resolutionLease.environment; const expectedGeneration = pinnedGeneration(inspected); - const view = new EnvironmentWorkspaceView(inspected, environmentId === undefined ? { - workDir: this.workspaceCtx.workDir, + const roots = this.environment.workspaceRoots(environmentId); + const view = new EnvironmentWorkspaceView(inspected, { + workDir: roots.workDir, additionalDirs: [ - ...this.workspaceCtx.additionalDirs, - ...(this.skillCatalog?.catalog.getSkillRoots() ?? []), + ...(roots.additionalDirs ?? []), + ...(primary ? this.skillCatalog?.catalog.getSkillRoots() ?? [] : []), ], - } : this.environment.workspaceRoots(environmentId)); + }); const env = { _serviceBrand: undefined, ...view.host, ready: Promise.resolve() }; const workspace = this.workspaceConfig(view); let path: string | undefined; diff --git a/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts b/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts index 42944173c21..a52dd861ffc 100644 --- a/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts @@ -18,7 +18,6 @@ import { acquireOrWhenReady, IAgentEnvironmentService, pinnedGeneration } from ' import type { Environment } from '#/environment/environment'; import { EnvironmentWorkspaceView } from '#/environment/environmentWorkspaceView'; import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { resolvePathAccessPath, type PathClass, @@ -72,7 +71,6 @@ export class GrepTool implements IGrepTool { readonly parameters: Record = toInputJsonSchema(GrepInputSchema); constructor( @IAgentEnvironmentService private readonly environment: IAgentEnvironmentService, - @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, @ITelemetryService private readonly telemetry: ITelemetryService, @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, @IOAuthService private readonly auth?: IOAuthService, @@ -83,18 +81,20 @@ export class GrepTool implements IGrepTool { } async resolveExecution(args: GrepInput, context?: ToolResolutionContext): Promise { - const environmentId = context?.environmentId; + const environmentId = context?.environment?.environmentId; + const primary = context?.environment?.primary !== false; const resolutionLease = await acquireOrWhenReady(this.environment, ['fs'], environmentId); try { const inspected = resolutionLease.environment; const expectedGeneration = pinnedGeneration(inspected); - const view = new EnvironmentWorkspaceView(inspected, environmentId === undefined ? { - workDir: this.workspaceCtx.workDir, + const roots = this.environment.workspaceRoots(environmentId); + const view = new EnvironmentWorkspaceView(inspected, { + workDir: roots.workDir, additionalDirs: [ - ...this.workspaceCtx.additionalDirs, - ...(this.skillCatalog?.catalog.getSkillRoots() ?? []), + ...(roots.additionalDirs ?? []), + ...(primary ? this.skillCatalog?.catalog.getSkillRoots() ?? [] : []), ], - } : this.environment.workspaceRoots(environmentId)); + }); const env = { _serviceBrand: undefined, ...view.host, ready: Promise.resolve() }; const workspace = this.workspace(view); let path: string | undefined; diff --git a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts index 16f5b6d0ddf..67e34047fcf 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts @@ -18,7 +18,6 @@ import { attachmentFileSource, environmentFileSource, withAttachmentLocation, ty import { EnvironmentWorkspaceView } from '#/environment/environmentWorkspaceView'; import { unwrapErrorCause } from '#/_base/errors/errors'; import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { IConfigService } from '#/app/config/config'; import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender'; import { @@ -184,7 +183,6 @@ export class ReadTool implements IReadTool { readonly parameters: Record = toInputJsonSchema(ReadInputSchema); constructor( @IAgentEnvironmentService private readonly environment: IAgentEnvironmentService, - @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, @IAgentToolResultTruncationService private readonly resultTruncation: IAgentToolResultTruncationService, @IConfigService private readonly config: IConfigService, @@ -213,12 +211,13 @@ export class ReadTool implements IReadTool { } async resolveExecution(args: ReadInput, context?: ToolResolutionContext): Promise { - const environmentId = context?.environmentId; + const environmentId = context?.environment?.environmentId; + const primary = context?.environment?.primary !== false; if (args.column_offset !== undefined && (args.line_offset ?? 1) < 0) { return { isError: true, output: 'column_offset is only supported for forward reads. Use a positive line_offset or the forward Next Read arguments.' }; } if (isDaemonFileUrl(args.path)) return this.attachmentExecution(args); - if (environmentId === undefined) { + if (primary) { const planTarget = await this.planService?.resolveFile(args.path, 'read'); if (planTarget !== undefined) return this.localArtifactExecution(args, planTarget, true); const artifactTarget = this.localOutputTarget(args.path); @@ -228,10 +227,11 @@ export class ReadTool implements IReadTool { try { const inspected = resolutionLease.environment; const expectedGeneration = pinnedGeneration(inspected); - const view = new EnvironmentWorkspaceView(inspected, environmentId === undefined ? { - workDir: this.workspaceCtx.workDir, - additionalDirs: [...this.workspaceCtx.additionalDirs, ...this.skillCatalog.catalog.getSkillRoots()], - } : this.environment.workspaceRoots(environmentId)); + const roots = this.environment.workspaceRoots(environmentId); + const view = new EnvironmentWorkspaceView(inspected, { + workDir: roots.workDir, + additionalDirs: [...(roots.additionalDirs ?? []), ...(primary ? this.skillCatalog.catalog.getSkillRoots() : [])], + }); const env = { _serviceBrand: undefined, ...view.host, ready: Promise.resolve() }; const workspace = this.workspaceConfig(view); const path = resolvePathAccessPath(args.path, { diff --git a/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts b/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts index e6906eeeb58..6ed3b7ea959 100644 --- a/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts @@ -6,7 +6,6 @@ import { acquireOrWhenReady, IAgentEnvironmentService, pinnedGeneration } from ' import { IAgentPlanService } from '#/features/plan/plan'; import { EnvironmentWorkspaceView } from '#/environment/environmentWorkspaceView'; import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { ToolAccesses, type ExecutableToolResult, @@ -31,7 +30,6 @@ export class WriteTool implements IWriteTool { constructor( @IAgentEnvironmentService private readonly environment: IAgentEnvironmentService, - @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, @IAgentPlanService private readonly planService?: IAgentPlanService, ) {} @@ -41,8 +39,9 @@ export class WriteTool implements IWriteTool { } async resolveExecution(args: WriteInput, context?: ToolResolutionContext): Promise { - const environmentId = context?.environmentId; - const planTarget = environmentId === undefined ? await this.planService?.resolveFile(args.path) : undefined; + const environmentId = context?.environment?.environmentId; + const primary = context?.environment?.primary !== false; + const planTarget = primary ? await this.planService?.resolveFile(args.path) : undefined; if (planTarget !== undefined) { const path = planTarget.path; return { @@ -64,13 +63,14 @@ export class WriteTool implements IWriteTool { try { const inspected = resolutionLease.environment; const expectedGeneration = pinnedGeneration(inspected); - const view = new EnvironmentWorkspaceView(inspected, environmentId === undefined ? { - workDir: this.workspaceCtx.workDir, + const roots = this.environment.workspaceRoots(environmentId); + const view = new EnvironmentWorkspaceView(inspected, { + workDir: roots.workDir, additionalDirs: [ - ...this.workspaceCtx.additionalDirs, - ...(this.skillCatalog?.catalog.getSkillRoots() ?? []), + ...(roots.additionalDirs ?? []), + ...(primary ? this.skillCatalog?.catalog.getSkillRoots() ?? [] : []), ], - } : this.environment.workspaceRoots(environmentId)); + }); const env = { _serviceBrand: undefined, ...view.host, ready: Promise.resolve() }; const workspace = this.workspaceConfig(view); const path = resolvePathAccessPath(args.path, { diff --git a/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts b/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts index 97f6ed96976..f37b8f64ce2 100644 --- a/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts +++ b/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts @@ -229,7 +229,8 @@ export class ReadMediaFileTool implements AgentTool { } async resolveExecution(args: ReadMediaFileInput, context?: ToolResolutionContext): Promise { - const environmentId = context?.environmentId; + const environmentId = context?.environment?.environmentId; + const primary = context?.environment?.primary !== false; if (!args.path) { return { isError: true, output: 'File path cannot be empty.' }; } @@ -240,10 +241,11 @@ export class ReadMediaFileTool implements AgentTool { try { const inspected = resolutionLease.environment; const expectedGeneration = pinnedGeneration(inspected); - const view = new EnvironmentWorkspaceView(inspected, environmentId === undefined ? { - workDir: this.workspace.workspaceDir, - additionalDirs: this.workspace.additionalDirs, - } : this.environment.workspaceRoots(environmentId)); + const roots = this.environment.workspaceRoots(environmentId); + const view = new EnvironmentWorkspaceView(inspected, { + workDir: roots.workDir, + additionalDirs: primary ? this.workspace.additionalDirs : roots.additionalDirs, + }); const env = view.host; const workspace = { workspaceDir: view.workDir, additionalDirs: view.additionalDirs }; const path = resolvePathAccessPath(args.path, { @@ -258,7 +260,7 @@ export class ReadMediaFileTool implements AgentTool { approvalRule: literalRulePattern(this.name, path), matchesRule: (ruleArgs) => matchesPathRuleSubject(ruleArgs, path, { - cwd: environmentId === undefined ? this.workspace.workspaceDir : workspace.workspaceDir, + cwd: workspace.workspaceDir, pathClass: env.pathClass, homeDir: env.homeDir, }), diff --git a/packages/agent-core-v2/src/environment/environment.ts b/packages/agent-core-v2/src/environment/environment.ts index 08c1a8c361f..d7230b6fc28 100644 --- a/packages/agent-core-v2/src/environment/environment.ts +++ b/packages/agent-core-v2/src/environment/environment.ts @@ -13,6 +13,10 @@ export interface EnvironmentBinding { readonly cwd?: string; } +export interface EnvironmentTarget extends EnvironmentBinding { + readonly primary: boolean; +} + export function environmentBindingId(environmentId: string, cwd?: string): string { return cwd === undefined ? environmentId : `${environmentId}\0${cwd}`; } diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts index 2d0abb6288d..279c9e3d0d8 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts @@ -27,6 +27,7 @@ export type FileHistoryChangeStatus = 'added' | 'modified' | 'deleted'; export interface FileHistoryChange { readonly path: string; + readonly environmentId?: string; readonly status: FileHistoryChangeStatus; readonly additions: number; readonly deletions: number; @@ -52,6 +53,7 @@ export interface IAgentFileHistoryService { turnId: number, path: string, phase?: FileHistoryCheckpointPhase, + environmentId?: string, ): Promise; } diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts index 4ad4d7ff35b..3b1b51bb5e1 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -159,13 +159,15 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor const changes: FileHistoryChange[] = []; const lcsBudget = { remaining: LCS_AGGREGATE_CELL_BUDGET }; - for (const path of paths.toSorted()) { - const before = Object.hasOwn(start.entries, path) ? start.entries[path] : undefined; - const after = end !== undefined ? end.entries[path] : undefined; + for (const pathKey of paths.toSorted()) { + const before = Object.hasOwn(start.entries, pathKey) ? start.entries[pathKey] : undefined; + const after = end !== undefined ? end.entries[pathKey] : undefined; + const environmentId = before?.environmentId; + const path = this.entryPath(pathKey, environmentId); if (end !== undefined && before?.version === after?.version) continue; if (before?.oversize === true && after?.oversize === true) { if (before.version !== after.version) { - changes.push({ path, status: 'modified', additions: 0, deletions: 0, oversize: true }); + changes.push({ path, environmentId, status: 'modified', additions: 0, deletions: 0, oversize: true }); } continue; } @@ -178,7 +180,7 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor afterBytes = await this.entryBytes(after); } } else { - const current = await this.readCurrent(path, before?.environmentId); + const current = await this.readCurrent(pathKey, environmentId); if (current === 'unreadable') continue; if (current instanceof Uint8Array) afterBytes = current; else if (current === 'missing') liveMissing = true; @@ -198,7 +200,7 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor continue; } const status = beforeMissing ? 'added' : afterMissing ? 'deleted' : 'modified'; - changes.push({ path, status, additions: 0, deletions: 0, oversize: true }); + changes.push({ path, environmentId, status, additions: 0, deletions: 0, oversize: true }); continue; } const beforeBytes = await this.entryBytes(before); @@ -208,11 +210,11 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor end !== undefined && after !== undefined && after.key !== null && afterBytes === undefined; if (beforeLost || afterLost) { const status = beforeMissing ? 'added' : afterMissing ? 'deleted' : 'modified'; - changes.push({ path, status, additions: 0, deletions: 0, binary: true }); + changes.push({ path, environmentId, status, additions: 0, deletions: 0, binary: true }); continue; } const change = diffChange(path, beforeBytes, afterBytes, lcsBudget); - if (change !== undefined) changes.push(change); + if (change !== undefined) changes.push({ ...change, environmentId }); } return changes; } @@ -221,14 +223,16 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor turnId: number, path: string, phase: FileHistoryCheckpointPhase = 'start', + environmentId?: string, ): Promise { - return this.enqueueValue(() => this.readContentAt(turnId, path, phase)); + return this.enqueueValue(() => this.readContentAt(turnId, path, phase, environmentId)); } private async readContentAt( turnId: number, path: string, phase: FileHistoryCheckpointPhase, + environmentId: string | undefined, ): Promise { const state = this.history(); const index = state.checkpoints.findIndex( @@ -236,7 +240,7 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor ); if (index < 0) return undefined; const record = state.checkpoints[index]!; - const pathKey = this.pathKey(path); + const pathKey = this.entryKey(path, this.recordedEnvironmentId(environmentId)); let entry = Object.hasOwn(record.entries, pathKey) ? record.entries[pathKey] : undefined; if (entry === undefined && phase === 'end') { const start = state.checkpoints.find( @@ -259,7 +263,7 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor private onWillExecuteTool(event: WillExecuteToolEvent): void { const path = editTargetPath(event.execution.display); if (path === undefined) return; - event.waitUntil(this.enqueue(() => this.capture(path, event.turnId, event.environmentId))); + event.waitUntil(this.enqueue(() => this.capture(path, event.turnId, event.environment?.environmentId))); } private onSubagentWillExecuteTool(event: WillExecuteToolEvent): void { @@ -267,7 +271,9 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor if (path === undefined) return; const main = this.agentLifecycle.handleOf(MAIN_AGENT_ID); if (main === undefined) return; - event.waitUntil(main.accessor.get(IAgentFileHistoryService).captureForActiveTurn(path, event.environmentId)); + event.waitUntil( + main.accessor.get(IAgentFileHistoryService).captureForActiveTurn(path, event.environment?.environmentId), + ); } captureForActiveTurn(path: string, environmentId?: string): Promise { @@ -291,8 +297,9 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor return run; } - private async capture(path: string, turnId: number, environmentId?: string): Promise { - const pathKey = environmentId === undefined ? this.pathKey(path) : `${environmentId}:${path}`; + private async capture(path: string, turnId: number, routedEnvironmentId?: string): Promise { + const environmentId = this.recordedEnvironmentId(routedEnvironmentId); + const pathKey = this.entryKey(path, environmentId); const state = this.history(); const startCheckpoint = state.checkpoints.find( (c) => c.turnId === turnId && checkpointPhaseOf(c) === 'start', @@ -475,11 +482,11 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor ): Promise< Uint8Array | 'missing' | 'unreadable' | { oversizeBytes: number; mtimeMs?: number } > { + let workDir: string; let lease: EnvironmentLease; try { - lease = environmentId === undefined - ? this.environment.acquire(['fs']) - : await this.environment.acquireWhenReady(['fs'], environmentId); + workDir = this.environment.workspaceRoots(environmentId).workDir; + lease = this.environment.acquire(['fs'], environmentId); } catch (error) { if (error instanceof EnvironmentError) return 'unreadable'; throw error; @@ -489,9 +496,7 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor if (fs === undefined) return 'unreadable'; const path = lease.environment.path; if (path === undefined) return 'unreadable'; - const absolute = environmentId === undefined - ? resolveWorkspacePath(path, this.workspaceCtx.workDir, pathKey) - : pathKey.slice(environmentId.length + 1); + const absolute = resolveWorkspacePath(path, workDir, this.entryPath(pathKey, environmentId)); let info; try { info = await fs.stat(absolute); @@ -521,6 +526,19 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor } } + private recordedEnvironmentId(environmentId: string | undefined): string | undefined { + const primary = this.environment.targets().find((target) => target.primary); + return environmentId === primary?.environmentId ? undefined : environmentId; + } + + private entryKey(path: string, environmentId: string | undefined): string { + return environmentId === undefined ? this.pathKey(path) : `${environmentId}:${path}`; + } + + private entryPath(pathKey: string, environmentId: string | undefined): string { + return environmentId === undefined ? pathKey : pathKey.slice(environmentId.length + 1); + } + private pathKey(path: string): string { let raw = path; const { semantics, caseInsensitive } = this.pathSemantics(); diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index 5f04ae70d50..28b29dae8ba 100644 --- a/packages/agent-core-v2/src/tool/toolContract.ts +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -1,5 +1,6 @@ import type { ContentPart, ToolCall, ToolDescription as Tool } from '#human/llm/message'; import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import type { EnvironmentTarget } from '#/environment/environment'; import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; export type ExecutableToolOutput = string | ContentPart[]; @@ -90,7 +91,7 @@ export interface RunnableToolExecution { export type ToolExecution = RunnableToolExecution | ExecutableToolErrorResult; export interface ToolResolutionContext { - readonly environmentId?: string; + readonly environment?: EnvironmentTarget; } export interface ExecutableTool extends Tool { diff --git a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts index 226df92a613..668c00b5198 100644 --- a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts +++ b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts @@ -15,7 +15,7 @@ import type { ToolCall } from '#human/message'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem, type HostFileStat } from '#/os/interface/hostFileSystem'; -import type { Environment } from '#/environment/environment'; +import type { Environment, EnvironmentTarget } from '#/environment/environment'; import { EnvironmentError } from '#/environment/environmentRegistry'; import { IAgentEnvironmentService } from '#/agent/environmentBinding/agentEnvironment'; import { stubAgentEnvironment } from '../../environment/stubs'; @@ -273,7 +273,7 @@ function didCtx( readonly result?: ExecutableToolResult; readonly preflightRejected?: boolean; readonly accesses?: ToolAccessesType; - readonly environmentId?: string; + readonly environment?: EnvironmentTarget; } = {}, ): ToolDidExecuteContext { const toolCall: ToolCall = { @@ -288,7 +288,7 @@ function didCtx( toolCall, toolCalls: [toolCall], args, - environmentId: options.environmentId, + environment: options.environment, tool: options.preflightRejected === true ? undefined : ({} as ExecutableTool), outcome: options.preflightRejected === true ? 'preflight-rejected' : 'executed', accesses: @@ -1563,11 +1563,25 @@ describe('agentsMdReminder environment degradation', () => { throw new Error('primary environment must not be read'); }; - await fire(h, didCtx('Read', { path: '/srv/app/AGENTS.md' }, { environmentId: 'gpu' })); + await fire(h, didCtx('Read', { path: '/srv/app/AGENTS.md' }, { environment: { environmentId: 'gpu', primary: false } })); expect(agentsMdMessages(h)).toHaveLength(0); expect(h.reminders).toHaveLength(0); }); + + it('inspects the primary environment for calls that name it', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'named'); + const subAgentsMd = await writeAgentsMd(subDir, 'package instructions'); + h.reminder.seedInjected([], workDir); + + await fire( + h, + didCtx('Read', { path: join(subDir, 'index.ts') }, { environment: { environmentId: 'local', primary: true } }), + ); + + expect(reminderText(h)).toContain(subAgentsMd); + }); }); describe('extractBashTargetDirs', () => { diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts index 77b92684527..506d3c47092 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts @@ -26,6 +26,7 @@ import { } from '#/agent/permissionRules/permissionRules'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentEnvironmentService } from '#/agent/environmentBinding/agentEnvironment'; +import type { EnvironmentTarget } from '#/environment/environment'; import { EnvironmentError } from '#/environment/environmentRegistry'; import { IConfigService } from '#/app/config/config'; import { PERMISSION_SECTION } from '#/agent/permissionRules/configSection'; @@ -87,9 +88,9 @@ describe('AgentPermissionPolicyService chain', () => { ); reg.definePartialInstance(IAgentPermissionRulesService, permissionRulesStub({ rules: () => rules, - sessionApprovalRulePatterns: () => sessionApprovalRulePatterns, - environmentSessionApprovalRulePatterns: (environmentId) => - environmentSessionApprovalRulePatterns[environmentId] ?? [], + sessionApprovalRulePatterns: (environment) => environment?.primary === false + ? environmentSessionApprovalRulePatterns[environment.environmentId] ?? [] + : sessionApprovalRulePatterns, })); reg.defineInstance(ISessionWorkspaceContext, workspace.stub); reg.defineInstance(IHostEnvironment, kaosStub()); @@ -123,7 +124,7 @@ describe('AgentPermissionPolicyService chain', () => { dispose: () => {}, }), acquireWhenReady() { return Promise.resolve((this as IAgentEnvironmentService).acquire()); }, - workspaceRoots: () => ({ workDir: '/workspace', additionalDirs: [] }), + workspaceRoots: () => ({ workDir: workspace.stub.workDir, additionalDirs: workspace.stub.additionalDirs }), }); reg.defineInstance(ITelemetryService, recordingTelemetry([])); reg.definePartialInstance(IGitService, { findWorkTree: async () => null }); @@ -238,7 +239,7 @@ describe('AgentPermissionPolicyService chain', () => { const routed = { toolName: 'Bash', args: { command: 'printf first', timeout: 60 }, - environmentId: 'gpu', + environment: { environmentId: 'gpu', primary: false }, }; await expect(evaluate(routed)).resolves.toMatchObject({ @@ -251,7 +252,7 @@ describe('AgentPermissionPolicyService chain', () => { policyName: 'session-approval-history', result: { kind: 'approve' }, }); - await expect(evaluate({ ...routed, environmentId: 'staging' })).resolves.toMatchObject({ + await expect(evaluate({ ...routed, environment: { environmentId: 'staging', primary: false } })).resolves.toMatchObject({ policyName: 'user-configured-ask', }); }); @@ -590,7 +591,7 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { dirname: (path: string) => dirname(path), }, workspace: { mapRoots: (roots) => roots }, - fs: environmentId !== undefined + fs: environmentId !== undefined && environmentId !== 'local' ? ({ stat: async () => { throw new Error('missing'); } } as unknown as typeof hostFs) : environmentFsAvailable ? hostFs : undefined, }, @@ -598,10 +599,9 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { }; }, acquireWhenReady() { return Promise.resolve((this as IAgentEnvironmentService).acquire()); }, - workspaceRoots: (environmentId) => ({ - workDir: environmentId === undefined ? '/workspace' : workspaceDir, - additionalDirs: [], - }), + workspaceRoots: (environmentId) => environmentId === undefined || environmentId === 'local' + ? { workDir: workspace.stub.workDir, additionalDirs: workspace.stub.additionalDirs } + : { workDir: workspaceDir, additionalDirs: [] }, }); reg.defineInstance(ITelemetryService, recordingTelemetry([])); reg.definePartialInstance(IGitService, { @@ -652,7 +652,7 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { toolName: 'Write', args: { path: 'src/a.ts', content: 'x' }, accesses: ToolAccesses.writeFile(join(workspaceDir, 'src/a.ts')), - environmentId: 'gpu', + environment: { environmentId: 'gpu', primary: false }, })).resolves.toMatchObject({ policyName: 'fallback-ask', result: { kind: 'ask' }, @@ -661,6 +661,19 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { expect(acquiredEnvironments).not.toContain(undefined); }); + it('judges a Write routed to the primary by name like an unrouted one', async () => { + await expect(evaluate({ + toolName: 'Write', + args: { path: 'src/a.ts', content: 'x' }, + accesses: ToolAccesses.writeFile(join(workspaceDir, 'src/a.ts')), + environment: { environmentId: 'local', primary: true }, + })).resolves.toMatchObject({ + policyName: 'git-cwd-write-approve', + result: { kind: 'approve' }, + }); + expect([...new Set(acquiredEnvironments)]).toEqual(['local']); + }); + it('approves Edit on an additionalDir path in manual mode', async () => { const extraDir = await mkdtemp(join(tmpdir(), 'kimi-permission-extra-')); cleanupDirs.push(extraDir); @@ -776,23 +789,18 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { interface MutablePermissionRulesStubOptions { readonly rules?: () => readonly PermissionRule[]; - readonly sessionApprovalRulePatterns?: () => readonly string[]; - readonly environmentSessionApprovalRulePatterns?: (environmentId: string) => readonly string[]; + readonly sessionApprovalRulePatterns?: (environment?: EnvironmentTarget) => readonly string[]; } function permissionRulesStub( options: MutablePermissionRulesStubOptions = {}, ): Partial { const rules = options.rules ?? (() => []); - const sessionApprovalRulePatterns = options.sessionApprovalRulePatterns ?? (() => []); return { get rules() { return rules(); }, - get sessionApprovalRulePatterns() { - return sessionApprovalRulePatterns(); - }, - environmentSessionApprovalRulePatterns: options.environmentSessionApprovalRulePatterns ?? (() => []), + sessionApprovalRulePatterns: options.sessionApprovalRulePatterns ?? (() => []), addRules: () => {}, recordApprovalResult: () => {}, }; @@ -803,7 +811,7 @@ interface PolicyContextInput { readonly toolName: string; readonly args: Record; readonly accesses?: ToolAccessList; - readonly environmentId?: string; + readonly environment?: EnvironmentTarget; } function policyContext(input: PolicyContextInput): ResolvedToolExecutionHookContext { @@ -815,7 +823,7 @@ function policyContext(input: PolicyContextInput): ResolvedToolExecutionHookCont toolCall, toolCalls: [toolCall], args: input.args, - environmentId: input.environmentId, + environment: input.environment, execution: { description: description(input.toolName), display: display(input.toolName, input.args), diff --git a/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts b/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts index f5b53ac8e93..aed4e9c434b 100644 --- a/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts +++ b/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts @@ -84,19 +84,20 @@ describe('AgentPermissionRulesService (wire-backed)', () => { const approval = sessionApproval('Bash(rm *)'); svc.recordApprovalResult(approval); - expect(svc.sessionApprovalRulePatterns).toEqual(['Bash(rm *)']); + expect(svc.sessionApprovalRulePatterns()).toEqual(['Bash(rm *)']); svc.recordApprovalResult(approval); - expect(svc.sessionApprovalRulePatterns).toEqual(['Bash(rm *)']); + expect(svc.sessionApprovalRulePatterns()).toEqual(['Bash(rm *)']); }); it('keeps session approvals for another environment out of the primary pattern set', () => { svc.recordApprovalResult({ ...sessionApproval('Bash(rm *)'), environmentId: 'gpu' }); svc.recordApprovalResult({ ...sessionApproval('Bash(rm *)'), environmentId: 'gpu' }); - expect(svc.sessionApprovalRulePatterns).toEqual([]); - expect(svc.environmentSessionApprovalRulePatterns('gpu')).toEqual(['Bash(rm *)']); - expect(svc.environmentSessionApprovalRulePatterns('staging')).toEqual([]); + expect(svc.sessionApprovalRulePatterns()).toEqual([]); + expect(svc.sessionApprovalRulePatterns({ environmentId: 'local', primary: true })).toEqual([]); + expect(svc.sessionApprovalRulePatterns({ environmentId: 'gpu', primary: false })).toEqual(['Bash(rm *)']); + expect(svc.sessionApprovalRulePatterns({ environmentId: 'staging', primary: false })).toEqual([]); }); it('ignores non-session approvals for the pattern set', () => { @@ -108,7 +109,7 @@ describe('AgentPermissionRulesService (wire-backed)', () => { result: { decision: 'approved' }, }; svc.recordApprovalResult(oneTime); - expect(svc.sessionApprovalRulePatterns).toEqual([]); + expect(svc.sessionApprovalRulePatterns()).toEqual([]); }); it('only persists approval records (permission.rules.add is live-only)', async () => { diff --git a/packages/agent-core-v2/test/agent/permissionRules/stubs.ts b/packages/agent-core-v2/test/agent/permissionRules/stubs.ts index 9f4ee00cbd1..34620cf3fbb 100644 --- a/packages/agent-core-v2/test/agent/permissionRules/stubs.ts +++ b/packages/agent-core-v2/test/agent/permissionRules/stubs.ts @@ -11,8 +11,7 @@ export function stubPermissionRulesService( get rules() { return rules(); }, - sessionApprovalRulePatterns: [], - environmentSessionApprovalRulePatterns: () => [], + sessionApprovalRulePatterns: () => [], addRules: () => {}, recordApprovalResult: () => {}, }; diff --git a/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts b/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts index 80dd3b730b3..2e5be6703a6 100644 --- a/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts +++ b/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts @@ -116,8 +116,7 @@ describe('AgentToolApprovalService', () => { reg.defineInstance(IAgentPermissionRulesService, { _serviceBrand: undefined, rules: [], - sessionApprovalRulePatterns: [], - environmentSessionApprovalRulePatterns: () => [], + sessionApprovalRulePatterns: () => [], addRules: () => {}, recordApprovalResult: (record) => { recorded.push(record); diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index 89a6b205fda..05a4bfabe72 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -53,9 +53,9 @@ import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { HostProcessService } from '#/os/backends/node-local/hostProcessService'; import { FakeEnvironment } from '#/environment/fakeEnvironment'; -import { IAgentEnvironmentService, type AgentEnvironmentTarget } from '#/agent/environmentBinding/agentEnvironment'; +import { IAgentEnvironmentService } from '#/agent/environmentBinding/agentEnvironment'; +import type { EnvironmentTarget } from '#/environment/environment'; import type { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; -import { stubWorkspaceContext } from '../../session/workspaceContext/stub-workspace-context'; import { stubAgentEnvironment } from '../../environment/stubs'; import { ConfigRegistry, ConfigService } from '#/app/config/configService'; import { IConfigRegistry, IConfigService } from '#/app/config/config'; @@ -94,7 +94,7 @@ let events: ToolExecutorEvent[]; let protocolEvents: ProtocolEvent[]; let telemetryEvents: TelemetryRecord[]; let truncateForModel: IAgentToolResultTruncationService['truncateForModel']; -let environmentTargets: AgentEnvironmentTarget[]; +let environmentTargets: EnvironmentTarget[]; let durations: number[]; @@ -1109,11 +1109,10 @@ describe('truncation pipeline', () => { { environmentId: 'local', generation: 'test' }, { capabilities: ['fs', 'process'] }, ), { fs: new HostFileSystem(), process: globProcess }); - const binding = stubAgentEnvironment(environment); + const binding = stubAgentEnvironment(environment, { workDir: homeDir }); mediaEnvironment = binding; registry.register(new ReadTool( binding, - stubWorkspaceContext(homeDir), { catalog: { getSkillRoots: () => [] } } as unknown as ISessionSkillCatalog, truncation, readConfig, @@ -1124,7 +1123,7 @@ describe('truncation pipeline', () => { { resolve: () => ({}) } as unknown as IAgentToolRegistryService, attachmentStore, )); - registry.register(new GlobTool(binding, stubWorkspaceContext(homeDir), noopTelemetryService)); + registry.register(new GlobTool(binding, noopTelemetryService)); }); afterEach(async () => { @@ -1647,9 +1646,9 @@ describe('AgentToolExecutorService environment routing', () => { it('routes a call to the named environment, hands the tool its own arguments, and stamps the display', async () => { const tool = new RoutedTool('scoped', textSchema); registry.register(tool, { environmentScoped: true }); - const observed: { readonly environmentId: string | undefined; readonly args: unknown }[] = []; + const observed: { readonly environment: EnvironmentTarget | undefined; readonly args: unknown }[] = []; executor.onBeforeExecuteTool((event) => { - observed.push({ environmentId: event.environmentId, args: event.args }); + observed.push({ environment: event.environment, args: event.args }); }); await execute([ @@ -1658,15 +1657,16 @@ describe('AgentToolExecutorService environment routing', () => { toolCall('call_default', 'scoped', { text: 'c' }), ]); + const [local, gpu] = environmentTargets; expect(tool.resolutions).toEqual([ - { args: { text: 'a' }, environmentId: 'gpu' }, - { args: { text: 'b' }, environmentId: undefined }, - { args: { text: 'c' }, environmentId: undefined }, + { args: { text: 'a' }, environment: gpu }, + { args: { text: 'b' }, environment: local }, + { args: { text: 'c' }, environment: local }, ]); expect(observed).toEqual([ - { environmentId: 'gpu', args: { text: 'a', environment: 'gpu' } }, - { environmentId: undefined, args: { text: 'b', environment: 'local' } }, - { environmentId: undefined, args: { text: 'c' } }, + { environment: gpu, args: { text: 'a', environment: 'gpu' } }, + { environment: local, args: { text: 'b', environment: 'local' } }, + { environment: local, args: { text: 'c' } }, ]); const displays = protocolEvents .filter((event): event is ToolCallStarted => event.type === 'tool.call.started') @@ -1753,7 +1753,6 @@ describe('AgentToolExecutorService environment routing', () => { registry.register( new ReadTool( ix.get(IAgentEnvironmentService), - stubWorkspaceContext('/workspace'), { catalog: { getSkillRoots: () => [] } } as unknown as ISessionSkillCatalog, stubToolResultTruncationService(), stubConfigService(), @@ -1784,7 +1783,7 @@ describe('AgentToolExecutorService environment routing', () => { class RoutedTool implements ExecutableTool> { readonly description = 'Routed tool.'; - readonly resolutions: Array<{ readonly args: Record; readonly environmentId: string | undefined }> = []; + readonly resolutions: Array<{ readonly args: Record; readonly environment: EnvironmentTarget | undefined }> = []; constructor( readonly name: string, @@ -1792,7 +1791,7 @@ class RoutedTool implements ExecutableTool> { ) {} resolveExecution(args: Record, context?: ToolResolutionContext): ToolExecution { - this.resolutions.push({ args, environmentId: context?.environmentId }); + this.resolutions.push({ args, environment: context?.environment }); return { approvalRule: this.name, display: { kind: 'command', command: String(args['text']) }, diff --git a/packages/agent-core-v2/test/agent/tools/os/environmentGenerationGuard.test.ts b/packages/agent-core-v2/test/agent/tools/os/environmentGenerationGuard.test.ts index 91c2fdfae64..e681b2a35b5 100644 --- a/packages/agent-core-v2/test/agent/tools/os/environmentGenerationGuard.test.ts +++ b/packages/agent-core-v2/test/agent/tools/os/environmentGenerationGuard.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { IFileEditService } from '#/app/edit/fileEdit'; import { noopTelemetryService } from '#/app/telemetry/telemetry'; -import type { Environment, EnvironmentBinding, EnvironmentCapability } from '#/environment/environment'; +import type { Environment, EnvironmentBinding, EnvironmentCapability, EnvironmentTarget } from '#/environment/environment'; import { FakeEnvironment } from '#/environment/fakeEnvironment'; import { EnvironmentRegistry, @@ -30,7 +30,6 @@ import { createFakeHostFs } from '../../../tools/fixtures/fake-exec'; import { stubConfigService } from '../../../app/config/stubs'; import { stubToolResultTruncationService } from '../../toolResultTruncation/stubs'; import { makeSessionContext } from '#/session/sessionContext/sessionContext'; -import { stubWorkspaceContext } from '../../../session/workspaceContext/stub-workspace-context'; vi.mock('#/os/backends/node-local/tools/rgLocator', async (importOriginal) => { const original = await importOriginal(); @@ -186,7 +185,6 @@ const editService = { } as unknown as IFileEditService; function toolCases(): readonly ToolCase[] { - const workspace = stubWorkspaceContext('/workspace'); return [ { name: 'Read', @@ -194,7 +192,6 @@ function toolCases(): readonly ToolCase[] { resolve: (service, path = '/workspace/a.txt') => new ReadTool( service, - workspace, skillCatalog, stubToolResultTruncationService(), stubConfigService(), @@ -211,7 +208,7 @@ function toolCases(): readonly ToolCase[] { name: 'Write', fs: writableFs(), resolve: (service, path = '/workspace/a.txt') => - new WriteTool(service, workspace).resolveExecution({ path, content: 'hello' }), + new WriteTool(service).resolveExecution({ path, content: 'hello' }), expectOutcome: (result) => { expect(result.isError).not.toBe(true); expect(result.output).toContain('Wrote 5 bytes'); @@ -221,7 +218,7 @@ function toolCases(): readonly ToolCase[] { name: 'Edit', fs: createFakeHostFs(), resolve: (service, path = '/workspace/a.txt') => - new EditTool(editService, service, workspace).resolveExecution({ + new EditTool(editService, service).resolveExecution({ path, old_string: 'foo', new_string: 'bar', @@ -235,7 +232,7 @@ function toolCases(): readonly ToolCase[] { name: 'Grep', fs: createFakeHostFs(), resolve: (service) => - new GrepTool(service, workspace, noopTelemetryService).resolveExecution({ pattern: 'needle' }), + new GrepTool(service, noopTelemetryService).resolveExecution({ pattern: 'needle' }), expectOutcome: (result) => { expect(outputText(result)).toContain('ripgrep'); }, @@ -244,7 +241,7 @@ function toolCases(): readonly ToolCase[] { name: 'Glob', fs: writableFs(), resolve: (service) => - new GlobTool(service, workspace, noopTelemetryService).resolveExecution({ pattern: '**/*.ts' }), + new GlobTool(service, noopTelemetryService).resolveExecution({ pattern: '**/*.ts' }), expectOutcome: (result) => { expect(outputText(result)).toContain('ripgrep'); }, @@ -351,7 +348,7 @@ describe('tilde resolution against the reconnected host', () => { it('resolves ~ against the reconnected host home instead of /', async () => { const { service, calls } = pendingThenReadyHarness({ homeDir: '/home/me' }); - const tool = new GlobTool(service, stubWorkspaceContext('/workspace'), noopTelemetryService); + const tool = new GlobTool(service, noopTelemetryService); const execution = await tool.resolveExecution({ pattern: '**/*.ts', path: '~/projects' }); expect(calls).toEqual(['connect']); expect((execution as Extract).accesses).toEqual( @@ -374,7 +371,7 @@ describe('tilde resolution against the reconnected host', () => { }, ); registry.register(pending); - const tool = new GlobTool(registryBackedService(registry), stubWorkspaceContext('/workspace'), noopTelemetryService); + const tool = new GlobTool(registryBackedService(registry), noopTelemetryService); await expect(tool.resolveExecution({ pattern: '**/*.ts', path: '~/projects' })).rejects.toThrow( 'connection refused', ); @@ -392,7 +389,6 @@ describe('Bash environment generation guard', () => { sessionScope: 'ses', cwd: '/workspace', }), - stubWorkspaceContext('/workspace'), {} as never, toolPolicy, stubConfigService(), @@ -457,7 +453,7 @@ describe('tool environment routing', () => { acquire: (required = [], environmentId) => registry.acquire(binding(environmentId), required), acquireWhenReady: (required = [], environmentId) => registry.acquireWhenReady(binding(environmentId), required), workspaceRoots: (environmentId) => ({ - workDir: environmentId === undefined ? '/workspace' : '/gpu', + workDir: binding(environmentId).environmentId === 'local' ? '/workspace' : '/gpu', additionalDirs: [], }), }; @@ -466,25 +462,28 @@ describe('tool environment routing', () => { it('reads from the requested environment and resolves relative paths against its working directory', async () => { const { service } = routedHarness(); - const read = (environmentId?: string) => + const read = (environment?: EnvironmentTarget) => new ReadTool( service, - stubWorkspaceContext('/workspace'), skillCatalog, stubToolResultTruncationService(), stubConfigService(), profile, toolPolicy, toolRegistry, - ).resolveExecution({ path: 'notes.txt' }, { environmentId }); + ).resolveExecution({ path: 'notes.txt' }, { environment }); - const routed = await read('gpu'); + const routed = await read({ environmentId: 'gpu', primary: false }); expect(routed).toMatchObject({ display: { kind: 'file_io', operation: 'read', path: '/gpu/notes.txt' } }); expect(outputText(await finish(routed))).toContain('gpu content'); const primary = await read(); expect(primary).toMatchObject({ display: { path: '/workspace/notes.txt' } }); expect(outputText(await finish(primary))).toContain('local content'); + + const named = await read({ environmentId: 'local', primary: true }); + expect(named).toMatchObject({ display: { path: '/workspace/notes.txt' } }); + expect(outputText(await finish(named))).toContain('local content'); }); it('runs Bash on the requested environment in its working directory', async () => { @@ -498,13 +497,12 @@ describe('tool environment routing', () => { sessionScope: 'ses', cwd: '/workspace', }), - stubWorkspaceContext('/workspace'), {} as never, toolPolicy, stubConfigService(), ); - const resolved = bash.resolveExecution({ command: 'pwd' }, { environmentId: 'gpu' }); + const resolved = bash.resolveExecution({ command: 'pwd' }, { environment: { environmentId: 'gpu', primary: false } }); expect(resolved).toMatchObject({ display: { kind: 'command', cwd: '/gpu' } }); const result = await finish(resolved); diff --git a/packages/agent-core-v2/test/agent/tools/os/rgUnavailableGuidance.test.ts b/packages/agent-core-v2/test/agent/tools/os/rgUnavailableGuidance.test.ts index 8998335b8a2..b2b72ac805e 100644 --- a/packages/agent-core-v2/test/agent/tools/os/rgUnavailableGuidance.test.ts +++ b/packages/agent-core-v2/test/agent/tools/os/rgUnavailableGuidance.test.ts @@ -10,7 +10,6 @@ import { FakeEnvironment } from '#/environment/fakeEnvironment'; import { ensureRgPath } from '#/os/backends/node-local/tools/rgLocator'; import { GlobTool } from '#/agent/tools/os/glob/globTool'; import { GrepTool } from '#/agent/tools/os/grep/grepTool'; -import { stubWorkspaceContext } from '../../../session/workspaceContext/stub-workspace-context'; import { stubAgentEnvironment } from '../../../environment/stubs'; vi.mock('#/os/backends/node-local/tools/rgLocator', async (importOriginal) => { @@ -88,7 +87,6 @@ const TOOLS: readonly { finish( await new GlobTool( stubAgentEnvironment(backend), - stubWorkspaceContext('/workspace'), noopTelemetryService, ).resolveExecution({ pattern: '**/*.ts' }), ), @@ -99,7 +97,6 @@ const TOOLS: readonly { finish( await new GrepTool( stubAgentEnvironment(backend), - stubWorkspaceContext('/workspace'), noopTelemetryService, ).resolveExecution({ pattern: 'needle' }), ), diff --git a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts index dfeffb81d52..9b8dca31103 100644 --- a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts +++ b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts @@ -79,8 +79,8 @@ function buildTool( onDidChangeStatus: () => ({ dispose: () => {} }), dispose: () => {}, } as unknown as Environment; - const environment = stubAgentEnvironment(environmentValue); - return new EditTool(ix.get(IFileEditService), environment, workspace); + const environment = stubAgentEnvironment(environmentValue, { workDir: workspace.workDir, additionalDirs: workspace.additionalDirs }); + return new EditTool(ix.get(IFileEditService), environment); } function isPromiseLike( diff --git a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts index 1343b064b60..d86070d4cee 100644 --- a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts +++ b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts @@ -30,6 +30,8 @@ import type { ToolCall } from '#human/llm/message'; import type { IAgentEnvironmentService } from '#/agent/environmentBinding/agentEnvironment'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { HostFsError } from '#/os/interface/hostFsErrors'; +import type { EnvironmentTarget } from '#/environment/environment'; +import { EnvironmentError } from '#/environment/environmentRegistry'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { BlobStoreService } from '#/persistence/backends/node-fs/blobStoreService'; @@ -51,6 +53,8 @@ import { createTestAgent, homeDirServices } from '../../harness'; const SCOPE = 'wire'; const KEY = 'file-history-test'; const WORK_DIR = '/ws'; +const LOCAL: EnvironmentTarget = { environmentId: 'local', primary: true }; +const GPU: EnvironmentTarget = { environmentId: 'gpu', primary: false }; const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -64,6 +68,8 @@ describe('AgentFileHistoryService', () => { let scopeCtx: IAgentScopeContext; let files: Map; let gpuFiles: Map; + let gpuReachable: boolean; + let connectAttempts: number; beforeEach(() => { disposables = new DisposableStore(); @@ -87,6 +93,8 @@ describe('AgentFileHistoryService', () => { blobs = new BlobStoreService(new InMemoryStorageService()); files = new Map(); gpuFiles = new Map(); + gpuReachable = true; + connectAttempts = 0; }); afterEach(() => { @@ -101,14 +109,20 @@ describe('AgentFileHistoryService', () => { }; const gpu = { ...environment, fs: hostFs(false, gpuFiles) }; return { + targets: () => [LOCAL, GPU], inspect: () => environment, - acquire: (_required: unknown, environmentId?: string) => ({ - environment: environmentId === 'gpu' ? gpu : environment, - dispose: () => {}, - }), - acquireWhenReady: async (_required: unknown, environmentId?: string) => ({ - environment: environmentId === 'gpu' ? gpu : environment, - dispose: () => {}, + acquire: (_required: unknown, environmentId?: string) => { + if (environmentId !== 'gpu') return { environment, dispose: () => {} }; + if (!gpuReachable) throw new EnvironmentError('environment.unavailable', 'environment gpu is disconnected'); + return { environment: gpu, dispose: () => {} }; + }, + acquireWhenReady: async () => { + connectAttempts += 1; + throw new Error('file history must not connect environments'); + }, + workspaceRoots: (environmentId?: string) => ({ + workDir: environmentId === 'gpu' ? '/srv/app' : WORK_DIR, + additionalDirs: [], }), } as unknown as IAgentEnvironmentService; } @@ -194,7 +208,7 @@ describe('AgentFileHistoryService', () => { service: AgentFileHistoryService, path: string, turnId: number, - environmentId?: string, + environment?: EnvironmentTarget, ): Promise { const toolCall: ToolCall = { type: 'function', id: `call-${String(turnId)}`, name: 'Edit', arguments: null }; const execution: RunnableToolExecution = { @@ -203,7 +217,7 @@ describe('AgentFileHistoryService', () => { execute: async () => ({ output: '' }), }; await executorEvents.fireWillExecute( - { turnId, toolCall, execution, args: {}, environmentId }, + { turnId, toolCall, execution, args: {}, environment }, new AbortController().signal, ); await service.settled(); @@ -551,16 +565,16 @@ describe('AgentFileHistoryService', () => { expect(await blobs.list(scopeCtx.scope(), 'file-history/')).toEqual([]); }); - it('tracks an edit routed to another environment under an environment-qualified path', async () => { + it('reports an edit routed to another environment by its own path and environment', async () => { const service = createService(); setFile('/srv/app/a.txt', 'primary copy\n'); gpuFiles.set('/srv/app/a.txt', encoder.encode('one\ntwo\n')); startTurn(1); - await fireEdit(service, '/srv/app/a.txt', 1, 'gpu'); + await fireEdit(service, '/srv/app/a.txt', 1, GPU); gpuFiles.set('/srv/app/a.txt', encoder.encode('one\nTWO\n')); expect(await service.changes(1)).toEqual([ - { path: 'gpu:/srv/app/a.txt', status: 'modified', additions: 1, deletions: 1 }, + { path: '/srv/app/a.txt', environmentId: 'gpu', status: 'modified', additions: 1, deletions: 1 }, ]); endTurn(1); startTurn(2); @@ -568,10 +582,48 @@ describe('AgentFileHistoryService', () => { expect(service.history().tracked).toEqual(['gpu:/srv/app/a.txt']); expect(await service.changes(1)).toEqual([ - { path: 'gpu:/srv/app/a.txt', status: 'modified', additions: 1, deletions: 1 }, + { path: '/srv/app/a.txt', environmentId: 'gpu', status: 'modified', additions: 1, deletions: 1 }, ]); - expect((await service.contentAt(1, 'gpu:/srv/app/a.txt'))?.content).toBe('one\ntwo\n'); - expect((await service.contentAt(1, 'gpu:/srv/app/a.txt', 'end'))?.content).toBe('one\nTWO\n'); + expect((await service.contentAt(1, '/srv/app/a.txt', 'start', 'gpu'))?.content).toBe('one\ntwo\n'); + expect((await service.contentAt(1, '/srv/app/a.txt', 'end', 'gpu'))?.content).toBe('one\nTWO\n'); + expect(await service.contentAt(1, '/srv/app/a.txt')).toBeUndefined(); + expect(connectAttempts).toBe(0); + }); + + it('records a turn whose routed environment is unreachable at its end without connecting it', async () => { + const service = createService(); + setFile(join(WORK_DIR, 'a.txt'), 'one\n'); + gpuFiles.set('/srv/app/b.txt', encoder.encode('one\n')); + + startTurn(1); + await fireEdit(service, join(WORK_DIR, 'a.txt'), 1, LOCAL); + await fireEdit(service, '/srv/app/b.txt', 1, GPU); + setFile(join(WORK_DIR, 'a.txt'), 'two\n'); + gpuReachable = false; + endTurn(1); + startTurn(2); + await service.settled(); + + expect(await service.turnRecorded(1)).toBe(true); + expect(await service.changes(1)).toEqual([ + { path: 'a.txt', status: 'modified', additions: 1, deletions: 1 }, + ]); + expect((await service.contentAt(1, 'a.txt'))?.content).toBe('one\n'); + expect((await service.contentAt(1, 'a.txt', 'start', 'local'))?.content).toBe('one\n'); + expect(connectAttempts).toBe(0); + }); + + it('keys a capture by environment id against its own primary, whichever role the caller saw', async () => { + const service = createService(); + setFile(join(WORK_DIR, 'a.txt'), 'one\n'); + gpuFiles.set('/srv/app/b.txt', encoder.encode('one\n')); + + startTurn(1); + await service.captureForActiveTurn(join(WORK_DIR, 'a.txt'), 'local'); + await service.captureForActiveTurn('/srv/app/b.txt', 'gpu'); + await service.settled(); + + expect(service.history().tracked).toEqual(['a.txt', 'gpu:/srv/app/b.txt']); }); it('keeps the environment on a reused backup so an unchanged routed file is not read from the primary', async () => { @@ -579,11 +631,11 @@ describe('AgentFileHistoryService', () => { gpuFiles.set('/srv/app/a.txt', encoder.encode('one\n')); startTurn(1); - await fireEdit(service, '/srv/app/a.txt', 1, 'gpu'); + await fireEdit(service, '/srv/app/a.txt', 1, GPU); gpuFiles.set('/srv/app/a.txt', encoder.encode('two\n')); endTurn(1); startTurn(2); - await fireEdit(service, '/srv/app/a.txt', 2, 'gpu'); + await fireEdit(service, '/srv/app/a.txt', 2, GPU); endTurn(2); startTurn(3); await service.settled(); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 0dd38afd648..2b652651986 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -2404,10 +2404,7 @@ function createPermissionRulesStub( get rules() { return rules; }, - get sessionApprovalRulePatterns() { - return []; - }, - environmentSessionApprovalRulePatterns: () => [], + sessionApprovalRulePatterns: () => [], addRules: (nextRules) => { rules = [...rules, ...nextRules]; }, diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts index 89ad4ba2919..1723843806c 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts @@ -17,20 +17,12 @@ import type { IConfigService } from '#/app/config/config'; import { ProcessTask } from '#/agent/tools/os/bash/process-task'; import type { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { FakeEnvironment } from '#/environment/fakeEnvironment'; -import { stubWorkspaceContext } from '../../../../session/workspaceContext/stub-workspace-context'; import { stubAgentEnvironment } from '../../../../environment/stubs'; import type { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; -import type { IAgentEnvironmentService } from '#/agent/environmentBinding/agentEnvironment'; import { type ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess'; import { type BashInput, BashInputSchema } from '#/agent/tools/os/bash/bash'; import { BashTool } from '#/agent/tools/os/bash/bashTool'; -import { AgentWorkspaceContextService } from '#/session/workspaceContext/agentWorkspaceContextService'; -import { SessionStateService } from '#/session/state/sessionStateService'; -import { - workspaceContextAdditionalDirsKey, - workspaceContextWorkDirKey, -} from '#/session/workspaceContext/workspaceContextService'; import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/tool/toolContract'; const posixEnv: IHostEnvironment = { @@ -737,7 +729,7 @@ function bashTool( { host: env, process: processService }, ); const environment = stubAgentEnvironment(backend, { workDir: ctx.cwd }); - return new BashTool(environment, ctx, stubWorkspaceContext(ctx.cwd), background, toolPolicy, config); + return new BashTool(environment, ctx, background, toolPolicy, config); } describe('BashTool', () => { @@ -920,18 +912,9 @@ describe('BashTool', () => { { host: createTestEnv(), process: processService }, ); const environment = stubAgentEnvironment(backend, { workDir: '/remote-work' }); - const sessionState = new SessionStateService(); - sessionState.contributeState(workspaceContextWorkDirKey); - sessionState.contributeState(workspaceContextAdditionalDirsKey); - sessionState.set(workspaceContextWorkDirKey, ctx.cwd); - const shadow = new AgentWorkspaceContextService(sessionState, { - current: environment, - onDidChange: () => ({ dispose: () => {} }), - }); const tool = new BashTool( environment, ctx, - shadow, createFakeTaskService().service, stubToolPolicy(), stubConfig(), @@ -1437,15 +1420,10 @@ describe('BashTool', () => { }); describe('BashTool display cwd', () => { - function environmentService(environment: FakeEnvironment): IAgentEnvironmentService { - return stubAgentEnvironment(environment, { workDir: '' }); - } - - function displayCwdTool(environment: IAgentEnvironmentService, ctx: ISessionContext, workDir: string): BashTool { + function displayCwdTool(environment: FakeEnvironment, ctx: ISessionContext, workDir: string): BashTool { return new BashTool( - environment, + stubAgentEnvironment(environment, { workDir }), ctx, - stubWorkspaceContext(workDir), {} as unknown as IAgentTaskService, {} as unknown as IAgentToolPolicyService, {} as unknown as IConfigService, @@ -1461,7 +1439,7 @@ describe('BashTool display cwd', () => { it('resolves a relative cwd argument against the binding cwd on the bound environment', async () => { const environment = new FakeEnvironment({ environmentId: 'dev-box', generation: 'g' }); - const tool = displayCwdTool(environmentService(environment), createTestCtx('/Users/mac/project'), '/home/deploy/app'); + const tool = displayCwdTool(environment, createTestCtx('/Users/mac/project'), '/home/deploy/app'); await expect(displayCwd(tool, { command: 'ls', cwd: 'src/lib' })).resolves.toBe( '/home/deploy/app/src/lib', @@ -1486,9 +1464,8 @@ describe('BashTool spawn cwd', () => { function spawnCwdTool(backend: FakeEnvironment, workDir: string): BashTool { return new BashTool( - stubAgentEnvironment(backend), + stubAgentEnvironment(backend, { workDir }), createTestCtx('/Users/mac/project'), - stubWorkspaceContext(workDir), createFakeTaskService().service, stubToolPolicy(), stubConfig(), diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts index dd7099e4f2c..bd2a919f381 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts @@ -101,6 +101,7 @@ function createEnvironment( fs: IHostFileSystem, environment: IHostEnvironment, process: IHostProcessService, + workspaceContext: ISessionWorkspaceContext = workspace, ): IAgentEnvironmentService { const backend = Object.assign( new FakeEnvironment( @@ -109,7 +110,10 @@ function createEnvironment( ), { fs, host: environment, process }, ); - return stubAgentEnvironment(backend); + return stubAgentEnvironment(backend, { + workDir: workspaceContext.workDir, + additionalDirs: workspaceContext.additionalDirs, + }); } function createRealRgProbe(processService: IHostProcessService): RgProbe { @@ -230,8 +234,7 @@ function makeTool( const processService = createTestProcessService(exec); const env = createTestEnv({ home: opts.home, pathClass: opts.pathClass }); const tool = new GlobTool( - createEnvironment(fs, env, processService), - workspaceConfig, + createEnvironment(fs, env, processService, workspaceConfig), opts.telemetry ?? noopTelemetryService, ); return { tool, exec, withCwd: withCwdOf(exec) }; @@ -610,7 +613,10 @@ describe('GlobTool', () => { ), { fs, host: createTestEnv({ home: '/home/remote' }), process: processService }, ); - const tool = new GlobTool(stubAgentEnvironment(backend), workspace, noopTelemetryService); + const tool = new GlobTool( + stubAgentEnvironment(backend, { workDir: workspace.workDir, additionalDirs: workspace.additionalDirs }), + noopTelemetryService, + ); const result = await execute(tool, { pattern: '**/*.ts' }); @@ -967,7 +973,7 @@ describe('GlobTool integration (real ripgrep)', () => { }; realProcessService = new HostProcessService(); realFs = new HostFileSystem(); - tool = new GlobTool(createEnvironment(realFs, realEnv, realProcessService), stubWorkspaceContext(tmpDir!), noopTelemetryService); + tool = new GlobTool(createEnvironment(realFs, realEnv, realProcessService, stubWorkspaceContext(tmpDir)), noopTelemetryService); }); afterEach(async () => { diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts index 3c41eeb76ef..fd67725b7eb 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts @@ -195,11 +195,7 @@ class GrepTool extends ProductionGrepTool { workDir: workspaceConfig.workspaceDir, additionalDirs: workspaceConfig.additionalDirs, }); - super( - environmentService, - stubWorkspaceContext(workspaceConfig.workspaceDir, workspaceConfig.additionalDirs), - telemetry, - ); + super(environmentService, telemetry); } } diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts index 6a0bfe29fde..c60e17135ab 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts @@ -97,8 +97,8 @@ function createReadTool( ), { host: env, fs }, ); - const resolver = stubAgentEnvironment(environment); - return new ReadTool(resolver, workspace, skillCatalog, truncation, stubConfigService(), profile, toolPolicy, toolRegistry); + const resolver = stubAgentEnvironment(environment, { workDir: workspace.workDir, additionalDirs: workspace.additionalDirs }); + return new ReadTool(resolver, skillCatalog, truncation, stubConfigService(), profile, toolPolicy, toolRegistry); } function createSpiedFs(content: string) { @@ -210,7 +210,6 @@ function createRegistryBackedTool(environmentValue: FakeEnvironment) { }; const tool = new ReadTool( environment, - stubWorkspaceContext('/workspace'), { catalog: { getSkillRoots: () => [] } } as unknown as ISessionSkillCatalog, stubToolResultTruncationService(), stubConfigService(), diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts index e1565adc2ff..c74e9cb925b 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts @@ -68,8 +68,8 @@ function makeToolWithFs(fs: IHostFileSystem, workspace = PERMISSIVE_WORKSPACE) { ), { fs, host: createTestEnv() }, ); - const environment = stubAgentEnvironment(backend); - return new WriteTool(environment, workspace); + const environment = stubAgentEnvironment(backend, { workDir: workspace.workDir, additionalDirs: workspace.additionalDirs }); + return new WriteTool(environment); } function makeTool(options: WriteFsOptions = {}, workspace = PERMISSIVE_WORKSPACE) { @@ -180,8 +180,8 @@ describe('WriteTool', () => { ), { fs: fakes.fs, host: environment }, ); - const environmentService = stubAgentEnvironment(backend); - const tool = new WriteTool(environmentService, PERMISSIVE_WORKSPACE); + const environmentService = stubAgentEnvironment(backend, { workDir: PERMISSIVE_WORKSPACE.workDir }); + const tool = new WriteTool(environmentService); const result = await execute(tool, { path: '~/notes/today.txt', content: 'hello' }); diff --git a/packages/kap-server/src/protocol/rest-file-history.ts b/packages/kap-server/src/protocol/rest-file-history.ts index e9bd91509e3..69f1503bcc2 100644 --- a/packages/kap-server/src/protocol/rest-file-history.ts +++ b/packages/kap-server/src/protocol/rest-file-history.ts @@ -9,11 +9,13 @@ export const fileHistoryContentQuerySchema = z.object({ turn_id: z.coerce.number().int().nonnegative(), path: z.string().min(1), phase: z.enum(['start', 'end']).optional(), + environment_id: z.string().min(1).optional(), }); export type FileHistoryContentQuery = z.infer; export const fileHistoryChangeSchema = z.object({ path: z.string(), + environment_id: z.string().optional(), status: z.enum(['added', 'modified', 'deleted']), additions: z.number(), deletions: z.number(), diff --git a/packages/kap-server/src/routes/fileHistory.ts b/packages/kap-server/src/routes/fileHistory.ts index a7f81b1f7cb..74c16f7ca18 100644 --- a/packages/kap-server/src/routes/fileHistory.ts +++ b/packages/kap-server/src/routes/fileHistory.ts @@ -59,7 +59,10 @@ export function registerFileHistoryRoutes(app: FileHistoryRouteHost, core: Scope reply.send( okEnvelope( { - changes: await history.changes(req.query.turn_id), + changes: (await history.changes(req.query.turn_id)).map(({ environmentId, ...change }) => ({ + ...change, + environment_id: environmentId, + })), recorded: await history.turnRecorded(req.query.turn_id), }, req.id, @@ -97,7 +100,12 @@ export function registerFileHistoryRoutes(app: FileHistoryRouteHost, core: Scope } const agent = await ensureMainAgent(session); const history = agent.accessor.get(IAgentFileHistoryService); - const content = await history.contentAt(req.query.turn_id, req.query.path, req.query.phase); + const content = await history.contentAt( + req.query.turn_id, + req.query.path, + req.query.phase, + req.query.environment_id, + ); reply.send(okEnvelope({ content: content ?? null }, req.id)); }, ); From cabbf1691caa60f82a2acd093f3b4854a0c77a3e Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Fri, 30 Oct 2026 13:26:50 +0800 Subject: [PATCH 3/9] fix: harden per-environment history keys, approval buckets, and roster cwd Encode file-history keys for non-primary environments as an unambiguous tuple so environment ids containing ':' or '/' cannot collide, look up per-environment approval buckets by own property so ids such as '__proto__' keep their approvals, and let the environment roster report a working directory resolved on first connection without reverting on disconnect. --- .../environmentBinding/environmentRoster.ts | 13 +++++++++++- .../permissionRules/permissionRulesOps.ts | 2 +- .../permissionRules/permissionRulesService.ts | 3 ++- .../fileHistory/fileHistoryService.ts | 4 ++-- .../environmentBindingService.test.ts | 20 +++++++++++++++++++ .../permissionRules/permissionRules.test.ts | 10 ++++++++++ .../features/fileHistory/fileHistory.test.ts | 19 ++++++++++++++++-- 7 files changed, 64 insertions(+), 7 deletions(-) diff --git a/packages/agent-core-v2/src/agent/environmentBinding/environmentRoster.ts b/packages/agent-core-v2/src/agent/environmentBinding/environmentRoster.ts index 046b4e437ae..3c225d439d2 100644 --- a/packages/agent-core-v2/src/agent/environmentBinding/environmentRoster.ts +++ b/packages/agent-core-v2/src/agent/environmentBinding/environmentRoster.ts @@ -32,6 +32,17 @@ export class AgentEnvironmentRosterService extends Disposable implements IAgentE @IAgentEnvironmentService environment: IAgentEnvironmentService, ) { super(); + const resolvedCwds = new Map(); + const secondaryCwd = (environmentId: string): string | undefined => { + if (!resolvedCwds.has(environmentId) && environment.isAvailable([], environmentId)) { + try { + resolvedCwds.set(environmentId, environment.workspaceRoots(environmentId).workDir); + } catch { + return undefined; + } + } + return resolvedCwds.get(environmentId); + }; this._register( reminder.register(ENVIRONMENT_ROSTER_REMINDER_VARIANT, ({ lastInjection, lastDisclosure }) => { const targets = environment.targets(); @@ -44,7 +55,7 @@ export class AgentEnvironmentRosterService extends Disposable implements IAgentE } const lines = targets.map((target) => { const label = target.primary ? `\`${target.environmentId}\` (primary)` : `\`${target.environmentId}\``; - const cwd = target.primary ? environment.workspaceRoots().workDir : target.cwd; + const cwd = target.primary ? environment.workspaceRoots().workDir : target.cwd ?? secondaryCwd(target.environmentId); return cwd === undefined ? `- ${label}: working directory resolved on first use` : `- ${label}: working directory ${cwd}`; diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts index b7285688f80..b456dbf76c7 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts @@ -67,7 +67,7 @@ export const permissionRulesKey = defineState( return; } const byEnvironment = s.environmentSessionApprovalRulePatterns ?? {}; - const patterns = byEnvironment[e.environmentId] ?? []; + const patterns = Object.hasOwn(byEnvironment, e.environmentId) ? byEnvironment[e.environmentId]! : []; if (patterns.includes(pattern)) return; s.environmentSessionApprovalRulePatterns = { ...byEnvironment, [e.environmentId]: [...patterns, pattern] }; }); diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts index f53cf481ee6..5c4fb7c0b0e 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts @@ -35,7 +35,8 @@ export class AgentPermissionRulesService implements IAgentPermissionRulesService sessionApprovalRulePatterns(environment?: EnvironmentTarget): readonly string[] { const state = this.agentState.get(permissionRulesKey); if (environment?.primary !== false) return [...state.sessionApprovalRulePatterns]; - return [...(state.environmentSessionApprovalRulePatterns?.[environment.environmentId] ?? [])]; + const byEnvironment = state.environmentSessionApprovalRulePatterns ?? {}; + return Object.hasOwn(byEnvironment, environment.environmentId) ? [...byEnvironment[environment.environmentId]!] : []; } addRules(rules: readonly PermissionRule[]): void { diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts index 3b1b51bb5e1..1bc2f9a961c 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -532,11 +532,11 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor } private entryKey(path: string, environmentId: string | undefined): string { - return environmentId === undefined ? this.pathKey(path) : `${environmentId}:${path}`; + return environmentId === undefined ? this.pathKey(path) : JSON.stringify([environmentId, path]); } private entryPath(pathKey: string, environmentId: string | undefined): string { - return environmentId === undefined ? pathKey : pathKey.slice(environmentId.length + 1); + return environmentId === undefined ? pathKey : (JSON.parse(pathKey) as [string, string])[1]; } private pathKey(path: string): string { diff --git a/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts b/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts index 3ccab479dd3..fd291c0c39f 100644 --- a/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts +++ b/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts @@ -654,6 +654,26 @@ describe('AgentEnvironmentRosterService', () => { expect(provide({ lastDisclosure: first?.disclosure })?.content).toBe(first?.content); }); + it('reports the working directory a connected environment resolved and keeps it across disconnects', () => { + const { agentEnvironment, registry } = setup({ + multiEnvironment: { declarations: [{ id: 'staging' }] }, + }); + const staging = environment('staging', 'staging-one', 'pending', [], { cwd: '/home/deploy' }); + registry.register(staging); + const { provide } = rosterProvider(agentEnvironment); + const injected = { role: 'user' as const, content: [] }; + + const before = provide({}); + expect(before?.content).toContain('- `staging`: working directory resolved on first use'); + + staging.setStatus('ready'); + const connected = provide({ lastInjection: injected, lastDisclosure: before?.disclosure }); + expect(connected?.content).toContain('- `staging`: working directory /home/deploy'); + + staging.setStatus('disconnected'); + expect(provide({ lastInjection: injected, lastDisclosure: connected?.disclosure })).toBeUndefined(); + }); + it('withdraws the roster once only the primary environment remains', () => { const { agentEnvironment } = setup({ multiEnvironment: { enabled: false, declarations: [{ id: 'remote', defaultCwd: '/srv/app' }] }, diff --git a/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts b/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts index aed4e9c434b..c7e652c97ab 100644 --- a/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts +++ b/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts @@ -100,6 +100,16 @@ describe('AgentPermissionRulesService (wire-backed)', () => { expect(svc.sessionApprovalRulePatterns({ environmentId: 'staging', primary: false })).toEqual([]); }); + it('keeps session approvals for environments named like object prototype members', () => { + expect(svc.sessionApprovalRulePatterns({ environmentId: 'toString', primary: false })).toEqual([]); + + svc.recordApprovalResult({ ...sessionApproval('Bash(rm *)'), environmentId: '__proto__' }); + svc.recordApprovalResult({ ...sessionApproval('Bash(ls)'), environmentId: 'constructor' }); + + expect(svc.sessionApprovalRulePatterns({ environmentId: '__proto__', primary: false })).toEqual(['Bash(rm *)']); + expect(svc.sessionApprovalRulePatterns({ environmentId: 'constructor', primary: false })).toEqual(['Bash(ls)']); + }); + it('ignores non-session approvals for the pattern set', () => { const oneTime: PermissionApprovalResultRecord = { turnId: 2, diff --git a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts index d86070d4cee..41aa9522721 100644 --- a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts +++ b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts @@ -580,7 +580,7 @@ describe('AgentFileHistoryService', () => { startTurn(2); await service.settled(); - expect(service.history().tracked).toEqual(['gpu:/srv/app/a.txt']); + expect(service.history().tracked).toEqual([JSON.stringify(['gpu', '/srv/app/a.txt'])]); expect(await service.changes(1)).toEqual([ { path: '/srv/app/a.txt', environmentId: 'gpu', status: 'modified', additions: 1, deletions: 1 }, ]); @@ -623,7 +623,22 @@ describe('AgentFileHistoryService', () => { await service.captureForActiveTurn('/srv/app/b.txt', 'gpu'); await service.settled(); - expect(service.history().tracked).toEqual(['a.txt', 'gpu:/srv/app/b.txt']); + expect(service.history().tracked).toEqual(['a.txt', JSON.stringify(['gpu', '/srv/app/b.txt'])]); + }); + + it('keeps environment and path pairs apart when their concatenations coincide', async () => { + const service = createService(); + setFile('/b:/x', 'first\n'); + setFile('/x', 'second\n'); + + startTurn(1); + await service.captureForActiveTurn('/b:/x', 'a'); + await service.captureForActiveTurn('/x', 'a:/b'); + await service.settled(); + + expect(service.history().tracked).toHaveLength(2); + expect((await service.contentAt(1, '/b:/x', 'start', 'a'))?.content).toBe('first\n'); + expect((await service.contentAt(1, '/x', 'start', 'a:/b'))?.content).toBe('second\n'); }); it('keeps the environment on a reused backup so an unchanged routed file is not read from the primary', async () => { From f0db49955ab813c6f1547884e81d977435f99988 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Fri, 30 Oct 2026 13:47:39 +0800 Subject: [PATCH 4/9] fix: connect unready environments before bash approval --- .../src/agent/tools/os/bash/bashTool.ts | 62 +++++------- .../os/environmentGenerationGuard.test.ts | 94 ++++++++++++++++++- 2 files changed, 117 insertions(+), 39 deletions(-) diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts index 6bd0dd413cf..03d62e055f5 100644 --- a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts @@ -5,7 +5,6 @@ import type { HostEnvironmentInfo } from '#/os/interface/hostEnvironment'; import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { acquireOrWhenReady, pinnedGeneration, IAgentEnvironmentService } from '#/agent/environmentBinding/agentEnvironment'; -import type { EnvironmentTarget } from '#/environment/environment'; import { EnvironmentWorkspaceView } from '#/environment/environmentWorkspaceView'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { getShellPathBridge } from '#/_base/execEnv/shellPathBridge'; @@ -134,45 +133,34 @@ export class BashTool implements IBashTool { return renderedDescription; } - resolveExecution(args: BashInput, context?: ToolResolutionContext): ToolExecution { - const environment = context?.environment; - const expectedGeneration = this.reviewedGeneration(environment?.environmentId); - const preview = args.command.length > 50 ? `${args.command.slice(0, 50)}…` : args.command; - return { - description: args.run_in_background - ? `Starting background: ${preview}` - : `Running: ${preview}`, - display: { - kind: 'command', - command: args.command, - cwd: this.executionCwd(args, environment), - description: args.description, - language: 'bash', - }, - approvalRule: literalRulePattern(this.name, args.command), - matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.command), - execute: ({ signal, onUpdate, onForegroundTaskStart, toolCallId }) => - this.execution(args, signal, toolCallId, onUpdate, onForegroundTaskStart, expectedGeneration, environment?.environmentId), - }; - } - - private reviewedGeneration(environmentId: string | undefined): string | undefined { - try { - return pinnedGeneration(this.environment.inspect(environmentId)); - } catch { - return undefined; - } - } - - private executionCwd(args: BashInput, environment: EnvironmentTarget | undefined): string | undefined { + async resolveExecution(args: BashInput, context?: ToolResolutionContext): Promise { + const environmentId = context?.environment?.environmentId; + const resolutionLease = await acquireOrWhenReady(this.environment, ['process'], environmentId); try { + const expectedGeneration = pinnedGeneration(resolutionLease.environment); const view = new EnvironmentWorkspaceView( - this.environment.inspect(environment?.environmentId), - this.environment.workspaceRoots(environment?.environmentId), + resolutionLease.environment, + this.environment.workspaceRoots(environmentId), ); - return view.resolve(args.cwd ?? view.workDir); - } catch { - return args.cwd ?? (environment?.primary === false ? undefined : this.ctx.cwd); + const preview = args.command.length > 50 ? `${args.command.slice(0, 50)}…` : args.command; + return { + description: args.run_in_background + ? `Starting background: ${preview}` + : `Running: ${preview}`, + display: { + kind: 'command', + command: args.command, + cwd: view.resolve(args.cwd ?? view.workDir), + description: args.description, + language: 'bash', + }, + approvalRule: literalRulePattern(this.name, args.command), + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.command), + execute: ({ signal, onUpdate, onForegroundTaskStart, toolCallId }) => + this.execution(args, signal, toolCallId, onUpdate, onForegroundTaskStart, expectedGeneration, environmentId), + }; + } finally { + resolutionLease.dispose(); } } diff --git a/packages/agent-core-v2/test/agent/tools/os/environmentGenerationGuard.test.ts b/packages/agent-core-v2/test/agent/tools/os/environmentGenerationGuard.test.ts index e681b2a35b5..be03ff4c070 100644 --- a/packages/agent-core-v2/test/agent/tools/os/environmentGenerationGuard.test.ts +++ b/packages/agent-core-v2/test/agent/tools/os/environmentGenerationGuard.test.ts @@ -5,6 +5,7 @@ import { noopTelemetryService } from '#/app/telemetry/telemetry'; import type { Environment, EnvironmentBinding, EnvironmentCapability, EnvironmentTarget } from '#/environment/environment'; import { FakeEnvironment } from '#/environment/fakeEnvironment'; import { + EnvironmentError, EnvironmentRegistry, environmentIsReady, type EnvironmentRegistrationHandle, @@ -397,7 +398,7 @@ describe('Bash environment generation guard', () => { it('rejects execution when the generation changes after a ready resolve', async () => { const harness = readyHarness(createFakeHostFs()); - const resolved = bashTool(harness.service).resolveExecution({ command: 'echo hi' }); + const resolved = await bashTool(harness.service).resolveExecution({ command: 'echo hi' }); await harness.registration.remove(); harness.registry.register(replacementReadyEnvironment()); @@ -502,11 +503,100 @@ describe('tool environment routing', () => { stubConfigService(), ); - const resolved = bash.resolveExecution({ command: 'pwd' }, { environment: { environmentId: 'gpu', primary: false } }); + const resolved = await bash.resolveExecution({ command: 'pwd' }, { environment: { environmentId: 'gpu', primary: false } }); expect(resolved).toMatchObject({ display: { kind: 'command', cwd: '/gpu' } }); const result = await finish(resolved); expect(spawns).toEqual([{ environmentId: 'gpu', cwd: '/gpu' }]); expect(outputText(result)).toContain('spawned on gpu'); }); + + it('connects an unready secondary environment before the approval preview', async () => { + const registry = new EnvironmentRegistry(); + const calls: string[] = []; + let registration: EnvironmentRegistrationHandle; + const ready = Object.assign( + new FakeEnvironment( + { environmentId: 'gpu', generation: 'gpu-one' }, + { status: 'ready', capabilities: ['process'], host: { cwd: '/srv/app' } }, + ), + { process: {} }, + ); + const pending = Object.assign( + new FakeEnvironment( + { environmentId: 'gpu', generation: 'gpu-pending' }, + { status: 'pending', capabilities: [] }, + ), + { + host: undefined, + connect: async () => { + calls.push('connect'); + await registration.remove(); + registry.register(ready); + }, + }, + ); + registration = registry.register(pending); + const binding = (environmentId?: string): EnvironmentBinding => ({ environmentId: environmentId ?? 'local' }); + const service: IAgentEnvironmentService = { + _serviceBrand: undefined, + onDidChange: () => ({ dispose: () => {} }), + targets: () => [ + { environmentId: 'local', primary: true }, + { environmentId: 'gpu', primary: false }, + ], + isAvailable: (required = [], environmentId) => { + try { + const environment = registry.inspect(binding(environmentId)); + return environmentIsReady(environment) && required.every((capability) => environment.capabilities.has(capability)); + } catch { + return false; + } + }, + inspect: (environmentId) => registry.inspect(binding(environmentId)), + acquire: (required = [], environmentId) => registry.acquire(binding(environmentId), required), + acquireWhenReady: async (required = [], environmentId) => { + const environment = registry.inspect(binding(environmentId)); + if (!environmentIsReady(environment) && typeof environment.connect === 'function') await environment.connect(); + return registry.acquireWhenReady(binding(environmentId), required); + }, + workspaceRoots: (environmentId) => { + const workDir = environmentId === 'gpu' ? registry.inspect(binding(environmentId)).host?.cwd : '/workspace'; + if (workDir === undefined) { + throw new EnvironmentError('environment.unavailable', 'environment gpu has no working directory until it connects'); + } + return { workDir, additionalDirs: [] }; + }, + }; + const bash = new BashTool( + service, + makeSessionContext({ + sessionId: 'ses', + workspaceId: 'ws', + sessionDir: '/tmp/ses', + sessionScope: 'ses', + cwd: '/workspace', + }), + {} as never, + toolPolicy, + stubConfigService(), + ); + + const resolved = await bash.resolveExecution({ command: 'pwd' }, { environment: { environmentId: 'gpu', primary: false } }); + + expect(calls).toEqual(['connect']); + expect(resolved).toMatchObject({ display: { kind: 'command', cwd: '/srv/app' } }); + + await registration.remove(); + registry.register(Object.assign( + new FakeEnvironment( + { environmentId: 'gpu', generation: 'gpu-two' }, + { status: 'ready', capabilities: ['process'], host: { cwd: '/srv/app' } }, + ), + { process: {} }, + )); + const result = await finish(resolved); + expect(result.isError).toBe(true); + expect(result.output).toBe(ENVIRONMENT_CHANGED_OUTPUT); + }); }); From 4dffeea86015d05f906d8a3182c08ff4fde7565d Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Fri, 30 Oct 2026 15:44:02 +0800 Subject: [PATCH 5/9] feat: mount connected environments' AGENTS.md and project skills Once a secondary environment connects, the session opens its workdir view once: the roster lists the AGENTS.md files found there and its project skills join the session catalog as @. Skill activations outside the primary environment name their owning environment, and views of non-local environments no longer merge local workspace registry dirs that happen to share a path. --- .changeset/multi-environment-routing.md | 2 +- docs/en/guides/remote-environment.md | 4 +- docs/zh/guides/remote-environment.md | 4 +- .../agent-core-v2/docs/state-manifest.d.ts | 16 ++ .../environmentBinding/environmentRoster.ts | 94 +++++-- .../src/features/skill/catalog/types.ts | 1 + .../src/features/skill/prompt.ts | 15 +- .../features/skill/session/skillCatalog.ts | 2 +- .../src/features/skill/skillService.ts | 6 +- .../src/features/skill/tools/skillTool.ts | 10 +- .../src/program/programWorkdirs.ts | 2 +- .../environmentMounts/environmentMounts.ts | 94 +++++++ .../src/session/workdir/workdir.ts | 9 + .../sessionLifecycleService.ts | 1 + .../workspaceDirs/workspaceDirsService.ts | 2 + .../changeNotifier/changeNotifier.test.ts | 2 + .../environmentBindingService.test.ts | 238 ++++++++++++++++-- .../test/agent/plugin/agentPlugin.test.ts | 8 + .../test/agent/profile/apply-profile.test.ts | 2 + .../test/agent/profile/binding.test.ts | 6 + .../test/features/skill/catalog/stubs.ts | 1 + .../test/features/skill/prompt.test.ts | 37 +++ .../test/features/skill/skill.test.ts | 2 + packages/agent-core-v2/test/harness/agent.ts | 3 + .../workspaceDirsService.test.ts | 11 + 25 files changed, 515 insertions(+), 57 deletions(-) create mode 100644 packages/agent-core-v2/src/session/environmentMounts/environmentMounts.ts diff --git a/.changeset/multi-environment-routing.md b/.changeset/multi-environment-routing.md index 1519e722bcf..63eb1a3d9f7 100644 --- a/.changeset/multi-environment-routing.md +++ b/.changeset/multi-environment-routing.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Add experimental per-call environment routing for file and shell tools. Enable it with `multi_environment = true` under `[experimental]` or `KIMI_CODE_EXPERIMENTAL_MULTI_ENVIRONMENT`. +Add experimental per-call environment routing for file and shell tools, which lists a connected environment's `AGENTS.md` files and adds its project skills as `@`. Enable it with `multi_environment = true` under `[experimental]` or `KIMI_CODE_EXPERIMENTAL_MULTI_ENVIRONMENT`. diff --git a/docs/en/guides/remote-environment.md b/docs/en/guides/remote-environment.md index 7175657005a..b43d8d09e9c 100644 --- a/docs/en/guides/remote-environment.md +++ b/docs/en/guides/remote-environment.md @@ -94,7 +94,9 @@ You can instead set `KIMI_CODE_EXPERIMENTAL_MULTI_ENVIRONMENT=true` for one proc All configured environments are candidates. The first call to another environment connects it lazily and uses its `defaultCwd`, or the working directory reported by the target when `defaultCwd` is unset. The primary keeps its existing working directory and additional directories. For example, a local session can inspect a remote checkout with `Read` using `environment = "dev-box"`, then run its tests with `Bash` in the same environment. -Approval prompts show the environment for each call, and "allow for this session" approvals are remembered separately per environment. Tools outside the list above, skills, and project instructions (`AGENTS.md`) continue to use the primary environment or the local host as described elsewhere on this page. The model is asked to read a secondary project's `AGENTS.md` before modifying files there. +Approval prompts show the environment for each call, and "allow for this session" approvals are remembered separately per environment. Tools outside the list above keep using the primary environment or the local host as described elsewhere on this page. + +The system prompt still describes only the primary environment. Once another environment connects, the session reads that environment's project context once: the `AGENTS.md` files found from its working directory are listed for the model, which is asked to read them before modifying files there, and its project skills join the session's skill list as `@` (for example `deploy@dev-box`). Environments that have not connected yet contribute nothing until their first use. When the primary environment is remote, user-level skills from the local host are marked as belonging to `local`, so the model reads their supporting files there. ## Disconnects and reconnecting diff --git a/docs/zh/guides/remote-environment.md b/docs/zh/guides/remote-environment.md index 8be8a870f08..75aec44d5ab 100644 --- a/docs/zh/guides/remote-environment.md +++ b/docs/zh/guides/remote-environment.md @@ -94,7 +94,9 @@ multi_environment = true 所有已配置的环境都是候选项。首次使用另一个环境时才建立连接,工作目录使用它的 `defaultCwd`;未设置时使用目标环境报告的工作目录。主环境保留原有的工作目录和附加目录。例如,本地会话可以让 `Read` 通过 `environment = "dev-box"` 查看远端代码,再让 `Bash` 在同一环境运行测试。 -审批面板会显示每次调用所在的环境,「本会话内允许」的审批按环境分别记忆。上述列表之外的工具、skills 和项目说明(`AGENTS.md`)仍按本页其他章节的规则使用主环境或本机。模型会先读取次环境项目的 `AGENTS.md`,再修改其中的文件。 +审批面板会显示每次调用所在的环境,「本会话内允许」的审批按环境分别记忆。上述列表之外的工具仍按本页其他章节的规则使用主环境或本机。 + +系统提示仍只描述主环境。另一个环境连接后,会话会读取一次它的项目上下文:从其工作目录向上找到的 `AGENTS.md` 会列给模型,并要求模型在修改该环境的文件前先读取;它的项目级 skills 以 `@` 的名字加入会话的 skill 列表(例如 `deploy@dev-box`)。尚未连接的环境在首次使用前不贡献任何内容。主环境是远程环境时,来自本机的用户级 skills 会标注为属于 `local`,模型会到本机读取它们的附属文件。 ## 断线与重连 diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 89cacfff882..2fdad725d07 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -152,6 +152,7 @@ export interface WorkspaceStateSnapshot { [key: string]: unknown; }; readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly environmentId?: string; readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { readonly id: string; readonly instructions?: string; @@ -190,6 +191,7 @@ export interface WorkspaceStateSnapshot { [key: string]: unknown; }; readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly environmentId?: string; readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { readonly id: string; readonly instructions?: string; @@ -218,6 +220,7 @@ export interface WorkspaceStateSnapshot { [key: string]: unknown; }; readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly environmentId?: string; readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { readonly id: string; readonly instructions?: string; @@ -254,6 +257,7 @@ export interface WorkspaceStateSnapshot { [key: string]: unknown; }; readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly environmentId?: string; readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { readonly id: string; readonly instructions?: string; @@ -282,6 +286,7 @@ export interface WorkspaceStateSnapshot { [key: string]: unknown; }; readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly environmentId?: string; readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { readonly id: string; readonly instructions?: string; @@ -310,6 +315,7 @@ export interface WorkspaceStateSnapshot { [key: string]: unknown; }; readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly environmentId?: string; readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { readonly id: string; readonly instructions?: string; @@ -340,6 +346,7 @@ export interface WorkspaceStateSnapshot { [key: string]: unknown; }; readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly environmentId?: string; readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { readonly id: string; readonly instructions?: string; @@ -368,6 +375,7 @@ export interface WorkspaceStateSnapshot { [key: string]: unknown; }; readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly environmentId?: string; readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { readonly id: string; readonly instructions?: string; @@ -427,6 +435,7 @@ export interface SessionStateSnapshot { [key: string]: unknown; }; readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly environmentId?: string; readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { readonly id: string; readonly instructions?: string; @@ -465,6 +474,7 @@ export interface SessionStateSnapshot { [key: string]: unknown; }; readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly environmentId?: string; readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { readonly id: string; readonly instructions?: string; @@ -493,6 +503,7 @@ export interface SessionStateSnapshot { [key: string]: unknown; }; readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly environmentId?: string; readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { readonly id: string; readonly instructions?: string; @@ -529,6 +540,7 @@ export interface SessionStateSnapshot { [key: string]: unknown; }; readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly environmentId?: string; readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { readonly id: string; readonly instructions?: string; @@ -557,6 +569,7 @@ export interface SessionStateSnapshot { [key: string]: unknown; }; readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly environmentId?: string; readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { readonly id: string; readonly instructions?: string; @@ -585,6 +598,7 @@ export interface SessionStateSnapshot { [key: string]: unknown; }; readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly environmentId?: string; readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { readonly id: string; readonly instructions?: string; @@ -615,6 +629,7 @@ export interface SessionStateSnapshot { [key: string]: unknown; }; readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly environmentId?: string; readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { readonly id: string; readonly instructions?: string; @@ -643,6 +658,7 @@ export interface SessionStateSnapshot { [key: string]: unknown; }; readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly environmentId?: string; readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { readonly id: string; readonly instructions?: string; diff --git a/packages/agent-core-v2/src/agent/environmentBinding/environmentRoster.ts b/packages/agent-core-v2/src/agent/environmentBinding/environmentRoster.ts index 3c225d439d2..946144bc0f1 100644 --- a/packages/agent-core-v2/src/agent/environmentBinding/environmentRoster.ts +++ b/packages/agent-core-v2/src/agent/environmentBinding/environmentRoster.ts @@ -2,7 +2,9 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio import { Disposable } from '#/_base/di/lifecycle'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { LifecycleScope } from '#/app/scopes'; +import type { EnvironmentTarget } from '#/environment/environment'; import { IAgentReminderService } from '#/features/reminder/reminderService'; +import { ISessionEnvironmentMounts, type EnvironmentMount } from '#/session/environmentMounts/environmentMounts'; import { IAgentEnvironmentService } from './agentEnvironment'; @@ -10,12 +12,21 @@ export const ENVIRONMENT_ROSTER_REMINDER_VARIANT = 'environment_roster'; const SINGLE_ENVIRONMENT_DISCLOSURE = 'single'; +type RosterDisclosure = typeof SINGLE_ENVIRONMENT_DISCLOSURE | Readonly>; + +interface RosterEntry { + readonly environmentId: string; + readonly text: string; + readonly listsAgentsMd: boolean; +} + const ROSTER_GUIDANCE = 'Tools with an `environment` argument run in the environment you name; omit it to use the primary environment. ' + 'Each environment has its own files, shell, and installed programs, and relative paths resolve against its working directory. ' + 'A remote environment connects on its first use; if it cannot connect, the call fails instead of running elsewhere. ' + - "The system prompt's working directory, AGENTS.md instructions, and skills describe the primary environment only; " + - "read a project's AGENTS.md before changing files in another environment."; + "The system prompt's working directory, AGENTS.md instructions, and skills describe the primary environment. " + + 'Once another environment connects, the AGENTS.md files found there are listed under it; read them before changing files in that environment. ' + + 'Skills found in another environment are named `@`.'; export interface IAgentEnvironmentRosterService { readonly _serviceBrand: undefined; @@ -30,21 +41,52 @@ export class AgentEnvironmentRosterService extends Disposable implements IAgentE constructor( @IAgentReminderService reminder: IAgentReminderService, @IAgentEnvironmentService environment: IAgentEnvironmentService, + @ISessionEnvironmentMounts mounts: ISessionEnvironmentMounts, ) { super(); - const resolvedCwds = new Map(); - const secondaryCwd = (environmentId: string): string | undefined => { - if (!resolvedCwds.has(environmentId) && environment.isAvailable([], environmentId)) { - try { - resolvedCwds.set(environmentId, environment.workspaceRoots(environmentId).workDir); - } catch { - return undefined; + const mounted = new Map }>(); + const connectedCwd = (environmentId: string): string | undefined => { + try { + return environment.workspaceRoots(environmentId).workDir; + } catch { + return undefined; + } + }; + const entry = (target: EnvironmentTarget, lines: readonly string[], listsAgentsMd = false): RosterEntry => ({ + environmentId: target.environmentId, + text: lines.join('\n'), + listsAgentsMd, + }); + const describe = async (target: EnvironmentTarget): Promise => { + const label = `\`${target.environmentId}\``; + if (target.primary) return entry(target, [`- ${label} (primary): working directory ${environment.workspaceRoots().workDir}`]); + let pending = mounted.get(target.environmentId); + if (pending === undefined && environment.isAvailable([], target.environmentId)) { + const cwd = connectedCwd(target.environmentId); + if (cwd !== undefined) { + pending = { cwd, mount: mounts.mount(target.environmentId, cwd) }; + mounted.set(target.environmentId, pending); } } - return resolvedCwds.get(environmentId); + if (pending === undefined) { + return entry(target, [ + target.cwd === undefined + ? `- ${label}: not connected yet; working directory resolved on first use` + : `- ${label}: working directory ${target.cwd}; not connected yet`, + ]); + } + const mount = await pending.mount; + if (mount.status === 'failed') { + return entry(target, [`- ${label}: working directory ${pending.cwd}; its AGENTS.md files and skills could not be loaded: ${mount.error}`]); + } + return entry( + target, + [`- ${label}: working directory ${pending.cwd}`, ...mount.agentsMdPaths.map((path) => ` - AGENTS.md: ${path}`)], + mount.agentsMdPaths.length > 0, + ); }; this._register( - reminder.register(ENVIRONMENT_ROSTER_REMINDER_VARIANT, ({ lastInjection, lastDisclosure }) => { + reminder.register(ENVIRONMENT_ROSTER_REMINDER_VARIANT, async ({ lastInjection, lastDisclosure }) => { const targets = environment.targets(); if (targets.length < 2) { if (lastInjection === undefined || lastDisclosure === SINGLE_ENVIRONMENT_DISCLOSURE) return undefined; @@ -53,17 +95,27 @@ export class AgentEnvironmentRosterService extends Disposable implements IAgentE disclosure: SINGLE_ENVIRONMENT_DISCLOSURE, }; } - const lines = targets.map((target) => { - const label = target.primary ? `\`${target.environmentId}\` (primary)` : `\`${target.environmentId}\``; - const cwd = target.primary ? environment.workspaceRoots().workDir : target.cwd ?? secondaryCwd(target.environmentId); - return cwd === undefined - ? `- ${label}: working directory resolved on first use` - : `- ${label}: working directory ${cwd}`; - }); - const disclosure = lines.join('\n'); - if (lastInjection !== undefined && lastDisclosure === disclosure) return undefined; + const entries = await Promise.all(targets.map(describe)); + const disclosure = Object.fromEntries(entries.map((current) => [current.environmentId, current.text])); + const previous = lastInjection !== undefined && typeof lastDisclosure === 'object' ? lastDisclosure : undefined; + if (previous === undefined) { + return { + content: ['Execution environments available in this session:', ...entries.map((current) => current.text), ROSTER_GUIDANCE].join('\n'), + disclosure, + }; + } + const changed = entries.filter((current) => previous[current.environmentId] !== current.text); + const removed = Object.keys(previous).filter((environmentId) => disclosure[environmentId] === undefined); + if (changed.length === 0 && removed.length === 0) return undefined; return { - content: ['Execution environments available in this session:', ...lines, ROSTER_GUIDANCE].join('\n'), + content: [ + 'Execution environment updates:', + ...changed.map((current) => current.text), + ...removed.map((environmentId) => `- \`${environmentId}\` is no longer available.`), + ...(changed.some((current) => current.listsAgentsMd) + ? ['Read the AGENTS.md files listed above before changing files in that environment.'] + : []), + ].join('\n'), disclosure, }; }), diff --git a/packages/agent-core-v2/src/features/skill/catalog/types.ts b/packages/agent-core-v2/src/features/skill/catalog/types.ts index e149f1b8b52..adb56ebef7e 100644 --- a/packages/agent-core-v2/src/features/skill/catalog/types.ts +++ b/packages/agent-core-v2/src/features/skill/catalog/types.ts @@ -22,6 +22,7 @@ export interface SkillDefinition { readonly content: string; readonly metadata: SkillMetadata; readonly source: SkillSource; + readonly environmentId?: string; readonly plugin?: SkillPluginContext; readonly mermaid?: string | undefined; readonly d2?: string; diff --git a/packages/agent-core-v2/src/features/skill/prompt.ts b/packages/agent-core-v2/src/features/skill/prompt.ts index 6c53cdf04bb..20077f2733f 100644 --- a/packages/agent-core-v2/src/features/skill/prompt.ts +++ b/packages/agent-core-v2/src/features/skill/prompt.ts @@ -1,6 +1,7 @@ import { escapeXml } from '#/_base/utils/xml-escape'; import { promptMetadataTextFromContentParts } from '#/agent/prompt/promptMetadataText'; -import type { SkillSource } from '#/features/skill/catalog/types'; +import { LOCAL_ENVIRONMENT_ID, type EnvironmentTarget } from '#/environment/environment'; +import type { SkillDefinition, SkillSource } from '#/features/skill/catalog/types'; import type { SkillActivationInput } from './skill'; @@ -19,9 +20,20 @@ export interface RenderSkillPromptInput { readonly skillArgs: string; readonly skillContent: string; readonly skillSource?: SkillSource | undefined; + readonly skillEnvironment?: string; readonly skillDir?: string | undefined; } +export function routedSkillEnvironment( + skill: SkillDefinition, + targets: readonly EnvironmentTarget[], +): string | undefined { + const primary = targets.find((target) => target.primary)?.environmentId; + if (targets.length < 2 || primary === undefined || skill.source === 'builtin') return undefined; + const owner = skill.environmentId ?? (skill.source === 'project' ? primary : LOCAL_ENVIRONMENT_ID); + return owner === primary ? undefined : owner; +} + interface RenderSkillLoadedBlockInput extends RenderSkillPromptInput { readonly trigger: SkillPromptTrigger; } @@ -59,6 +71,7 @@ function renderSkillAttributes(input: RenderSkillLoadedBlockInput): string { ['name', input.skillName], ['trigger', input.trigger], ['source', input.skillSource], + ['environment', input.skillEnvironment], ['dir', input.skillDir], ['args', input.skillArgs], ]; diff --git a/packages/agent-core-v2/src/features/skill/session/skillCatalog.ts b/packages/agent-core-v2/src/features/skill/session/skillCatalog.ts index 9df82ec01ac..0d822471269 100644 --- a/packages/agent-core-v2/src/features/skill/session/skillCatalog.ts +++ b/packages/agent-core-v2/src/features/skill/session/skillCatalog.ts @@ -4,7 +4,7 @@ import type { Event } from '#/_base/event'; import type { SkillContribution } from '#/features/skill/catalog/skillSource'; import type { SkillCatalog, SkillSummary } from '#/features/skill/catalog/types'; -export interface ISessionSkillCatalog { +export interface ISessionSkillCatalog extends ISkillCatalogSink { readonly _serviceBrand: undefined; readonly catalog: SkillCatalog; diff --git a/packages/agent-core-v2/src/features/skill/skillService.ts b/packages/agent-core-v2/src/features/skill/skillService.ts index 248cc098992..2b1f9d067d8 100644 --- a/packages/agent-core-v2/src/features/skill/skillService.ts +++ b/packages/agent-core-v2/src/features/skill/skillService.ts @@ -8,6 +8,7 @@ import type { } from '#/agent/contextMemory/types'; import { IAgentLoopService, type PromptLaunchResult, type Turn } from '#/agent/loop/loop'; import { promptMetadataTextFromContentParts } from '#/agent/prompt/promptMetadataText'; +import { IAgentEnvironmentService } from '#/agent/environmentBinding/agentEnvironment'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IEventService } from '#/app/event/event'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -21,7 +22,7 @@ import { applyPromptMetadataUpdate } from '#/session/sessionMetadata/promptMetad import { IEventDispatcher } from '#/state/eventDispatcher'; import { isUserActivatableSkillType, type SkillDefinition } from './catalog/types'; -import { promptMetadataTextFromSkill, renderUserSlashSkillPrompt } from './prompt'; +import { promptMetadataTextFromSkill, renderUserSlashSkillPrompt, routedSkillEnvironment } from './prompt'; import { ISessionSkillCatalog } from './session/skillCatalog'; import type { PromptSkillActivation, @@ -52,6 +53,7 @@ export class AgentSkillService implements IAgentSkillService { @ITelemetryService private readonly telemetry: ITelemetryService, @IEventDispatcher private readonly dispatcher: IEventDispatcher, @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentEnvironmentService private readonly environment: IAgentEnvironmentService, ) {} async activate(input: SkillActivationInput): Promise { @@ -78,6 +80,7 @@ export class AgentSkillService implements IAgentSkillService { skillArgs, skillContent, skillSource: skill.source, + skillEnvironment: routedSkillEnvironment(skill, this.environment.targets()), skillDir: skill.dir, }), }, @@ -231,6 +234,7 @@ export class AgentSkillService implements IAgentSkillService { skillArgs, skillContent, skillSource: skill.source, + skillEnvironment: routedSkillEnvironment(skill, this.environment.targets()), skillDir: skill.dir, }), activationId, diff --git a/packages/agent-core-v2/src/features/skill/tools/skillTool.ts b/packages/agent-core-v2/src/features/skill/tools/skillTool.ts index 3d2c6847f2a..88fdbb04ab2 100644 --- a/packages/agent-core-v2/src/features/skill/tools/skillTool.ts +++ b/packages/agent-core-v2/src/features/skill/tools/skillTool.ts @@ -1,7 +1,9 @@ import { randomUUID } from 'node:crypto'; import type { SkillActivationOrigin } from '#/agent/contextMemory/types'; -import { renderModelToolSkillPrompt } from '#/features/skill/prompt'; +import type { EnvironmentTarget } from '#/environment/environment'; +import { IAgentEnvironmentService } from '#/agent/environmentBinding/agentEnvironment'; +import { renderModelToolSkillPrompt, routedSkillEnvironment } from '#/features/skill/prompt'; import { IAgentSkillService } from '#/features/skill/skillService'; import type { ExecutableToolResult, ToolDeliveryMessage, ToolExecution } from '#/tool/toolContract'; import { isInlineSkillType } from '#/features/skill/catalog/types'; @@ -34,6 +36,7 @@ export class SkillTool implements ISkillTool { @ISessionSkillCatalog private readonly catalog: ISessionSkillCatalog, @IAgentSkillService private readonly skill: IAgentSkillService, @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentEnvironmentService private readonly environment: IAgentEnvironmentService, ) {} resolveExecution(args: SkillToolInput): ToolExecution { @@ -47,7 +50,7 @@ export class SkillTool implements ISkillTool { } withInitialQueryDepth(initialQueryDepth: number): SkillTool { - const clone = new SkillTool(this.catalog, this.skill, this.sessionContext); + const clone = new SkillTool(this.catalog, this.skill, this.sessionContext, this.environment); clone.queryDepth = initialQueryDepth; return clone; } @@ -59,6 +62,7 @@ export class SkillTool implements ISkillTool { args, this.queryDepth, this.sessionContext.sessionId, + this.environment.targets(), ); } } @@ -69,6 +73,7 @@ export async function executeModelSkill( args: SkillToolInput, queryDepth: number, sessionId: string, + targets: readonly EnvironmentTarget[], ): Promise { const currentDepth = queryDepth; if (currentDepth >= MAX_SKILL_QUERY_DEPTH) { @@ -114,6 +119,7 @@ export async function executeModelSkill( skillArgs, skillContent, skillSource: skill.source, + skillEnvironment: routedSkillEnvironment(skill, targets), skillDir: skill.dir, trigger, }), diff --git a/packages/agent-core-v2/src/program/programWorkdirs.ts b/packages/agent-core-v2/src/program/programWorkdirs.ts index cfdd198ca94..22a624374ab 100644 --- a/packages/agent-core-v2/src/program/programWorkdirs.ts +++ b/packages/agent-core-v2/src/program/programWorkdirs.ts @@ -193,7 +193,7 @@ export class ProgramWorkdirs { private buildView(own: Own, input: ProgramViewInput): ProgramView { const state = own(new WorkspaceStateService(this.dependencies.appState)); const localConfig = new FileProjectLocalConfigService(this.dependencies.bootstrap, input.hostFs, input.homeDir); - const dirs = own(new WorkspaceDirsService(input.context, localConfig, this.dependencies.workspaces, this.dependencies.workspaceStore, this.dependencies.log, state)); + const dirs = own(new WorkspaceDirsService(input.context, localConfig, this.dependencies.workspaces, this.dependencies.workspaceStore, this.dependencies.log, state, input.environmentId === LOCAL_ENVIRONMENT_ID)); const git = new WorkspaceGitService(input.context, input.git); const fs = new WorkspaceFsService(input.context, dirs, input.hostFs, this.environments, this.dependencies.telemetry, git, input.environmentId); const instructions = own(new WorkspaceInstructionsService(input.context, input.hostFs, input.instructionsHost, this.dependencies.bootstrap, this.dependencies.log, state, input.personalFs, input.watch)); diff --git a/packages/agent-core-v2/src/session/environmentMounts/environmentMounts.ts b/packages/agent-core-v2/src/session/environmentMounts/environmentMounts.ts new file mode 100644 index 00000000000..f9b8821e00b --- /dev/null +++ b/packages/agent-core-v2/src/session/environmentMounts/environmentMounts.ts @@ -0,0 +1,94 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { Disposable, toDisposable } from '#/_base/di/lifecycle'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { SKILL_SOURCE_PRIORITY } from '#/features/skill/catalog/skillSource'; +import type { SkillDefinition } from '#/features/skill/catalog/types'; +import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; +import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; +import { ISessionWorkdir, type SessionEnvironmentWorkdir } from '#/session/workdir/workdir'; + +export type EnvironmentMount = + | { readonly status: 'mounted'; readonly agentsMdPaths: readonly string[] } + | { readonly status: 'failed'; readonly error: string }; + +export interface ISessionEnvironmentMounts { + readonly _serviceBrand: undefined; + mount(environmentId: string, cwd: string): Promise; +} + +export const ISessionEnvironmentMounts: ServiceIdentifier = + createDecorator('sessionEnvironmentMounts'); + +export function environmentSkillName(name: string, environmentId: string): string { + return `${name}@${environmentId}`; +} + +export class SessionEnvironmentMountsService extends Disposable implements ISessionEnvironmentMounts { + declare readonly _serviceBrand: undefined; + + private readonly mounts = new Map>(); + private disposed = false; + + constructor( + @ISessionWorkdir private readonly workdir: ISessionWorkdir, + @ISessionSkillCatalog private readonly catalog: ISessionSkillCatalog, + @ISessionInstructionsProvider private readonly instructions: ISessionInstructionsProvider, + ) { + super(); + this._register(toDisposable(() => { + this.disposed = true; + })); + } + + mount(environmentId: string, cwd: string): Promise { + let pending = this.mounts.get(environmentId); + if (pending === undefined) { + pending = this.load(environmentId, cwd); + this.mounts.set(environmentId, pending); + } + return pending; + } + + private async load(environmentId: string, cwd: string): Promise { + let workdir: SessionEnvironmentWorkdir; + try { + workdir = await this.workdir.acquireEnvironmentWorkdir(environmentId, cwd); + } catch (error) { + return { status: 'failed', error: errorMessage(error) }; + } + this._register(toDisposable(() => workdir.dispose())); + try { + await Promise.all([workdir.skills.load(), workdir.instructions.ready, this.instructions.ready]); + } catch (error) { + return { status: 'failed', error: errorMessage(error) }; + } + if (this.disposed) return { status: 'failed', error: 'session closed' }; + const skills = workdir.skills.catalog + .listSkills() + .filter((skill) => skill.source === 'project') + .map((skill): SkillDefinition => ({ + ...skill, + name: environmentSkillName(skill.name, environmentId), + environmentId, + })); + if (skills.length > 0) { + this.catalog.set(`environment:${environmentId}`, { skills }, { priority: SKILL_SOURCE_PRIORITY.workspace }); + } + const primaryPaths = new Set(this.instructions.agentsMdPaths ?? []); + const agentsMdPaths = (workdir.instructions.snapshot.agentsMdPaths ?? []).filter((path) => !primaryPaths.has(path)); + return { status: 'mounted', agentsMdPaths }; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +registerScopedService( + LifecycleScope.Session, + ISessionEnvironmentMounts, + SessionEnvironmentMountsService, + ScopeActivation.OnDemand, + 'sessionEnvironmentMounts', +); diff --git a/packages/agent-core-v2/src/session/workdir/workdir.ts b/packages/agent-core-v2/src/session/workdir/workdir.ts index 8f2cc48c596..41218032e4f 100644 --- a/packages/agent-core-v2/src/session/workdir/workdir.ts +++ b/packages/agent-core-v2/src/session/workdir/workdir.ts @@ -1,5 +1,13 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IWorkspaceSkillCatalog } from '#/features/skill/workspace/workspaceSkillCatalog'; import type { IWorkspaceFsService } from '#/workspace/workspaceFs/fs'; +import type { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; + +export interface SessionEnvironmentWorkdir { + readonly skills: IWorkspaceSkillCatalog; + readonly instructions: IWorkspaceInstructionsService; + dispose(): void; +} export interface ISessionWorkdir { readonly _serviceBrand: undefined; @@ -7,6 +15,7 @@ export interface ISessionWorkdir { readonly environmentId: string; readonly cwd?: string; readonly fs: IWorkspaceFsService; + acquireEnvironmentWorkdir(environmentId: string, cwd: string): Promise; } export const ISessionWorkdir: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index ea211dd154e..5282b40f4a6 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -325,6 +325,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec environmentId: environment?.environmentId ?? LOCAL_ENVIRONMENT_ID, cwd: environment?.cwd, fs: workdir.fs, + acquireEnvironmentWorkdir: (environmentId: string, cwd: string) => this.acquireWorkdir(environmentId, cwd), }], ...sessionEphemeralMcpServersSeed(opts.mcpServers ?? {}), ], diff --git a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts index 573c79abf3f..214159b78aa 100644 --- a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts @@ -60,6 +60,7 @@ export class WorkspaceDirsService implements IWorkspaceDirs { @IWorkspacePersistence private readonly workspaceStore: IWorkspacePersistence, @ILogService private readonly log: ILogService, @IWorkspaceStateService private readonly states: IWorkspaceStateService, + private readonly readsRegistry = true, ) { this.states.contributeState(workspaceDirsFileDirsKey); this.states.contributeState(workspaceDirsEphemeralDirsKey); @@ -222,6 +223,7 @@ export class WorkspaceDirsService implements IWorkspaceDirs { } private async reloadFromRegistry(): Promise { + if (!this.readsRegistry) return; const registered = await this.workspaces.list(); const current = this.workspace.meta.kind === 'managed' ? undefined diff --git a/packages/agent-core-v2/test/agent/changeNotifier/changeNotifier.test.ts b/packages/agent-core-v2/test/agent/changeNotifier/changeNotifier.test.ts index d4d2bc44684..65ffd3d6877 100644 --- a/packages/agent-core-v2/test/agent/changeNotifier/changeNotifier.test.ts +++ b/packages/agent-core-v2/test/agent/changeNotifier/changeNotifier.test.ts @@ -197,6 +197,8 @@ function createHarness(options: { onDidChange: skillCatalogChange.event, load: async () => {}, reload: async () => {}, + set: () => {}, + remove: () => {}, list: async () => [], catalog: { getModelSkillListing: () => skillListing.value, diff --git a/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts b/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts index fd291c0c39f..8e8e1574e7f 100644 --- a/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts +++ b/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts @@ -29,6 +29,19 @@ import type { IEnvironmentDeclarationService } from '#/app/environmentDeclaratio import type { IFlagService } from '#/app/flag/flag'; import { MULTI_ENVIRONMENT_FLAG_ID } from '#/agent/environmentBinding/flag'; import { AgentEnvironmentRosterService, ENVIRONMENT_ROSTER_REMINDER_VARIANT } from '#/agent/environmentBinding/environmentRoster'; +import { + SessionEnvironmentMountsService, + type EnvironmentMount, + type ISessionEnvironmentMounts, +} from '#/session/environmentMounts/environmentMounts'; +import { InMemorySkillCatalog } from '#/features/skill/catalog/registry'; +import type { SkillContribution } from '#/features/skill/catalog/skillSource'; +import type { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; +import type { IWorkspaceSkillCatalog } from '#/features/skill/workspace/workspaceSkillCatalog'; +import type { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; +import type { ISessionWorkdir, SessionEnvironmentWorkdir } from '#/session/workdir/workdir'; +import type { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; +import { stubSkill } from '../../features/skill/catalog/stubs'; import type { ContextInjectionContext, ContextInjectionProvider, ContextInjectionResult } from '#/features/reminder/types'; import type { RemoteEnvironmentEntry } from '#/remote/declaration/remoteEnvironmentDeclaration'; import { stubAgentContext } from '../agentContext/stubs'; @@ -609,8 +622,31 @@ describe('AgentEnvironmentService multi-environment targets', () => { }); }); +const ROSTER_GUIDANCE = + 'Tools with an `environment` argument run in the environment you name; omit it to use the primary environment. ' + + 'Each environment has its own files, shell, and installed programs, and relative paths resolve against its working directory. ' + + 'A remote environment connects on its first use; if it cannot connect, the call fails instead of running elsewhere. ' + + "The system prompt's working directory, AGENTS.md instructions, and skills describe the primary environment. " + + 'Once another environment connects, the AGENTS.md files found there are listed under it; read them before changing files in that environment. ' + + 'Skills found in another environment are named `@`.'; + +function stubMounts(results: Record = {}) { + const calls: { readonly environmentId: string; readonly cwd: string }[] = []; + const mounts: ISessionEnvironmentMounts = { + _serviceBrand: undefined, + mount: (environmentId: string, cwd: string) => { + calls.push({ environmentId, cwd }); + return Promise.resolve(results[environmentId] ?? { status: 'mounted', agentsMdPaths: [] }); + }, + }; + return { mounts, calls }; +} + describe('AgentEnvironmentRosterService', () => { - function rosterProvider(agentEnvironment: ConstructorParameters[1]) { + function rosterProvider( + agentEnvironment: ConstructorParameters[1], + mounts: ISessionEnvironmentMounts = stubMounts().mounts, + ) { let provider: ContextInjectionProvider | undefined; const reminder = { _serviceBrand: undefined, @@ -620,72 +656,220 @@ describe('AgentEnvironmentRosterService', () => { return { dispose: () => {} }; }, } as unknown as IAgentReminderService; - const roster = new AgentEnvironmentRosterService(reminder, agentEnvironment); - const provide = (context: Partial>) => - provider!({ injectedPositions: [], lastInjectedAt: null, isNewTurn: true, ...context }) as + const roster = new AgentEnvironmentRosterService(reminder, agentEnvironment, mounts); + const provide = async (context: Partial>) => + (await provider!({ injectedPositions: [], lastInjectedAt: null, isNewTurn: true, ...context })) as | ContextInjectionResult | undefined; return { roster, provide }; } - it('lists every environment once and repeats only after the roster changes or leaves the context', () => { + it('lists every environment once and repeats only after the roster changes or leaves the context', async () => { const { agentEnvironment, registry } = setup({ multiEnvironment: { declarations: [{ id: 'remote', defaultCwd: '/srv/app' }, { id: 'staging' }, { id: 'unregistered' }] }, }); registry.register(environment('staging', 'staging-one', 'pending')); - const { provide } = rosterProvider(agentEnvironment); + const { mounts, calls } = stubMounts(); + const { provide } = rosterProvider(agentEnvironment, mounts); - const first = provide({}); + const first = await provide({}); expect(first?.content).toBe( [ 'Execution environments available in this session:', '- `local` (primary): working directory /workspace', '- `remote`: working directory /srv/app', - '- `staging`: working directory resolved on first use', - 'Tools with an `environment` argument run in the environment you name; omit it to use the primary environment. ' + - 'Each environment has its own files, shell, and installed programs, and relative paths resolve against its working directory. ' + - 'A remote environment connects on its first use; if it cannot connect, the call fails instead of running elsewhere. ' + - "The system prompt's working directory, AGENTS.md instructions, and skills describe the primary environment only; " + - "read a project's AGENTS.md before changing files in another environment.", + '- `staging`: not connected yet; working directory resolved on first use', + ROSTER_GUIDANCE, ].join('\n'), ); const injected = { role: 'user' as const, content: [] }; - expect(provide({ lastInjection: injected, lastDisclosure: first?.disclosure })).toBeUndefined(); - expect(provide({ lastDisclosure: first?.disclosure })?.content).toBe(first?.content); + expect(await provide({ lastInjection: injected, lastDisclosure: first?.disclosure })).toBeUndefined(); + expect((await provide({ lastDisclosure: first?.disclosure }))?.content).toBe(first?.content); + expect(calls).toEqual([{ environmentId: 'remote', cwd: '/srv/app' }]); }); - it('reports the working directory a connected environment resolved and keeps it across disconnects', () => { + it('shows the declared working directory of an environment that has not connected yet', async () => { + const { agentEnvironment, registry } = setup({ + multiEnvironment: { declarations: [{ id: 'staging', defaultCwd: '/srv/staging' }] }, + }); + registry.register(environment('staging', 'staging-one', 'pending')); + const { mounts, calls } = stubMounts(); + const { provide } = rosterProvider(agentEnvironment, mounts); + + expect((await provide({}))?.content).toContain('- `staging`: working directory /srv/staging; not connected yet'); + expect(calls).toEqual([]); + }); + + it('mounts an environment once it connects, lists its AGENTS.md files, and keeps them across disconnects', async () => { const { agentEnvironment, registry } = setup({ multiEnvironment: { declarations: [{ id: 'staging' }] }, }); const staging = environment('staging', 'staging-one', 'pending', [], { cwd: '/home/deploy' }); registry.register(staging); - const { provide } = rosterProvider(agentEnvironment); + const { mounts, calls } = stubMounts({ + staging: { status: 'mounted', agentsMdPaths: ['/home/deploy/AGENTS.md', '/home/deploy/api/AGENTS.md'] }, + }); + const { provide } = rosterProvider(agentEnvironment, mounts); const injected = { role: 'user' as const, content: [] }; - const before = provide({}); - expect(before?.content).toContain('- `staging`: working directory resolved on first use'); + const before = await provide({}); + expect(before?.content).toContain('- `staging`: not connected yet; working directory resolved on first use'); staging.setStatus('ready'); - const connected = provide({ lastInjection: injected, lastDisclosure: before?.disclosure }); - expect(connected?.content).toContain('- `staging`: working directory /home/deploy'); + const connected = await provide({ lastInjection: injected, lastDisclosure: before?.disclosure }); + expect(connected?.content).toBe( + [ + 'Execution environment updates:', + '- `staging`: working directory /home/deploy', + ' - AGENTS.md: /home/deploy/AGENTS.md', + ' - AGENTS.md: /home/deploy/api/AGENTS.md', + 'Read the AGENTS.md files listed above before changing files in that environment.', + ].join('\n'), + ); staging.setStatus('disconnected'); - expect(provide({ lastInjection: injected, lastDisclosure: connected?.disclosure })).toBeUndefined(); + expect(await provide({ lastInjection: injected, lastDisclosure: connected?.disclosure })).toBeUndefined(); + expect(calls).toEqual([{ environmentId: 'staging', cwd: '/home/deploy' }]); + }); + + it('sends only the environments that left since the last roster', async () => { + const { agentEnvironment, registry } = setup({ + multiEnvironment: { declarations: [{ id: 'remote', defaultCwd: '/srv/app' }, { id: 'staging' }] }, + }); + const staging = registry.register(environment('staging', 'staging-one', 'pending')); + const { provide } = rosterProvider(agentEnvironment); + const injected = { role: 'user' as const, content: [] }; + + const first = await provide({}); + await staging.remove(); + + expect(await provide({ lastInjection: injected, lastDisclosure: first?.disclosure })).toEqual({ + content: ['Execution environment updates:', '- `staging` is no longer available.'].join('\n'), + disclosure: { + local: '- `local` (primary): working directory /workspace', + remote: '- `remote`: working directory /srv/app', + }, + }); }); - it('withdraws the roster once only the primary environment remains', () => { + it('reports an environment whose project context could not be loaded', async () => { + const { agentEnvironment } = setup({ + multiEnvironment: { declarations: [{ id: 'remote', defaultCwd: '/srv/app' }] }, + }); + const { mounts } = stubMounts({ remote: { status: 'failed', error: 'cwd /srv/app is not a directory' } }); + const { provide } = rosterProvider(agentEnvironment, mounts); + + expect((await provide({}))?.content).toContain( + '- `remote`: working directory /srv/app; its AGENTS.md files and skills could not be loaded: cwd /srv/app is not a directory', + ); + }); + + it('withdraws the roster once only the primary environment remains', async () => { const { agentEnvironment } = setup({ multiEnvironment: { enabled: false, declarations: [{ id: 'remote', defaultCwd: '/srv/app' }] }, }); - const { provide } = rosterProvider(agentEnvironment); + const { mounts, calls } = stubMounts(); + const { provide } = rosterProvider(agentEnvironment, mounts); const injected = { role: 'user' as const, content: [] }; - expect(provide({})).toBeUndefined(); - expect(provide({ lastInjection: injected, lastDisclosure: '- `local` (primary): working directory /workspace' })).toEqual({ + expect(await provide({})).toBeUndefined(); + expect(await provide({ lastInjection: injected, lastDisclosure: '- `local` (primary): working directory /workspace' })).toEqual({ content: 'Only the primary environment `local` is available now. Tools no longer take an `environment` argument.', disclosure: 'single', }); - expect(provide({ lastInjection: injected, lastDisclosure: 'single' })).toBeUndefined(); + expect(await provide({ lastInjection: injected, lastDisclosure: 'single' })).toBeUndefined(); + expect(calls).toEqual([]); + }); +}); + +describe('SessionEnvironmentMountsService', () => { + function setupMounts(options: { + readonly acquire?: (environmentId: string, cwd: string) => Promise; + readonly primaryAgentsMdPaths?: readonly string[]; + } = {}) { + const contributions = new Map(); + const acquired: { readonly environmentId: string; readonly cwd: string }[] = []; + let disposedWorkdirs = 0; + const environmentCatalog = new InMemorySkillCatalog(); + environmentCatalog.register(stubSkill('deploy', { source: 'project', dir: '/srv/app/.agents/skills/deploy' })); + environmentCatalog.register(stubSkill('notes', { source: 'user' })); + const defaultWorkdir: SessionEnvironmentWorkdir = { + skills: { catalog: environmentCatalog, load: async () => {} } as unknown as IWorkspaceSkillCatalog, + instructions: { + ready: Promise.resolve(), + snapshot: { + agentsMd: undefined, + agentsMdWarning: undefined, + agentsMdPaths: ['/kimi-home/AGENTS.md', '/srv/app/AGENTS.md'], + }, + } as unknown as IWorkspaceInstructionsService, + dispose: () => { + disposedWorkdirs += 1; + }, + }; + const workdir = { + _serviceBrand: undefined, + environmentId: 'local', + acquireEnvironmentWorkdir: (environmentId: string, cwd: string) => { + acquired.push({ environmentId, cwd }); + return options.acquire?.(environmentId, cwd) ?? Promise.resolve(defaultWorkdir); + }, + } as unknown as ISessionWorkdir; + const catalog = { + set: (id: string, contribution: SkillContribution) => { + contributions.set(id, contribution); + }, + } as unknown as ISessionSkillCatalog; + const instructions = { + ready: Promise.resolve(), + agentsMdPaths: options.primaryAgentsMdPaths ?? ['/kimi-home/AGENTS.md', '/workspace/AGENTS.md'], + } as unknown as ISessionInstructionsProvider; + const service = new SessionEnvironmentMountsService(workdir, catalog, instructions); + return { service, contributions, acquired, disposedWorkdirs: () => disposedWorkdirs }; + } + + it('adds the project skills of a mounted environment under an environment-qualified name', async () => { + const { service, contributions } = setupMounts(); + + await service.mount('remote', '/srv/app'); + + const skills = contributions.get('environment:remote')?.skills ?? []; + expect(skills.map((skill) => [skill.name, skill.environmentId, skill.dir])).toEqual([ + ['deploy@remote', 'remote', '/srv/app/.agents/skills/deploy'], + ]); + }); + + it('lists only the AGENTS.md files the primary environment did not already load', async () => { + const { service } = setupMounts(); + + await expect(service.mount('remote', '/srv/app')).resolves.toEqual({ + status: 'mounted', + agentsMdPaths: ['/srv/app/AGENTS.md'], + }); + }); + + it('loads each environment once and releases its workdir with the session', async () => { + const { service, acquired, disposedWorkdirs } = setupMounts(); + + const first = service.mount('remote', '/srv/app'); + const second = service.mount('remote', '/srv/app'); + expect(second).toBe(first); + await first; + expect(acquired).toEqual([{ environmentId: 'remote', cwd: '/srv/app' }]); + + service.dispose(); + expect(disposedWorkdirs()).toBe(1); + }); + + it('reports a workdir that cannot be opened without adding skills', async () => { + const { service, contributions } = setupMounts({ + acquire: () => Promise.reject(new Error('cwd /srv/app is not a directory on environment remote')), + }); + + await expect(service.mount('remote', '/srv/app')).resolves.toEqual({ + status: 'failed', + error: 'cwd /srv/app is not a directory on environment remote', + }); + expect(contributions.size).toBe(0); }); }); diff --git a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts index 949970282f8..ca17b951cc1 100644 --- a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts +++ b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts @@ -191,6 +191,8 @@ describe('AgentPluginService plugin session-start wiring', () => { onDidChange: sinkChange.event, load: async () => {}, reload: async () => {}, + set: () => {}, + remove: () => {}, list: async () => catalog.listSkills().map(summarizeSkill), }; @@ -238,6 +240,8 @@ describe('AgentPluginService plugin session-start wiring', () => { onDidChange: sinkChange.event, load: async () => {}, reload: async () => {}, + set: () => {}, + remove: () => {}, list: async () => catalog.listSkills().map(summarizeSkill), }; @@ -280,6 +284,8 @@ describe('AgentPluginService plugin session-start wiring', () => { onDidChange: sinkChange.event, load: async () => {}, reload: async () => {}, + set: () => {}, + remove: () => {}, list: async () => catalog.listSkills().map(summarizeSkill), }; @@ -391,6 +397,8 @@ describe('AgentPluginService plugin-change reminder', () => { onDidChange: change.event, load: async () => {}, reload: async () => {}, + set: () => {}, + remove: () => {}, list: async () => catalog.listSkills().map(summarizeSkill), }; return skillCatalog; diff --git a/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts b/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts index 7f09f48d769..b75a008e9dd 100644 --- a/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts +++ b/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts @@ -539,6 +539,8 @@ function skillCatalogWithChange( onDidChange: change.event, load: async () => {}, reload: async () => {}, + set: () => {}, + remove: () => {}, list: async () => [], }); } diff --git a/packages/agent-core-v2/test/agent/profile/binding.test.ts b/packages/agent-core-v2/test/agent/profile/binding.test.ts index 00bfce6a94d..fdf1896edb5 100644 --- a/packages/agent-core-v2/test/agent/profile/binding.test.ts +++ b/packages/agent-core-v2/test/agent/profile/binding.test.ts @@ -956,6 +956,8 @@ describe('AgentToolPolicyService.setSessionDisabledTools', () => { onDidChange: Event.None as Event, load: async () => {}, reload: async () => {}, + set: () => {}, + remove: () => {}, list: async () => [], }), ); @@ -982,6 +984,8 @@ describe('AgentToolPolicyService.setSessionDisabledTools', () => { onDidChange: Event.None as Event, load: async () => {}, reload: async () => {}, + set: () => {}, + remove: () => {}, list: async () => [], }), ); @@ -1003,6 +1007,8 @@ describe('AgentToolPolicyService.setSessionDisabledTools', () => { onDidChange: Event.None as Event, load: async () => {}, reload: async () => {}, + set: () => {}, + remove: () => {}, list: async () => [], }), ); diff --git a/packages/agent-core-v2/test/features/skill/catalog/stubs.ts b/packages/agent-core-v2/test/features/skill/catalog/stubs.ts index e345b63597b..a46096c37ef 100644 --- a/packages/agent-core-v2/test/features/skill/catalog/stubs.ts +++ b/packages/agent-core-v2/test/features/skill/catalog/stubs.ts @@ -13,6 +13,7 @@ export function stubSkill( content: overrides.content ?? `body of ${name}`, metadata: overrides.metadata ?? {}, source: overrides.source ?? 'user', + environmentId: overrides.environmentId, plugin: overrides.plugin, mermaid: overrides.mermaid, d2: overrides.d2, diff --git a/packages/agent-core-v2/test/features/skill/prompt.test.ts b/packages/agent-core-v2/test/features/skill/prompt.test.ts index 2a92c8a65ed..e51f6fb413a 100644 --- a/packages/agent-core-v2/test/features/skill/prompt.test.ts +++ b/packages/agent-core-v2/test/features/skill/prompt.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from 'vitest'; +import type { EnvironmentTarget } from '#/environment/environment'; import { renderModelToolSkillPrompt, renderUserSlashSkillPrompt, + routedSkillEnvironment, } from '#/features/skill/prompt'; +import { stubSkill } from './catalog/stubs'; + describe('renderSkillLoadedBlock skill directory', () => { const base = { skillName: 'review', @@ -46,3 +50,36 @@ describe('renderSkillLoadedBlock skill directory', () => { expect(text).toContain('source="user"'); }); }); + +describe('routedSkillEnvironment', () => { + const remotePrimary: readonly EnvironmentTarget[] = [ + { environmentId: 'devbox', primary: true, cwd: '/srv/app' }, + { environmentId: 'local', primary: false, cwd: '/workspace' }, + ]; + + it('names the environment that owns a skill outside the primary environment', () => { + expect(routedSkillEnvironment(stubSkill('notes', { source: 'user' }), remotePrimary)).toBe('local'); + expect( + routedSkillEnvironment(stubSkill('deploy@local', { source: 'project', environmentId: 'local' }), remotePrimary), + ).toBe('local'); + }); + + it('omits the environment for primary project skills, builtin skills, and single-environment sessions', () => { + expect(routedSkillEnvironment(stubSkill('deploy', { source: 'project' }), remotePrimary)).toBeUndefined(); + expect(routedSkillEnvironment(stubSkill('mcp-config', { source: 'builtin' }), remotePrimary)).toBeUndefined(); + expect(routedSkillEnvironment(stubSkill('notes', { source: 'user' }), remotePrimary.slice(0, 1))).toBeUndefined(); + }); + + it('renders the owning environment beside the skill directory', () => { + const text = renderModelToolSkillPrompt({ + skillName: 'deploy@local', + skillArgs: '', + skillContent: 'body', + skillSource: 'project', + skillEnvironment: 'local', + skillDir: '/workspace/.agents/skills/deploy', + trigger: 'model-tool', + }); + expect(text).toContain('source="project" environment="local" dir="/workspace/.agents/skills/deploy"'); + }); +}); diff --git a/packages/agent-core-v2/test/features/skill/skill.test.ts b/packages/agent-core-v2/test/features/skill/skill.test.ts index b2df89caefe..7499e01aff4 100644 --- a/packages/agent-core-v2/test/features/skill/skill.test.ts +++ b/packages/agent-core-v2/test/features/skill/skill.test.ts @@ -83,6 +83,8 @@ describe('AgentSkillService', () => { onDidChange: () => ({ dispose: () => {} }), load: async () => {}, reload: async () => {}, + set: () => {}, + remove: () => {}, list: async () => skills.listSkills().map(summarizeSkill), }), ); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 2b652651986..3953cdd700e 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -786,6 +786,8 @@ function createSessionSkillCatalog(catalog: SkillCatalog): ISessionSkillCatalog onDidChange: Event.None as Event, load: async () => { }, reload: async () => { }, + set: () => {}, + remove: () => {}, list: async () => catalog.listSkills().map(summarizeSkill), }; } @@ -1321,6 +1323,7 @@ export class AgentTestContext { environmentId: 'local', cwd: this.cwd, fs: undefined as never, + acquireEnvironmentWorkdir: () => Promise.reject(new Error('environment workdirs are not available in the agent harness')), }); reg.defineInstance( IWorkspaceStateService, diff --git a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirsService.test.ts b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirsService.test.ts index c00e5f1702d..2dae448ae0d 100644 --- a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirsService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirsService.test.ts @@ -78,6 +78,7 @@ describe('WorkspaceDirsService (registry source)', () => { cwd: string, workspaceId: string, kind: 'managed' | 'implicit' = 'implicit', + readsRegistry = true, ): WorkspaceDirsService { const bootstrap = stubBootstrap(homeDir); const hostFs = new HostFileSystem(); @@ -97,6 +98,7 @@ describe('WorkspaceDirsService (registry source)', () => { persistence, noopLog, new WorkspaceStateService(), + readsRegistry, ), ); } @@ -120,6 +122,15 @@ describe('WorkspaceDirsService (registry source)', () => { expect(outsider.sessionInfo().primaryByWorkspace).toEqual({}); }); + it('ignores the local registry for a view of another environment that shares a registered path', async () => { + const project = await workspaces.create({ root: dirA, additionalDirs: [dirB], name: 'proj' }); + + const remoteView = createDirs(dirA, project.id, 'managed', false); + await remoteView.ready; + expect(remoteView.additionalDirs).toEqual([]); + expect(remoteView.sessionInfo().primaryByWorkspace).toEqual({}); + }); + it('keeps same-root managed workspaces scoped to their workspace id', async () => { const first = await workspaces.create({ root: dirA, additionalDirs: [dirB], name: 'first' }); const second = await workspaces.create({ root: dirA, additionalDirs: [dirC], name: 'second' }); From 82b6d6deab65d303facf0e0e7ef834a25eb8e55b Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Fri, 30 Oct 2026 16:25:03 +0800 Subject: [PATCH 6/9] fix: keep environment project context accurate across mounts List the AGENTS.md files a mounted environment found from its own working directory instead of deduplicating paths against the primary, which hid same-path files on another filesystem. Drop a mounted environment's skills and workdir once it leaves the roster, and accept the routed environment in the Kap tool display schema. --- .../agent-core-v2/docs/state-manifest.d.ts | 1 + .../environmentBinding/environmentRoster.ts | 5 ++ .../src/agent/profile/context.ts | 20 +++-- .../environmentMounts/environmentMounts.ts | 67 ++++++++++----- .../workspaceInstructions.ts | 1 + .../workspaceInstructionsService.ts | 3 +- .../environmentBindingService.test.ts | 83 ++++++++++++++++--- .../test/agent/profile/context.test.ts | 14 ++++ packages/kap-server/src/protocol/display.ts | 2 + 9 files changed, 159 insertions(+), 37 deletions(-) diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 2fdad725d07..0f6fdefb597 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -405,6 +405,7 @@ export interface WorkspaceStateSnapshot { readonly agentsMd: string | undefined; readonly agentsMdWarning: string | undefined; readonly agentsMdPaths: readonly string[] | undefined; + readonly agentsMdProjectPaths: readonly string[] | undefined; }; // src/workspace/workspaceTrust/workspaceTrustService.ts 'workspaceTrust.trusted': boolean; diff --git a/packages/agent-core-v2/src/agent/environmentBinding/environmentRoster.ts b/packages/agent-core-v2/src/agent/environmentBinding/environmentRoster.ts index 946144bc0f1..7bd67fd3afb 100644 --- a/packages/agent-core-v2/src/agent/environmentBinding/environmentRoster.ts +++ b/packages/agent-core-v2/src/agent/environmentBinding/environmentRoster.ts @@ -88,6 +88,11 @@ export class AgentEnvironmentRosterService extends Disposable implements IAgentE this._register( reminder.register(ENVIRONMENT_ROSTER_REMINDER_VARIANT, async ({ lastInjection, lastDisclosure }) => { const targets = environment.targets(); + for (const [environmentId, pending] of mounted) { + if (targets.some((target) => target.environmentId === environmentId)) continue; + mounted.delete(environmentId); + mounts.unmount(environmentId, pending.mount); + } if (targets.length < 2) { if (lastInjection === undefined || lastDisclosure === SINGLE_ENVIRONMENT_DISCLOSURE) return undefined; return { diff --git a/packages/agent-core-v2/src/agent/profile/context.ts b/packages/agent-core-v2/src/agent/profile/context.ts index 2075e71ec37..2e4679d9bc2 100644 --- a/packages/agent-core-v2/src/agent/profile/context.ts +++ b/packages/agent-core-v2/src/agent/profile/context.ts @@ -66,7 +66,7 @@ export async function loadAgentsMdDetailed( deps: ProfileContextDeps, workDir: string, brandHome?: string, -): Promise { +): Promise { return loadAgentsMdForRoots(deps, brandHome, [workDir]); } @@ -77,6 +77,10 @@ export interface LoadedAgentsMd { readonly files: ReadonlyMap; } +export interface DiscoveredAgentsMd extends LoadedAgentsMd { + readonly projectPaths: readonly string[]; +} + export function splitAgentsMdFiles( content: string, paths: readonly string[], @@ -163,8 +167,9 @@ export async function loadAgentsMdForRoots( brandHome: string | undefined, workDirs: readonly string[], personalFs: IHostFileSystem = deps.fs, -): Promise { +): Promise { const discovered: AgentFile[] = []; + const projectPaths: string[] = []; const seenByFs = new Map>(); const loadWarnings: string[] = []; const warnLoad = (message: string): void => { @@ -200,10 +205,15 @@ export async function loadAgentsMdForRoots( const projectRoot = (await findGitWorkTree(deps.fs, rootWorkDir))?.root ?? rootWorkDir; const dirs = dirsRootToLeaf(rootWorkDir, projectRoot); + const collectProject = async (path: string): Promise => { + if (!(await collect(deps.fs, path))) return false; + projectPaths.push(normalize(discovered.at(-1)!.path)); + return true; + }; for (const dir of dirs) { - await collect(deps.fs, dotKimiAgentsMdPath(dir)); + await collectProject(dotKimiAgentsMdPath(dir)); for (const fileName of AGENTS_MD_PLAIN_NAMES) { - if (await collect(deps.fs, join(dir, fileName))) break; + if (await collectProject(join(dir, fileName))) break; } } } @@ -220,7 +230,7 @@ export async function loadAgentsMdForRoots( const warning = loadWarnings.length > 0 ? loadWarnings.join('\n') : undefined; const paths = discovered.map((file) => normalize(file.path)); const files = new Map(discovered.map((file) => [normalize(file.path), file.content])); - return { content, warning, paths, files }; + return { content, warning, paths, files, projectPaths }; } export interface AgentsMdWatchRoot { diff --git a/packages/agent-core-v2/src/session/environmentMounts/environmentMounts.ts b/packages/agent-core-v2/src/session/environmentMounts/environmentMounts.ts index f9b8821e00b..c0bca552208 100644 --- a/packages/agent-core-v2/src/session/environmentMounts/environmentMounts.ts +++ b/packages/agent-core-v2/src/session/environmentMounts/environmentMounts.ts @@ -5,7 +5,6 @@ import { LifecycleScope } from '#/app/scopes'; import { SKILL_SOURCE_PRIORITY } from '#/features/skill/catalog/skillSource'; import type { SkillDefinition } from '#/features/skill/catalog/types'; import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; -import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; import { ISessionWorkdir, type SessionEnvironmentWorkdir } from '#/session/workdir/workdir'; export type EnvironmentMount = @@ -15,6 +14,7 @@ export type EnvironmentMount = export interface ISessionEnvironmentMounts { readonly _serviceBrand: undefined; mount(environmentId: string, cwd: string): Promise; + unmount(environmentId: string, mount: Promise): void; } export const ISessionEnvironmentMounts: ServiceIdentifier = @@ -24,46 +24,72 @@ export function environmentSkillName(name: string, environmentId: string): strin return `${name}@${environmentId}`; } +interface MountHandle { + released: boolean; + workdir?: SessionEnvironmentWorkdir; + contributed: boolean; +} + +interface MountEntry { + readonly result: Promise; + readonly handle: MountHandle; +} + export class SessionEnvironmentMountsService extends Disposable implements ISessionEnvironmentMounts { declare readonly _serviceBrand: undefined; - private readonly mounts = new Map>(); - private disposed = false; + private readonly mounts = new Map(); constructor( @ISessionWorkdir private readonly workdir: ISessionWorkdir, @ISessionSkillCatalog private readonly catalog: ISessionSkillCatalog, - @ISessionInstructionsProvider private readonly instructions: ISessionInstructionsProvider, ) { super(); this._register(toDisposable(() => { - this.disposed = true; + for (const { handle } of this.mounts.values()) { + handle.released = true; + handle.workdir?.dispose(); + } + this.mounts.clear(); })); } mount(environmentId: string, cwd: string): Promise { - let pending = this.mounts.get(environmentId); - if (pending === undefined) { - pending = this.load(environmentId, cwd); - this.mounts.set(environmentId, pending); - } - return pending; + const existing = this.mounts.get(environmentId); + if (existing !== undefined) return existing.result; + const handle: MountHandle = { released: false, contributed: false }; + const result = this.load(environmentId, cwd, handle); + this.mounts.set(environmentId, { result, handle }); + return result; } - private async load(environmentId: string, cwd: string): Promise { + unmount(environmentId: string, mount: Promise): void { + const entry = this.mounts.get(environmentId); + if (entry === undefined || entry.result !== mount) return; + this.mounts.delete(environmentId); + entry.handle.released = true; + entry.handle.workdir?.dispose(); + if (entry.handle.contributed) this.catalog.remove(catalogContributionId(environmentId)); + } + + private async load(environmentId: string, cwd: string, handle: MountHandle): Promise { let workdir: SessionEnvironmentWorkdir; try { workdir = await this.workdir.acquireEnvironmentWorkdir(environmentId, cwd); } catch (error) { return { status: 'failed', error: errorMessage(error) }; } - this._register(toDisposable(() => workdir.dispose())); + if (handle.released) { + workdir.dispose(); + return { status: 'failed', error: `environment ${environmentId} is no longer available` }; + } + handle.workdir = workdir; try { - await Promise.all([workdir.skills.load(), workdir.instructions.ready, this.instructions.ready]); + await Promise.all([workdir.skills.load(), workdir.instructions.ready]); } catch (error) { return { status: 'failed', error: errorMessage(error) }; } - if (this.disposed) return { status: 'failed', error: 'session closed' }; + if (handle.released) return { status: 'failed', error: `environment ${environmentId} is no longer available` }; const skills = workdir.skills.catalog .listSkills() .filter((skill) => skill.source === 'project') @@ -73,14 +99,17 @@ export class SessionEnvironmentMountsService extends Disposable implements ISess environmentId, })); if (skills.length > 0) { - this.catalog.set(`environment:${environmentId}`, { skills }, { priority: SKILL_SOURCE_PRIORITY.workspace }); + this.catalog.set(catalogContributionId(environmentId), { skills }, { priority: SKILL_SOURCE_PRIORITY.workspace }); + handle.contributed = true; } - const primaryPaths = new Set(this.instructions.agentsMdPaths ?? []); - const agentsMdPaths = (workdir.instructions.snapshot.agentsMdPaths ?? []).filter((path) => !primaryPaths.has(path)); - return { status: 'mounted', agentsMdPaths }; + return { status: 'mounted', agentsMdPaths: workdir.instructions.snapshot.agentsMdProjectPaths ?? [] }; } } +function catalogContributionId(environmentId: string): string { + return `environment:${environmentId}`; +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts index aef85c53236..5fec2525511 100644 --- a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts +++ b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts @@ -7,6 +7,7 @@ export interface WorkspaceInstructionsSnapshot { readonly agentsMd: string | undefined; readonly agentsMdWarning: string | undefined; readonly agentsMdPaths: readonly string[] | undefined; + readonly agentsMdProjectPaths: readonly string[] | undefined; } export interface IWorkspaceInstructionsService { diff --git a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts index a3a92a0c9c7..5383132812f 100644 --- a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts @@ -22,7 +22,7 @@ const WATCH_DEBOUNCE_MS = 200; export const workspaceInstructionsCurrentKey = defineState( 'workspaceInstructions.current', - () => ({ agentsMd: undefined, agentsMdWarning: undefined, agentsMdPaths: undefined }), + () => ({ agentsMd: undefined, agentsMdWarning: undefined, agentsMdPaths: undefined, agentsMdProjectPaths: undefined }), ); export class WorkspaceInstructionsService @@ -79,6 +79,7 @@ export class WorkspaceInstructionsService agentsMd: result.content, agentsMdWarning: result.warning, agentsMdPaths: result.paths, + agentsMdProjectPaths: result.projectPaths, }; const changed = next.agentsMd !== this.current.agentsMd || diff --git a/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts b/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts index 8e8e1574e7f..e09bbf2ad9e 100644 --- a/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts +++ b/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts @@ -38,7 +38,6 @@ import { InMemorySkillCatalog } from '#/features/skill/catalog/registry'; import type { SkillContribution } from '#/features/skill/catalog/skillSource'; import type { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; import type { IWorkspaceSkillCatalog } from '#/features/skill/workspace/workspaceSkillCatalog'; -import type { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; import type { ISessionWorkdir, SessionEnvironmentWorkdir } from '#/session/workdir/workdir'; import type { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; import { stubSkill } from '../../features/skill/catalog/stubs'; @@ -632,14 +631,18 @@ const ROSTER_GUIDANCE = function stubMounts(results: Record = {}) { const calls: { readonly environmentId: string; readonly cwd: string }[] = []; + const unmounted: string[] = []; const mounts: ISessionEnvironmentMounts = { _serviceBrand: undefined, mount: (environmentId: string, cwd: string) => { calls.push({ environmentId, cwd }); return Promise.resolve(results[environmentId] ?? { status: 'mounted', agentsMdPaths: [] }); }, + unmount: (environmentId: string) => { + unmounted.push(environmentId); + }, }; - return { mounts, calls }; + return { mounts, calls, unmounted }; } describe('AgentEnvironmentRosterService', () => { @@ -752,6 +755,23 @@ describe('AgentEnvironmentRosterService', () => { }); }); + it('unmounts an environment once it leaves the roster', async () => { + const { agentEnvironment, registry } = setup({ + multiEnvironment: { declarations: [{ id: 'remote', defaultCwd: '/srv/app' }, { id: 'staging' }] }, + }); + registry.register(environment('staging', 'staging-one', 'pending')); + const { mounts, unmounted } = stubMounts(); + const { provide } = rosterProvider(agentEnvironment, mounts); + const injected = { role: 'user' as const, content: [] }; + + const first = await provide({}); + await registry.remove('remote'); + const update = await provide({ lastInjection: injected, lastDisclosure: first?.disclosure }); + + expect(update?.content).toBe(['Execution environment updates:', '- `remote` is no longer available.'].join('\n')); + expect(unmounted).toEqual(['remote']); + }); + it('reports an environment whose project context could not be loaded', async () => { const { agentEnvironment } = setup({ multiEnvironment: { declarations: [{ id: 'remote', defaultCwd: '/srv/app' }] }, @@ -785,7 +805,6 @@ describe('AgentEnvironmentRosterService', () => { describe('SessionEnvironmentMountsService', () => { function setupMounts(options: { readonly acquire?: (environmentId: string, cwd: string) => Promise; - readonly primaryAgentsMdPaths?: readonly string[]; } = {}) { const contributions = new Map(); const acquired: { readonly environmentId: string; readonly cwd: string }[] = []; @@ -800,7 +819,8 @@ describe('SessionEnvironmentMountsService', () => { snapshot: { agentsMd: undefined, agentsMdWarning: undefined, - agentsMdPaths: ['/kimi-home/AGENTS.md', '/srv/app/AGENTS.md'], + agentsMdPaths: ['/kimi-home/AGENTS.md', '/workspace/AGENTS.md'], + agentsMdProjectPaths: ['/workspace/AGENTS.md'], }, } as unknown as IWorkspaceInstructionsService, dispose: () => { @@ -819,12 +839,11 @@ describe('SessionEnvironmentMountsService', () => { set: (id: string, contribution: SkillContribution) => { contributions.set(id, contribution); }, + remove: (id: string) => { + contributions.delete(id); + }, } as unknown as ISessionSkillCatalog; - const instructions = { - ready: Promise.resolve(), - agentsMdPaths: options.primaryAgentsMdPaths ?? ['/kimi-home/AGENTS.md', '/workspace/AGENTS.md'], - } as unknown as ISessionInstructionsProvider; - const service = new SessionEnvironmentMountsService(workdir, catalog, instructions); + const service = new SessionEnvironmentMountsService(workdir, catalog); return { service, contributions, acquired, disposedWorkdirs: () => disposedWorkdirs }; } @@ -839,15 +858,55 @@ describe('SessionEnvironmentMountsService', () => { ]); }); - it('lists only the AGENTS.md files the primary environment did not already load', async () => { + it('lists the project AGENTS.md files of the environment even when the primary has the same path', async () => { const { service } = setupMounts(); - await expect(service.mount('remote', '/srv/app')).resolves.toEqual({ + await expect(service.mount('remote', '/workspace')).resolves.toEqual({ status: 'mounted', - agentsMdPaths: ['/srv/app/AGENTS.md'], + agentsMdPaths: ['/workspace/AGENTS.md'], }); }); + it('drops the skills and workdir of an unmounted environment and mounts it again on return', async () => { + const { service, contributions, acquired, disposedWorkdirs } = setupMounts(); + + const first = service.mount('remote', '/srv/app'); + await first; + service.unmount('remote', first); + expect(contributions.has('environment:remote')).toBe(false); + expect(disposedWorkdirs()).toBe(1); + + const second = service.mount('remote', '/srv/app'); + await second; + service.unmount('remote', first); + expect(contributions.has('environment:remote')).toBe(true); + expect(acquired).toHaveLength(2); + }); + + it('adds no skills when the environment is unmounted while its workdir is still loading', async () => { + let open!: () => void; + const opened = new Promise((resolve) => { + open = resolve; + }); + const { service, contributions } = setupMounts({ + acquire: async () => { + await opened; + return { + skills: { catalog: new InMemorySkillCatalog(), load: async () => {} } as unknown as IWorkspaceSkillCatalog, + instructions: { ready: Promise.resolve(), snapshot: {} } as unknown as IWorkspaceInstructionsService, + dispose: () => {}, + }; + }, + }); + + const pending = service.mount('remote', '/srv/app'); + service.unmount('remote', pending); + open(); + + expect((await pending).status).toBe('failed'); + expect(contributions.size).toBe(0); + }); + it('loads each environment once and releases its workdir with the session', async () => { const { service, acquired, disposedWorkdirs } = setupMounts(); diff --git a/packages/agent-core-v2/test/agent/profile/context.test.ts b/packages/agent-core-v2/test/agent/profile/context.test.ts index 035a6f0c35b..859eb741d9d 100644 --- a/packages/agent-core-v2/test/agent/profile/context.test.ts +++ b/packages/agent-core-v2/test/agent/profile/context.test.ts @@ -52,6 +52,20 @@ describe('loadAgentsMd user-level discovery', () => { expect(result.indexOf('user generic')).toBeLessThan(result.indexOf('project instructions')); }); + it('reports only the files found from the work dir as project paths', async () => { + await mkdir(join(homeDir, '.kimi-code'), { recursive: true }); + await writeFile(join(homeDir, '.kimi-code', 'AGENTS.md'), 'user branded', 'utf-8'); + await writeFile(join(workDir, 'AGENTS.md'), 'project instructions', 'utf-8'); + + const result = await loadAgentsMdDetailed({ fs, homeDir }, workDir); + + expect(result.paths).toEqual([ + normalize(join(homeDir, '.kimi-code', 'AGENTS.md')), + normalize(join(workDir, 'AGENTS.md')), + ]); + expect(result.projectPaths).toEqual([normalize(join(workDir, 'AGENTS.md'))]); + }); + it('loads generic user-level .agents/AGENTS.md', async () => { await mkdir(join(homeDir, '.agents'), { recursive: true }); await writeFile(join(homeDir, '.agents', 'AGENTS.md'), 'dot-agents generic', 'utf-8'); diff --git a/packages/kap-server/src/protocol/display.ts b/packages/kap-server/src/protocol/display.ts index 2d5ef7edc7d..eee5545f4c9 100644 --- a/packages/kap-server/src/protocol/display.ts +++ b/packages/kap-server/src/protocol/display.ts @@ -7,6 +7,7 @@ export const ToolInputDisplaySchema = z.discriminatedUnion('kind', [ cwd: z.string().optional(), description: z.string().optional(), language: z.literal('bash').optional(), + environment: z.string().optional(), }), z.object({ kind: z.literal('file_io'), @@ -16,6 +17,7 @@ export const ToolInputDisplaySchema = z.discriminatedUnion('kind', [ content: z.string().optional(), before: z.string().optional(), after: z.string().optional(), + environment: z.string().optional(), }), z.object({ kind: z.literal('diff'), From 475f54f4a999bd9c6aac8a1e90823d2e4e291f30 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Fri, 30 Oct 2026 16:41:35 +0800 Subject: [PATCH 7/9] fix: keep environment tool schemas cache-stable --- .../src/agent/toolRegistry/toolRegistryService.ts | 7 +++---- .../test/agent/toolExecutor/toolExecutor.test.ts | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts index c599d0bc494..90319f70e4d 100644 --- a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts +++ b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts @@ -54,13 +54,12 @@ export class AgentToolRegistryService implements IAgentToolRegistryService { } list(): readonly ToolInfo[] { - const targets = this.environment?.current?.targets() ?? []; - const environmentSchema = targets.length < 2 + const multipleEnvironments = (this.environment?.current?.targets().length ?? 0) >= 2; + const environmentSchema = !multipleEnvironments ? undefined : { type: 'string', - enum: targets.map((target) => target.environmentId), - description: 'Environment to run this call in. Omit to use the primary environment.', + description: 'Environment id from the environment roster. Omit to use the primary environment.', }; return [...this.tools.values()] .map(({ tool, source, disclosure, environmentScoped }) => ({ diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index 05a4bfabe72..a7a02878dc8 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -1635,14 +1635,25 @@ describe('AgentToolExecutorService environment routing', () => { text: { type: 'string' }, environment: { type: 'string', - enum: ['local', 'gpu'], - description: 'Environment to run this call in. Omit to use the primary environment.', + description: 'Environment id from the environment roster. Omit to use the primary environment.', }, }, }); expect(parameters['plain']).toBe(textSchema); }); + it('keeps environment schemas stable as the available environment ids change', () => { + registry.register(new RoutedTool('scoped', textSchema), { environmentScoped: true }); + const parameters = () => registry.list().find((info) => info.name === 'scoped')?.parameters; + const initial = parameters(); + + environmentTargets = [...environmentTargets, { environmentId: 'staging', primary: false, cwd: '/staging' }]; + expect(parameters()).toEqual(initial); + + environmentTargets = environmentTargets.filter((target) => target.environmentId === 'local'); + expect(parameters()).toBe(textSchema); + }); + it('routes a call to the named environment, hands the tool its own arguments, and stamps the display', async () => { const tool = new RoutedTool('scoped', textSchema); registry.register(tool, { environmentScoped: true }); From 3246a47c1900858a20243923f08c046baac5a7ed Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Mon, 2 Nov 2026 11:44:38 +0800 Subject: [PATCH 8/9] test: consolidate multi-environment coverage --- .../environmentBindingService.test.ts | 76 ++----- .../agent/toolExecutor/toolExecutor.test.ts | 38 +--- .../os/environmentGenerationGuard.test.ts | 213 +++++------------- .../test/features/skill/prompt.test.ts | 5 +- 4 files changed, 91 insertions(+), 241 deletions(-) diff --git a/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts b/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts index e09bbf2ad9e..14cd1f0fb81 100644 --- a/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts +++ b/packages/agent-core-v2/test/agent/environmentBinding/environmentBindingService.test.ts @@ -669,7 +669,9 @@ describe('AgentEnvironmentRosterService', () => { it('lists every environment once and repeats only after the roster changes or leaves the context', async () => { const { agentEnvironment, registry } = setup({ - multiEnvironment: { declarations: [{ id: 'remote', defaultCwd: '/srv/app' }, { id: 'staging' }, { id: 'unregistered' }] }, + multiEnvironment: { + declarations: [{ id: 'remote', defaultCwd: '/srv/app' }, { id: 'staging', defaultCwd: '/srv/staging' }, { id: 'unregistered' }], + }, }); registry.register(environment('staging', 'staging-one', 'pending')); const { mounts, calls } = stubMounts(); @@ -681,7 +683,7 @@ describe('AgentEnvironmentRosterService', () => { 'Execution environments available in this session:', '- `local` (primary): working directory /workspace', '- `remote`: working directory /srv/app', - '- `staging`: not connected yet; working directory resolved on first use', + '- `staging`: working directory /srv/staging; not connected yet', ROSTER_GUIDANCE, ].join('\n'), ); @@ -691,18 +693,6 @@ describe('AgentEnvironmentRosterService', () => { expect(calls).toEqual([{ environmentId: 'remote', cwd: '/srv/app' }]); }); - it('shows the declared working directory of an environment that has not connected yet', async () => { - const { agentEnvironment, registry } = setup({ - multiEnvironment: { declarations: [{ id: 'staging', defaultCwd: '/srv/staging' }] }, - }); - registry.register(environment('staging', 'staging-one', 'pending')); - const { mounts, calls } = stubMounts(); - const { provide } = rosterProvider(agentEnvironment, mounts); - - expect((await provide({}))?.content).toContain('- `staging`: working directory /srv/staging; not connected yet'); - expect(calls).toEqual([]); - }); - it('mounts an environment once it connects, lists its AGENTS.md files, and keeps them across disconnects', async () => { const { agentEnvironment, registry } = setup({ multiEnvironment: { declarations: [{ id: 'staging' }] }, @@ -735,40 +725,29 @@ describe('AgentEnvironmentRosterService', () => { expect(calls).toEqual([{ environmentId: 'staging', cwd: '/home/deploy' }]); }); - it('sends only the environments that left since the last roster', async () => { + it('reports and unmounts environments that leave the roster', async () => { const { agentEnvironment, registry } = setup({ multiEnvironment: { declarations: [{ id: 'remote', defaultCwd: '/srv/app' }, { id: 'staging' }] }, }); const staging = registry.register(environment('staging', 'staging-one', 'pending')); - const { provide } = rosterProvider(agentEnvironment); + const { mounts, unmounted } = stubMounts(); + const { provide } = rosterProvider(agentEnvironment, mounts); const injected = { role: 'user' as const, content: [] }; const first = await provide({}); await staging.remove(); - - expect(await provide({ lastInjection: injected, lastDisclosure: first?.disclosure })).toEqual({ + const removed = await provide({ lastInjection: injected, lastDisclosure: first?.disclosure }); + expect(removed).toEqual({ content: ['Execution environment updates:', '- `staging` is no longer available.'].join('\n'), disclosure: { local: '- `local` (primary): working directory /workspace', remote: '- `remote`: working directory /srv/app', }, }); - }); - - it('unmounts an environment once it leaves the roster', async () => { - const { agentEnvironment, registry } = setup({ - multiEnvironment: { declarations: [{ id: 'remote', defaultCwd: '/srv/app' }, { id: 'staging' }] }, - }); - registry.register(environment('staging', 'staging-one', 'pending')); - const { mounts, unmounted } = stubMounts(); - const { provide } = rosterProvider(agentEnvironment, mounts); - const injected = { role: 'user' as const, content: [] }; - const first = await provide({}); await registry.remove('remote'); - const update = await provide({ lastInjection: injected, lastDisclosure: first?.disclosure }); - - expect(update?.content).toBe(['Execution environment updates:', '- `remote` is no longer available.'].join('\n')); + const update = await provide({ lastInjection: injected, lastDisclosure: removed?.disclosure }); + expect(update?.content).toBe('Only the primary environment `local` is available now. Tools no longer take an `environment` argument.'); expect(unmounted).toEqual(['remote']); }); @@ -847,24 +826,20 @@ describe('SessionEnvironmentMountsService', () => { return { service, contributions, acquired, disposedWorkdirs: () => disposedWorkdirs }; } - it('adds the project skills of a mounted environment under an environment-qualified name', async () => { - const { service, contributions } = setupMounts(); - - await service.mount('remote', '/srv/app'); + it('mounts project context once and releases it with the session', async () => { + const { service, contributions, acquired, disposedWorkdirs } = setupMounts(); + const first = service.mount('remote', '/workspace'); + expect(service.mount('remote', '/workspace')).toBe(first); + await expect(first).resolves.toEqual({ status: 'mounted', agentsMdPaths: ['/workspace/AGENTS.md'] }); + expect(acquired).toEqual([{ environmentId: 'remote', cwd: '/workspace' }]); const skills = contributions.get('environment:remote')?.skills ?? []; expect(skills.map((skill) => [skill.name, skill.environmentId, skill.dir])).toEqual([ ['deploy@remote', 'remote', '/srv/app/.agents/skills/deploy'], ]); - }); - - it('lists the project AGENTS.md files of the environment even when the primary has the same path', async () => { - const { service } = setupMounts(); - await expect(service.mount('remote', '/workspace')).resolves.toEqual({ - status: 'mounted', - agentsMdPaths: ['/workspace/AGENTS.md'], - }); + service.dispose(); + expect(disposedWorkdirs()).toBe(1); }); it('drops the skills and workdir of an unmounted environment and mounts it again on return', async () => { @@ -907,19 +882,6 @@ describe('SessionEnvironmentMountsService', () => { expect(contributions.size).toBe(0); }); - it('loads each environment once and releases its workdir with the session', async () => { - const { service, acquired, disposedWorkdirs } = setupMounts(); - - const first = service.mount('remote', '/srv/app'); - const second = service.mount('remote', '/srv/app'); - expect(second).toBe(first); - await first; - expect(acquired).toEqual([{ environmentId: 'remote', cwd: '/srv/app' }]); - - service.dispose(); - expect(disposedWorkdirs()).toBe(1); - }); - it('reports a workdir that cannot be opened without adding skills', async () => { const { service, contributions } = setupMounts({ acquire: () => Promise.reject(new Error('cwd /srv/app is not a directory on environment remote')), diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index a7a02878dc8..55878eb2924 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -1624,12 +1624,13 @@ describe('AgentToolExecutorService environment routing', () => { ]; }); - it('adds the environment argument to environment-scoped tools only', () => { - registry.register(new RoutedTool('scoped', textSchema), { environmentScoped: true }); + it('exposes a stable environment argument only while secondary targets exist', async () => { + const tool = new RoutedTool('scoped', textSchema); + registry.register(tool, { environmentScoped: true }); registry.register(new RoutedTool('plain', textSchema)); + const parameters = (name = 'scoped') => registry.list().find((info) => info.name === name)?.parameters; - const parameters = Object.fromEntries(registry.list().map((info) => [info.name, info.parameters])); - expect(parameters['scoped']).toEqual({ + expect(parameters()).toEqual({ ...textSchema, properties: { text: { type: 'string' }, @@ -1639,19 +1640,19 @@ describe('AgentToolExecutorService environment routing', () => { }, }, }); - expect(parameters['plain']).toBe(textSchema); - }); - - it('keeps environment schemas stable as the available environment ids change', () => { - registry.register(new RoutedTool('scoped', textSchema), { environmentScoped: true }); - const parameters = () => registry.list().find((info) => info.name === 'scoped')?.parameters; + expect(parameters('plain')).toBe(textSchema); const initial = parameters(); - environmentTargets = [...environmentTargets, { environmentId: 'staging', primary: false, cwd: '/staging' }]; expect(parameters()).toEqual(initial); environmentTargets = environmentTargets.filter((target) => target.environmentId === 'local'); expect(parameters()).toBe(textSchema); + const results = await execute([toolCall('call_single', 'scoped', { text: 'a', environment: 'local' })]); + expect(results[0]).toMatchObject({ + isError: true, + output: expect.stringContaining('Invalid args for tool "scoped"'), + }); + expect(tool.resolutions).toEqual([]); }); it('routes a call to the named environment, hands the tool its own arguments, and stamps the display', async () => { @@ -1704,21 +1705,6 @@ describe('AgentToolExecutorService environment routing', () => { expect(tool.resolutions).toEqual([]); }); - it('keeps rejecting the environment argument while only the primary environment is available', async () => { - environmentTargets = [{ environmentId: 'local', primary: true }]; - const tool = new RoutedTool('scoped', textSchema); - registry.register(tool, { environmentScoped: true }); - - const results = await execute([toolCall('call_single', 'scoped', { text: 'a', environment: 'local' })]); - - expect(results[0]).toMatchObject({ - isError: true, - output: expect.stringContaining('Invalid args for tool "scoped"'), - }); - expect(tool.resolutions).toEqual([]); - expect(registry.list().find((info) => info.name === 'scoped')?.parameters).toBe(textSchema); - }); - it('routes Read through the registry to a lazily connected exec-server', async () => { const remoteDir = await mkdtemp(join(tmpdir(), 'multi-env-read-')); const require = createRequire(import.meta.url); diff --git a/packages/agent-core-v2/test/agent/tools/os/environmentGenerationGuard.test.ts b/packages/agent-core-v2/test/agent/tools/os/environmentGenerationGuard.test.ts index be03ff4c070..03e39358037 100644 --- a/packages/agent-core-v2/test/agent/tools/os/environmentGenerationGuard.test.ts +++ b/packages/agent-core-v2/test/agent/tools/os/environmentGenerationGuard.test.ts @@ -60,14 +60,15 @@ function modelCapabilities(): ModelCapability { }; } -function registryBackedService(registry: EnvironmentRegistry): IAgentEnvironmentService { +function registryBackedService(registry: EnvironmentRegistry, environmentId = BINDING.environmentId): IAgentEnvironmentService { + const binding = { environmentId }; return { _serviceBrand: undefined, onDidChange: (listener) => registry.onDidChange(() => listener()), targets: () => [{ environmentId: 'local', primary: true }], isAvailable: (required: readonly EnvironmentCapability[] = []) => { try { - const environment = registry.inspect(BINDING); + const environment = registry.inspect(binding); return ( environmentIsReady(environment) && required.every((capability) => environment.capabilities.has(capability)) @@ -76,33 +77,37 @@ function registryBackedService(registry: EnvironmentRegistry): IAgentEnvironment return false; } }, - inspect: () => registry.inspect(BINDING), - acquire: (required = []) => registry.acquire(BINDING, required), + inspect: () => registry.inspect(binding), + acquire: (required = []) => registry.acquire(binding, required), acquireWhenReady: async (required = []) => { - const environment = registry.inspect(BINDING); + const environment = registry.inspect(binding); if (!environmentIsReady(environment) && typeof environment.connect === 'function') { await environment.connect(); } - return registry.acquireWhenReady(BINDING, required); + return registry.acquireWhenReady(binding, required); }, workspaceRoots: () => ({ workDir: '/workspace', additionalDirs: [] }), }; } -function connectSwappingHarness(readyFs: IHostFileSystem) { +function connectSwappingHarness( + readyFs: IHostFileSystem, + host?: Partial>, + environmentId = BINDING.environmentId, +) { const registry = new EnvironmentRegistry(); const calls: string[] = []; let registration: EnvironmentRegistrationHandle; const ready = Object.assign( new FakeEnvironment( - { ...BINDING, generation: 'remote-ready' }, - { status: 'ready', capabilities: ['fs', 'process'] }, + { environmentId, generation: `${environmentId}-ready` }, + { status: 'ready', capabilities: ['fs', 'process'], host }, ), { fs: readyFs, process: {} }, ); const pending = Object.assign( new FakeEnvironment( - { ...BINDING, generation: 'remote-pending' }, + { environmentId, generation: `${environmentId}-pending` }, { status: 'pending', capabilities: [] }, ), { @@ -110,12 +115,19 @@ function connectSwappingHarness(readyFs: IHostFileSystem) { connect: async () => { calls.push('connect'); await registration.remove(); - registry.register(ready); + registration = registry.register(ready); }, }, ); registration = registry.register(pending); - return { registration, service: registryBackedService(registry), calls }; + return { + registry, + get registration() { + return registration; + }, + service: registryBackedService(registry, environmentId), + calls, + }; } function readyHarness(fs: IHostFileSystem) { @@ -185,8 +197,30 @@ const editService = { edit: async () => ({ ok: true as const, count: 1 }), } as unknown as IFileEditService; +function bashTool(service: IAgentEnvironmentService): BashTool { + return new BashTool( + service, + makeSessionContext({ + sessionId: 'ses', + workspaceId: 'ws', + sessionDir: '/tmp/ses', + sessionScope: 'ses', + cwd: '/workspace', + }), + {} as never, + toolPolicy, + stubConfigService(), + ); +} + function toolCases(): readonly ToolCase[] { return [ + { + name: 'Bash', + fs: createFakeHostFs(), + resolve: (service) => bashTool(service).resolveExecution({ command: 'echo hi' }), + expectOutcome: () => {}, + }, { name: 'Read', fs: textFs('visible content'), @@ -318,37 +352,8 @@ describe('tool environment generation guard', () => { }); describe('tilde resolution against the reconnected host', () => { - function pendingThenReadyHarness(host: Partial>) { - const registry = new EnvironmentRegistry(); - const calls: string[] = []; - const ready = Object.assign( - new FakeEnvironment( - { ...BINDING, generation: 'remote-ready' }, - { status: 'ready', capabilities: ['fs', 'process'], host }, - ), - { fs: createFakeHostFs(), process: {} }, - ); - let registration: EnvironmentRegistrationHandle; - const pending = Object.assign( - new FakeEnvironment( - { ...BINDING, generation: 'remote-pending' }, - { status: 'pending', capabilities: [] }, - ), - { - host: undefined, - connect: async () => { - calls.push('connect'); - await registration.remove(); - registry.register(ready); - }, - }, - ); - registration = registry.register(pending); - return { registry, service: registryBackedService(registry), calls }; - } - it('resolves ~ against the reconnected host home instead of /', async () => { - const { service, calls } = pendingThenReadyHarness({ homeDir: '/home/me' }); + const { service, calls } = connectSwappingHarness(createFakeHostFs(), { homeDir: '/home/me' }); const tool = new GlobTool(service, noopTelemetryService); const execution = await tool.resolveExecution({ pattern: '**/*.ts', path: '~/projects' }); expect(calls).toEqual(['connect']); @@ -379,44 +384,6 @@ describe('tilde resolution against the reconnected host', () => { }); }); -describe('Bash environment generation guard', () => { - function bashTool(service: IAgentEnvironmentService): BashTool { - return new BashTool( - service, - makeSessionContext({ - sessionId: 'ses', - workspaceId: 'ws', - sessionDir: '/tmp/ses', - sessionScope: 'ses', - cwd: '/workspace', - }), - {} as never, - toolPolicy, - stubConfigService(), - ); - } - - it('rejects execution when the generation changes after a ready resolve', async () => { - const harness = readyHarness(createFakeHostFs()); - const resolved = await bashTool(harness.service).resolveExecution({ command: 'echo hi' }); - - await harness.registration.remove(); - harness.registry.register(replacementReadyEnvironment()); - - const result = await finish(resolved); - expect(result.isError).toBe(true); - expect(result.output).toBe(ENVIRONMENT_CHANGED_OUTPUT); - }); - - it('does not treat a pending resolve reconnect as an environment change', async () => { - const harness = connectSwappingHarness(createFakeHostFs()); - const result = await finish(bashTool(harness.service).resolveExecution({ command: 'echo hi' })); - - expect(harness.calls).toEqual(['connect']); - expect(outputText(result)).not.toContain(ENVIRONMENT_CHANGED_OUTPUT); - }); -}); - describe('tool environment routing', () => { function routedHarness() { const registry = new EnvironmentRegistry(); @@ -489,21 +456,10 @@ describe('tool environment routing', () => { it('runs Bash on the requested environment in its working directory', async () => { const { service, spawns } = routedHarness(); - const bash = new BashTool( - service, - makeSessionContext({ - sessionId: 'ses', - workspaceId: 'ws', - sessionDir: '/tmp/ses', - sessionScope: 'ses', - cwd: '/workspace', - }), - {} as never, - toolPolicy, - stubConfigService(), + const resolved = await bashTool(service).resolveExecution( + { command: 'pwd' }, + { environment: { environmentId: 'gpu', primary: false } }, ); - - const resolved = await bash.resolveExecution({ command: 'pwd' }, { environment: { environmentId: 'gpu', primary: false } }); expect(resolved).toMatchObject({ display: { kind: 'command', cwd: '/gpu' } }); const result = await finish(resolved); @@ -512,82 +468,31 @@ describe('tool environment routing', () => { }); it('connects an unready secondary environment before the approval preview', async () => { - const registry = new EnvironmentRegistry(); - const calls: string[] = []; - let registration: EnvironmentRegistrationHandle; - const ready = Object.assign( - new FakeEnvironment( - { environmentId: 'gpu', generation: 'gpu-one' }, - { status: 'ready', capabilities: ['process'], host: { cwd: '/srv/app' } }, - ), - { process: {} }, - ); - const pending = Object.assign( - new FakeEnvironment( - { environmentId: 'gpu', generation: 'gpu-pending' }, - { status: 'pending', capabilities: [] }, - ), - { - host: undefined, - connect: async () => { - calls.push('connect'); - await registration.remove(); - registry.register(ready); - }, - }, - ); - registration = registry.register(pending); - const binding = (environmentId?: string): EnvironmentBinding => ({ environmentId: environmentId ?? 'local' }); + const harness = connectSwappingHarness(createFakeHostFs(), { cwd: '/srv/app' }, 'gpu'); + const { registry, calls } = harness; const service: IAgentEnvironmentService = { - _serviceBrand: undefined, - onDidChange: () => ({ dispose: () => {} }), + ...harness.service, targets: () => [ { environmentId: 'local', primary: true }, { environmentId: 'gpu', primary: false }, ], - isAvailable: (required = [], environmentId) => { - try { - const environment = registry.inspect(binding(environmentId)); - return environmentIsReady(environment) && required.every((capability) => environment.capabilities.has(capability)); - } catch { - return false; - } - }, - inspect: (environmentId) => registry.inspect(binding(environmentId)), - acquire: (required = [], environmentId) => registry.acquire(binding(environmentId), required), - acquireWhenReady: async (required = [], environmentId) => { - const environment = registry.inspect(binding(environmentId)); - if (!environmentIsReady(environment) && typeof environment.connect === 'function') await environment.connect(); - return registry.acquireWhenReady(binding(environmentId), required); - }, workspaceRoots: (environmentId) => { - const workDir = environmentId === 'gpu' ? registry.inspect(binding(environmentId)).host?.cwd : '/workspace'; + const workDir = environmentId === 'gpu' ? registry.inspect({ environmentId: 'gpu' }).host?.cwd : '/workspace'; if (workDir === undefined) { throw new EnvironmentError('environment.unavailable', 'environment gpu has no working directory until it connects'); } return { workDir, additionalDirs: [] }; }, }; - const bash = new BashTool( - service, - makeSessionContext({ - sessionId: 'ses', - workspaceId: 'ws', - sessionDir: '/tmp/ses', - sessionScope: 'ses', - cwd: '/workspace', - }), - {} as never, - toolPolicy, - stubConfigService(), - ); - - const resolved = await bash.resolveExecution({ command: 'pwd' }, { environment: { environmentId: 'gpu', primary: false } }); + const resolved = await bashTool(service).resolveExecution( + { command: 'pwd' }, + { environment: { environmentId: 'gpu', primary: false } }, + ); expect(calls).toEqual(['connect']); expect(resolved).toMatchObject({ display: { kind: 'command', cwd: '/srv/app' } }); - await registration.remove(); + await harness.registration.remove(); registry.register(Object.assign( new FakeEnvironment( { environmentId: 'gpu', generation: 'gpu-two' }, diff --git a/packages/agent-core-v2/test/features/skill/prompt.test.ts b/packages/agent-core-v2/test/features/skill/prompt.test.ts index e51f6fb413a..8c683e2dc70 100644 --- a/packages/agent-core-v2/test/features/skill/prompt.test.ts +++ b/packages/agent-core-v2/test/features/skill/prompt.test.ts @@ -57,14 +57,11 @@ describe('routedSkillEnvironment', () => { { environmentId: 'local', primary: false, cwd: '/workspace' }, ]; - it('names the environment that owns a skill outside the primary environment', () => { + it('names only skills owned outside the primary environment', () => { expect(routedSkillEnvironment(stubSkill('notes', { source: 'user' }), remotePrimary)).toBe('local'); expect( routedSkillEnvironment(stubSkill('deploy@local', { source: 'project', environmentId: 'local' }), remotePrimary), ).toBe('local'); - }); - - it('omits the environment for primary project skills, builtin skills, and single-environment sessions', () => { expect(routedSkillEnvironment(stubSkill('deploy', { source: 'project' }), remotePrimary)).toBeUndefined(); expect(routedSkillEnvironment(stubSkill('mcp-config', { source: 'builtin' }), remotePrimary)).toBeUndefined(); expect(routedSkillEnvironment(stubSkill('notes', { source: 'user' }), remotePrimary.slice(0, 1))).toBeUndefined(); From cb8ae61b0c9c63fa046caf5df1b824eb3edd09a1 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Mon, 2 Nov 2026 15:53:22 +0800 Subject: [PATCH 9/9] fix: keep bash cwd stable after approval --- .../src/agent/tools/os/bash/bashTool.ts | 5 +++-- .../test/os/backends/node-local/tools/bash.test.ts | 13 ++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts index b69b3fa2d89..7493b6490e1 100644 --- a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts @@ -122,6 +122,7 @@ export class BashTool implements IBashTool { resolutionLease.environment, this.environment.workspaceRoots(environmentId), ); + const cwd = view.resolve(args.cwd ?? view.workDir); const preview = args.command.length > 50 ? `${args.command.slice(0, 50)}…` : args.command; return { description: args.run_in_background @@ -130,14 +131,14 @@ export class BashTool implements IBashTool { display: { kind: 'command', command: args.command, - cwd: view.resolve(args.cwd ?? view.workDir), + cwd, description: args.description, language: 'bash', }, approvalRule: literalRulePattern(this.name, args.command), matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.command), execute: ({ signal, onUpdate, onForegroundTaskStart, toolCallId }) => - this.execution(args, signal, toolCallId, onUpdate, onForegroundTaskStart, expectedGeneration, environmentId), + this.execution({ ...args, cwd }, signal, toolCallId, onUpdate, onForegroundTaskStart, expectedGeneration, environmentId), }; } finally { resolutionLease.dispose(); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts index 1dc1f709272..169fddd879a 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts @@ -898,7 +898,7 @@ describe('BashTool', () => { expect(exec.mock.calls[0]?.[1]).toEqual(['-c', "cd '/var/app' && pwd"]); }); - it('resolves an omitted cwd from the bound environment workspace, not the host session cwd', async () => { + it('keeps the resolve-time environment cwd through execution', async () => { const { runner, exec } = createTestRunner(processWithOutput({ stdout: '' })); const ctx = createTestCtx('/host/session/cwd'); const processService: IHostProcessService = { @@ -912,7 +912,9 @@ describe('BashTool', () => { ), { host: createTestEnv(), process: processService }, ); - const environment = stubAgentEnvironment(backend, { workDir: '/remote-work' }); + let workDir = '/remote-work'; + const environment = stubAgentEnvironment(backend, { workDir }); + environment.workspaceRoots = () => ({ workDir, additionalDirs: [] }); const tool = new BashTool( environment, ctx, @@ -921,7 +923,12 @@ describe('BashTool', () => { stubConfig(), ); - await executeTool(tool, context({ command: 'pwd', timeout: 60 })); + const { args, ...executionContext } = context({ command: 'pwd', timeout: 60 }); + const execution = await tool.resolveExecution(args); + if (execution.isError === true) throw new Error('expected executable Bash tool'); + expect(execution.display).toMatchObject({ cwd: '/remote-work' }); + workDir = '/changed-after-resolution'; + await execution.execute(executionContext); expect(exec.mock.calls[0]?.[1]).toEqual(['-c', "cd '/remote-work' && pwd"]); });