diff --git a/.changeset/multi-environment-routing.md b/.changeset/multi-environment-routing.md new file mode 100644 index 00000000000..63eb1a3d9f7 --- /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, 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/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..b43d8d09e9c 100644 --- a/docs/en/guides/remote-environment.md +++ b/docs/en/guides/remote-environment.md @@ -81,6 +81,23 @@ 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 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 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..75aec44d5ab 100644 --- a/docs/zh/guides/remote-environment.md +++ b/docs/zh/guides/remote-environment.md @@ -81,6 +81,23 @@ 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` 在同一环境运行测试。 + +审批面板会显示每次调用所在的环境,「本会话内允许」的审批按环境分别记忆。上述列表之外的工具仍按本页其他章节的规则使用主环境或本机。 + +系统提示仍只描述主环境。另一个环境连接后,会话会读取一次它的项目上下文:从其工作目录向上找到的 `AGENTS.md` 会列给模型,并要求模型在修改该环境的文件前先读取;它的项目级 skills 以 `@` 的名字加入会话的 skill 列表(例如 `deploy@dev-box`)。尚未连接的环境在首次使用前不贡献任何内容。主环境是远程环境时,来自本机的用户级 skills 会标注为属于 `local`,模型会到本机读取它们的附属文件。 + ## 断线与重连 每个环境 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..0f6fdefb597 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; @@ -397,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; @@ -427,6 +436,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 +475,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 +504,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 +541,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 +570,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 +599,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 +630,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 +659,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; @@ -879,6 +896,7 @@ export interface AgentStateSnapshot { cwd?: string; description?: string; language?: 'bash'; + environment?: string; } | { kind: 'file_io'; operation: 'read' | 'write' | 'edit' | 'glob' | 'grep'; @@ -887,6 +905,7 @@ export interface AgentStateSnapshot { content?: string; before?: string; after?: string; + environment?: string; } | { kind: 'diff'; path: string; @@ -1092,6 +1111,7 @@ export interface AgentStateSnapshot { cwd?: string; description?: string; language?: 'bash'; + environment?: string; } | { kind: 'file_io'; operation: 'read' | 'write' | 'edit' | 'glob' | 'grep'; @@ -1100,6 +1120,7 @@ export interface AgentStateSnapshot { content?: string; before?: string; after?: string; + environment?: string; } | { kind: 'diff'; path: string; @@ -1314,6 +1335,7 @@ export interface AgentStateSnapshot { cwd?: string; description?: string; language?: 'bash'; + environment?: string; } | { kind: 'file_io'; operation: 'read' | 'write' | 'edit' | 'glob' | 'grep'; @@ -1322,6 +1344,7 @@ export interface AgentStateSnapshot { content?: string; before?: string; after?: string; + environment?: string; } | { kind: 'diff'; path: string; @@ -1528,6 +1551,7 @@ export interface AgentStateSnapshot { cwd?: string; description?: string; language?: 'bash'; + environment?: string; } | { kind: 'file_io'; operation: 'read' | 'write' | 'edit' | 'glob' | 'grep'; @@ -1536,6 +1560,7 @@ export interface AgentStateSnapshot { content?: string; before?: string; after?: string; + environment?: string; } | { kind: 'diff'; path: string; @@ -1790,6 +1815,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..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 => { - 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 e7541315058..155b5ce8c12 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 type { Environment, EnvironmentBinding, EnvironmentCapability, EnvironmentLease, EnvironmentPath, EnvironmentTarget, 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; @@ -29,11 +33,12 @@ export interface AgentEnvironmentBindingSnapshot { 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 EnvironmentTarget[]; + 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 +47,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 +125,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 +137,31 @@ export class AgentEnvironmentService implements IAgentEnvironmentService { }); } - inspect(): Environment { - return this.resolver.inspect(this.binding.current); + targets(): readonly EnvironmentTarget[] { + const current = this.binding.current; + 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]), + ); + 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): EnvironmentTarget => ({ + 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 +171,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[] = [], environmentId?: string): Promise { + return this.resolver.acquireWhenReady(this.bindingFor(environmentId), required); } - async acquireWhenReady(required: readonly EnvironmentCapability[] = []): Promise { - return this.resolver.acquireWhenReady(this.binding.current, 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..7bd67fd3afb --- /dev/null +++ b/packages/agent-core-v2/src/agent/environmentBinding/environmentRoster.ts @@ -0,0 +1,137 @@ +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 type { EnvironmentTarget } from '#/environment/environment'; +import { IAgentReminderService } from '#/features/reminder/reminderService'; +import { ISessionEnvironmentMounts, type EnvironmentMount } from '#/session/environmentMounts/environmentMounts'; + +import { IAgentEnvironmentService } from './agentEnvironment'; + +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. " + + '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; +} + +export const IAgentEnvironmentRosterService: ServiceIdentifier = + createDecorator('agentEnvironmentRosterService'); + +export class AgentEnvironmentRosterService extends Disposable implements IAgentEnvironmentRosterService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentReminderService reminder: IAgentReminderService, + @IAgentEnvironmentService environment: IAgentEnvironmentService, + @ISessionEnvironmentMounts mounts: ISessionEnvironmentMounts, + ) { + super(); + 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); + } + } + 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, 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 { + content: `Only the primary environment \`${targets[0]!.environmentId}\` is available now. Tools no longer take an \`environment\` argument.`, + disclosure: SINGLE_ENVIRONMENT_DISCLOSURE, + }; + } + 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 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, + }; + }), + ); + } +} + +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..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,11 +26,12 @@ export class GitControlPathAccessAskPermissionPolicyService implements Permissio context: ResolvedToolExecutionHookContext, ): Promise { if (!isDangerousCommandGuardEnabled(this.config)) return undefined; - const cwd = this.workspace.workDir; - if (cwd.length === 0) return undefined; - const lease = acquireEnvironmentLease(this.environment); + 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 b744d7a21f9..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,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.environment?.environmentId; + const lease = acquireEnvironmentLease(this.environment, [], environmentId); if (lease === undefined) return undefined; try { const pathClass = lease.environment.host?.pathClass; @@ -32,7 +30,8 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli const fs = lease.environment.fs; if (fs === undefined) return undefined; - const cwd = this.workspace.workDir; + const roots = this.environment.workspaceRoots(environmentId); + const cwd = roots.workDir; if (cwd.length === 0) return undefined; const writeAccesses = writeFileAccesses(context); @@ -41,7 +40,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..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,7 +14,7 @@ export class SessionApprovalHistoryPermissionPolicyService implements Permission ) {} evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { - for (const pattern of this.rulesService.sessionApprovalRulePatterns) { + 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 f524b5b4166..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; @@ -7,6 +8,7 @@ export interface PermissionApprovalResultRecord { readonly toolName: string; readonly action: string; readonly sessionApprovalRule?: string; + readonly environmentId?: string; readonly result: ApprovalResponse; } @@ -25,7 +27,7 @@ export interface IAgentPermissionRulesService { readonly _serviceBrand: undefined; readonly rules: readonly PermissionRule[]; - readonly sessionApprovalRulePatterns: 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/permissionRulesOps.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts index bf32582e78e..b456dbf76c7 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 = 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 046ed8c9674..5c4fb7c0b0e 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,8 +32,11 @@ export class AgentPermissionRulesService implements IAgentPermissionRulesService return [...this.agentState.get(permissionRulesKey).rules]; } - get sessionApprovalRulePatterns(): readonly string[] { - return [...this.agentState.get(permissionRulesKey).sessionApprovalRulePatterns]; + sessionApprovalRulePatterns(environment?: EnvironmentTarget): readonly string[] { + const state = this.agentState.get(permissionRulesKey); + if (environment?.primary !== false) return [...state.sessionApprovalRulePatterns]; + 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/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/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..fb2b1b29158 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.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 0ccc3e02373..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,6 +29,7 @@ export class BeforeToolExecuteEventImpl implements BeforeToolExecuteEvent { readonly toolCalls: readonly ToolCall[]; readonly tool?: ExecutableTool | undefined; readonly args: unknown; + readonly environment?: EnvironmentTarget; readonly execution: RunnableToolExecution; private _vetoResult: ExecutableToolResult | undefined; @@ -44,6 +46,7 @@ export class BeforeToolExecuteEventImpl implements BeforeToolExecuteEvent { this.toolCalls = context.toolCalls; this.tool = context.tool; this.args = context.args; + 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 e3411505a3c..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.args); + execution = await call.tool.resolveExecution(call.input, { environment: call.environment }); } catch (error) { const output = error instanceof PathSecurityError @@ -396,6 +397,13 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { : `Tool "${call.toolName}" failed to resolve execution: ${errorMessage(error)}`; return settleError(call.args, output, 'resolution-failed'); } + if ( + call.environment?.primary === false && + execution.isError !== true && + (execution.display?.kind === 'command' || execution.display?.kind === 'file_io') + ) { + execution = { ...execution, display: { ...execution.display, environment: call.environment.environmentId } }; + } const displayFields = toolCallDisplayFieldsFromExecution(execution); @@ -427,6 +435,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { toolCall: call.toolCall, execution, args: call.args, + environment: call.environment, }, options.signal, ); @@ -640,6 +649,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { toolCalls: [call.toolCall], tool: call.kind === 'runnable' ? call.tool : undefined, args: call.args, + environment: call.kind === 'runnable' ? call.environment : undefined, outcome, accesses: resolvedAccesses, result: result as ExecutableToolResult, @@ -689,6 +699,8 @@ interface RunnableToolCall { readonly toolName: string; readonly tool: ExecutableTool; readonly args: unknown; + readonly input: unknown; + readonly environment?: EnvironmentTarget; } interface RejectedToolCall { @@ -725,6 +737,7 @@ function buildBeforeExecuteContext( toolCalls: allCalls, tool: call.tool, args: call.args, + environment: call.environment, execution, }; } @@ -778,7 +791,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 +811,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, + environment: route.environment, + }; } 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..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,6 +18,7 @@ export interface ToolExecutionHookContext { readonly toolCalls: readonly ToolCall[]; readonly tool?: ExecutableTool | undefined; readonly args: unknown; + readonly environment?: EnvironmentTarget; } export interface ResolvedToolExecutionHookContext extends ToolExecutionHookContext { @@ -40,6 +42,7 @@ export interface WillExecuteToolEvent extends IWaitUntil { readonly toolCall: ToolCall; readonly execution: RunnableToolExecution; readonly args: unknown; + readonly environment?: EnvironmentTarget; } 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..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, @@ -10,6 +11,7 @@ import type { export interface ToolRegistrationOptions { readonly source?: ToolSource; readonly disclosure?: ToolDisclosure; + readonly environmentScoped?: boolean; } export interface ToolReference { @@ -17,6 +19,14 @@ export interface ToolReference { readonly source: ToolSource; } +export interface ToolEnvironmentRoute { + readonly args: unknown; + readonly environment?: EnvironmentTarget; + readonly error?: string; +} + +export const ENVIRONMENT_ARGUMENT = 'environment'; + export interface IAgentToolRegistryService { readonly _serviceBrand: undefined; @@ -24,6 +34,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..90319f70e4d 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,24 @@ export class AgentToolRegistryService implements IAgentToolRegistryService { } list(): readonly ToolInfo[] { + const multipleEnvironments = (this.environment?.current?.targets().length ?? 0) >= 2; + const environmentSchema = !multipleEnvironments + ? undefined + : { + type: 'string', + description: 'Environment id from the environment roster. 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 +88,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) return { args }; + const targets = this.environment?.current?.targets() ?? []; + 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) { + 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, environment: target }; + } + 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..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,11 +10,11 @@ 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, type ToolExecution, + type ToolResolutionContext, } from '#/tool/toolContract'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; @@ -30,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, ) {} @@ -39,8 +38,10 @@ 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?.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 { @@ -58,15 +59,16 @@ 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 roots = this.environment.workspaceRoots(environmentId); const view = new EnvironmentWorkspaceView(inspected, { - workDir: this.workspaceCtx.workDir, + workDir: roots.workDir, additionalDirs: [ - ...this.workspaceCtx.additionalDirs, - ...(this.skillCatalog?.catalog.getSkillRoots() ?? []), + ...(roots.additionalDirs ?? []), + ...(primary ? this.skillCatalog?.catalog.getSkillRoots() ?? [] : []), ], }); const env = view.host; @@ -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..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 @@ -4,7 +4,6 @@ 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 { EnvironmentWorkspaceView } from '#/environment/environmentWorkspaceView'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; @@ -13,6 +12,7 @@ import { DEFAULT_TOOL_RESULT_MAX_CHARS, type ExecutableToolResult, type ToolExecution, + type ToolResolutionContext, type ToolUpdate, } from '#/tool/toolContract'; import { @@ -101,7 +101,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, @@ -134,41 +133,34 @@ export class BashTool implements IBashTool { return renderedDescription; } - resolveExecution(args: BashInput): ToolExecution { - const expectedGeneration = this.reviewedGeneration(); - 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), - 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), - }; - } - - private reviewedGeneration(): string | undefined { - try { - return pinnedGeneration(this.environment.inspect()); - } catch { - return undefined; - } - } - - private executionCwd(args: BashInput): string { + async resolveExecution(args: BashInput, context?: ToolResolutionContext): Promise { + const environmentId = context?.environment?.environmentId; + const resolutionLease = await acquireOrWhenReady(this.environment, ['process'], environmentId); try { - const view = new EnvironmentWorkspaceView(this.environment.inspect(), this.workspaceCtx); - return view.resolve(args.cwd ?? view.workDir); - } catch { - return args.cwd ?? this.ctx.cwd; + const expectedGeneration = pinnedGeneration(resolutionLease.environment); + const view = new EnvironmentWorkspaceView( + resolutionLease.environment, + this.environment.workspaceRoots(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: 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(); } } @@ -197,18 +189,19 @@ 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, 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 +456,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..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 { @@ -23,6 +22,7 @@ import { ToolAccesses, type ExecutableToolResult, type ToolExecution, + type ToolResolutionContext, } from '#/tool/toolContract'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { @@ -66,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, @@ -82,16 +81,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?.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 roots = this.environment.workspaceRoots(environmentId); const view = new EnvironmentWorkspaceView(inspected, { - workDir: this.workspaceCtx.workDir, + workDir: roots.workDir, additionalDirs: [ - ...this.workspaceCtx.additionalDirs, - ...(this.skillCatalog?.catalog.getSkillRoots() ?? []), + ...(roots.additionalDirs ?? []), + ...(primary ? this.skillCatalog?.catalog.getSkillRoots() ?? [] : []), ], }); const env = { _serviceBrand: undefined, ...view.host, ready: Promise.resolve() }; @@ -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..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 @@ -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'; @@ -17,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, @@ -71,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, @@ -81,16 +80,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?.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 roots = this.environment.workspaceRoots(environmentId); const view = new EnvironmentWorkspaceView(inspected, { - workDir: this.workspaceCtx.workDir, + workDir: roots.workDir, additionalDirs: [ - ...this.workspaceCtx.additionalDirs, - ...(this.skillCatalog?.catalog.getSkillRoots() ?? []), + ...(roots.additionalDirs ?? []), + ...(primary ? this.skillCatalog?.catalog.getSkillRoots() ?? [] : []), ], }); const env = { _serviceBrand: undefined, ...view.host, ready: Promise.resolve() }; @@ -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..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,13 +18,13 @@ 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 { ToolAccesses, type ExecutableToolResult, type ToolExecution, + type ToolResolutionContext, } from '#/tool/toolContract'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { @@ -183,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, @@ -211,22 +210,27 @@ 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?.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); - 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 (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); + 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 roots = this.environment.workspaceRoots(environmentId); const view = new EnvironmentWorkspaceView(inspected, { - workDir: this.workspaceCtx.workDir, - additionalDirs: [...this.workspaceCtx.additionalDirs, ...this.skillCatalog.catalog.getSkillRoots()], + 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); @@ -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..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,11 +6,11 @@ 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, type ToolExecution, + type ToolResolutionContext, } from '#/tool/toolContract'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { @@ -30,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, ) {} @@ -39,8 +38,10 @@ 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?.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 { @@ -58,15 +59,16 @@ 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 roots = this.environment.workspaceRoots(environmentId); const view = new EnvironmentWorkspaceView(inspected, { - workDir: this.workspaceCtx.workDir, + workDir: roots.workDir, additionalDirs: [ - ...this.workspaceCtx.additionalDirs, - ...(this.skillCatalog?.catalog.getSkillRoots() ?? []), + ...(roots.additionalDirs ?? []), + ...(primary ? this.skillCatalog?.catalog.getSkillRoots() ?? [] : []), ], }); const env = { _serviceBrand: undefined, ...view.host, ready: Promise.resolve() }; @@ -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..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 @@ -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,20 +228,23 @@ export class ReadMediaFileTool implements AgentTool { return inlineVideoPart(data, mimeType); } - async resolveExecution(args: ReadMediaFileInput): Promise { + async resolveExecution(args: ReadMediaFileInput, context?: ToolResolutionContext): Promise { + const environmentId = context?.environment?.environmentId; + const primary = context?.environment?.primary !== false; 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 roots = this.environment.workspaceRoots(environmentId); const view = new EnvironmentWorkspaceView(inspected, { - workDir: this.workspace.workspaceDir, - additionalDirs: this.workspace.additionalDirs, + workDir: roots.workDir, + additionalDirs: primary ? this.workspace.additionalDirs : roots.additionalDirs, }); const env = view.host; const workspace = { workspaceDir: view.workDir, additionalDirs: view.additionalDirs }; @@ -256,12 +260,12 @@ export class ReadMediaFileTool implements AgentTool { approvalRule: literalRulePattern(this.name, path), matchesRule: (ruleArgs) => matchesPathRuleSubject(ruleArgs, path, { - cwd: this.workspace.workspaceDir, + cwd: 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/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 e0d0c86ffb6..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; @@ -45,13 +46,14 @@ 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( 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 d343b45b504..1bc2f9a961c 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); + 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.waitUntil(this.enqueue(() => this.capture(path, event.turnId, event.environment?.environmentId))); } private onSubagentWillExecuteTool(event: WillExecuteToolEvent): void { @@ -267,13 +271,15 @@ 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.environment?.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 +297,16 @@ 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, 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', ); 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 +337,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 +370,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 +478,15 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor private async readCurrent( pathKey: string, + environmentId?: string, ): Promise< Uint8Array | 'missing' | 'unreadable' | { oversizeBytes: number; mtimeMs?: number } > { + let workDir: string; let lease: EnvironmentLease; try { - lease = this.environment.acquire(['fs']); + workDir = this.environment.workspaceRoots(environmentId).workDir; + lease = this.environment.acquire(['fs'], environmentId); } catch (error) { if (error instanceof EnvironmentError) return 'unreadable'; throw error; @@ -481,7 +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 = resolveWorkspacePath(path, this.workspaceCtx.workDir, pathKey); + const absolute = resolveWorkspacePath(path, workDir, this.entryPath(pathKey, environmentId)); let info; try { info = await fs.stat(absolute); @@ -511,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) : JSON.stringify([environmentId, path]); + } + + private entryPath(pathKey: string, environmentId: string | undefined): string { + return environmentId === undefined ? pathKey : (JSON.parse(pathKey) as [string, string])[1]; + } + private pathKey(path: string): string { let raw = path; const { semantics, caseInsensitive } = this.pathSemantics(); 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/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/session/environmentMounts/environmentMounts.ts b/packages/agent-core-v2/src/session/environmentMounts/environmentMounts.ts new file mode 100644 index 00000000000..c0bca552208 --- /dev/null +++ b/packages/agent-core-v2/src/session/environmentMounts/environmentMounts.ts @@ -0,0 +1,123 @@ +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 { 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; + unmount(environmentId: string, mount: Promise): void; +} + +export const ISessionEnvironmentMounts: ServiceIdentifier = + createDecorator('sessionEnvironmentMounts'); + +export function environmentSkillName(name: string, environmentId: string): string { + 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(); + + constructor( + @ISessionWorkdir private readonly workdir: ISessionWorkdir, + @ISessionSkillCatalog private readonly catalog: ISessionSkillCatalog, + ) { + super(); + this._register(toDisposable(() => { + for (const { handle } of this.mounts.values()) { + handle.released = true; + handle.workdir?.dispose(); + } + this.mounts.clear(); + })); + } + + mount(environmentId: string, cwd: string): Promise { + 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; + } + + 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) }; + } + 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]); + } catch (error) { + return { status: 'failed', error: errorMessage(error) }; + } + if (handle.released) return { status: 'failed', error: `environment ${environmentId} is no longer available` }; + 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(catalogContributionId(environmentId), { skills }, { priority: SKILL_SOURCE_PRIORITY.workspace }); + handle.contributed = true; + } + 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); +} + +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/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index e319c4fa1c8..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[]; @@ -89,8 +90,12 @@ export interface RunnableToolExecution { export type ToolExecution = RunnableToolExecution | ExecutableToolErrorResult; +export interface ToolResolutionContext { + readonly environment?: EnvironmentTarget; +} + 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/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/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/agentsMdReminder/agentsMdReminder.test.ts b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts index 6ec86916a55..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,6 +273,7 @@ function didCtx( readonly result?: ExecutableToolResult; readonly preflightRejected?: boolean; readonly accesses?: ToolAccessesType; + readonly environment?: EnvironmentTarget; } = {}, ): ToolDidExecuteContext { const toolCall: ToolCall = { @@ -287,6 +288,7 @@ function didCtx( toolCall, toolCalls: [toolCall], args, + environment: options.environment, tool: options.preflightRejected === true ? undefined : ({} as ExecutableTool), outcome: options.preflightRejected === true ? 'preflight-rejected' : 'executed', accesses: @@ -1554,6 +1556,32 @@ 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' }, { 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/changeNotifier/changeNotifier.test.ts b/packages/agent-core-v2/test/agent/changeNotifier/changeNotifier.test.ts index b8b0b5e6f1c..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, @@ -267,6 +269,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..e09bbf2ad9e 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,24 @@ 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 { + 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 { 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'; import { noopLogger } from '../../wire/stubs'; import { fakeEnvironment } from '../../environment/stubs'; @@ -123,10 +141,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 +240,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 +547,388 @@ 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'); + }); +}); + +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 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, unmounted }; +} + +describe('AgentEnvironmentRosterService', () => { + function rosterProvider( + agentEnvironment: ConstructorParameters[1], + mounts: ISessionEnvironmentMounts = stubMounts().mounts, + ) { + 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, 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', async () => { + const { agentEnvironment, registry } = setup({ + multiEnvironment: { declarations: [{ id: 'remote', defaultCwd: '/srv/app' }, { id: 'staging' }, { id: 'unregistered' }] }, + }); + registry.register(environment('staging', 'staging-one', 'pending')); + const { mounts, calls } = stubMounts(); + const { provide } = rosterProvider(agentEnvironment, mounts); + + 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`: not connected yet; working directory resolved on first use', + ROSTER_GUIDANCE, + ].join('\n'), + ); + const injected = { role: 'user' as const, 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('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 { 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 = await provide({}); + expect(before?.content).toContain('- `staging`: not connected yet; working directory resolved on first use'); + + staging.setStatus('ready'); + 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(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('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' }] }, + }); + 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 { mounts, calls } = stubMounts(); + const { provide } = rosterProvider(agentEnvironment, mounts); + const injected = { role: 'user' as const, content: [] }; + + 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(await provide({ lastInjection: injected, lastDisclosure: 'single' })).toBeUndefined(); + expect(calls).toEqual([]); + }); +}); + +describe('SessionEnvironmentMountsService', () => { + function setupMounts(options: { + readonly acquire?: (environmentId: string, cwd: string) => Promise; + } = {}) { + 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', '/workspace/AGENTS.md'], + agentsMdProjectPaths: ['/workspace/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); + }, + remove: (id: string) => { + contributions.delete(id); + }, + } as unknown as ISessionSkillCatalog; + const service = new SessionEnvironmentMountsService(workdir, catalog); + 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 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'], + }); + }); + + 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(); + + 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/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..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'; @@ -52,6 +53,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 +63,7 @@ describe('AgentPermissionPolicyService chain', () => { mode = 'manual'; rules = []; sessionApprovalRulePatterns = []; + environmentSessionApprovalRulePatterns = {}; workspace = workspaceStub('/workspace'); hostArgs = { requestHeaders: {}, nonInteractive: false }; dangerousCommandGuardEnabled = true; @@ -85,13 +88,16 @@ describe('AgentPermissionPolicyService chain', () => { ); reg.definePartialInstance(IAgentPermissionRulesService, permissionRulesStub({ rules: () => rules, - sessionApprovalRulePatterns: () => sessionApprovalRulePatterns, + sessionApprovalRulePatterns: (environment) => environment?.primary === false + ? environmentSessionApprovalRulePatterns[environment.environmentId] ?? [] + : sessionApprovalRulePatterns, })); 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: () => ({ @@ -118,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 }); @@ -223,6 +229,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 }, + environment: { environmentId: 'gpu', primary: false }, + }; + + 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, environment: { environmentId: 'staging', primary: false } })).resolves.toMatchObject({ + policyName: 'user-configured-ask', + }); + }); + it.each(['manual', 'yolo'] as const)( 'asks for shutdown in %s mode', async (currentMode) => { @@ -499,12 +533,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 +565,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 +591,17 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { dirname: (path: string) => dirname(path), }, workspace: { mapRoots: (roots) => roots }, - fs: environmentFsAvailable ? hostFs : undefined, + fs: environmentId !== undefined && environmentId !== 'local' + ? ({ 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) => environmentId === undefined || environmentId === 'local' + ? { workDir: workspace.stub.workDir, additionalDirs: workspace.stub.additionalDirs } + : { workDir: workspaceDir, additionalDirs: [] }, }); reg.defineInstance(ITelemetryService, recordingTelemetry([])); reg.definePartialInstance(IGitService, { @@ -605,6 +647,33 @@ 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')), + environment: { environmentId: 'gpu', primary: false }, + })).resolves.toMatchObject({ + policyName: 'fallback-ask', + result: { kind: 'ask' }, + }); + expect(acquiredEnvironments).toContain('gpu'); + 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); @@ -720,21 +789,18 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { interface MutablePermissionRulesStubOptions { readonly rules?: () => readonly PermissionRule[]; - readonly sessionApprovalRulePatterns?: () => 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(); - }, + sessionApprovalRulePatterns: options.sessionApprovalRulePatterns ?? (() => []), addRules: () => {}, recordApprovalResult: () => {}, }; @@ -745,6 +811,7 @@ interface PolicyContextInput { readonly toolName: string; readonly args: Record; readonly accesses?: ToolAccessList; + readonly environment?: EnvironmentTarget; } function policyContext(input: PolicyContextInput): ResolvedToolExecutionHookContext { @@ -756,6 +823,7 @@ function policyContext(input: PolicyContextInput): ResolvedToolExecutionHookCont toolCall, toolCalls: [toolCall], args: input.args, + 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 7e1d2f6b3c8..c7e652c97ab 100644 --- a/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts +++ b/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts @@ -84,10 +84,30 @@ 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.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('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', () => { @@ -99,7 +119,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 () => { @@ -126,6 +146,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 +170,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..34620cf3fbb 100644 --- a/packages/agent-core-v2/test/agent/permissionRules/stubs.ts +++ b/packages/agent-core-v2/test/agent/permissionRules/stubs.ts @@ -11,7 +11,7 @@ export function stubPermissionRulesService( get rules() { return rules(); }, - sessionApprovalRulePatterns: [], + sessionApprovalRulePatterns: () => [], addRules: () => {}, recordApprovalResult: () => {}, }; 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/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/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..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,7 +116,7 @@ describe('AgentToolApprovalService', () => { reg.defineInstance(IAgentPermissionRulesService, { _serviceBrand: undefined, rules: [], - sessionApprovalRulePatterns: [], + 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 f28f4ae62d3..a7a02878dc8 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,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 type { IAgentEnvironmentService } 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'; @@ -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: EnvironmentTarget[]; 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, @@ -1098,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, @@ -1113,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 () => { @@ -1599,6 +1609,208 @@ 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', + 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 }); + const observed: { readonly environment: EnvironmentTarget | undefined; readonly args: unknown }[] = []; + executor.onBeforeExecuteTool((event) => { + observed.push({ environment: event.environment, 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' }), + ]); + + const [local, gpu] = environmentTargets; + expect(tool.resolutions).toEqual([ + { args: { text: 'a' }, environment: gpu }, + { args: { text: 'b' }, environment: local }, + { args: { text: 'c' }, environment: local }, + ]); + expect(observed).toEqual([ + { 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') + .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), + { 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 environment: EnvironmentTarget | undefined }> = []; + + constructor( + readonly name: string, + readonly parameters: Record, + ) {} + + resolveExecution(args: Record, context?: ToolResolutionContext): ToolExecution { + this.resolutions.push({ args, environment: context?.environment }); + 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..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 @@ -2,9 +2,10 @@ 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 { + EnvironmentError, EnvironmentRegistry, environmentIsReady, type EnvironmentRegistrationHandle, @@ -30,7 +31,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(); @@ -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); @@ -185,7 +186,6 @@ const editService = { } as unknown as IFileEditService; function toolCases(): readonly ToolCase[] { - const workspace = stubWorkspaceContext('/workspace'); return [ { name: 'Read', @@ -193,7 +193,6 @@ function toolCases(): readonly ToolCase[] { resolve: (service, path = '/workspace/a.txt') => new ReadTool( service, - workspace, skillCatalog, stubToolResultTruncationService(), stubConfigService(), @@ -210,7 +209,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'); @@ -220,7 +219,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', @@ -234,7 +233,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'); }, @@ -243,7 +242,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'); }, @@ -350,7 +349,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( @@ -373,7 +372,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', ); @@ -391,7 +390,6 @@ describe('Bash environment generation guard', () => { sessionScope: 'ses', cwd: '/workspace', }), - stubWorkspaceContext('/workspace'), {} as never, toolPolicy, stubConfigService(), @@ -400,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()); @@ -418,3 +416,187 @@ 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: binding(environmentId).environmentId === 'local' ? '/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 = (environment?: EnvironmentTarget) => + new ReadTool( + service, + skillCatalog, + stubToolResultTruncationService(), + stubConfigService(), + profile, + toolPolicy, + toolRegistry, + ).resolveExecution({ path: 'notes.txt' }, { environment }); + + 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 () => { + 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 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); + }); +}); 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/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..41aa9522721 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(); @@ -63,6 +67,9 @@ describe('AgentFileHistoryService', () => { let blobs: IBlobStore; let scopeCtx: IAgentScopeContext; let files: Map; + let gpuFiles: Map; + let gpuReachable: boolean; + let connectAttempts: number; beforeEach(() => { disposables = new DisposableStore(); @@ -85,6 +92,9 @@ describe('AgentFileHistoryService', () => { executorEvents = stubToolExecutorEvents(); blobs = new BlobStoreService(new InMemoryStorageService()); files = new Map(); + gpuFiles = new Map(); + gpuReachable = true; + connectAttempts = 0; }); afterEach(() => { @@ -97,16 +107,27 @@ describe('AgentFileHistoryService', () => { path: posix, workspace: { mapRoots: (roots: unknown) => roots }, }; + const gpu = { ...environment, fs: hostFs(false, gpuFiles) }; return { + targets: () => [LOCAL, GPU], inspect: () => environment, - acquire: () => ({ - 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; } - 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 +136,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 +204,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, + environment?: EnvironmentTarget, + ): Promise { const toolCall: ToolCall = { type: 'function', id: `call-${String(turnId)}`, name: 'Edit', arguments: null }; const execution: RunnableToolExecution = { approvalRule: 'Edit', @@ -191,7 +217,7 @@ describe('AgentFileHistoryService', () => { execute: async () => ({ output: '' }), }; await executorEvents.fireWillExecute( - { turnId, toolCall, execution, args: {} }, + { turnId, toolCall, execution, args: {}, environment }, new AbortController().signal, ); await service.settled(); @@ -539,6 +565,99 @@ describe('AgentFileHistoryService', () => { expect(await blobs.list(scopeCtx.scope(), 'file-history/')).toEqual([]); }); + 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); + gpuFiles.set('/srv/app/a.txt', encoder.encode('one\nTWO\n')); + expect(await service.changes(1)).toEqual([ + { path: '/srv/app/a.txt', environmentId: 'gpu', status: 'modified', additions: 1, deletions: 1 }, + ]); + endTurn(1); + startTurn(2); + await service.settled(); + + 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 }, + ]); + 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', 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 () => { + 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/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 399a6bb3cab..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, @@ -1383,6 +1386,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 => { @@ -2403,9 +2407,7 @@ function createPermissionRulesStub( get rules() { return rules; }, - get sessionApprovalRulePatterns() { - return []; - }, + 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 87c84919874..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) { @@ -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); @@ -209,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/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' }); 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'), 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)); }, );