refactor(agent-core-v2): add kaos domain for execution environment

- Add `kaos` domain (`IKaos` per session + `IKaosFactory`) wrapping
  `@moonshot-ai/kaos`, so business code imports `#/kaos` instead of the
  package directly.
- Back `agentFs` and `process` with `IKaos`; drop the per-domain
  `IFileSystemBackend` / `IProcessBackend` interfaces and their
  local/ssh stubs.
- Migrate `read-media`, `path-access`, `background/process-task`, and
  `bootstrap` off direct kaos-package imports.
- Seed `IKaos` per session in `SessionLifecycleService` and expose
  `agentFs` / `fs` on the server-v2 action map.
- Create the server-v2 main agent on demand (`ensureMainAgent`)
  instead of failing requests when it is missing.
This commit is contained in:
haozhe.yang 2026-06-30 12:17:09 +08:00
parent d4f9933b92
commit 9076f55643
37 changed files with 613 additions and 369 deletions

View file

@ -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],

View file

@ -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<Kaos, 'pathClass' | 'gethome'>;
readonly kaos: Pick<IKaos, 'pathClass' | 'gethome'>;
readonly workspace: WorkspaceConfig;
readonly operation: PathAccessOperation;
readonly policy?: WorkspaceAccessPolicy;

View file

@ -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<string>;
writeText(path: string, data: string): Promise<void>;
readBytes(path: string): Promise<Uint8Array>;
readBytes(path: string, n?: number): Promise<Uint8Array>;
writeBytes(path: string, data: Uint8Array): Promise<void>;
stat(path: string): Promise<AgentFileStat>;
readdir(path: string): Promise<readonly string[]>;
@ -34,19 +32,3 @@ export interface IAgentFileSystem {
export const IAgentFileSystem: ServiceIdentifier<IAgentFileSystem> =
createDecorator<IAgentFileSystem>('agentFileSystem');
export interface IFileSystemBackend {
readonly _serviceBrand: undefined;
readText(absPath: string): Promise<string>;
writeText(absPath: string, data: string): Promise<void>;
readBytes(absPath: string): Promise<Uint8Array>;
writeBytes(absPath: string, data: Uint8Array): Promise<void>;
stat(absPath: string): Promise<AgentFileStat>;
readdir(absPath: string): Promise<readonly string[]>;
glob(absDir: string, pattern: string): Promise<readonly string[]>;
mkdir(absPath: string): Promise<void>;
}
export const IFileSystemBackend: ServiceIdentifier<IFileSystemBackend> =
createDecorator<IFileSystemBackend>('fileSystemBackend');

View file

@ -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<AgentFileStat, 'isFile' | 'isDirectory'> {
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<string> {
return this.backend.readText(this.abs(path));
return this.kaos.backend.readText(path);
}
writeText(path: string, data: string): Promise<void> {
return this.backend.writeText(this.abs(path), data);
return this.kaos.backend.writeText(path, data).then(() => undefined);
}
readBytes(path: string): Promise<Uint8Array> {
return this.backend.readBytes(this.abs(path));
readBytes(path: string, n?: number): Promise<Uint8Array> {
return this.kaos.backend.readBytes(path, n);
}
writeBytes(path: string, data: Uint8Array): Promise<void> {
return this.backend.writeBytes(this.abs(path), data);
return this.kaos.backend.writeBytes(path, Buffer.from(data)).then(() => undefined);
}
stat(path: string): Promise<AgentFileStat> {
return this.backend.stat(this.abs(path));
async stat(path: string): Promise<AgentFileStat> {
const s = await this.kaos.backend.stat(path);
return { ...statKind(s), size: s.stSize };
}
readdir(path: string): Promise<readonly string[]> {
return this.backend.readdir(this.abs(path));
async readdir(path: string): Promise<readonly string[]> {
const names: string[] = [];
for await (const entry of this.kaos.backend.iterdir(path)) {
names.push(basename(entry));
}
return names;
}
glob(pattern: string): Promise<readonly string[]> {
return this.backend.glob(this.cwd, pattern);
async glob(pattern: string): Promise<readonly string[]> {
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<void> {
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));
}
}

View file

@ -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';

View file

@ -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<string> {
return this.hostFs.readText(absPath);
}
writeText(absPath: string, data: string): Promise<void> {
return this.hostFs.writeText(absPath, data);
}
readBytes(absPath: string): Promise<Uint8Array> {
return this.hostFs.readBytes(absPath);
}
writeBytes(absPath: string, data: Uint8Array): Promise<void> {
return this.hostFs.writeBytes(absPath, data);
}
async stat(absPath: string): Promise<AgentFileStat> {
const s = await this.hostFs.stat(absPath);
return { isFile: s.isFile, isDirectory: s.isDirectory, size: s.size };
}
async readdir(absPath: string): Promise<readonly string[]> {
const entries = await this.hostFs.readdir(absPath);
return entries.map((e) => e.name);
}
glob(_absDir: string, _pattern: string): Promise<readonly string[]> {
throw new NotImplementedError('localFileSystemBackend.glob');
}
mkdir(absPath: string): Promise<void> {
return this.hostFs.mkdir(absPath, { recursive: true });
}
}
registerScopedService(
LifecycleScope.Session,
IFileSystemBackend,
LocalFileSystemBackend,
InstantiationType.Delayed,
'agentFs',
);

View file

@ -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<string> {
throw new NotImplementedError('sshFileSystemBackend');
}
writeText(_absPath: string, _data: string): Promise<void> {
throw new NotImplementedError('sshFileSystemBackend');
}
readBytes(_absPath: string): Promise<Uint8Array> {
throw new NotImplementedError('sshFileSystemBackend');
}
writeBytes(_absPath: string, _data: Uint8Array): Promise<void> {
throw new NotImplementedError('sshFileSystemBackend');
}
stat(_absPath: string): Promise<AgentFileStat> {
throw new NotImplementedError('sshFileSystemBackend');
}
readdir(_absPath: string): Promise<readonly string[]> {
throw new NotImplementedError('sshFileSystemBackend');
}
glob(_absDir: string, _pattern: string): Promise<readonly string[]> {
throw new NotImplementedError('sshFileSystemBackend');
}
mkdir(_absPath: string): Promise<void> {
throw new NotImplementedError('sshFileSystemBackend');
}
}

View file

@ -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,

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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<string, string>): IKaos;
}
export const IKaos: ServiceIdentifier<IKaos> = createDecorator<IKaos>('kaos');
export interface IKaosFactory {
readonly _serviceBrand: undefined;
/** Build a local execution environment rooted at `cwd`. */
createLocal(cwd: string): Promise<IKaos>;
}
export const IKaosFactory: ServiceIdentifier<IKaosFactory> =
createDecorator<IKaosFactory>('kaosFactory');

View file

@ -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<IKaos> {
const base = await LocalKaos.create();
return new KaosService(base.withCwd(cwd));
}
}
registerScopedService(
LifecycleScope.Core,
IKaosFactory,
KaosFactory,
InstantiationType.Delayed,
'kaos',
);

View file

@ -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<string, string>): IKaos {
return new KaosService(this.backend.withEnv(env));
}
}

View file

@ -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,
),
);
}

View file

@ -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<typeof ReadMediaFileInputSchema>;
export type ReadMediaFileInput = z.infer<typeof ReadMediaFileInputSchema>;
// ── Tool description (capability-driven) ─────────────────────────────
@ -135,7 +136,8 @@ export class ReadMediaFileTool implements BuiltinTool<ReadMediaFileInput> {
readonly description: string;
readonly parameters: Record<string, unknown> = 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<ReadMediaFileInput> {
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<ReadMediaFileInput> {
};
}
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<ReadMediaFileInput> {
const systemText = buildSystemSummary({
kind: fileType.kind,
mimeType: fileType.mimeType,
byteSize: stat.stSize,
byteSize: stat.size,
dimensions,
});

View file

@ -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';

View file

@ -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<number> {
return new Promise((resolve, reject) => {
this.child.once('exit', (code) => resolve(code ?? -1));
this.child.once('error', reject);
});
}
kill(signal?: NodeJS.Signals): Promise<void> {
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<string, string> },
): Promise<IProcess> {
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',
);

View file

@ -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<number>;
kill(signal?: NodeJS.Signals): Promise<void>;
dispose(): Promise<void> | void;
}
export interface ProcessExecOptions {
@ -34,15 +35,3 @@ export interface IProcessRunner {
export const IProcessRunner: ServiceIdentifier<IProcessRunner> =
createDecorator<IProcessRunner>('processRunner');
export interface IProcessBackend {
readonly _serviceBrand: undefined;
spawn(
args: readonly string[],
options: { readonly cwd: string; readonly env?: Record<string, string> },
): Promise<IProcess>;
}
export const IProcessBackend: ServiceIdentifier<IProcessBackend> =
createDecorator<IProcessBackend>('processBackend');

View file

@ -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<IProcess> {
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<string, string>)
: undefined;
return k.backend.execWithEnv([...args], env);
}
}

View file

@ -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<string, string> },
): Promise<IProcess> {
throw new NotImplementedError('sshProcessBackend');
}
}

View file

@ -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<IScopeHandle> {
@ -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;

View file

@ -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<IAgentFileSystem> {
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');
});
});

View file

@ -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();
});
});

View file

@ -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<string, FakeFile>): Kaos {
function createTestFs(files: Record<string, FakeFile>): 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 }),

View file

@ -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<string> {
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<IProcessRunner> {
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);
});
});

View file

@ -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);

View file

@ -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<IContextMemory['get']>[number];
@ -232,8 +229,9 @@ async function loadProtocolMessages(core: Scope, sid: string): Promise<Message[]
if (summary === undefined) return undefined;
const session = core.accessor.get(ISessionLifecycleService).get(sid);
const agent = session?.accessor.get(IAgentLifecycleService).getHandle(MAIN_AGENT_ID);
const history = agent?.accessor.get(IContextMemory).get() ?? [];
if (session === undefined) return [];
const agent = await ensureMainAgent(session);
const history = agent.accessor.get(IContextMemory).get();
return history.map((msg, index) => toProtocolMessage(sid, index, msg, summary.createdAt));
}

View file

@ -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<IPromptLegacyService> {
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)

View file

@ -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<Resol
if (summary === undefined) return { kind: 'not_found' };
const session = core.accessor.get(ISessionLifecycleService).get(sid);
const agent = session?.accessor.get(IAgentLifecycleService).getHandle(MAIN_AGENT_ID);
const bg = agent?.accessor.get(IBackgroundService);
if (session === undefined) return { kind: 'resolved', bg: undefined };
const agent = await ensureMainAgent(session);
const bg = agent.accessor.get(IBackgroundService);
return { kind: 'resolved', bg };
}

View file

@ -45,7 +45,6 @@
import {
ErrorCodes,
IAgentLifecycleService,
IMcpService,
ISessionIndex,
ISessionLifecycleService,
@ -67,11 +66,9 @@ import {
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 tool registry and MCP connections. */
const MAIN_AGENT_ID = 'main';
/** v2 MCP tool-name prefix / separator (see `mcp/tool-naming.ts`). */
const MCP_NAME_PREFIX = 'mcp__';
const MCP_NAME_SEPARATOR = '__';
@ -217,7 +214,8 @@ async function resolveEffectiveAgent(core: Scope, sessionId: string | undefined)
const sid = sessionId ?? (await mostRecentSessionId(core));
if (sid === undefined) return undefined;
const session = core.accessor.get(ISessionLifecycleService).get(sid);
return session?.accessor.get(IAgentLifecycleService).getHandle(MAIN_AGENT_ID);
if (session === undefined) return undefined;
return ensureMainAgent(session);
}
/** Pick the most-recently-created session id, mirroring v1's fallback. */

View file

@ -22,6 +22,7 @@
*/
import {
IAgentFileSystem,
IAgentRPCService,
IApprovalService,
IAuthSummaryService,
@ -31,6 +32,7 @@ import {
IContextMemory,
IContextSizeService,
IFlagService,
IFsService,
IGoalService,
IHostFolderBrowser,
IInteractionService,
@ -139,6 +141,17 @@ export const actionMap: Record<ScopeKind, Record<string, ActionTarget>> = {
'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 },
},
// -------------------------------------------------------------------------

View file

@ -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<string, string>,
): Scope | IScopeHandle | undefined {
): Promise<Scope | IScopeHandle | undefined> {
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<unknown> {
const scope = resolveScope(core, scopeKind, params);
const scope = await resolveScope(core, scopeKind, params);
if (scope === undefined) {
throw new KimiError(
ErrorCodes.SESSION_NOT_FOUND,

View file

@ -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<IScopeHandle> {
const agents = session.accessor.get(IAgentLifecycleService);
const existing = agents.getHandle(MAIN_AGENT_ID);
if (existing !== undefined) return existing;
return agents.createMain();
}

View file

@ -172,7 +172,7 @@ export class WsConnection {
}
}
private onListen(msg: ListenMessage): void {
private async onListen(msg: ListenMessage): Promise<void> {
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;
}

View file

@ -209,7 +209,7 @@ describe('server-v2 /api/v2 RPC', () => {
const id = await createSession(home as string);
const { body } = await call<null>(
'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);