diff --git a/apps/cli/src/ai/agent.ts b/apps/cli/src/ai/agent.ts index 9ed95fe1..f7c7cdcd 100644 --- a/apps/cli/src/ai/agent.ts +++ b/apps/cli/src/ai/agent.ts @@ -7,7 +7,10 @@ import { DEFAULT_MAX_TOOL_ROUNDS, + buildPlanGuidance, + createAnalysisPlanner, createLlmRouteDecider, + createPlanContentBlock, decideRequestRoute, runAgentCore, checkAndCompress, @@ -166,24 +169,22 @@ export async function runServerAgent(options: RunAgentOptions): Promise { let cachedMessages: PiMessage[] = [] try { + const routeInput = { + userMessage, + chatType, + locale, + dataSnapshot, + availableTools: tools.map((tool) => tool.name), + skillSummary: skillDef?.name ?? (skillMenu ? 'auto_skill_menu' : undefined), + } const routeStartedAt = Date.now() - const routeDecision = await decideRequestRoute( - { - userMessage, - chatType, - locale, - dataSnapshot, - availableTools: tools.map((tool) => tool.name), - skillSummary: skillDef?.name ?? (skillMenu ? 'auto_skill_menu' : undefined), - }, - { - llmRouter: createLlmRouteDecider({ - piModel, - apiKey: llmConfig.apiKey, - abortSignal, - }), - } - ) + const routeDecision = await decideRequestRoute(routeInput, { + llmRouter: createLlmRouteDecider({ + piModel, + apiKey: llmConfig.apiKey, + abortSignal, + }), + }) aiLogger?.info('Router', 'Shadow route decision', { ...routeDecision, elapsedMs: Date.now() - routeStartedAt, @@ -191,10 +192,33 @@ export async function runServerAgent(options: RunAgentOptions): Promise { shadowOnly: true, }) + let effectiveSystemPrompt = systemPrompt + if (routeDecision.route === 'planned_execution') { + const planStartedAt = Date.now() + const planner = createAnalysisPlanner({ piModel, apiKey: llmConfig.apiKey }) + const plan = await planner(routeInput, abortSignal) + if (plan) { + const planBlock = createPlanContentBlock(plan) + onEvent({ type: 'plan', plan: planBlock }) + effectiveSystemPrompt = `${systemPrompt}\n\n${buildPlanGuidance(plan)}` + aiLogger?.info('Planner', 'Plan generated', { + title: plan.title, + steps: plan.steps.length, + successCriteria: plan.successCriteria.length, + elapsedMs: Date.now() - planStartedAt, + }) + } else { + aiLogger?.warn('Planner', 'Plan generation skipped or failed', { + elapsedMs: Date.now() - planStartedAt, + route: routeDecision.route, + }) + } + } + const result = await runAgentCore({ piModel, apiKey: llmConfig.apiKey, - systemPrompt, + systemPrompt: effectiveSystemPrompt, tools, history, userMessage, diff --git a/apps/desktop/main/ai/agent/index.ts b/apps/desktop/main/ai/agent/index.ts index 35e28887..7754ef29 100644 --- a/apps/desktop/main/ai/agent/index.ts +++ b/apps/desktop/main/ai/agent/index.ts @@ -11,7 +11,10 @@ import { aiLogger, isDebugMode } from '../logger' import { t as i18nT } from '../../i18n' import { DEFAULT_MAX_TOOL_ROUNDS, + buildPlanGuidance, + createAnalysisPlanner, createLlmRouteDecider, + createPlanContentBlock, decideRequestRoute, runAgentCore, streamSimple, @@ -129,25 +132,24 @@ export class Agent { piTools.push(createActivateSkillTool(this.chatType, allowedTools, this.locale)) } + const routeInput = { + userMessage, + chatType: this.chatType, + locale: this.locale, + dataSnapshot: this.context.dataSnapshot, + availableTools: piTools.map((tool) => tool.name), + assistantSummary: this.assistantConfig?.name, + skillSummary: this.skillCtx?.skillDef?.name ?? (this.skillCtx?.skillMenu ? 'auto_skill_menu' : undefined), + } + const routeStartedAt = Date.now() - const routeDecision = await decideRequestRoute( - { - userMessage, - chatType: this.chatType, - locale: this.locale, - dataSnapshot: this.context.dataSnapshot, - availableTools: piTools.map((tool) => tool.name), - assistantSummary: this.assistantConfig?.name, - skillSummary: this.skillCtx?.skillDef?.name ?? (this.skillCtx?.skillMenu ? 'auto_skill_menu' : undefined), - }, - { - llmRouter: createLlmRouteDecider({ - piModel: this.piModel, - apiKey: this.apiKey, - abortSignal: this.abortSignal, - }), - } - ) + const routeDecision = await decideRequestRoute(routeInput, { + llmRouter: createLlmRouteDecider({ + piModel: this.piModel, + apiKey: this.apiKey, + abortSignal: this.abortSignal, + }), + }) aiLogger.info('Router', 'Shadow route decision', { ...routeDecision, elapsedMs: Date.now() - routeStartedAt, @@ -155,6 +157,29 @@ export class Agent { shadowOnly: true, }) + let effectiveSystemPrompt = systemPrompt + if (routeDecision.route === 'planned_execution') { + const planStartedAt = Date.now() + const planner = createAnalysisPlanner({ piModel: this.piModel, apiKey: this.apiKey }) + const plan = await planner(routeInput, this.abortSignal) + if (plan) { + const planBlock = createPlanContentBlock(plan) + onChunk({ type: 'plan', plan: planBlock }) + effectiveSystemPrompt = `${systemPrompt}\n\n${buildPlanGuidance(plan)}` + aiLogger.info('Planner', 'Plan generated', { + title: plan.title, + steps: plan.steps.length, + successCriteria: plan.successCriteria.length, + elapsedMs: Date.now() - planStartedAt, + }) + } else { + aiLogger.warn('Planner', 'Plan generation skipped or failed', { + elapsedMs: Date.now() - planStartedAt, + route: routeDecision.route, + }) + } + } + const historyMessages = this.loadHistory() let cachedMessages: PiMessage[] = [] @@ -168,7 +193,7 @@ export class Agent { const result = await runAgentCore({ piModel: this.piModel, apiKey: this.apiKey, - systemPrompt, + systemPrompt: effectiveSystemPrompt, tools: maxToolRounds > 0 ? piTools : [], history: historyMessages, userMessage, diff --git a/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts b/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts index d04eb55f..2ffd54d6 100644 --- a/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts +++ b/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts @@ -1,6 +1,7 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { createAnalysisPlanner } from '../planner' +import { buildPlanGuidance, createAnalysisPlanner, createPlanContentBlock } from '../planner' +import type { AnalysisPlanSummary } from '../planning-types' const baseInput = { userMessage: '分析过去一年群里话题的变化趋势,按季度总结主要变化,并举出证据。', @@ -64,4 +65,39 @@ describe('createAnalysisPlanner', () => { assert.equal(plan, null) }) + + it('creates versioned plan content blocks', () => { + const plan: AnalysisPlanSummary = { + version: 1, + title: '年度话题趋势分析', + route: 'planned_execution', + intent: 'trend', + steps: [{ goal: '按季度检索', suggestedTools: ['search_messages'], evidenceNeeded: '季度证据' }], + successCriteria: ['覆盖全年'], + } + + const block = createPlanContentBlock(plan) + + assert.equal(block.type, 'plan') + assert.equal(block.version, 1) + assert.equal(block.status, 'created') + assert.deepEqual(block.plan, plan) + }) + + it('builds soft guidance from plan summaries', () => { + const guidance = buildPlanGuidance({ + version: 1, + title: '年度话题趋势分析', + route: 'planned_execution', + intent: 'trend', + steps: [{ goal: '按季度检索', suggestedTools: ['search_messages'], evidenceNeeded: '季度证据' }], + successCriteria: ['覆盖全年'], + }) + + assert.match(guidance, /suggested analysis plan/i) + assert.match(guidance, /do not follow it mechanically/i) + assert.match(guidance, /年度话题趋势分析/) + assert.match(guidance, /search_messages/) + assert.match(guidance, /覆盖全年/) + }) }) diff --git a/packages/node-runtime/src/ai/agent/event-handler.ts b/packages/node-runtime/src/ai/agent/event-handler.ts index 6a07f6e9..f712b43d 100644 --- a/packages/node-runtime/src/ai/agent/event-handler.ts +++ b/packages/node-runtime/src/ai/agent/event-handler.ts @@ -7,6 +7,7 @@ import type { Message as PiMessage } from '@earendil-works/pi-ai' import type { AgentCoreEvent } from './types' +import type { PlanContentBlock } from './planning-types' // ==================== Shared types ==================== @@ -27,7 +28,7 @@ export interface AgentRuntimeStatus { } export interface AgentStreamChunk { - type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'compression_done' | 'done' | 'error' + type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'compression_done' | 'plan' | 'done' | 'error' content?: string thinkTag?: string thinkDurationMs?: number @@ -38,6 +39,7 @@ export interface AgentStreamChunk { isFinished?: boolean usage?: TokenUsage status?: AgentRuntimeStatus + plan?: PlanContentBlock compressionResult?: { summaryContent: string tokensBefore: number diff --git a/packages/node-runtime/src/ai/agent/index.ts b/packages/node-runtime/src/ai/agent/index.ts index 7576d47e..90d6814c 100644 --- a/packages/node-runtime/src/ai/agent/index.ts +++ b/packages/node-runtime/src/ai/agent/index.ts @@ -3,7 +3,7 @@ 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 { buildPlanGuidance, createAnalysisPlanner, createPlanContentBlock } from './planner' export type { AnalysisPlanIntent, AnalysisPlanner, diff --git a/packages/node-runtime/src/ai/agent/planner.ts b/packages/node-runtime/src/ai/agent/planner.ts index 217775e8..95e4e3d2 100644 --- a/packages/node-runtime/src/ai/agent/planner.ts +++ b/packages/node-runtime/src/ai/agent/planner.ts @@ -4,7 +4,13 @@ import { type Model as PiModel, type TextContent as PiTextContent, } from '@earendil-works/pi-ai' -import type { AnalysisPlanIntent, AnalysisPlanSummary, AnalysisPlanner, PlannerInput } from './planning-types' +import type { + AnalysisPlanIntent, + AnalysisPlanSummary, + AnalysisPlanner, + PlanContentBlock, + PlannerInput, +} from './planning-types' export type PlannerCompletionResult = string | { text: string } @@ -149,6 +155,39 @@ export function createAnalysisPlanner(options: CreateAnalysisPlannerOptions): An } } +export function createPlanContentBlock( + plan: AnalysisPlanSummary, + status: PlanContentBlock['status'] = 'created' +): PlanContentBlock { + return { + type: 'plan', + version: 1, + status, + plan, + } +} + +export function buildPlanGuidance(plan: AnalysisPlanSummary): string { + const steps = plan.steps + .map((step, index) => { + const tools = step.suggestedTools.length > 0 ? step.suggestedTools.join(', ') : 'none' + return `${index + 1}. ${step.goal}\n Evidence needed: ${step.evidenceNeeded}\n Suggested tools: ${tools}` + }) + .join('\n') + const successCriteria = plan.successCriteria.map((item) => `- ${item}`).join('\n') + + return `A suggested analysis plan is available below. Use it as guidance when helpful, but do not follow it mechanically if the user's question can be answered more directly. + +Plan title: ${plan.title} +Intent: ${plan.intent} + +Steps: +${steps} + +Success criteria: +${successCriteria}` +} + async function completeWithPiAi( prompt: string, options: CreateAnalysisPlannerOptions, diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts index 2c71233a..9302a321 100644 --- a/packages/node-runtime/src/ai/index.ts +++ b/packages/node-runtime/src/ai/index.ts @@ -99,7 +99,7 @@ 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 { buildPlanGuidance, createAnalysisPlanner, createPlanContentBlock } from './agent' export type { AnalysisPlanIntent, AnalysisPlanner, diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index 0e522a9d..ecda4fe1 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -205,7 +205,7 @@ 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 { buildPlanGuidance, createAnalysisPlanner, createPlanContentBlock } from './ai' export type { AnalysisPlanIntent, AnalysisPlanner,