From 4571edf90a7fa8f07a256b4c048381fad9513a0a Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 1 Jul 2026 22:54:24 +0800 Subject: [PATCH] refactor(agent-core-v2): extract SessionBtwService for btw - add Session-scoped ISessionBtwService/SessionBtwService that forks the main agent into a side-question child agent (tools disabled, side-channel reminder) via IAgentLifecycleService.fork('main') - remove startBtw from AgentAPI (core-api) and ISessionLegacyService; the server-v2 POST /sessions/{id}/btw route now resolves ISessionBtwService directly - add a unit test for SessionBtwService --- .../agent-core-v2/src/agent/rpc/core-api.ts | 1 - .../src/app/sessionLegacy/sessionLegacy.ts | 2 - packages/agent-core-v2/src/session/btw/btw.ts | 45 ++++++++++++++ .../src/session/btw/btwService.ts | 51 ++++++++++++++++ .../agent-core-v2/src/session/btw/index.ts | 7 +++ packages/agent-core-v2/test/btw/btw.test.ts | 60 +++++++++++++++++++ packages/server-v2/src/routes/sessions.ts | 15 ++++- 7 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 packages/agent-core-v2/src/session/btw/btw.ts create mode 100644 packages/agent-core-v2/src/session/btw/btwService.ts create mode 100644 packages/agent-core-v2/src/session/btw/index.ts create mode 100644 packages/agent-core-v2/test/btw/btw.test.ts diff --git a/packages/agent-core-v2/src/agent/rpc/core-api.ts b/packages/agent-core-v2/src/agent/rpc/core-api.ts index cc2386dd7..06d0d9a9b 100644 --- a/packages/agent-core-v2/src/agent/rpc/core-api.ts +++ b/packages/agent-core-v2/src/agent/rpc/core-api.ts @@ -402,7 +402,6 @@ export interface AgentAPI { clearContext: (payload: EmptyPayload) => void; activateSkill: (payload: ActivateSkillPayload) => void; activatePluginCommand: (payload: ActivatePluginCommandPayload) => void; - startBtw: (payload: EmptyPayload) => string; createGoal: (payload: CreateGoalPayload) => GoalSnapshot; getGoal: (payload: EmptyPayload) => GoalToolResult; pauseGoal: (payload: EmptyPayload) => GoalSnapshot; diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts index b0d8e7f33..c9d4794ac 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts @@ -19,7 +19,6 @@ import type { ForkSessionRequest, SessionAbortResponse, SessionStatus, - StartBtwSessionResponse, UndoSessionRequest, UndoSessionResponse, } from '@moonshot-ai/protocol'; @@ -65,7 +64,6 @@ export interface ISessionLegacyService { compact(sessionId: string, body: CompactSessionRequest): Promise; undo(sessionId: string, body: UndoSessionRequest): Promise; abort(sessionId: string): Promise; - startBtw(sessionId: string): Promise; archive(sessionId: string): Promise; } diff --git a/packages/agent-core-v2/src/session/btw/btw.ts b/packages/agent-core-v2/src/session/btw/btw.ts new file mode 100644 index 000000000..b8eea8e7f --- /dev/null +++ b/packages/agent-core-v2/src/session/btw/btw.ts @@ -0,0 +1,45 @@ +/** + * `btw` domain — side-question ("by the way") child agent contract. + * + * A `btw` agent is a lightweight fork of the main agent used for a side-channel + * conversation: it inherits the parent's profile and context, but all tool calls + * are disabled and a side-channel system reminder is appended so it answers with + * text only. Follow-up turns reuse the same child agent. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +/** Rejection message returned by the deny-all permission policy for tool calls. */ +export const TOOL_CALL_DISABLED_MESSAGE = + 'Tool calls are disabled for side questions. Answer with text only.'; + +/** + * System reminder appended to a `btw` child agent. Tool definitions remain + * visible only for prompt-cache reasons; the model must not call them. + */ +export const SIDE_QUESTION_SYSTEM_REMINDER = ` +This is a side-channel conversation with the user. You should answer user questions directly based on what you already know. + +IMPORTANT: +- You are a separate, lightweight instance. +- The main agent continues independently; do not reference being interrupted. +- Do not call any tools. All tool calls are disabled and will be rejected. + Even though tool definitions are visible in this request, they exist only + for technical reasons (prompt cache). You must not use them. +- Respond only with text based on what you already know from the conversation + and this side-channel conversation. +- Follow-up turns may happen in this side-channel conversation. +- If you do not know the answer, say so directly. +`.trim(); + +export interface ISessionBtwService { + readonly _serviceBrand: undefined; + /** + * Fork the main agent into a side-question child agent (tools disabled, + * side-channel reminder appended) and return the child's id. + */ + start(): Promise; +} + +export const ISessionBtwService: ServiceIdentifier = + createDecorator('sessionBtwService'); diff --git a/packages/agent-core-v2/src/session/btw/btwService.ts b/packages/agent-core-v2/src/session/btw/btwService.ts new file mode 100644 index 000000000..0f1757666 --- /dev/null +++ b/packages/agent-core-v2/src/session/btw/btwService.ts @@ -0,0 +1,51 @@ +/** + * `btw` domain — `ISessionBtwService` implementation. + * + * Forks the main agent into a side-question child: inherits profile/context via + * `IAgentLifecycleService.fork`, then disables tool calls (deny-all permission + * policy) and appends the side-channel system reminder. Bound at Session scope — + * `fork('main')` is a session-level operation, so the service injects the + * session's `IAgentLifecycleService` directly rather than resolving it through + * the main agent's accessor. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { + DenyAllPermissionPolicyService, + IAgentPermissionPolicyService, +} from '#/agent/permissionPolicy'; +import { IAgentSystemReminderService } from '#/agent/systemReminder'; +import { IAgentLifecycleService } from '#/session/agent-lifecycle'; + +import { ISessionBtwService, SIDE_QUESTION_SYSTEM_REMINDER, TOOL_CALL_DISABLED_MESSAGE } from './btw'; + +export class SessionBtwService implements ISessionBtwService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentLifecycleService private readonly lifecycle: IAgentLifecycleService, + ) {} + + async start(): Promise { + const child = await this.lifecycle.fork('main'); + child.accessor + .get(IAgentSystemReminderService) + ?.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER, { + kind: 'system_trigger', + name: 'btw', + }); + child.accessor + .get(IAgentPermissionPolicyService) + ?.registerPolicy(new DenyAllPermissionPolicyService(TOOL_CALL_DISABLED_MESSAGE)); + return child.id; + } +} + +registerScopedService( + LifecycleScope.Session, + ISessionBtwService, + SessionBtwService, + InstantiationType.Delayed, + 'session-btw', +); diff --git a/packages/agent-core-v2/src/session/btw/index.ts b/packages/agent-core-v2/src/session/btw/index.ts new file mode 100644 index 000000000..5930559f7 --- /dev/null +++ b/packages/agent-core-v2/src/session/btw/index.ts @@ -0,0 +1,7 @@ +/** + * `btw` domain barrel — re-exports the side-question child-agent contract and + * implementation. + */ + +export * from './btw'; +export * from './btwService'; diff --git a/packages/agent-core-v2/test/btw/btw.test.ts b/packages/agent-core-v2/test/btw/btw.test.ts new file mode 100644 index 000000000..e3a93fed9 --- /dev/null +++ b/packages/agent-core-v2/test/btw/btw.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { + DenyAllPermissionPolicyService, + IAgentPermissionPolicyService, +} from '#/agent/permissionPolicy'; +import { IAgentSystemReminderService } from '#/agent/systemReminder'; +import { IAgentLifecycleService } from '#/session/agent-lifecycle'; +import { ISessionBtwService, SIDE_QUESTION_SYSTEM_REMINDER } from '#/session/btw/btw'; +import { SessionBtwService } from '#/session/btw/btwService'; + +describe('SessionBtwService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let fork: ReturnType; + let appendSystemReminder: ReturnType; + let registerPolicy: ReturnType; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + appendSystemReminder = vi.fn(); + registerPolicy = vi.fn(); + + const child = { + id: 'agent-btw-1', + accessor: { + get: (id: unknown) => { + if (id === IAgentSystemReminderService) return { appendSystemReminder }; + if (id === IAgentPermissionPolicyService) return { registerPolicy }; + return undefined; + }, + }, + }; + fork = vi.fn(async () => child); + ix.stub(IAgentLifecycleService, { + _serviceBrand: undefined, + fork, + } as unknown as IAgentLifecycleService); + ix.set(ISessionBtwService, new SyncDescriptor(SessionBtwService)); + }); + afterEach(() => disposables.dispose()); + + it('forks main and configures a side-question child agent', async () => { + const svc = ix.get(ISessionBtwService); + const id = await svc.start(); + + expect(id).toBe('agent-btw-1'); + expect(fork).toHaveBeenCalledWith('main'); + expect(appendSystemReminder).toHaveBeenCalledWith(SIDE_QUESTION_SYSTEM_REMINDER, { + kind: 'system_trigger', + name: 'btw', + }); + expect(registerPolicy).toHaveBeenCalledTimes(1); + expect(registerPolicy.mock.calls[0]![0]).toBeInstanceOf(DenyAllPermissionPolicyService); + }); +}); diff --git a/packages/server-v2/src/routes/sessions.ts b/packages/server-v2/src/routes/sessions.ts index 93f261987..f9b3e8a37 100644 --- a/packages/server-v2/src/routes/sessions.ts +++ b/packages/server-v2/src/routes/sessions.ts @@ -42,6 +42,9 @@ */ import { + ErrorCodes, + IAuthSummaryService, + ISessionBtwService, ISessionActivity, ISessionContext, ISessionIndex, @@ -526,8 +529,16 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void } if (parsed.action === 'btw') { - const result = await legacy.startBtw(parsed.id); - reply.send(okEnvelope(result, req.id)); + const session = core.accessor.get(ISessionLifecycleService).get(parsed.id); + if (session === undefined) { + throw new KimiError( + ErrorCodes.SESSION_NOT_FOUND, + `session ${parsed.id} does not exist`, + ); + } + await core.accessor.get(IAuthSummaryService).ensureReady(); + const agentId = await session.accessor.get(ISessionBtwService).start(); + reply.send(okEnvelope({ agent_id: agentId }, req.id)); return; }