diff --git a/packages/agent-core-v2/src/contextMemory/types.ts b/packages/agent-core-v2/src/contextMemory/types.ts index 0dd142cba..107fdec72 100644 --- a/packages/agent-core-v2/src/contextMemory/types.ts +++ b/packages/agent-core-v2/src/contextMemory/types.ts @@ -1,6 +1,7 @@ import type { ContentPart, Message } from '@moonshot-ai/kosong'; import type { BackgroundTaskStatus } from '../background'; +import type { CronJobOrigin, CronMissedOrigin } from '@moonshot-ai/protocol'; export type SkillSource = 'project' | 'user' | 'extra' | 'builtin'; @@ -42,23 +43,6 @@ export interface BackgroundTaskOrigin { readonly notificationId: string; } -export interface CronJobOrigin { - readonly kind: 'cron_job'; - readonly jobId: string; - readonly cron: string; - readonly recurring: boolean; - /** Number of theoretical fires that were collapsed into this single delivery (>= 1). */ - readonly coalescedCount: number; - /** True for recurring tasks past the 7-day age threshold. */ - readonly stale: boolean; -} - -export interface CronMissedOrigin { - readonly kind: 'cron_missed'; - /** Number of one-shot tasks bundled into this missed-fire notification. */ - readonly count: number; -} - export interface HookResultOrigin { readonly kind: 'hook_result'; readonly event: string; diff --git a/packages/agent-core-v2/src/cron/cron.ts b/packages/agent-core-v2/src/cron/cron.ts index adf005649..fded542ec 100644 --- a/packages/agent-core-v2/src/cron/cron.ts +++ b/packages/agent-core-v2/src/cron/cron.ts @@ -34,20 +34,6 @@ export interface CronFireOptions { readonly firedAt?: number; } -export interface CronJobOrigin { - readonly kind: 'cron_job'; - readonly jobId: string; - readonly cron: string; - readonly recurring: boolean; - readonly coalescedCount: number; - readonly stale: boolean; -} - -export interface CronMissedOrigin { - readonly kind: 'cron_missed'; - readonly count: number; -} - export interface ICronService extends CronToolManager { readonly _serviceBrand: undefined; readonly isEnabled: boolean; diff --git a/packages/agent-core-v2/src/cron/cronService.ts b/packages/agent-core-v2/src/cron/cronService.ts index 4ed6a395e..327a7e10a 100644 --- a/packages/agent-core-v2/src/cron/cronService.ts +++ b/packages/agent-core-v2/src/cron/cronService.ts @@ -29,9 +29,7 @@ import { IWireRecord } from '#/wireRecord'; import { ICronService, type CronFireOptions, - type CronJobOrigin, type CronLoadOptions, - type CronMissedOrigin, type CronOptions, type CronPersistence, type CronTaskInit, @@ -65,6 +63,7 @@ import { CRON_SCHEDULED, } from './tools/telemetry-events'; import type { CronTask, CronToolManager } from './tools/types'; +import type { CronJobOrigin, CronMissedOrigin } from '@moonshot-ai/protocol'; declare module '#/wireRecord' { interface WireRecordMap { diff --git a/packages/agent-core-v2/src/cron/tools/cron-fire-xml.ts b/packages/agent-core-v2/src/cron/tools/cron-fire-xml.ts index 5fc652b1b..f7c11e6a3 100644 --- a/packages/agent-core-v2/src/cron/tools/cron-fire-xml.ts +++ b/packages/agent-core-v2/src/cron/tools/cron-fire-xml.ts @@ -14,7 +14,7 @@ * verbatim. The injection target is an LLM-visible transcript where * double-escaping would be noisier than literal punctuation. */ -import type { CronJobOrigin } from '../cron'; +import type { CronJobOrigin } from "@moonshot-ai/protocol"; export function renderCronFireXml( origin: CronJobOrigin, diff --git a/packages/agent-core-v2/src/loop/types.ts b/packages/agent-core-v2/src/loop/types.ts index 8cfd7e093..dff7d49a0 100644 --- a/packages/agent-core-v2/src/loop/types.ts +++ b/packages/agent-core-v2/src/loop/types.ts @@ -15,8 +15,6 @@ import type { Message, TokenUsage, ToolCall } from '@moonshot-ai/kosong'; import type { ExecutableTool, ExecutableToolResult, RunnableToolExecution } from '#/tool'; import type { LLM } from './llm'; -export type { ToolCall }; - export type LoopMessageBuilder = () => Message[] | Promise; /** diff --git a/packages/agent-core-v2/src/permissionPolicy/types.ts b/packages/agent-core-v2/src/permissionPolicy/types.ts index e58bdfdb8..c76e0161e 100644 --- a/packages/agent-core-v2/src/permissionPolicy/types.ts +++ b/packages/agent-core-v2/src/permissionPolicy/types.ts @@ -1,5 +1,5 @@ import type { PrepareToolExecutionResult, ResolvedToolExecutionHookContext } from '#/loop'; -import type { ToolInputDisplay } from '@moonshot-ai/protocol'; +import type { ApprovalResponse } from '@moonshot-ai/protocol'; import type { PermissionRule } from '#/permissionRules'; /** @@ -13,21 +13,6 @@ import type { PermissionRule } from '#/permissionRules'; */ export type PermissionMode = 'manual' | 'yolo' | 'auto'; - -export interface ApprovalRequest { - toolCallId: string; - toolName: string; - action: string; - display: ToolInputDisplay; -} - -export interface ApprovalResponse { - decision: 'approved' | 'rejected' | 'cancelled'; - scope?: 'session'; - feedback?: string; - selectedLabel?: string; -} - export interface PermissionData { mode: PermissionMode; rules: PermissionRule[]; diff --git a/packages/agent-core-v2/test/question/question.test.ts b/packages/agent-core-v2/test/question/question.test.ts index c69ffc15a..65b518c6a 100644 --- a/packages/agent-core-v2/test/question/question.test.ts +++ b/packages/agent-core-v2/test/question/question.test.ts @@ -1,234 +1,56 @@ -import type { ToolCall } from '@moonshot-ai/kosong'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ErrorCodes, KimiError } from '../../../src/errors'; -import { IQuestionService, type QuestionResult } from '../../../src/services'; -import { testAgent } from './harness'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices, type TestInstantiationService } from '#/_base/di/test'; +import { IInteractionService } from '#/interaction'; +import { InteractionService } from '#/interaction/interactionService'; +import { IQuestionService, type QuestionRequest } from '#/question'; +import { QuestionService } from '#/question/questionService'; -describe('Agent question', () => { - it('roundtrips a question request through wire rpc', async () => { - const ctx = testAgent(); +function makeRequest(id: string): QuestionRequest { + return { id, prompt: 'name?' }; +} - const resultPromise = ctx.service(IQuestionService).request({ - sessionId: 'session-1', - agentId: 'agent-1', - questions: [ - { - question: 'Pick one', - options: [{ label: 'Yes' }, { label: 'No' }], - }, - ], +describe('QuestionService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = createServices(disposables, { + additionalServices: (reg) => { + reg.define(IInteractionService, InteractionService); + reg.define(IQuestionService, QuestionService); + }, }); + }); + afterEach(() => disposables.dispose()); - expect(await ctx.untilQuestion({ Yes: true })).toMatchInlineSnapshot( - `[emit] requestQuestion { "questions": [ { "question": "Pick one", "options": [ { "label": "Yes" }, { "label": "No" } ] } ] }`, - ); + it('request parks until answer resolves it', async () => { + const svc = ix.get(IQuestionService); + const req = makeRequest('q1'); + const pending = svc.request(req); - await expect(resultPromise).resolves.toEqual({ Yes: true }); - await ctx.expectResumeMatches(); + expect(svc.listPending()).toEqual([req]); + svc.answer('q1', 'kimi'); + + await expect(pending).resolves.toBe('kimi'); + expect(svc.listPending()).toEqual([]); }); - it('sends multiple questions in one request', async () => { - const ctx = testAgent(); - - const resultPromise = ctx.service(IQuestionService).request({ - sessionId: 'session-1', - agentId: 'agent-1', - questions: [ - { - question: 'Pick one', - options: [{ label: 'Yes' }, { label: 'No' }], - }, - { - question: 'Pick storage', - options: [{ label: 'Postgres' }, { label: 'SQLite' }], - }, - ], - }); - - expect( - await ctx.untilQuestion({ Yes: true, 'Pick storage': 'Postgres' }), - ).toMatchInlineSnapshot( - `[emit] requestQuestion { "questions": [ { "question": "Pick one", "options": [ { "label": "Yes" }, { "label": "No" } ] }, { "question": "Pick storage", "options": [ { "label": "Postgres" }, { "label": "SQLite" } ] } ] }`, - ); - - await expect(resultPromise).resolves.toEqual({ Yes: true, 'Pick storage': 'Postgres' }); - await ctx.expectResumeMatches(); - }); - - it('registers AskUserQuestion and routes model calls through question service', async () => { - const telemetry = { track: vi.fn() }; - const ctx = testAgent({ - telemetry, - }); - ctx.configure({ tools: ['AskUserQuestion'] }); - - expect(ctx.toolsData().find((tool) => tool.name === 'AskUserQuestion')).toMatchObject({ - active: true, - name: 'AskUserQuestion', - source: 'builtin', - }); - - ctx.mockNextResponse( - { type: 'text', text: 'I need one choice.' }, - askQuestionCall('call_question'), - ); - await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Ask me.' }] }); - await ctx.untilQuestion({ - answers: { 'Pick one': 'Yes' }, - method: 'number_key', - }); - - expect(ctx.lastLlmInput()).toMatchInlineSnapshot(` - system: - tools: AskUserQuestion - messages: - user: text "Ask me." - `); - expect(requestQuestionArgs(ctx, 'call_question')).toEqual({ - turnId: 0, - toolCallId: 'call_question', - questions: [ - { - question: 'Pick one', - header: '', - options: [ - { label: 'Yes', description: '' }, - { label: 'No', description: '' }, - ], - multiSelect: false, - }, - ], - }); - - ctx.mockNextResponse({ type: 'text', text: 'Thanks for answering.' }); - await ctx.untilTurnEnd(); - - expect(toolResultArgs(ctx, 'call_question')).toMatchObject({ - output: JSON.stringify({ answers: { 'Pick one': 'Yes' } }), - }); - expect(telemetry.track).toHaveBeenCalledWith('question_answered', { - answered: 1, - method: 'number_key', - }); - expect(String(toolResultArgs(ctx, 'call_question').output)).not.toContain('number_key'); - expect(ctx.lastLlmInput()).toMatchInlineSnapshot(` - messages: - - assistant: text "I need one choice." calls call_question:AskUserQuestion { "questions": [ { "question": "Pick one", "options": [ { "label": "Yes" }, { "label": "No" } ] } ] } - tool[call_question]: text "{\\"answers\\":{\\"Pick one\\":\\"Yes\\"}}" - `); - await ctx.expectResumeMatches(); - }); - - it('returns a dismissed answer when the user dismisses AskUserQuestion', async () => { - const telemetry = { track: vi.fn() }; - const ctx = testAgent({ telemetry }); - ctx.configure({ tools: ['AskUserQuestion'] }); - - ctx.mockNextResponse( - { type: 'text', text: 'I need one choice.' }, - askQuestionCall('call_dismissed'), - ); - await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Ask me.' }] }); - await ctx.untilQuestion(null); - - ctx.mockNextResponse({ type: 'text', text: 'No answer was provided.' }); - await ctx.untilTurnEnd(); - - expect(JSON.parse(String(toolResultArgs(ctx, 'call_dismissed').output))).toEqual({ - answers: {}, - note: 'User dismissed the question without answering.', - }); - expect(telemetry.track).toHaveBeenCalledWith('question_dismissed', undefined); - }); - - it('returns a hard error when the question service reports unsupported questions', async () => { - const ctx = testAgent({ - questionService: unsupportedQuestionService(), - }); - ctx.configure({ tools: ['AskUserQuestion'] }); - - ctx.mockNextResponse( - { type: 'text', text: 'I need one choice.' }, - askQuestionCall('call_unsupported'), - ); - ctx.mockNextResponse({ type: 'text', text: 'I will ask directly instead.' }); - await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Ask me.' }] }); - await ctx.untilTurnEnd(); - - expect(toolResultArgs(ctx, 'call_unsupported')).toMatchObject({ - isError: true, - output: expect.stringContaining('does not support interactive questions'), - }); - expect(String(toolResultArgs(ctx, 'call_unsupported').output)).toContain( - 'Do NOT call this tool again', - ); - expect(requestQuestionArgs(ctx, 'call_unsupported')).toBeUndefined(); + it('answer on unknown id is a no-op', () => { + const svc = ix.get(IQuestionService); + expect(() => svc.answer('missing', 'kimi')).not.toThrow(); }); it('enqueue parks a question without blocking', () => { const svc = ix.get(IQuestionService); - const enqueued = svc.enqueue({ id: 'q1', prompt: 'name?' }); - expect(enqueued).toEqual({ id: 'q1', prompt: 'name?' }); - expect(svc.listPending()).toEqual([{ id: 'q1', prompt: 'name?' }]); + const req = makeRequest('q1'); + const enqueued = svc.enqueue(req); + + expect(enqueued).toEqual(req); + expect(svc.listPending()).toEqual([req]); svc.answer('q1', 'kimi'); expect(svc.listPending()).toEqual([]); }); }); - -function askQuestionCall(id: string): ToolCall { - return { - type: 'function', - id, - name: 'AskUserQuestion', - arguments: JSON.stringify({ - questions: [ - { - question: 'Pick one', - options: [{ label: 'Yes' }, { label: 'No' }], - }, - ], - }), - }; -} - -function requestQuestionArgs( - ctx: ReturnType, - toolCallId: string, -): unknown { - return rpcArgs(ctx, 'requestQuestion', toolCallId); -} - -function toolResultArgs( - ctx: ReturnType, - toolCallId: string, -): { readonly output?: unknown; readonly isError?: boolean } { - const args = rpcArgs(ctx, 'tool.result', toolCallId); - expect(args).toBeDefined(); - return args as { readonly output?: unknown; readonly isError?: boolean }; -} - -function rpcArgs( - ctx: ReturnType, - event: string, - toolCallId: string, -): unknown { - return ctx.allEvents.find((entry) => { - if (entry.type !== '[rpc]' || entry.event !== event) return false; - const args = entry.args as { readonly toolCallId?: string }; - return args.toolCallId === toolCallId; - })?.args; -} - -function unsupportedQuestionService(): IQuestionService { - return { - _serviceBrand: undefined, - request: async (): Promise => { - throw new KimiError(ErrorCodes.NOT_IMPLEMENTED, 'Client does not support questions'); - }, - resolve: () => {}, - dismiss: () => {}, - listPending: () => [], - }; -}