Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-v2-caller-mcp-servers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/agent-core-v2": patch
---

Fix caller-supplied MCP servers being silently dropped when creating a session through the v2 engine (experimental).
7 changes: 7 additions & 0 deletions .changeset/fix-v2-skill-dirs-access.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@moonshot-ai/agent-core-v2": patch
"@moonshot-ai/kap-server": patch
"@moonshot-ai/kimi-code": patch
---

Make file tools able to reach skill directories outside the working directory in the v2 engine (experimental), and honor --skillsDir in v2 print mode and the server's skillDirs option.
4 changes: 4 additions & 0 deletions apps/kimi-code/src/cli/v2/run-v2-print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
logSeed,
resolveKimiHome,
resolveLoggingConfig,
skillCatalogRuntimeOptionsSeed,
type DomainEvent,
type IAgentScopeHandle,
type ISessionScopeHandle,
Expand Down Expand Up @@ -115,6 +116,9 @@ export async function runV2Print(
const { app } = bootstrap({ homeDir, clientVersion: version }, [
...logSeed(logging),
...hostRequestHeadersSeed(hostHeaders),
// `--skillsDir` (v1 print parity): explicit skill dirs replace default
// user / project discovery for this process.
...skillCatalogRuntimeOptionsSeed(opts.skillsDirs),
]);
const auth = app.accessor.get(IOAuthToolkit);

Expand Down
218 changes: 128 additions & 90 deletions apps/kimi-code/test/cli/v2-run-print.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import {
IOAuthToolkit,
ISessionIndex,
ISessionLifecycleService,
ISkillCatalogRuntimeOptions,
ITelemetryService,
type DomainEvent,
type ScopeSeed,
} from '@moonshot-ai/agent-core-v2';

import { runV2Print } from '../../src/cli/v2/run-v2-print';
Expand Down Expand Up @@ -112,6 +114,99 @@ function opts(overrides: Record<string, unknown> = {}) {
} as const;
}

function makeFakeHarness() {
// Native event listeners registered on the main agent's IEventBus; the turn
// emits a streaming assistant delta before completing.
const eventListeners = new Set<(event: DomainEvent) => void>();

const agentServices = new Map<unknown, unknown>([
[IAgentProfileService, { setModel: vi.fn(async () => ({ model: 'k2' })), getModel: () => 'k2' }],
[IAgentPermissionModeService, { mode: 'auto', setMode: vi.fn() }],
[IAuthSummaryService, { ensureReady: vi.fn(async () => {}) }],
[
IEventBus,
{
subscribe: vi.fn((handler: (event: DomainEvent) => void) => {
eventListeners.add(handler);
return { dispose: () => eventListeners.delete(handler) };
}),
},
],
[
IAgentPromptService,
{
enqueue: vi.fn(async () => {
// Emit a native assistant delta on the main agent bus, then complete.
for (const listener of [...eventListeners]) {
listener({ type: 'assistant.delta', turnId: 1, delta: 'hello world' } as DomainEvent);
}
return {
launched: Promise.resolve({
id: 1,
result: Promise.resolve({ type: 'completed' }),
}),
};
}),
},
],
[IAgentTaskService, { list: vi.fn(() => []) }],
[IAgentGoalService, { createGoal: vi.fn(), getGoal: vi.fn() }],
]);
const agent = fakeScope('main', agentServices);

const sessionServices = new Map<unknown, unknown>([
// drain enumerates agents; empty → no background work to wait on.
[IAgentLifecycleService, { list: vi.fn(() => []) }],
]);
const session = fakeScope('ses_v2', sessionServices);

const appServices = new Map<unknown, unknown>([
[
IConfigService,
{
ready: Promise.resolve(),
get: vi.fn((section: string) => (section === 'defaultModel' ? 'k2' : undefined)),
diagnostics: vi.fn(() => []),
},
],
[
ISessionLifecycleService,
{
create: vi.fn(async () => session),
resume: vi.fn(async () => session),
},
],
[ISessionIndex, { list: vi.fn(async () => ({ items: [] })) }],
[
IBootstrapService,
{
platform: 'linux',
arch: 'x64',
clientVersion: '1.2.3-test',
getEnv: () => undefined,
},
],
[IOAuthToolkit, { getCachedAccessToken: vi.fn(async () => undefined) }],
[IFileSystemStorageService, {}],
[
ITelemetryService,
(() => {
const svc = {
setAppender: vi.fn(),
setContext: vi.fn(),
track: vi.fn(),
track2: vi.fn(),
shutdown: vi.fn(async () => {}),
withContext: vi.fn(() => svc),
};
return svc;
})(),
],
]);
const app = fakeScope('app', appServices);
return { app, agent, session, agentServices };
}

describe('runV2Print', () => {
beforeEach(() => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1');
Expand All @@ -126,96 +221,7 @@ describe('runV2Print', () => {
it('submits a prompt, renders native events, awaits completion, and drains', async () => {
const stdout = writer();
const stderr = writer();

// Native event listeners registered on the main agent's IEventBus; the turn
// emits a streaming assistant delta before completing.
const eventListeners = new Set<(event: DomainEvent) => void>();

const agentServices = new Map<unknown, unknown>([
[IAgentProfileService, { setModel: vi.fn(async () => ({ model: 'k2' })), getModel: () => 'k2' }],
[IAgentPermissionModeService, { mode: 'auto', setMode: vi.fn() }],
[IAuthSummaryService, { ensureReady: vi.fn(async () => {}) }],
[
IEventBus,
{
subscribe: vi.fn((handler: (event: DomainEvent) => void) => {
eventListeners.add(handler);
return { dispose: () => eventListeners.delete(handler) };
}),
},
],
[
IAgentPromptService,
{
enqueue: vi.fn(async () => {
// Emit a native assistant delta on the main agent bus, then complete.
for (const listener of [...eventListeners]) {
listener({ type: 'assistant.delta', turnId: 1, delta: 'hello world' } as DomainEvent);
}
return {
launched: Promise.resolve({
id: 1,
result: Promise.resolve({ type: 'completed' }),
}),
};
}),
},
],
[IAgentTaskService, { list: vi.fn(() => []) }],
[IAgentGoalService, { createGoal: vi.fn(), getGoal: vi.fn() }],
]);
const agent = fakeScope('main', agentServices);

const sessionServices = new Map<unknown, unknown>([
// drain enumerates agents; empty → no background work to wait on.
[IAgentLifecycleService, { list: vi.fn(() => []) }],
]);
const session = fakeScope('ses_v2', sessionServices);

const appServices = new Map<unknown, unknown>([
[
IConfigService,
{
ready: Promise.resolve(),
get: vi.fn((section: string) => (section === 'defaultModel' ? 'k2' : undefined)),
diagnostics: vi.fn(() => []),
},
],
[
ISessionLifecycleService,
{
create: vi.fn(async () => session),
resume: vi.fn(async () => session),
},
],
[ISessionIndex, { list: vi.fn(async () => ({ items: [] })) }],
[
IBootstrapService,
{
platform: 'linux',
arch: 'x64',
clientVersion: '1.2.3-test',
getEnv: () => undefined,
},
],
[IOAuthToolkit, { getCachedAccessToken: vi.fn(async () => undefined) }],
[IFileSystemStorageService, {}],
[
ITelemetryService,
(() => {
const svc = {
setAppender: vi.fn(),
setContext: vi.fn(),
track: vi.fn(),
track2: vi.fn(),
shutdown: vi.fn(async () => {}),
withContext: vi.fn(() => svc),
};
return svc;
})(),
],
]);
const app = fakeScope('app', appServices);
const { app, agent, agentServices } = makeFakeHarness();

mocks.bootstrap.mockReturnValue({ app });
mocks.ensureMainAgent.mockResolvedValue(agent);
Expand All @@ -236,4 +242,36 @@ describe('runV2Print', () => {
expect(stdout.text()).toContain('hello world');
expect(app.dispose).toHaveBeenCalled();
});

it('seeds explicit skill dirs from --skillsDir into bootstrap', async () => {
const stdout = writer();
const stderr = writer();
const { app, agent } = makeFakeHarness();

mocks.bootstrap.mockReturnValue({ app });
mocks.ensureMainAgent.mockResolvedValue(agent);

await runV2Print(opts({ skillsDirs: ['/skills'] }) as never, '1.2.3-test', {
stdout,
stderr,
});

const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed;
const seeded = seeds.find(([id]) => id === ISkillCatalogRuntimeOptions);
expect(seeded?.[1]).toMatchObject({ explicitDirs: ['/skills'] });
});

it('leaves the skill runtime options unseeded when --skillsDir is empty', async () => {
const stdout = writer();
const stderr = writer();
const { app, agent } = makeFakeHarness();

mocks.bootstrap.mockReturnValue({ app });
mocks.ensureMainAgent.mockResolvedValue(agent);

await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr });

const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed;
expect(seeds.some(([id]) => id === ISkillCatalogRuntimeOptions)).toBe(false);
});
});
17 changes: 15 additions & 2 deletions packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ import { IEventBus } from '#/app/event/eventBus';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { extendWorkspaceWithSkillRoots } from '#/tool/path-access';

import { IAgentMediaToolsRegistrar } from './mediaTools';
import { createVideoUploader, registerMediaTools } from './registerMediaTools';
Expand All @@ -47,6 +49,9 @@ export class AgentMediaToolsRegistrar extends Disposable implements IAgentMediaT
@IHostEnvironment private readonly env: IHostEnvironment,
@ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext,
@ITelemetryService private readonly telemetry: ITelemetryService,
// Optional so unit tests that construct the registrar directly (bypassing
// DI) keep working; always registered in production scopes.
@ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog,
) {
super();
this.refresh();
Expand All @@ -65,18 +70,26 @@ export class AgentMediaToolsRegistrar extends Disposable implements IAgentMediaT
this.registeredKey = key;
this.registration?.dispose();
const workspaceCtx = this.workspaceCtx;
const skillCatalog = this.skillCatalog;
const env = this.env;
const model = this.profile.resolveModel();
this.registration = registerMediaTools(this.toolRegistry, {
fs: this.fs,
env: this.env,
// Live view: `workDir` is runtime-mutable (`/cwd`), and the tool keeps
// its WorkspaceConfig across calls, so a snapshot would go stale.
// its WorkspaceConfig across calls, so a snapshot would go stale. Skill
// roots are merged per read for the same reason (the catalog loads
// asynchronously and gains roots on plugin reloads).
workspace: {
get workspaceDir() {
return workspaceCtx.workDir;
},
get additionalDirs() {
return workspaceCtx.additionalDirs;
return extendWorkspaceWithSkillRoots(
{ workspaceDir: workspaceCtx.workDir, additionalDirs: workspaceCtx.additionalDirs },
skillCatalog?.catalog.getSkillRoots() ?? [],
env.pathClass,
).additionalDirs;
},
},
capabilities,
Expand Down
24 changes: 19 additions & 5 deletions packages/agent-core-v2/src/app/edit/tools/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,16 @@

import { z } from 'zod';

import { resolvePathAccessPath, type WorkspaceConfig } from '#/tool/path-access';
import {
extendWorkspaceWithSkillRoots,
resolvePathAccessPath,
type WorkspaceConfig,
} from '#/tool/path-access';
import { toInputJsonSchema } from '#/tool/input-schema';
import { literalRulePattern, matchesPathRuleSubject } from '#/tool/rule-match';
import { IFileEditService } from '../fileEdit';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import {
ToolAccesses,
Expand Down Expand Up @@ -73,13 +78,22 @@ export class EditTool implements BuiltinTool<EditInput> {
@IFileEditService private readonly editor: IFileEditService,
@IHostEnvironment private readonly env: IHostEnvironment,
@ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext,
// Optional so unit tests that construct the tool directly (bypassing DI)
// keep working; always registered in production scopes.
@ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog,
) {}

private get workspaceConfig(): WorkspaceConfig {
return {
workspaceDir: this.workspaceCtx.workDir,
additionalDirs: this.workspaceCtx.additionalDirs,
};
// Skill roots are merged per call (v1 merged once at tool construction):
// the catalog loads asynchronously and gains roots on plugin reloads.
return extendWorkspaceWithSkillRoots(
{
workspaceDir: this.workspaceCtx.workDir,
additionalDirs: this.workspaceCtx.additionalDirs,
},
this.skillCatalog?.catalog.getSkillRoots() ?? [],
this.env.pathClass,
);
}

resolveExecution(args: EditInput): ToolExecution {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { ISessionScopeHandle } from '#/_base/di/scope';
import type { Event } from '#/_base/event';
import type { McpServerConfig } from '#/agent/mcp/config-schema';
import type { Hooks } from '#/hooks';

export interface CreateSessionOptions {
Expand All @@ -29,6 +30,13 @@ export interface CreateSessionOptions {
readonly workDir: string;
/** Extra workspace roots for this session; relative paths resolve against workDir. */
readonly additionalDirs?: readonly string[];
/**
* Caller-supplied MCP servers for this session (v1's
* `CreateSessionPayload.mcpServers`): merged over the file config and under
* plugin servers when the session's MCP connections are established. Only
* `create` carries them; resumes and forks connect from the file config alone.
*/
readonly mcpServers?: Readonly<Record<string, McpServerConfig>>;
}

export interface ForkSessionOptions {
Expand Down
Loading
Loading