diff --git a/.changeset/roleadditional-session-api.md b/.changeset/roleadditional-session-api.md new file mode 100644 index 0000000000..388ab9aa29 --- /dev/null +++ b/.changeset/roleadditional-session-api.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code-sdk": minor +--- + +Add a `roleAdditional` option to the session API. The agent profile system already renders a `{{ROLE_ADDITIONAL}}` slot, but there was no way to set it through the SDK — the render half existed with no input path. `createSession` / `resumeSession` now accept an optional `roleAdditional` string that is threaded (parallel to `additionalDirs`) into the resolved profile's system prompt for every agent in the session. When resume or reload supplies a `roleAdditional` that differs from the value that rendered an agent's persisted prompt, that agent's system prompt is re-rendered from the restored profile; an omitted value preserves the persisted standing prompt (so `Session.reloadSession()` / `/reload`, which cannot pass one, no longer clears it), while an explicit empty string clears it. Additive and optional — no behavior change when unset. diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 90444d709a..e2c66f89c4 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -101,6 +101,8 @@ export interface AgentOptions { readonly imageLimits?: ImageLimits; readonly replay?: ReplayBuilderOptions; readonly additionalDirs?: readonly string[]; + /** Standing prompt rendered into the profile's {{ROLE_ADDITIONAL}} slot. */ + readonly roleAdditional?: string; readonly systemPromptContextProvider?: (() => Promise) | undefined; } @@ -160,6 +162,7 @@ export class Agent { printDrainAgentTasksOnStop = false; private additionalDirs: readonly string[]; + private roleAdditional?: string; private activeProfile?: ResolvedAgentProfile; private brandHome?: string; private readonly emittedThinkingEffortWarnings = new Set(); @@ -193,6 +196,7 @@ export class Agent { this.experimentalFlags = options.experimentalFlags ?? new FlagResolver(); this.imageLimits = options.imageLimits ?? new ImageLimits(); this.additionalDirs = normalizeAdditionalDirs(options.additionalDirs ?? []); + this.roleAdditional = options.roleAdditional; this.systemPromptContextProvider = options.systemPromptContextProvider; this.llmRequestLogger = new LlmRequestLogger(this.log); @@ -475,6 +479,7 @@ export class Agent { cwdListing: context?.cwdListing, agentsMd: context?.agentsMd, additionalDirsInfo: context?.additionalDirsInfo, + roleAdditional: this.roleAdditional, }); this.config.update({ profileName: profile.name, systemPrompt }); } diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index faa44f538f..54bc631148 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -61,6 +61,8 @@ export interface CreateSessionPayload { readonly metadata?: JsonObject | undefined; readonly mcpServers?: Readonly>; readonly additionalDirs?: readonly string[]; + /** Standing prompt rendered into the profile's {{ROLE_ADDITIONAL}} slot. */ + readonly roleAdditional?: string; readonly client?: ClientTelemetryInfo | undefined; readonly drainAgentTasksOnStop?: boolean; } @@ -81,6 +83,8 @@ export interface ResumeSessionPayload { readonly sessionId: string; readonly mcpServers?: Readonly>; readonly additionalDirs?: readonly string[]; + /** Standing prompt rendered into the profile's {{ROLE_ADDITIONAL}} slot, preserved across resume. */ + readonly roleAdditional?: string; /** Include persisted subagent states in the returned replay snapshot. */ readonly includeSubagents?: boolean; /** diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 4b34590d38..f7f555c09b 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -380,6 +380,7 @@ export class KimiCore implements PromisableMethods { pluginCommands, appVersion: this.appVersion, additionalDirs, + roleAdditional: options.roleAdditional, drainAgentTasksOnStop: options.drainAgentTasksOnStop, }); try { @@ -532,6 +533,7 @@ export class KimiCore implements PromisableMethods { pluginCommands, appVersion: this.appVersion, additionalDirs, + roleAdditional: input.roleAdditional, }); let warning: string | undefined; try { diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 63f8d6152e..7c17c7cf26 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -81,6 +81,8 @@ export interface SessionOptions { /** Owner-scoped [image] limits, threaded from the owning core into every agent. */ readonly imageLimits?: ImageLimits; readonly additionalDirs?: readonly string[]; + /** Session-level standing prompt rendered into every agent's {{ROLE_ADDITIONAL}} slot. */ + readonly roleAdditional?: string; /** * Print-mode (`kimi -p`) only: hold the main turn open while background * subagents (`kind === 'agent'`) are still running, idle-waiting until they @@ -105,6 +107,8 @@ export interface AgentMeta { readonly type: AgentType; readonly parentAgentId?: string | null; readonly swarmItem?: string; + /** The `roleAdditional` value used to render this agent's system prompt. */ + roleAdditional?: string; } interface ResumedAgent { @@ -183,6 +187,7 @@ export class Session { private persistenceKaos: Kaos; private additionalDirs: readonly string[]; private sessionAdditionalDirs: readonly string[] = []; + private roleAdditional?: string; private readonly pluginCommands: readonly PluginCommandDef[]; private agentIdCounter = 0; private readonly skillsReady: Promise; @@ -224,6 +229,7 @@ export class Session { this.toolKaos = options.kaos; this.persistenceKaos = options.persistenceKaos ?? options.kaos; this.additionalDirs = normalizeAdditionalDirs(options.additionalDirs ?? []); + this.roleAdditional = options.roleAdditional; this.pluginCommands = options.pluginCommands ?? []; this.skills = new SessionSkillRegistry({ sessionId: options.id, @@ -355,6 +361,16 @@ export class Session { additionalDirs, ); await this.setBaseAdditionalDirs(this.additionalDirs); + // Preserve the standing prompt persisted with the session when this + // resume/reload didn't supply a `roleAdditional`. `/reload` and plain + // `resumeSession({ id })` have no way to pass the value, so without this an + // omitted value would compare unequal to the persisted one below, clear the + // {{ROLE_ADDITIONAL}} slot, and drop the standing prompt (and newly-spawned + // agents would inherit `undefined`). An explicit value — including `""` to + // clear it — is left untouched and still overrides. + if (this.roleAdditional === undefined) { + this.roleAdditional = agents.main?.roleAdditional; + } this.agents.clear(); // Only the main agent is needed to reopen the session; subagents replay // lazily when an RPC or Agent(resume=...) call asks for their state. @@ -641,6 +657,7 @@ export class Session { type, parentAgentId, swarmItem: options.swarmItem, + roleAdditional: this.roleAdditional, }; void this.writeMetadata(); } @@ -948,6 +965,7 @@ export class Session { experimentalFlags: this.experimentalFlags, imageLimits: this.imageLimits, additionalDirs: parentAgent?.getAdditionalDirs() ?? this.additionalDirs, + roleAdditional: this.roleAdditional, systemPromptContextProvider: () => prepareSystemPromptContext( this.systemContextKaos(agent.kaos.getcwd()), @@ -1035,6 +1053,16 @@ export class Session { ); const result = await agent.resume(); this.restoreAgentProfileHandle(agent, meta, parent?.agent); + // If the session was resumed with a different `roleAdditional` than the + // one used to render this agent's persisted system prompt, re-render the + // prompt using the restored profile and the fresh context. This keeps the + // persisted profile in sync with the new standing instructions without + // refreshing AGENTS.md/cwd on every resume. + if (this.roleAdditional !== meta.roleAdditional) { + await agent.refreshSystemPrompt(); + meta.roleAdditional = this.roleAdditional; + void this.writeMetadata(); + } this.agents.set(id, agent); return { agent, warning: parent?.warning ?? result.warning }; } catch (error) { diff --git a/packages/agent-core/test/session/resume-roleadditional.test.ts b/packages/agent-core/test/session/resume-roleadditional.test.ts new file mode 100644 index 0000000000..363c8a94b5 --- /dev/null +++ b/packages/agent-core/test/session/resume-roleadditional.test.ts @@ -0,0 +1,264 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { DEFAULT_AGENT_PROFILES } from '../../src/profile'; +import type { ResolvedAgentProfile } from '../../src/profile'; +import type { SDKSessionRPC } from '../../src/rpc'; +import { Session } from '../../src/session'; +import { ProviderManager } from '../../src/session/provider-manager'; +import { testKaos } from '../fixtures/test-kaos'; + +const MOCK_PROVIDER = { + type: 'kimi', + apiKey: 'test-key', + model: 'mock-model', +} as const; + +const tempDirs: string[] = []; + +afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); + +async function makeTempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'kimi-core-resume-roleadditional-')); + tempDirs.push(dir); + return dir; +} + +function testProviderManager(): ProviderManager { + return new ProviderManager({ + config: { + providers: { + test: { + type: MOCK_PROVIDER.type, + apiKey: MOCK_PROVIDER.apiKey, + }, + }, + models: { + [MOCK_PROVIDER.model]: { + provider: 'test', + model: MOCK_PROVIDER.model, + maxContextSize: 1_000_000, + }, + }, + }, + }); +} + +function createSessionRpc(): SDKSessionRPC { + return { + emitEvent: vi.fn(async () => {}), + requestApproval: vi.fn(async () => ({ decision: 'cancelled' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ + output: 'custom tools are not supported in this test', + isError: true, + })), + } as SDKSessionRPC; +} + +function customRoleAdditionalProfile(roleAdditionalMarker: string): ResolvedAgentProfile { + return { + name: 'role-additional-test', + systemPrompt: ({ roleAdditional }) => + `system prompt marker: ${roleAdditionalMarker}; roleAdditional: ${roleAdditional ?? ''}`, + tools: [], + }; +} + +describe('Session.resume with roleAdditional', () => { + it('re-renders the system prompt with the fresh roleAdditional after replay', async () => { + const sessionDir = await makeTempDir(); + const workDir = await makeTempDir(); + + // Launch 1: create and persist a session with roleAdditional "W1". + const session1 = new Session({ + id: 'test-resume-roleadditional', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc(), + providerManager: testProviderManager(), + roleAdditional: 'role-additional-marker-W1', + }); + + const { agent: mainAgent1 } = await session1.createAgent( + { type: 'main' }, + { profile: DEFAULT_AGENT_PROFILES['agent'] }, + ); + + expect(mainAgent1.config.systemPrompt).toContain('role-additional-marker-W1'); + expect(mainAgent1.config.systemPrompt).not.toContain('role-additional-marker-W2'); + + await session1.close(); + + // Launch 2: resume the same session with roleAdditional "W2". + const session2 = new Session({ + id: 'test-resume-roleadditional', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc(), + providerManager: testProviderManager(), + roleAdditional: 'role-additional-marker-W2', + }); + + try { + await session2.resume(); + const mainAgent2 = await session2.ensureAgentResumed('main'); + + // The resumed agent must use the fresh W2 roleAdditional, not the stale W1. + expect(mainAgent2.config.systemPrompt).toContain('role-additional-marker-W2'); + expect(mainAgent2.config.systemPrompt).not.toContain('role-additional-marker-W1'); + } finally { + await session2.close(); + } + }); + + it('preserves the persisted roleAdditional when resume/reload omits it', async () => { + const sessionDir = await makeTempDir(); + const workDir = await makeTempDir(); + + // Launch 1: create and persist a session with roleAdditional "W1". + const session1 = new Session({ + id: 'test-resume-roleadditional-omitted', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc(), + providerManager: testProviderManager(), + roleAdditional: 'role-additional-marker-W1', + }); + + const { agent: mainAgent1 } = await session1.createAgent( + { type: 'main' }, + { profile: DEFAULT_AGENT_PROFILES['agent'] }, + ); + + expect(mainAgent1.config.systemPrompt).toContain('role-additional-marker-W1'); + await session1.close(); + + // Launch 2: resume WITHOUT a roleAdditional, exactly as `/reload` and a plain + // resumeSession({ id }) do — neither has a way to pass the value. The standing + // prompt persisted with the session must be preserved, not cleared. + const session2 = new Session({ + id: 'test-resume-roleadditional-omitted', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc(), + providerManager: testProviderManager(), + // roleAdditional intentionally omitted. + }); + + try { + await session2.resume(); + const mainAgent2 = await session2.ensureAgentResumed('main'); + + // The standing prompt survives the omitted resume/reload. + expect(mainAgent2.config.systemPrompt).toContain('role-additional-marker-W1'); + } finally { + await session2.close(); + } + }); + + it('does not re-render the system prompt when roleAdditional is unchanged', async () => { + const sessionDir = await makeTempDir(); + const workDir = await makeTempDir(); + await mkdir(join(workDir, '.git')); + await writeFile(join(workDir, 'AGENTS.md'), 'initial resume instructions', 'utf-8'); + + const session1 = new Session({ + id: 'test-resume-roleadditional-unchanged', + kaos: testKaos.withCwd(workDir), + persistenceKaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc(), + providerManager: testProviderManager(), + roleAdditional: 'role-additional-marker-W1', + }); + + const { agent: mainAgent1 } = await session1.createAgent( + { type: 'main' }, + { profile: DEFAULT_AGENT_PROFILES['agent'] }, + ); + + expect(mainAgent1.config.systemPrompt).toContain('initial resume instructions'); + await session1.closeForReload(); + + // Mutate AGENTS.md while the session is closed. + await writeFile(join(workDir, 'AGENTS.md'), 'updated resume instructions', 'utf-8'); + + // Resume with the same roleAdditional: the persisted prompt must stay intact. + const session2 = new Session({ + id: 'test-resume-roleadditional-unchanged', + kaos: testKaos.withCwd(workDir), + persistenceKaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc(), + providerManager: testProviderManager(), + roleAdditional: 'role-additional-marker-W1', + }); + + try { + await session2.resume(); + const mainAgent2 = await session2.ensureAgentResumed('main'); + + expect(mainAgent2.config.systemPrompt).toContain('initial resume instructions'); + expect(mainAgent2.config.systemPrompt).not.toContain('updated resume instructions'); + + // An explicit refresh still picks up the new AGENTS.md. + await mainAgent2.refreshSystemPrompt(); + expect(mainAgent2.config.systemPrompt).toContain('updated resume instructions'); + expect(mainAgent2.config.systemPrompt).not.toContain('initial resume instructions'); + } finally { + await session2.close(); + } + }); + + it('leaves an unresolved custom profile unchanged when roleAdditional changes', async () => { + const sessionDir = await makeTempDir(); + const workDir = await makeTempDir(); + + const session1 = new Session({ + id: 'test-resume-roleadditional-custom', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc(), + providerManager: testProviderManager(), + roleAdditional: 'role-additional-marker-W1', + }); + + const { agent: mainAgent1 } = await session1.createAgent( + { type: 'main' }, + { profile: customRoleAdditionalProfile('W1') }, + ); + + expect(mainAgent1.config.systemPrompt).toContain('roleAdditional: role-additional-marker-W1'); + await session1.close(); + + // Resume with a different roleAdditional but a custom profile that cannot be + // resolved from DEFAULT_AGENT_PROFILES. The persisted prompt must remain + // unchanged and the resume must not throw. + const session2 = new Session({ + id: 'test-resume-roleadditional-custom', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc(), + providerManager: testProviderManager(), + roleAdditional: 'role-additional-marker-W2', + }); + + try { + await session2.resume(); + const mainAgent2 = await session2.ensureAgentResumed('main'); + + expect(mainAgent2.config.systemPrompt).toContain('roleAdditional: role-additional-marker-W1'); + expect(mainAgent2.config.systemPrompt).not.toContain('roleAdditional: role-additional-marker-W2'); + } finally { + await session2.close(); + } + }); +}); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 0968d96a18..e585fedbfd 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -107,6 +107,12 @@ export interface CreateSessionOptions { readonly kaos?: Kaos | undefined; readonly persistenceKaos?: Kaos | undefined; readonly additionalDirs?: readonly string[]; + /** + * Extra standing-prompt text rendered into the resolved profile's + * {{ROLE_ADDITIONAL}} slot. Flows via createSession → session config → Agent + * → updateSystemPromptFromProfile. + */ + readonly roleAdditional?: string; readonly sessionStartedProperties?: TelemetryProperties; /** * Print-mode (`kimi -p`) only: when the main agent ends a turn while @@ -128,6 +134,8 @@ export interface ResumeSessionInput { readonly kaos?: Kaos | undefined; readonly persistenceKaos?: Kaos | undefined; readonly additionalDirs?: readonly string[]; + /** Standing-prompt text ({{ROLE_ADDITIONAL}}) preserved across resume. */ + readonly roleAdditional?: string; /** Include persisted subagent states in the returned replay snapshot. */ readonly includeSubagents?: boolean; /**