diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 510d93a4e..53df41e66 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -38,6 +38,10 @@ const DOMAIN_LAYER = new Map([ // `errors` is a top-level facade (src/errors.ts) that aggregates every // domain's error codes; any domain may import it, so it sits at L0. ['errors', 0], + // `kaos` is the execution-environment substrate (cwd/env/osEnv/backend); + // it wraps the `@moonshot-ai/kaos` package and depends on no business + // domain, so it sits at L0 where any domain may import it. + ['kaos', 0], // L1 — abstraction bridges & low-level capabilities ['log', 1], ['telemetry', 1], diff --git a/packages/agent-core-v2/src/_base/tools/policies/path-access.ts b/packages/agent-core-v2/src/_base/tools/policies/path-access.ts index d930cb810..898252fe2 100644 --- a/packages/agent-core-v2/src/_base/tools/policies/path-access.ts +++ b/packages/agent-core-v2/src/_base/tools/policies/path-access.ts @@ -14,7 +14,7 @@ import * as pathe from 'pathe'; -import type { Kaos } from '@moonshot-ai/kaos'; +import type { IKaos } from '#/kaos'; import type { WorkspaceConfig } from '../support/workspace'; import { isSensitiveFile } from './sensitive'; @@ -178,7 +178,7 @@ export interface ResolvePathAccessOptions { } export interface ResolvePathAccessPathOptions { - readonly kaos: Pick; + readonly kaos: Pick; readonly workspace: WorkspaceConfig; readonly operation: PathAccessOperation; readonly policy?: WorkspaceAccessPolicy; diff --git a/packages/agent-core-v2/src/agentFs/agentFs.ts b/packages/agent-core-v2/src/agentFs/agentFs.ts index 8fc7cd06c..b8fbcec09 100644 --- a/packages/agent-core-v2/src/agentFs/agentFs.ts +++ b/packages/agent-core-v2/src/agentFs/agentFs.ts @@ -1,11 +1,9 @@ /** - * `agentFs` domain (L1) — the Agent's filesystem and its pluggable backend. + * `agentFs` domain (L1) — the Agent's filesystem. * * Defines the `IAgentFileSystem` that business code injects to read and write - * files inside the Agent's execution environment, plus the internal - * `IFileSystemBackend` provider that hides the local/ssh/container split. - * Session-scoped. Business code depends on `IAgentFileSystem` only; the - * backend is wired through the scope registry. + * files inside the Agent's execution environment. Session-scoped and backed by + * the session `IKaos`; business code depends on `IAgentFileSystem` only. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -23,7 +21,7 @@ export interface IAgentFileSystem { readText(path: string): Promise; writeText(path: string, data: string): Promise; - readBytes(path: string): Promise; + readBytes(path: string, n?: number): Promise; writeBytes(path: string, data: Uint8Array): Promise; stat(path: string): Promise; readdir(path: string): Promise; @@ -34,19 +32,3 @@ export interface IAgentFileSystem { export const IAgentFileSystem: ServiceIdentifier = createDecorator('agentFileSystem'); - -export interface IFileSystemBackend { - readonly _serviceBrand: undefined; - - readText(absPath: string): Promise; - writeText(absPath: string, data: string): Promise; - readBytes(absPath: string): Promise; - writeBytes(absPath: string, data: Uint8Array): Promise; - stat(absPath: string): Promise; - readdir(absPath: string): Promise; - glob(absDir: string, pattern: string): Promise; - mkdir(absPath: string): Promise; -} - -export const IFileSystemBackend: ServiceIdentifier = - createDecorator('fileSystemBackend'); diff --git a/packages/agent-core-v2/src/agentFs/agentFsService.ts b/packages/agent-core-v2/src/agentFs/agentFsService.ts index 09b2dcab9..9bb138079 100644 --- a/packages/agent-core-v2/src/agentFs/agentFsService.ts +++ b/packages/agent-core-v2/src/agentFs/agentFsService.ts @@ -1,70 +1,85 @@ /** * `agentFs` domain (L1) — `IAgentFileSystem` implementation. * - * Resolves paths against the session workspace and delegates IO to the - * injected `IFileSystemBackend`; reads the work directory through - * `workspaceContext`. Bound at Session scope. + * Focused file-IO surface over the session execution environment (`IKaos.backend`). + * Relative-path resolution (in the target path style) and symlink-safe glob are + * handled by the kaos backend; this service exposes a kaos-free, filesystem-shaped + * interface to business code and derives sub-views via `withCwd`. Bound at Session + * scope. */ -import { isAbsolute, resolve } from 'node:path'; - import { InstantiationType } from '#/_base/di/extensions'; -import { NotImplementedError } from '#/_base/errors'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { IWorkspaceContext } from '#/workspaceContext'; +import { IKaos, type StatResult } from '#/kaos'; -import { type AgentFileStat, IAgentFileSystem, IFileSystemBackend } from './agentFs'; +import { type AgentFileStat, IAgentFileSystem } from './agentFs'; + +const S_IFMT = 0o170000; +const S_IFREG = 0o100000; +const S_IFDIR = 0o040000; + +function statKind(s: StatResult): Pick { + const kind = s.stMode & S_IFMT; + return { isFile: kind === S_IFREG, isDirectory: kind === S_IFDIR }; +} + +function basename(p: string): string { + const parts = p.split(/[\\/]/); + return parts[parts.length - 1] ?? p; +} export class AgentFileSystem implements IAgentFileSystem { declare readonly _serviceBrand: undefined; - constructor( - @IFileSystemBackend private readonly backend: IFileSystemBackend, - @IWorkspaceContext private readonly workspace: IWorkspaceContext, - ) {} + constructor(@IKaos private readonly kaos: IKaos) {} get cwd(): string { - return this.workspace.workDir; - } - - private abs(path: string): string { - return isAbsolute(path) ? path : resolve(this.cwd, path); + return this.kaos.cwd; } readText(path: string): Promise { - return this.backend.readText(this.abs(path)); + return this.kaos.backend.readText(path); } writeText(path: string, data: string): Promise { - return this.backend.writeText(this.abs(path), data); + return this.kaos.backend.writeText(path, data).then(() => undefined); } - readBytes(path: string): Promise { - return this.backend.readBytes(this.abs(path)); + readBytes(path: string, n?: number): Promise { + return this.kaos.backend.readBytes(path, n); } writeBytes(path: string, data: Uint8Array): Promise { - return this.backend.writeBytes(this.abs(path), data); + return this.kaos.backend.writeBytes(path, Buffer.from(data)).then(() => undefined); } - stat(path: string): Promise { - return this.backend.stat(this.abs(path)); + async stat(path: string): Promise { + const s = await this.kaos.backend.stat(path); + return { ...statKind(s), size: s.stSize }; } - readdir(path: string): Promise { - return this.backend.readdir(this.abs(path)); + async readdir(path: string): Promise { + const names: string[] = []; + for await (const entry of this.kaos.backend.iterdir(path)) { + names.push(basename(entry)); + } + return names; } - glob(pattern: string): Promise { - return this.backend.glob(this.cwd, pattern); + async glob(pattern: string): Promise { + const out: string[] = []; + for await (const match of this.kaos.backend.glob(this.kaos.cwd, pattern)) { + out.push(match); + } + return out; } mkdir(path: string): Promise { - return this.backend.mkdir(this.abs(path)); + return this.kaos.backend.mkdir(path, { parents: true, existOk: true }); } - withCwd(_cwd: string): IAgentFileSystem { - throw new NotImplementedError('agentFs.withCwd'); + withCwd(cwd: string): IAgentFileSystem { + return new AgentFileSystem(this.kaos.withCwd(cwd)); } } diff --git a/packages/agent-core-v2/src/agentFs/index.ts b/packages/agent-core-v2/src/agentFs/index.ts index 4df2f59c3..0e41b8b65 100644 --- a/packages/agent-core-v2/src/agentFs/index.ts +++ b/packages/agent-core-v2/src/agentFs/index.ts @@ -1,10 +1,9 @@ /** * `agentFs` domain barrel — re-exports the agent-filesystem contract * (`agentFs`) and its scoped service (`agentFsService`), the wire-shaped fs - * service (`fs`, `fsService`), the fs error codes (`errors`), and the backend - * implementations (`localFileSystemBackend`, `sshFileSystemBackend`). - * Importing this barrel registers the `IAgentFileSystem` / `IFsService` - * bindings and the default local `IFileSystemBackend` into the scope registry. + * service (`fs`, `fsService`), and the fs error codes (`errors`). Importing + * this barrel registers the `IAgentFileSystem` and `IFsService` bindings into + * the scope registry. */ export * from './agentFs'; @@ -12,5 +11,3 @@ export * from './agentFsService'; export * from './errors'; export * from './fs'; export * from './fsService'; -export * from './localFileSystemBackend'; -export * from './sshFileSystemBackend'; diff --git a/packages/agent-core-v2/src/agentFs/localFileSystemBackend.ts b/packages/agent-core-v2/src/agentFs/localFileSystemBackend.ts deleted file mode 100644 index eda7c773e..000000000 --- a/packages/agent-core-v2/src/agentFs/localFileSystemBackend.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * `agentFs` domain (L1) — local `IFileSystemBackend` implementation. - * - * Backs the Agent filesystem with the real local disk by delegating to the - * program-side `hostFs` primitives. Registered as the default - * `IFileSystemBackend` at Session scope; remote backends override it via the - * scope registry. - */ - -import { InstantiationType } from '#/_base/di/extensions'; -import { NotImplementedError } from '#/_base/errors'; -import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { IHostFileSystem } from '#/hostFs'; - -import { type AgentFileStat, IFileSystemBackend } from './agentFs'; - -export class LocalFileSystemBackend implements IFileSystemBackend { - declare readonly _serviceBrand: undefined; - - constructor(@IHostFileSystem private readonly hostFs: IHostFileSystem) {} - - readText(absPath: string): Promise { - return this.hostFs.readText(absPath); - } - - writeText(absPath: string, data: string): Promise { - return this.hostFs.writeText(absPath, data); - } - - readBytes(absPath: string): Promise { - return this.hostFs.readBytes(absPath); - } - - writeBytes(absPath: string, data: Uint8Array): Promise { - return this.hostFs.writeBytes(absPath, data); - } - - async stat(absPath: string): Promise { - const s = await this.hostFs.stat(absPath); - return { isFile: s.isFile, isDirectory: s.isDirectory, size: s.size }; - } - - async readdir(absPath: string): Promise { - const entries = await this.hostFs.readdir(absPath); - return entries.map((e) => e.name); - } - - glob(_absDir: string, _pattern: string): Promise { - throw new NotImplementedError('localFileSystemBackend.glob'); - } - - mkdir(absPath: string): Promise { - return this.hostFs.mkdir(absPath, { recursive: true }); - } -} - -registerScopedService( - LifecycleScope.Session, - IFileSystemBackend, - LocalFileSystemBackend, - InstantiationType.Delayed, - 'agentFs', -); diff --git a/packages/agent-core-v2/src/agentFs/sshFileSystemBackend.ts b/packages/agent-core-v2/src/agentFs/sshFileSystemBackend.ts deleted file mode 100644 index 7fbba92a8..000000000 --- a/packages/agent-core-v2/src/agentFs/sshFileSystemBackend.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * `agentFs` domain (L1) — ssh `IFileSystemBackend` stub. - * - * Placeholder for the remote backend; not registered into the scope registry - * yet. A composition root that needs ssh supplies it through - * `ScopeOptions.extra` to override the local backend. - */ - -import { NotImplementedError } from '#/_base/errors'; - -import { type AgentFileStat, IFileSystemBackend } from './agentFs'; - -export class SshFileSystemBackend implements IFileSystemBackend { - declare readonly _serviceBrand: undefined; - - readText(_absPath: string): Promise { - throw new NotImplementedError('sshFileSystemBackend'); - } - - writeText(_absPath: string, _data: string): Promise { - throw new NotImplementedError('sshFileSystemBackend'); - } - - readBytes(_absPath: string): Promise { - throw new NotImplementedError('sshFileSystemBackend'); - } - - writeBytes(_absPath: string, _data: Uint8Array): Promise { - throw new NotImplementedError('sshFileSystemBackend'); - } - - stat(_absPath: string): Promise { - throw new NotImplementedError('sshFileSystemBackend'); - } - - readdir(_absPath: string): Promise { - throw new NotImplementedError('sshFileSystemBackend'); - } - - glob(_absDir: string, _pattern: string): Promise { - throw new NotImplementedError('sshFileSystemBackend'); - } - - mkdir(_absPath: string): Promise { - throw new NotImplementedError('sshFileSystemBackend'); - } -} diff --git a/packages/agent-core-v2/src/background/process-task.ts b/packages/agent-core-v2/src/background/process-task.ts index 7eaf7a950..9cfa20245 100644 --- a/packages/agent-core-v2/src/background/process-task.ts +++ b/packages/agent-core-v2/src/background/process-task.ts @@ -1,6 +1,7 @@ -import type { KaosProcess } from '@moonshot-ai/kaos'; import type { Readable } from 'node:stream'; +import type { IProcess } from '#/process'; + import type { BackgroundTask, BackgroundTaskInfoBase, @@ -30,7 +31,7 @@ export class ProcessBackgroundTask implements BackgroundTask { private exitCode: number | null = null; constructor( - readonly proc: KaosProcess, + readonly proc: IProcess, readonly command: string, readonly description: string, private readonly onOutput?: ProcessBackgroundTaskOutputCallback, diff --git a/packages/agent-core-v2/src/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/bootstrap/bootstrap.ts index 87c592dd8..ad12ef62f 100644 --- a/packages/agent-core-v2/src/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/bootstrap/bootstrap.ts @@ -19,7 +19,7 @@ import { homedir } from 'node:os'; import { join } from 'pathe'; -import type { Environment } from '@moonshot-ai/kaos'; +import type { Environment } from '#/kaos'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/bootstrap/bootstrapService.ts b/packages/agent-core-v2/src/bootstrap/bootstrapService.ts index 657758b3d..3c3198d60 100644 --- a/packages/agent-core-v2/src/bootstrap/bootstrapService.ts +++ b/packages/agent-core-v2/src/bootstrap/bootstrapService.ts @@ -8,7 +8,7 @@ import { join } from 'pathe'; -import { type Environment, detectEnvironmentFromNode } from '@moonshot-ai/kaos'; +import { type Environment, detectEnvironmentFromNode } from '#/kaos'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 0373674cf..1f5ddb6f5 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -52,6 +52,7 @@ export * from './gateway/index'; export * from './workspaceContext/index'; export * from './workspaceRegistry/index'; export * from './hostFolderBrowser/index'; +export * from './kaos/index'; export * from './agentFs/index'; export * from './process/index'; export * from './terminal/index'; diff --git a/packages/agent-core-v2/src/kaos/index.ts b/packages/agent-core-v2/src/kaos/index.ts new file mode 100644 index 000000000..c8653e8eb --- /dev/null +++ b/packages/agent-core-v2/src/kaos/index.ts @@ -0,0 +1,8 @@ +/** + * `kaos` domain barrel — re-exports the execution-environment contracts + * (`kaos`) and the `IKaosFactory` binding (`kaosFactoryService`). Importing + * this barrel registers the `IKaosFactory` binding into the scope registry. + */ + +export * from './kaos'; +export * from './kaosFactoryService'; diff --git a/packages/agent-core-v2/src/kaos/kaos.ts b/packages/agent-core-v2/src/kaos/kaos.ts new file mode 100644 index 000000000..5820ab99a --- /dev/null +++ b/packages/agent-core-v2/src/kaos/kaos.ts @@ -0,0 +1,61 @@ +/** + * `kaos` domain (L1) — execution-environment contracts. + * + * Defines `IKaos`, the Agent's execution environment (cwd, env layers, the + * OS/shell probe, and the backend handle the fs/process domains delegate to), + * plus `IKaosFactory`, the Core factory that builds an `IKaos` for a session + * (local today; ssh/container behind the same factory later). + * + * Temporary: this domain wraps the `@moonshot-ai/kaos` package and re-exports + * a few of its data types so business code imports them from `#/kaos` instead + * of the package. `IKaos` is seeded into each Session scope by the composition + * root; `IKaosFactory` is bound at Core scope. + */ + +import type { Environment, Kaos } from '@moonshot-ai/kaos'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export type { Environment, KaosProcess, StatResult } from '@moonshot-ai/kaos'; +export { detectEnvironmentFromNode } from '@moonshot-ai/kaos'; + +export type PathClass = 'posix' | 'win32'; + +export interface IKaos { + readonly _serviceBrand: undefined; + + /** Human-readable backend name (e.g. `"local"`, `"ssh:host"`). */ + readonly name: string; + /** Current working directory of this execution environment. */ + readonly cwd: string; + /** OS / shell probe of the execution environment. */ + readonly osEnv: Environment; + /** + * The backend fs/process domains delegate to. Temporary — owned by this + * environment; business code should reach for `IAgentFileSystem` / + * `IProcessRunner` instead of touching this directly. + */ + readonly backend: Kaos; + + pathClass(): PathClass; + normpath(path: string): string; + gethome(): string; + getcwd(): string; + + /** Derive a new environment rooted at `cwd` (shares backend + osEnv). */ + withCwd(cwd: string): IKaos; + /** Derive a new environment that overlays `env` onto spawned processes. */ + withEnv(env: Record): IKaos; +} + +export const IKaos: ServiceIdentifier = createDecorator('kaos'); + +export interface IKaosFactory { + readonly _serviceBrand: undefined; + + /** Build a local execution environment rooted at `cwd`. */ + createLocal(cwd: string): Promise; +} + +export const IKaosFactory: ServiceIdentifier = + createDecorator('kaosFactory'); diff --git a/packages/agent-core-v2/src/kaos/kaosFactoryService.ts b/packages/agent-core-v2/src/kaos/kaosFactoryService.ts new file mode 100644 index 000000000..257fe7aab --- /dev/null +++ b/packages/agent-core-v2/src/kaos/kaosFactoryService.ts @@ -0,0 +1,31 @@ +/** + * `kaos` domain (L1) — `IKaosFactory` implementation. + * + * Builds an `IKaos` for a session. Today only local (`LocalKaos`); ssh/container + * are added behind the same factory later. Bound at Core scope. + */ + +import { LocalKaos } from '@moonshot-ai/kaos'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; + +import { type IKaos, IKaosFactory } from './kaos'; +import { KaosService } from './kaosService'; + +export class KaosFactory implements IKaosFactory { + declare readonly _serviceBrand: undefined; + + async createLocal(cwd: string): Promise { + const base = await LocalKaos.create(); + return new KaosService(base.withCwd(cwd)); + } +} + +registerScopedService( + LifecycleScope.Core, + IKaosFactory, + KaosFactory, + InstantiationType.Delayed, + 'kaos', +); diff --git a/packages/agent-core-v2/src/kaos/kaosService.ts b/packages/agent-core-v2/src/kaos/kaosService.ts new file mode 100644 index 000000000..48e862b89 --- /dev/null +++ b/packages/agent-core-v2/src/kaos/kaosService.ts @@ -0,0 +1,54 @@ +/** + * `kaos` domain (L1) — `IKaos` implementation. + * + * Thin wrapper around a `@moonshot-ai/kaos` `Kaos` backend, exposing cwd, the + * OS/shell probe, path primitives, and context derivation (`withCwd`/`withEnv`). + * Not registered directly — built by `IKaosFactory` and seeded into a Session + * scope by the composition root. + */ + +import type { Kaos } from '@moonshot-ai/kaos'; + +import type { Environment, IKaos, PathClass } from './kaos'; + +export class KaosService implements IKaos { + declare readonly _serviceBrand: undefined; + + constructor(readonly backend: Kaos) {} + + get name(): string { + return this.backend.name; + } + + get cwd(): string { + return this.backend.getcwd(); + } + + get osEnv(): Environment { + return this.backend.osEnv; + } + + pathClass(): PathClass { + return this.backend.pathClass(); + } + + normpath(path: string): string { + return this.backend.normpath(path); + } + + gethome(): string { + return this.backend.gethome(); + } + + getcwd(): string { + return this.backend.getcwd(); + } + + withCwd(cwd: string): IKaos { + return new KaosService(this.backend.withCwd(cwd)); + } + + withEnv(env: Record): IKaos { + return new KaosService(this.backend.withEnv(env)); + } +} diff --git a/packages/agent-core-v2/src/media/registerMediaTools.ts b/packages/agent-core-v2/src/media/registerMediaTools.ts index c40c0c256..1c227e41e 100644 --- a/packages/agent-core-v2/src/media/registerMediaTools.ts +++ b/packages/agent-core-v2/src/media/registerMediaTools.ts @@ -13,16 +13,18 @@ * left to the composition root, which owns the auth resolver. */ -import type { Kaos } from '@moonshot-ai/kaos'; import type { ChatProvider, ModelCapability } from '@moonshot-ai/kosong'; import { toDisposable, type IDisposable } from '#/_base/di'; import type { WorkspaceConfig } from '#/_base/tools/support/workspace'; +import type { IAgentFileSystem } from '#/agentFs'; +import type { IKaos } from '#/kaos'; import type { IToolRegistry } from '#/toolRegistry'; import { ReadMediaFileTool, type VideoUploader } from './tools/read-media'; export interface RegisterMediaToolsDeps { - readonly kaos: Kaos; + readonly fs: IAgentFileSystem; + readonly kaos: IKaos; readonly workspace: WorkspaceConfig; readonly capabilities: ModelCapability; readonly videoUploader?: VideoUploader; @@ -44,7 +46,13 @@ export function registerMediaTools( return toDisposable(() => {}); } return toolRegistry.register( - new ReadMediaFileTool(deps.kaos, deps.workspace, deps.capabilities, deps.videoUploader), + new ReadMediaFileTool( + deps.fs, + deps.kaos, + deps.workspace, + deps.capabilities, + deps.videoUploader, + ), ); } diff --git a/packages/agent-core-v2/src/media/tools/read-media.ts b/packages/agent-core-v2/src/media/tools/read-media.ts index 1d8572e99..148840cf4 100644 --- a/packages/agent-core-v2/src/media/tools/read-media.ts +++ b/packages/agent-core-v2/src/media/tools/read-media.ts @@ -19,7 +19,6 @@ * only registered when the active model supports image or video input. */ -import type { Kaos } from '@moonshot-ai/kaos'; import type { ContentPart, ModelCapability, @@ -28,6 +27,8 @@ import type { } from '@moonshot-ai/kosong'; import { z } from 'zod'; +import { IAgentFileSystem } from '#/agentFs'; +import { IKaos } from '#/kaos'; import { ToolAccesses } from '#/tool'; import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/tool'; import { resolvePathAccessPath } from '#/_base/tools/policies/path-access'; @@ -63,7 +64,7 @@ export const ReadMediaFileInputSchema = z.object({ ), }); -export type ReadMediaFileInput = z.Infer; +export type ReadMediaFileInput = z.infer; // ── Tool description (capability-driven) ───────────────────────────── @@ -135,7 +136,8 @@ export class ReadMediaFileTool implements BuiltinTool { readonly description: string; readonly parameters: Record = toInputJsonSchema(ReadMediaFileInputSchema); constructor( - private readonly kaos: Kaos, + private readonly fs: IAgentFileSystem, + private readonly kaos: IKaos, private readonly workspace: WorkspaceConfig, private readonly capabilities: ModelCapability, private readonly videoUploader?: VideoUploader | undefined, @@ -181,7 +183,7 @@ export class ReadMediaFileTool implements BuiltinTool { try { // For media input, the bytes are authoritative; the extension is only // a fallback for formats that cannot be sniffed from the header. - const header = await this.kaos.readBytes(safePath, MEDIA_SNIFF_BYTES); + const header = await this.fs.readBytes(safePath, MEDIA_SNIFF_BYTES); const fileType = detectFileType(safePath, header, 'media'); if (fileType.kind === 'text') { @@ -216,20 +218,20 @@ export class ReadMediaFileTool implements BuiltinTool { }; } - const stat = await this.kaos.stat(safePath); - if (stat.stSize === 0) { + const stat = await this.fs.stat(safePath); + if (stat.size === 0) { return { isError: true, output: `"${args.path}" is empty.` }; } - if (stat.stSize > MAX_MEDIA_BYTES) { + if (stat.size > MAX_MEDIA_BYTES) { return { isError: true, output: - `"${args.path}" is ${String(stat.stSize)} bytes, which exceeds the ` + + `"${args.path}" is ${String(stat.size)} bytes, which exceeds the ` + `maximum ${String(MAX_MEDIA_MEGABYTES)}MB for media files.`, }; } - const data = await this.kaos.readBytes(safePath); + const data = Buffer.from(await this.fs.readBytes(safePath)); const base64 = data.toString('base64'); let mediaPart: ContentPart; if (fileType.kind === 'image') { @@ -259,7 +261,7 @@ export class ReadMediaFileTool implements BuiltinTool { const systemText = buildSystemSummary({ kind: fileType.kind, mimeType: fileType.mimeType, - byteSize: stat.stSize, + byteSize: stat.size, dimensions, }); diff --git a/packages/agent-core-v2/src/process/index.ts b/packages/agent-core-v2/src/process/index.ts index 3b8ab1f1e..408a6742f 100644 --- a/packages/agent-core-v2/src/process/index.ts +++ b/packages/agent-core-v2/src/process/index.ts @@ -1,12 +1,8 @@ /** - * `process` domain barrel — re-exports the process contract (`process`), its - * scoped service (`processRunnerService`), and the backend implementations - * (`localProcessBackend`, `sshProcessBackend`). Importing this barrel - * registers the `IProcessRunner` and default local `IProcessBackend` bindings - * into the scope registry. + * `process` domain barrel — re-exports the process contract (`process`) and + * its scoped service (`processRunnerService`). Importing this barrel registers + * the `IProcessRunner` binding into the scope registry. */ export * from './process'; export * from './processRunnerService'; -export * from './localProcessBackend'; -export * from './sshProcessBackend'; diff --git a/packages/agent-core-v2/src/process/localProcessBackend.ts b/packages/agent-core-v2/src/process/localProcessBackend.ts deleted file mode 100644 index 2727e2dcb..000000000 --- a/packages/agent-core-v2/src/process/localProcessBackend.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * `process` domain (L1) — local `IProcessBackend` implementation. - * - * Spawns real child processes on the host through `node:child_process`. - * Registered as the default `IProcessBackend` at Session scope; remote - * backends override it via the scope registry. - */ - -import { type ChildProcess, spawn } from 'node:child_process'; -import type { Readable, Writable } from 'node:stream'; - -import { InstantiationType } from '#/_base/di/extensions'; -import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; - -import { type IProcess, IProcessBackend } from './process'; - -class LocalProcess implements IProcess { - readonly stdin: Writable; - readonly stdout: Readable; - readonly stderr: Readable; - readonly pid: number; - - constructor(private readonly child: ChildProcess) { - if (child.stdin === null || child.stdout === null || child.stderr === null) { - throw new Error('LocalProcess: child must be spawned with piped stdio.'); - } - this.stdin = child.stdin; - this.stdout = child.stdout; - this.stderr = child.stderr; - this.pid = child.pid ?? -1; - } - - wait(): Promise { - return new Promise((resolve, reject) => { - this.child.once('exit', (code) => resolve(code ?? -1)); - this.child.once('error', reject); - }); - } - - kill(signal?: NodeJS.Signals): Promise { - this.child.kill(signal); - return Promise.resolve(); - } -} - -export class LocalProcessBackend implements IProcessBackend { - declare readonly _serviceBrand: undefined; - - spawn( - args: readonly string[], - options: { readonly cwd: string; readonly env?: Record }, - ): Promise { - const [command, ...rest] = args; - if (command === undefined) { - return Promise.reject(new Error('LocalProcessBackend.spawn: command is required.')); - } - const child = spawn(command, rest, { - cwd: options.cwd, - env: options.env === undefined ? process.env : { ...process.env, ...options.env }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - return Promise.resolve(new LocalProcess(child)); - } -} - -registerScopedService( - LifecycleScope.Session, - IProcessBackend, - LocalProcessBackend, - InstantiationType.Delayed, - 'process', -); diff --git a/packages/agent-core-v2/src/process/process.ts b/packages/agent-core-v2/src/process/process.ts index 8e5470e40..4a8486b76 100644 --- a/packages/agent-core-v2/src/process/process.ts +++ b/packages/agent-core-v2/src/process/process.ts @@ -1,11 +1,10 @@ /** - * `process` domain (L1) — the Agent's process runner and its pluggable backend. + * `process` domain (L1) — the Agent's process runner. * * Defines the `IProcessRunner` that business code injects to spawn processes - * inside the Agent's execution environment, the `IProcess` handle it returns, - * and the internal `IProcessBackend` provider that hides the - * local/ssh/container split. Session-scoped. Business code depends on - * `IProcessRunner` only; the backend is wired through the scope registry. + * inside the Agent's execution environment, plus the `IProcess` handle it + * returns. Session-scoped and backed by the session `IKaos`; business code + * depends on `IProcessRunner` only. */ import type { Readable, Writable } from 'node:stream'; @@ -17,8 +16,10 @@ export interface IProcess { readonly stdout: Readable; readonly stderr: Readable; readonly pid: number; + readonly exitCode: number | null; wait(): Promise; kill(signal?: NodeJS.Signals): Promise; + dispose(): Promise | void; } export interface ProcessExecOptions { @@ -34,15 +35,3 @@ export interface IProcessRunner { export const IProcessRunner: ServiceIdentifier = createDecorator('processRunner'); - -export interface IProcessBackend { - readonly _serviceBrand: undefined; - - spawn( - args: readonly string[], - options: { readonly cwd: string; readonly env?: Record }, - ): Promise; -} - -export const IProcessBackend: ServiceIdentifier = - createDecorator('processBackend'); diff --git a/packages/agent-core-v2/src/process/processRunnerService.ts b/packages/agent-core-v2/src/process/processRunnerService.ts index 375e95514..1d146620b 100644 --- a/packages/agent-core-v2/src/process/processRunnerService.ts +++ b/packages/agent-core-v2/src/process/processRunnerService.ts @@ -1,34 +1,29 @@ /** * `process` domain (L1) — `IProcessRunner` implementation. * - * Resolves the working directory through `workspaceContext` and delegates - * spawning to the injected `IProcessBackend`. Bound at Session scope. + * Spawns processes through the session execution environment (`IKaos.backend`), + * defaulting cwd/env to the execution environment and honoring per-call + * overrides via `withCwd` / `execWithEnv`. Bound at Session scope. */ import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { IWorkspaceContext } from '#/workspaceContext'; +import { IKaos } from '#/kaos'; -import { - type IProcess, - IProcessBackend, - IProcessRunner, - type ProcessExecOptions, -} from './process'; +import { type IProcess, IProcessRunner, type ProcessExecOptions } from './process'; export class ProcessRunner implements IProcessRunner { declare readonly _serviceBrand: undefined; - constructor( - @IProcessBackend private readonly backend: IProcessBackend, - @IWorkspaceContext private readonly workspace: IWorkspaceContext, - ) {} + constructor(@IKaos private readonly kaos: IKaos) {} exec(args: readonly string[], options?: ProcessExecOptions): Promise { - return this.backend.spawn(args, { - cwd: options?.cwd ?? this.workspace.workDir, - env: options?.env, - }); + const k = options?.cwd !== undefined ? this.kaos.withCwd(options.cwd) : this.kaos; + const env = + options?.env !== undefined + ? ({ ...process.env, ...options.env } as Record) + : undefined; + return k.backend.execWithEnv([...args], env); } } diff --git a/packages/agent-core-v2/src/process/sshProcessBackend.ts b/packages/agent-core-v2/src/process/sshProcessBackend.ts deleted file mode 100644 index 063c18dce..000000000 --- a/packages/agent-core-v2/src/process/sshProcessBackend.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * `process` domain (L1) — ssh `IProcessBackend` stub. - * - * Placeholder for the remote backend; not registered into the scope registry - * yet. A composition root that needs ssh supplies it through - * `ScopeOptions.extra` to override the local backend. - */ - -import { NotImplementedError } from '#/_base/errors'; - -import { type IProcess, IProcessBackend } from './process'; - -export class SshProcessBackend implements IProcessBackend { - declare readonly _serviceBrand: undefined; - - spawn( - _args: readonly string[], - _options: { readonly cwd: string; readonly env?: Record }, - ): Promise { - throw new NotImplementedError('sshProcessBackend'); - } -} diff --git a/packages/agent-core-v2/src/session-lifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/session-lifecycle/sessionLifecycleService.ts index 388805b13..6f12b87b6 100644 --- a/packages/agent-core-v2/src/session-lifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/session-lifecycle/sessionLifecycleService.ts @@ -21,6 +21,7 @@ import { import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { IBootstrapService } from '#/bootstrap'; import { NotImplementedError } from '#/errors'; +import { IKaos, IKaosFactory } from '#/kaos'; import { sessionLogSeed } from '#/log'; import { ISessionService } from '#/session'; import { type ISessionContext, sessionContextSeed } from '#/session-context'; @@ -39,6 +40,7 @@ export class SessionLifecycleService implements ISessionLifecycleService { constructor( @IInstantiationService private readonly instantiation: IInstantiationService, @IBootstrapService private readonly bootstrap: IBootstrapService, + @IKaosFactory private readonly kaosFactory: IKaosFactory, ) {} async create(opts: CreateSessionOptions): Promise { @@ -52,11 +54,18 @@ export class SessionLifecycleService implements ISessionLifecycleService { sessionDir, metaScope, }; + const kaos = await this.kaosFactory.createLocal(opts.workDir); const handle = createScopedChildHandle( this.instantiation, LifecycleScope.Session, opts.sessionId, - { extra: [...sessionContextSeed(ctx), ...sessionLogSeed(opts.sessionId, sessionDir)] }, + { + extra: [ + ...sessionContextSeed(ctx), + ...sessionLogSeed(opts.sessionId, sessionDir), + [IKaos, kaos] as const, + ], + }, ); this.sessions.set(opts.sessionId, handle); await handle.accessor.get(ISessionMetadata).ready; diff --git a/packages/agent-core-v2/test/agentFs/agentFsService.test.ts b/packages/agent-core-v2/test/agentFs/agentFsService.test.ts new file mode 100644 index 000000000..adf5459da --- /dev/null +++ b/packages/agent-core-v2/test/agentFs/agentFsService.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { + LifecycleScope, + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; +import { createScopedTestHost, stubPair } from '#/_base/di/test'; +import { AgentFileSystem, IAgentFileSystem } from '#/agentFs'; +import { IKaos, IKaosFactory, KaosFactory } from '#/kaos'; + +describe('AgentFileSystem (backed by IKaos)', () => { + let dir: string; + + beforeEach(async () => { + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.Core, + IKaosFactory, + KaosFactory, + InstantiationType.Delayed, + 'kaos', + ); + registerScopedService( + LifecycleScope.Session, + IAgentFileSystem, + AgentFileSystem, + InstantiationType.Delayed, + 'agentFs', + ); + dir = await mkdtemp(join(tmpdir(), 'agentfs-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + async function makeFs(): Promise { + const host = createScopedTestHost(); + const factory = host.core.accessor.get(IKaosFactory); + const kaos = await factory.createLocal(dir); + const session = host.child(LifecycleScope.Session, 's', [stubPair(IKaos, kaos)]); + return session.accessor.get(IAgentFileSystem); + } + + it('writes and reads text relative to cwd', async () => { + const fs = await makeFs(); + await fs.writeText('a.txt', 'hello'); + expect(await fs.readText('a.txt')).toBe('hello'); + }); + + it('stat reports file kind and byte size', async () => { + const fs = await makeFs(); + await fs.writeText('b.txt', 'abc'); + const st = await fs.stat('b.txt'); + expect(st.isFile).toBe(true); + expect(st.isDirectory).toBe(false); + expect(st.size).toBe(3); + }); + + it('stat reports directories', async () => { + const fs = await makeFs(); + await fs.mkdir('sub'); + const st = await fs.stat('sub'); + expect(st.isDirectory).toBe(true); + expect(st.isFile).toBe(false); + }); + + it('readdir returns entry names', async () => { + const fs = await makeFs(); + await fs.writeText('x.txt', ''); + await fs.mkdir('sub'); + const names = [...(await fs.readdir('.'))].sort(); + expect(names).toEqual(['sub', 'x.txt']); + }); + + it('withCwd derives a sub-view rooted at the new cwd', async () => { + const fs = await makeFs(); + await fs.mkdir('sub'); + await fs.writeText('sub/c.txt', 'deep'); + const sub = fs.withCwd(join(dir, 'sub')); + expect(sub.cwd).toBe(join(dir, 'sub')); + expect(await sub.readText('c.txt')).toBe('deep'); + }); +}); diff --git a/packages/agent-core-v2/test/kaos/kaosFactory.test.ts b/packages/agent-core-v2/test/kaos/kaosFactory.test.ts new file mode 100644 index 000000000..ec8f38629 --- /dev/null +++ b/packages/agent-core-v2/test/kaos/kaosFactory.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { + LifecycleScope, + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; +import { createScopedTestHost } from '#/_base/di/test'; +import { IKaosFactory, KaosFactory } from '#/kaos'; + +describe('KaosFactory', () => { + beforeEach(() => { + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.Core, + IKaosFactory, + KaosFactory, + InstantiationType.Delayed, + 'kaos', + ); + }); + + it('createLocal builds an IKaos rooted at the given cwd', async () => { + const host = createScopedTestHost(); + const factory = host.core.accessor.get(IKaosFactory); + const k = await factory.createLocal(process.cwd()); + + expect(k.name).toBe('local'); + expect(k.getcwd()).toBe(process.cwd()); + expect(k.cwd).toBe(process.cwd()); + expect(['posix', 'win32']).toContain(k.pathClass()); + expect(typeof k.osEnv.osKind).toBe('string'); + expect(typeof k.osEnv.shellPath).toBe('string'); + + host.dispose(); + }); + + it('withCwd derives an independent env without mutating the parent', async () => { + const host = createScopedTestHost(); + const factory = host.core.accessor.get(IKaosFactory); + const k = await factory.createLocal('/tmp'); + + const child = k.withCwd('/var'); + expect(child.getcwd()).toBe('/var'); + expect(k.getcwd()).toBe('/tmp'); + + host.dispose(); + }); + + it('backend delegates fs operations to the kaos backend', async () => { + const host = createScopedTestHost(); + const factory = host.core.accessor.get(IKaosFactory); + const k = await factory.createLocal(process.cwd()); + + const st = await k.backend.stat(process.cwd()); + expect(typeof st.stSize).toBe('number'); + + host.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/media/read-media.test.ts b/packages/agent-core-v2/test/media/read-media.test.ts index df688e478..be8660774 100644 --- a/packages/agent-core-v2/test/media/read-media.test.ts +++ b/packages/agent-core-v2/test/media/read-media.test.ts @@ -1,15 +1,15 @@ /** * ReadMediaFileTool tests for the v2 output/capability contract. * - * Self-contained: builds a minimal fake `Kaos` inline (the v2 shared kaos - * fixtures do not exist yet) so the tool can be exercised without the - * missing composition root. + * Self-contained: builds minimal fake `IAgentFileSystem` and `IKaos` inline + * so the tool can be exercised without the missing composition root. */ -import type { Kaos } from '@moonshot-ai/kaos'; import type { ContentPart, ModelCapability } from '@moonshot-ai/kosong'; import { describe, expect, it, vi } from 'vitest'; +import type { IAgentFileSystem } from '../../src/agentFs'; +import type { IKaos } from '../../src/kaos'; import { ReadMediaFileInputSchema, ReadMediaFileTool, @@ -65,17 +65,27 @@ interface FakeFile { readonly size?: number; } -function createTestKaos(files: Record): Kaos { +function createTestFs(files: Record): IAgentFileSystem { const lookup = (path: string): FakeFile | undefined => files[path]; return { - readBytes: vi.fn(async (path: string, _length?: number) => lookup(path)?.data ?? Buffer.alloc(0)), + cwd: '/workspace', + readBytes: vi.fn(async (path: string, _n?: number) => lookup(path)?.data ?? Buffer.alloc(0)), stat: vi.fn(async (path: string) => { const file = lookup(path); - return { stSize: file?.size ?? file?.data.length ?? 0 }; + return { + isFile: true, + isDirectory: false, + size: file?.size ?? file?.data.length ?? 0, + }; }), + } as unknown as IAgentFileSystem; +} + +function createTestKaos(): IKaos { + return { pathClass: () => 'posix', gethome: () => '/home', - } as unknown as Kaos; + } as unknown as IKaos; } function makeTool( @@ -83,7 +93,7 @@ function makeTool( caps: ModelCapability = capabilities(), videoUploader?: VideoUploader, ): ReadMediaFileTool { - return new ReadMediaFileTool(createTestKaos(files), WORKSPACE, caps, videoUploader); + return new ReadMediaFileTool(createTestFs(files), createTestKaos(), WORKSPACE, caps, videoUploader); } async function execute( @@ -250,11 +260,13 @@ describe('ReadMediaFileTool', () => { }); describe('registerMediaTools', () => { - const kaos = createTestKaos({}); + const fs = createTestFs({}); + const kaos = createTestKaos(); it('registers ReadMediaFile when the model supports image input', () => { const registry = new ToolRegistryService(); const disposable = registerMediaTools(registry, { + fs, kaos, workspace: WORKSPACE, capabilities: capabilities({ image_in: true, video_in: false }), @@ -267,6 +279,7 @@ describe('registerMediaTools', () => { it('registers ReadMediaFile when the model supports video input', () => { const registry = new ToolRegistryService(); registerMediaTools(registry, { + fs, kaos, workspace: WORKSPACE, capabilities: capabilities({ image_in: false, video_in: true }), @@ -277,6 +290,7 @@ describe('registerMediaTools', () => { it('does not register anything when the model lacks media capability', () => { const registry = new ToolRegistryService(); const disposable = registerMediaTools(registry, { + fs, kaos, workspace: WORKSPACE, capabilities: capabilities({ image_in: false, video_in: false }), diff --git a/packages/agent-core-v2/test/process/processRunnerService.test.ts b/packages/agent-core-v2/test/process/processRunnerService.test.ts new file mode 100644 index 000000000..b22ebc01a --- /dev/null +++ b/packages/agent-core-v2/test/process/processRunnerService.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { + LifecycleScope, + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; +import { createScopedTestHost, stubPair } from '#/_base/di/test'; +import { IKaos, IKaosFactory, KaosFactory } from '#/kaos'; +import { IProcessRunner, ProcessRunner } from '#/process'; + +async function collect(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString('utf8'); +} + +describe('ProcessRunner (backed by IKaos)', () => { + let dir: string; + + beforeEach(async () => { + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.Core, + IKaosFactory, + KaosFactory, + InstantiationType.Delayed, + 'kaos', + ); + registerScopedService( + LifecycleScope.Session, + IProcessRunner, + ProcessRunner, + InstantiationType.Delayed, + 'process', + ); + dir = await mkdtemp(join(tmpdir(), 'procrunner-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + async function makeRunner(): Promise { + const host = createScopedTestHost(); + const factory = host.core.accessor.get(IKaosFactory); + const kaos = await factory.createLocal(dir); + const session = host.child(LifecycleScope.Session, 's', [stubPair(IKaos, kaos)]); + return session.accessor.get(IProcessRunner); + } + + it('exec runs a command and captures stdout + exit code', async () => { + const runner = await makeRunner(); + const proc = await runner.exec(['node', '-e', 'process.stdout.write("ok")']); + const out = await collect(proc.stdout); + expect(out).toBe('ok'); + expect(await proc.wait()).toBe(0); + expect(proc.exitCode).toBe(0); + }); + + it('exec overlays per-call env', async () => { + const runner = await makeRunner(); + const proc = await runner.exec( + ['node', '-e', 'process.stdout.write(process.env.FOO ?? "")'], + { env: { FOO: 'bar' } }, + ); + const out = await collect(proc.stdout); + expect(out).toBe('bar'); + expect(await proc.wait()).toBe(0); + }); +}); diff --git a/packages/agent-core-v2/test/session-lifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/session-lifecycle/sessionLifecycle.test.ts index ef50656d6..a1ebc7a3a 100644 --- a/packages/agent-core-v2/test/session-lifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/session-lifecycle/sessionLifecycle.test.ts @@ -8,6 +8,7 @@ import { } from '#/_base/di/scope'; import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test'; import { IBootstrapService } from '#/bootstrap'; +import { IKaosFactory, type IKaos } from '#/kaos'; import { ISessionService } from '#/session'; import { ISessionLifecycleService } from '#/session-lifecycle/sessionLifecycle'; import { SessionLifecycleService } from '#/session-lifecycle/sessionLifecycleService'; @@ -32,6 +33,26 @@ function metadataStub(): ISessionMetadata { }; } +function kaosFactoryStub(): IKaosFactory { + const kaos: IKaos = { + _serviceBrand: undefined, + name: 'local', + cwd: '/tmp/proj', + osEnv: { osKind: 'test', osArch: 'x64', osVersion: '', shellName: 'sh', shellPath: '/bin/sh' }, + backend: undefined as never, + pathClass: () => 'posix', + normpath: (p) => p, + gethome: () => '/home', + getcwd: () => '/tmp/proj', + withCwd: (cwd) => ({ ...kaos, cwd, getcwd: () => cwd }), + withEnv: () => kaos, + }; + return { + _serviceBrand: undefined, + createLocal: (cwd) => Promise.resolve({ ...kaos, cwd, getcwd: () => cwd }), + }; +} + describe('SessionLifecycleService', () => { let host: ScopedTestHost | undefined; @@ -55,6 +76,7 @@ describe('SessionLifecycleService', () => { host = createScopedTestHost([ stubPair(IBootstrapService, bootstrapStub()), stubPair(ISessionMetadata, metadataStub()), + stubPair(IKaosFactory, kaosFactoryStub()), ...extra, ]); return host.core.accessor.get(ISessionLifecycleService); diff --git a/packages/server-v2/src/routes/messages.ts b/packages/server-v2/src/routes/messages.ts index f63ca02fb..32b336662 100644 --- a/packages/server-v2/src/routes/messages.ts +++ b/packages/server-v2/src/routes/messages.ts @@ -33,7 +33,6 @@ */ import { - IAgentLifecycleService, IContextMemory, ISessionIndex, ISessionLifecycleService, @@ -50,9 +49,7 @@ import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; import { defineRoute } from '../middleware/defineRoute'; - -/** Agent id that owns the session's primary conversation history. */ -const MAIN_AGENT_ID = 'main'; +import { ensureMainAgent } from '../transport/mainAgent'; /** One entry from the main agent's live history. */ type MemoryMessage = ReturnType[number]; @@ -232,8 +229,9 @@ async function loadProtocolMessages(core: Scope, sid: string): Promise toProtocolMessage(sid, index, msg, summary.createdAt)); } diff --git a/packages/server-v2/src/routes/prompts.ts b/packages/server-v2/src/routes/prompts.ts index 7bb6c30f7..ab31e71f3 100644 --- a/packages/server-v2/src/routes/prompts.ts +++ b/packages/server-v2/src/routes/prompts.ts @@ -6,7 +6,6 @@ */ import { - IAgentLifecycleService, IPromptLegacyService, ISessionLifecycleService, isKimiError, @@ -26,6 +25,7 @@ import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; import { defineRoute } from '../middleware/defineRoute'; +import { ensureMainAgent } from '../transport/mainAgent'; import { parseActionSuffix } from './action-suffix'; interface PromptRouteHost { @@ -53,17 +53,12 @@ const sessionIdParamSchema = z.object({ const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); -const MAIN_AGENT_ID = 'main'; - -function resolveLegacy(core: Scope, sessionId: string): IPromptLegacyService { +async function resolveLegacy(core: Scope, sessionId: string): Promise { const session = core.accessor.get(ISessionLifecycleService).get(sessionId); if (session === undefined) { throw new KimiError('session.not_found', `session ${sessionId} does not exist`); } - const agent = session.accessor.get(IAgentLifecycleService).getHandle(MAIN_AGENT_ID); - if (agent === undefined) { - throw new KimiError('agent.not_found', `main agent not found for session ${sessionId}`); - } + const agent = await ensureMainAgent(session); return agent.accessor.get(IPromptLegacyService); } @@ -82,7 +77,7 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void { async (req, reply) => { try { const { session_id } = req.params; - const result = resolveLegacy(core, session_id).list(); + const result = (await resolveLegacy(core, session_id)).list(); reply.send(okEnvelope(result, req.id)); } catch (error) { sendMappedError(reply, req.id, error); @@ -111,7 +106,8 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void { async (req, reply) => { try { const { session_id } = req.params; - const result = await resolveLegacy(core, session_id).submit(req.body); + const legacy = await resolveLegacy(core, session_id); + const result = await legacy.submit(req.body); reply.send(okEnvelope(result, req.id)); } catch (error) { sendMappedError(reply, req.id, error); @@ -139,7 +135,8 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void { async (req, reply) => { try { const { session_id } = req.params; - const result = await resolveLegacy(core, session_id).steer(req.body.prompt_ids); + const legacy = await resolveLegacy(core, session_id); + const result = await legacy.steer(req.body.prompt_ids); reply.send(okEnvelope(result, req.id)); } catch (error) { sendMappedError(reply, req.id, error); @@ -176,7 +173,7 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void { reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, message, req.id)); return; } - const legacy = resolveLegacy(core, session_id); + const legacy = await resolveLegacy(core, session_id); const result = parsed.action === 'abort' ? await legacy.abort(parsed.id) diff --git a/packages/server-v2/src/routes/tasks.ts b/packages/server-v2/src/routes/tasks.ts index a0a2e8b46..22030ec88 100644 --- a/packages/server-v2/src/routes/tasks.ts +++ b/packages/server-v2/src/routes/tasks.ts @@ -39,7 +39,6 @@ */ import { - IAgentLifecycleService, IBackgroundService, ISessionIndex, ISessionLifecycleService, @@ -59,11 +58,9 @@ import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; import { defineRoute } from '../middleware/defineRoute'; +import { ensureMainAgent } from '../transport/mainAgent'; import { parseActionSuffix } from './action-suffix'; -/** Agent id that owns the session's background tasks. */ -const MAIN_AGENT_ID = 'main'; - /** Default cap (bytes) for the opt-in output preview on GET-by-id. */ const DEFAULT_TASK_OUTPUT_PREVIEW_BYTES = 32 * 1024; @@ -281,8 +278,9 @@ async function resolveSessionBackground(core: Scope, sid: string): Promise> = { 'workspace:setWorkDir': { service: IWorkspaceContext, method: 'setWorkDir' }, 'workspace:addAdditionalDir': { service: IWorkspaceContext, method: 'addAdditionalDir' }, 'workspace:removeAdditionalDir': { service: IWorkspaceContext, method: 'removeAdditionalDir' }, + + 'agentFs:readText': { service: IAgentFileSystem, method: 'readText', readonly: true }, + 'agentFs:writeText': { service: IAgentFileSystem, method: 'writeText' }, + 'agentFs:stat': { service: IAgentFileSystem, method: 'stat', readonly: true }, + 'agentFs:readdir': { service: IAgentFileSystem, method: 'readdir', readonly: true }, + 'agentFs:mkdir': { service: IAgentFileSystem, method: 'mkdir' }, + + 'fs:search': { service: IFsService, method: 'search', readonly: true }, + 'fs:grep': { service: IFsService, method: 'grep', readonly: true }, + 'fs:gitStatus': { service: IFsService, method: 'gitStatus', readonly: true }, + 'fs:diff': { service: IFsService, method: 'diff', readonly: true }, }, // ------------------------------------------------------------------------- diff --git a/packages/server-v2/src/transport/dispatcher.ts b/packages/server-v2/src/transport/dispatcher.ts index 3d877c0f5..b77168167 100644 --- a/packages/server-v2/src/transport/dispatcher.ts +++ b/packages/server-v2/src/transport/dispatcher.ts @@ -17,16 +17,17 @@ import { import { resolveAction } from './actionMap'; import type { ActionTarget, ScopeKind, ServiceAction } from './channel'; import { assertSerializable } from './errors'; +import { MAIN_AGENT_ID, ensureMainAgent } from './mainAgent'; /** * Resolve the scope a request targets. Returns `undefined` when the referenced * session / agent does not exist (caller maps to `40401`). */ -export function resolveScope( +export async function resolveScope( core: Scope, scopeKind: ScopeKind, params: Record, -): Scope | IScopeHandle | undefined { +): Promise { switch (scopeKind) { case 'core': return core; @@ -39,6 +40,7 @@ export function resolveScope( const agentId = params['agent_id'] ?? ''; const session = core.accessor.get(ISessionLifecycleService).get(sessionId); if (session === undefined) return undefined; + if (agentId === MAIN_AGENT_ID) return ensureMainAgent(session); return session.accessor.get(IAgentLifecycleService).getHandle(agentId); } } @@ -56,7 +58,7 @@ export async function dispatch( sa: ServiceAction, arg: unknown, ): Promise { - const scope = resolveScope(core, scopeKind, params); + const scope = await resolveScope(core, scopeKind, params); if (scope === undefined) { throw new KimiError( ErrorCodes.SESSION_NOT_FOUND, diff --git a/packages/server-v2/src/transport/mainAgent.ts b/packages/server-v2/src/transport/mainAgent.ts new file mode 100644 index 000000000..a65cba5ec --- /dev/null +++ b/packages/server-v2/src/transport/mainAgent.ts @@ -0,0 +1,23 @@ +/** + * server-v2 — on-demand main-agent resolution. + * + * Sessions are created without a main agent; the first request that targets + * `main` materializes it here. Both the `/api/v1` routes and the `/api/v2` + * dispatcher resolve the main agent through {@link ensureMainAgent} so a + * missing main agent is created instead of reported as `agent.not_found`. + */ + +import { IAgentLifecycleService, type IScopeHandle } from '@moonshot-ai/agent-core-v2'; + +export const MAIN_AGENT_ID = 'main'; + +/** + * Return the session's main agent, creating it on demand when it does not + * exist yet. + */ +export async function ensureMainAgent(session: IScopeHandle): Promise { + const agents = session.accessor.get(IAgentLifecycleService); + const existing = agents.getHandle(MAIN_AGENT_ID); + if (existing !== undefined) return existing; + return agents.createMain(); +} diff --git a/packages/server-v2/src/transport/ws/wsConnection.ts b/packages/server-v2/src/transport/ws/wsConnection.ts index 372ce6dc7..e6df85cc8 100644 --- a/packages/server-v2/src/transport/ws/wsConnection.ts +++ b/packages/server-v2/src/transport/ws/wsConnection.ts @@ -172,7 +172,7 @@ export class WsConnection { } } - private onListen(msg: ListenMessage): void { + private async onListen(msg: ListenMessage): Promise { if (!this.gotHello) { this.send({ type: 'error', id: msg.id, code: 40112, msg: 'hello required' }); return; @@ -190,7 +190,7 @@ export class WsConnection { let scope; try { - scope = resolveScope(this.core, msg.scope as ScopeKind, scopeParams(msg)); + scope = await resolveScope(this.core, msg.scope as ScopeKind, scopeParams(msg)); } catch { scope = undefined; } diff --git a/packages/server-v2/test/rpc.test.ts b/packages/server-v2/test/rpc.test.ts index 9faac9c70..b179081fa 100644 --- a/packages/server-v2/test/rpc.test.ts +++ b/packages/server-v2/test/rpc.test.ts @@ -209,7 +209,7 @@ describe('server-v2 /api/v2 RPC', () => { const id = await createSession(home as string); const { body } = await call( 'POST', - `/api/v2/session/${id}/agent/main/prompts:submit`, + `/api/v2/session/${id}/agent/does-not-exist/prompts:submit`, { input: [{ type: 'text', text: 'hello' }] }, ); expect(body.code).toBe(40401);