diff --git a/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts b/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts new file mode 100644 index 00000000..d04eb55f --- /dev/null +++ b/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts @@ -0,0 +1,67 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { createAnalysisPlanner } from '../planner' + +const baseInput = { + userMessage: '分析过去一年群里话题的变化趋势,按季度总结主要变化,并举出证据。', + chatType: 'group' as const, + locale: 'zh-CN', + availableTools: ['search_messages', 'get_time_stats', 'get_member_stats'], +} + +describe('createAnalysisPlanner', () => { + it('parses valid JSON and filters suggested tools to the available set', async () => { + const planner = createAnalysisPlanner({ + complete: async () => + JSON.stringify({ + title: '年度话题趋势分析', + intent: 'trend', + steps: [ + { + goal: '按季度检索代表性话题', + suggestedTools: ['search_messages', 'missing_tool'], + evidenceNeeded: '每个季度的代表消息', + }, + ], + successCriteria: ['覆盖全年', '引用证据'], + }), + }) + + const plan = await planner(baseInput) + + assert.equal(plan?.route, 'planned_execution') + assert.equal(plan?.title, '年度话题趋势分析') + assert.deepEqual(plan?.steps[0]?.suggestedTools, ['search_messages']) + }) + + it('limits steps and success criteria to five items', async () => { + const planner = createAnalysisPlanner({ + complete: async () => + JSON.stringify({ + title: '复杂分析', + intent: 'mixed', + steps: Array.from({ length: 7 }, (_, index) => ({ + goal: `step ${index + 1}`, + suggestedTools: ['search_messages'], + evidenceNeeded: `evidence ${index + 1}`, + })), + successCriteria: ['a', 'b', 'c', 'd', 'e', 'f'], + }), + }) + + const plan = await planner(baseInput) + + assert.equal(plan?.steps.length, 5) + assert.equal(plan?.successCriteria.length, 5) + }) + + it('returns null for invalid planner JSON', async () => { + const planner = createAnalysisPlanner({ + complete: async () => '{"title": "broken", "intent": "unknown", "steps": []}', + }) + + const plan = await planner(baseInput) + + assert.equal(plan, null) + }) +}) diff --git a/packages/node-runtime/src/ai/agent/index.ts b/packages/node-runtime/src/ai/agent/index.ts index 0aa59456..7576d47e 100644 --- a/packages/node-runtime/src/ai/agent/index.ts +++ b/packages/node-runtime/src/ai/agent/index.ts @@ -3,3 +3,12 @@ export { runAgentCore } from './core' export { DEFAULT_MAX_TOOL_ROUNDS } from './constants' export { createLlmRouteDecider, decideRequestRoute } from './router' export type { LlmRouteDecider, RequestRoute, RouteDecision, RouteDecisionSource, RouterInput } from './routing-types' +export { createAnalysisPlanner } from './planner' +export type { + AnalysisPlanIntent, + AnalysisPlanner, + AnalysisPlanStep, + AnalysisPlanSummary, + PlannerInput, + PlanContentBlock, +} from './planning-types' diff --git a/packages/node-runtime/src/ai/agent/planner.ts b/packages/node-runtime/src/ai/agent/planner.ts new file mode 100644 index 00000000..217775e8 --- /dev/null +++ b/packages/node-runtime/src/ai/agent/planner.ts @@ -0,0 +1,181 @@ +import { + completeSimple, + type Api as PiApi, + type Model as PiModel, + type TextContent as PiTextContent, +} from '@earendil-works/pi-ai' +import type { AnalysisPlanIntent, AnalysisPlanSummary, AnalysisPlanner, PlannerInput } from './planning-types' + +export type PlannerCompletionResult = string | { text: string } + +export interface CreateAnalysisPlannerOptions { + piModel?: PiModel + apiKey?: string + complete?: (prompt: string, signal?: AbortSignal) => Promise | PlannerCompletionResult + maxTokens?: number + temperature?: number +} + +const PLAN_INTENTS: readonly AnalysisPlanIntent[] = [ + 'summary', + 'trend', + 'relationship', + 'search', + 'comparison', + 'mixed', +] + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isIntent(value: unknown): value is AnalysisPlanIntent { + return typeof value === 'string' && PLAN_INTENTS.includes(value as AnalysisPlanIntent) +} + +function normalizeString(value: unknown): string | null { + if (typeof value !== 'string') return null + const text = value.trim() + return text.length > 0 ? text : null +} + +function extractJsonObject(text: string): string { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i) + if (fenced?.[1]) return fenced[1].trim() + const start = text.indexOf('{') + const end = text.lastIndexOf('}') + if (start >= 0 && end > start) return text.slice(start, end + 1) + return text.trim() +} + +function parsePlanJson(rawText: string, availableTools: Set): AnalysisPlanSummary | null { + try { + const parsed = JSON.parse(extractJsonObject(rawText)) as unknown + if (!isRecord(parsed)) return null + const title = normalizeString(parsed.title) + if (!title || !isIntent(parsed.intent) || !Array.isArray(parsed.steps)) return null + + // 中文注释:Planner 输出来自 LLM,必须在进入 Agent guidance 前做结构约束; + // 这里裁剪长度并过滤不存在的工具,避免把幻觉工具名注入后续执行上下文。 + const steps = parsed.steps + .filter(isRecord) + .map((step) => { + const goal = normalizeString(step.goal) + const evidenceNeeded = normalizeString(step.evidenceNeeded) + if (!goal || !evidenceNeeded) return null + const suggestedTools = Array.isArray(step.suggestedTools) + ? step.suggestedTools.filter((tool): tool is string => typeof tool === 'string' && availableTools.has(tool)) + : [] + return { goal, suggestedTools: Array.from(new Set(suggestedTools)), evidenceNeeded } + }) + .filter((step): step is NonNullable => step !== null) + .slice(0, 5) + + if (steps.length === 0) return null + + const successCriteria = Array.isArray(parsed.successCriteria) + ? parsed.successCriteria + .map(normalizeString) + .filter((item): item is string => item !== null) + .slice(0, 5) + : [] + if (successCriteria.length === 0) return null + + return { + version: 1, + title, + route: 'planned_execution', + intent: parsed.intent, + steps, + successCriteria, + } + } catch { + return null + } +} + +function resultToText(result: PlannerCompletionResult): string { + return typeof result === 'string' ? result : result.text +} + +function buildPlannerPrompt(input: PlannerInput): string { + const dataSnapshot = input.dataSnapshot + ? `${input.dataSnapshot.name}, ${input.dataSnapshot.totalMessages} messages, ${input.dataSnapshot.totalMembers} members` + : '(none)' + + return `Create a concise user-visible analysis plan for a ChatLab planned_execution request. + +Return strict JSON only: +{ + "title": "short title", + "intent": "summary|trend|relationship|search|comparison|mixed", + "steps": [ + {"goal": "what to investigate", "suggestedTools": ["tool_name"], "evidenceNeeded": "what evidence is needed"} + ], + "successCriteria": ["criterion"] +} + +Limits: +- Max 5 steps. +- Max 5 successCriteria. +- suggestedTools must come only from availableTools. +- Do not reveal hidden chain-of-thought; write a user-readable plan summary. +- The plan is soft guidance, not a mandatory execution script. + +Context: +- locale: ${input.locale} +- chatType: ${input.chatType} +- availableTools: ${input.availableTools.join(', ') || '(none)'} +- dataSnapshot: ${dataSnapshot} +- assistantSummary: ${input.assistantSummary ?? '(none)'} +- skillSummary: ${input.skillSummary ?? '(none)'} +- recentIntentSummary: ${input.recentIntentSummary ?? '(none)'} + +User request: +${input.userMessage}` +} + +export function createAnalysisPlanner(options: CreateAnalysisPlannerOptions): AnalysisPlanner { + return async (input, signal) => { + try { + const prompt = buildPlannerPrompt(input) + const rawResult = options.complete + ? await options.complete(prompt, signal) + : await completeWithPiAi(prompt, options, signal) + return parsePlanJson(resultToText(rawResult), new Set(input.availableTools)) + } catch { + return null + } + } +} + +async function completeWithPiAi( + prompt: string, + options: CreateAnalysisPlannerOptions, + signal?: AbortSignal +): Promise { + if (!options.piModel || !options.apiKey) { + throw new Error('Planner requires piModel and apiKey') + } + + const result = await completeSimple( + options.piModel, + { + systemPrompt: 'You are a strict JSON planner for ChatLab analysis. Return JSON only.', + messages: [{ role: 'user', content: [{ type: 'text', text: prompt }], timestamp: Date.now() }] as any, + }, + { + apiKey: options.apiKey, + maxTokens: options.maxTokens ?? 700, + temperature: options.temperature ?? 0.2, + signal, + } + ) + + return { + text: result.content + .filter((item): item is PiTextContent => item.type === 'text') + .map((item) => item.text) + .join(''), + } +} diff --git a/packages/node-runtime/src/ai/agent/planning-types.ts b/packages/node-runtime/src/ai/agent/planning-types.ts new file mode 100644 index 00000000..77250ccb --- /dev/null +++ b/packages/node-runtime/src/ai/agent/planning-types.ts @@ -0,0 +1,38 @@ +import type { DataSnapshot } from './prompt-builder' + +export type AnalysisPlanIntent = 'summary' | 'trend' | 'relationship' | 'search' | 'comparison' | 'mixed' + +export interface AnalysisPlanStep { + goal: string + suggestedTools: string[] + evidenceNeeded: string +} + +export interface AnalysisPlanSummary { + version: 1 + title: string + route: 'planned_execution' + intent: AnalysisPlanIntent + steps: AnalysisPlanStep[] + successCriteria: string[] +} + +export interface PlanContentBlock { + type: 'plan' + version: 1 + status: 'created' | 'executing' | 'done' | 'skipped' + plan: AnalysisPlanSummary +} + +export interface PlannerInput { + userMessage: string + chatType: 'group' | 'private' + locale: string + dataSnapshot?: DataSnapshot + availableTools: string[] + assistantSummary?: string + skillSummary?: string + recentIntentSummary?: string +} + +export type AnalysisPlanner = (input: PlannerInput, signal?: AbortSignal) => Promise diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts index d8a47185..2c71233a 100644 --- a/packages/node-runtime/src/ai/index.ts +++ b/packages/node-runtime/src/ai/index.ts @@ -99,6 +99,15 @@ export type { PreprocessingPipelineOptions, PreprocessingPipelineResult } from ' export type { AgentCoreOptions, AgentCoreEvent, AgentCoreResult, AgentTokenUsage, SimpleHistoryMessage } from './agent' export { DEFAULT_MAX_TOOL_ROUNDS, createLlmRouteDecider, decideRequestRoute, runAgentCore } from './agent' export type { LlmRouteDecider, RequestRoute, RouteDecision, RouteDecisionSource, RouterInput } from './agent' +export { createAnalysisPlanner } from './agent' +export type { + AnalysisPlanIntent, + AnalysisPlanner, + AnalysisPlanStep, + AnalysisPlanSummary, + PlannerInput, + PlanContentBlock, +} from './agent' // Agent Event Handler export { AgentEventHandler, estimateTokensFromText } from './agent/event-handler' diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index 36bcd36d..0e522a9d 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -205,6 +205,15 @@ export type { AIChat, AIMessage, AIMessageRole, ContentBlock, TokenUsageData, AI export type { AgentCoreOptions, AgentCoreEvent, AgentCoreResult, AgentTokenUsage, SimpleHistoryMessage } from './ai' export { DEFAULT_MAX_TOOL_ROUNDS, createLlmRouteDecider, decideRequestRoute, runAgentCore } from './ai' export type { LlmRouteDecider, RequestRoute, RouteDecision, RouteDecisionSource, RouterInput } from './ai' +export { createAnalysisPlanner } from './ai' +export type { + AnalysisPlanIntent, + AnalysisPlanner, + AnalysisPlanStep, + AnalysisPlanSummary, + PlannerInput, + PlanContentBlock, +} from './ai' // Summary generation export {