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
This commit is contained in:
haozhe.yang 2026-07-01 22:54:24 +08:00
parent d4a6a20db8
commit 4571edf90a
7 changed files with 176 additions and 5 deletions

View file

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

View file

@ -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<CompactSessionResponse>;
undo(sessionId: string, body: UndoSessionRequest): Promise<UndoSessionResponse>;
abort(sessionId: string): Promise<SessionAbortResponse>;
startBtw(sessionId: string): Promise<StartBtwSessionResponse>;
archive(sessionId: string): Promise<ArchiveSessionResponse>;
}

View file

@ -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<string>;
}
export const ISessionBtwService: ServiceIdentifier<ISessionBtwService> =
createDecorator<ISessionBtwService>('sessionBtwService');

View file

@ -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<string> {
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',
);

View file

@ -0,0 +1,7 @@
/**
* `btw` domain barrel re-exports the side-question child-agent contract and
* implementation.
*/
export * from './btw';
export * from './btwService';

View file

@ -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<typeof vi.fn>;
let appendSystemReminder: ReturnType<typeof vi.fn>;
let registerPolicy: ReturnType<typeof vi.fn>;
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);
});
});

View file

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