diff --git a/apps/cli/src/ai/tool-adapter.test.ts b/apps/cli/src/ai/tool-adapter.test.ts index 0be16d85..9a5e7930 100644 --- a/apps/cli/src/ai/tool-adapter.test.ts +++ b/apps/cli/src/ai/tool-adapter.test.ts @@ -123,4 +123,25 @@ describe('adaptToolsForAgent', () => { assert.ok(text.includes('hello world')) assert.ok(text.includes('hi there')) }) + + it('bridges shared tool progress to Pi tool updates', async () => { + const tool: ToolDefinition = { + name: 'slow_tool', + description: 'Slow tool', + inputSchema: { type: 'object', properties: {} }, + async handler(_params, context) { + context.reportProgress?.({ phase: 'searching' }) + return { content: 'Done' } + }, + } + const [agentTool] = adaptToolsForAgent([tool], () => ({ + db: {} as DatabaseAdapter, + sessionId: 'session-1', + })) + const updates: unknown[] = [] + + await agentTool.execute('call-progress', {}, undefined, (update) => updates.push(update)) + + assert.deepEqual(updates, [{ content: [], details: { progress: { phase: 'searching' } } }]) + }) }) diff --git a/apps/cli/src/ai/tool-adapter.ts b/apps/cli/src/ai/tool-adapter.ts index 913845df..683febff 100644 --- a/apps/cli/src/ai/tool-adapter.ts +++ b/apps/cli/src/ai/tool-adapter.ts @@ -74,13 +74,15 @@ export function adaptToolsForAgent( description: tool.description, parameters: toAgentToolParameters(tool.inputSchema) as any, executionMode: tool.executionMode, - async execute(_toolCallId: string, params: unknown) { + async execute(_toolCallId: string, params: unknown, signal, onUpdate) { const ctx = getContext() const execCtx: ToolExecutionContext = { db: ctx.db, dataProvider: new CoreDataProvider(ctx.db), sessionId: ctx.sessionId, locale: ctx.locale, + abortSignal: signal, + reportProgress: (progress) => onUpdate?.({ content: [], details: { progress } }), semanticIndexService: ctx.semanticIndexService, preprocessConfig: ctx.preprocessConfig, ownerPlatformId: ctx.ownerPlatformId, diff --git a/apps/desktop/main/ai/tools/index.ts b/apps/desktop/main/ai/tools/index.ts index e0770d59..1efd18b6 100644 --- a/apps/desktop/main/ai/tools/index.ts +++ b/apps/desktop/main/ai/tools/index.ts @@ -90,8 +90,8 @@ function wrapWithPreprocessing(tool: AgentTool, context: ToolContext): Agen const originalExecute = tool.execute return { ...tool, - execute: async (toolCallId: string, params: any, _signal?: AbortSignal, _onUpdate?: unknown) => { - const result = await originalExecute(toolCallId, params) + execute: async (toolCallId: string, params: any, signal, onUpdate) => { + const result = await originalExecute(toolCallId, params, signal, onUpdate) const details = result.details as Record | undefined if (!details?.rawMessages || !Array.isArray(details.rawMessages)) { diff --git a/apps/desktop/main/ai/tools/shared-tool-adapter.ts b/apps/desktop/main/ai/tools/shared-tool-adapter.ts index 7fc8336f..b12defc9 100644 --- a/apps/desktop/main/ai/tools/shared-tool-adapter.ts +++ b/apps/desktop/main/ai/tools/shared-tool-adapter.ts @@ -10,6 +10,7 @@ import { toAgentToolParameters, type ToolDefinition, type ToolExecutionContext, + type ToolProgress, type RawMessage, } from '@openchatlab/tools' import type { AgentTool, PreprocessableMessage, PreprocessConfig } from '@openchatlab/node-runtime' @@ -18,13 +19,19 @@ import type { ToolContext, ToolRegistryEntry } from './types' import { WorkerDataProvider } from './worker-data-provider' import { t as i18nT } from '../../i18n' -function buildExecutionContext(ctx: ToolContext): ToolExecutionContext { +function buildExecutionContext( + ctx: ToolContext, + signal?: AbortSignal, + reportProgress?: (progress: ToolProgress) => void +): ToolExecutionContext { + const abortSignal = signal ?? ctx.abortSignal return { - dataProvider: new WorkerDataProvider(ctx.sessionId, ctx.abortSignal), + dataProvider: new WorkerDataProvider(ctx.sessionId, abortSignal), sessionId: ctx.sessionId, locale: ctx.locale, timeFilter: ctx.timeFilter, - abortSignal: ctx.abortSignal, + abortSignal, + reportProgress, searchContextBefore: ctx.searchContextBefore, searchContextAfter: ctx.searchContextAfter, maxMessagesLimit: ctx.maxMessagesLimit, @@ -59,8 +66,12 @@ export function adaptSharedTool(tool: ToolDefinition): ToolRegistryEntry { description: tool.description, parameters: toAgentToolParameters(tool.inputSchema) as any, executionMode: tool.executionMode, - async execute(_toolCallId: string, params: unknown) { - return executeToolForAgent(tool, params, buildExecutionContext(context)) + async execute(_toolCallId: string, params: unknown, signal, onUpdate) { + return executeToolForAgent( + tool, + params, + buildExecutionContext(context, signal, (progress) => onUpdate?.({ content: [], details: { progress } })) + ) }, } }, diff --git a/packages/node-runtime/src/ai/agent/__tests__/core.test.ts b/packages/node-runtime/src/ai/agent/__tests__/core.test.ts index 49d96f14..d4752fbf 100644 --- a/packages/node-runtime/src/ai/agent/__tests__/core.test.ts +++ b/packages/node-runtime/src/ai/agent/__tests__/core.test.ts @@ -88,6 +88,8 @@ function emitMessage(stream: AssistantMessageEventStream, message: AssistantMess if (message.stopReason === 'error' || message.stopReason === 'aborted') { stream.push({ type: 'error', reason: message.stopReason, error: message }) + } else if (message.stopReason === 'pending') { + throw new Error('A scripted assistant message must be complete before it is emitted') } else { stream.push({ type: 'done', reason: message.stopReason, message }) } @@ -219,6 +221,37 @@ describe('runAgentCore runtime contract', () => { ) }) + it('forwards tool execution progress as a stable core event', async () => { + const events: AgentCoreEvent[] = [] + const tool = createTool('lookup', async (_toolCallId, _params, _signal, onUpdate) => { + onUpdate?.({ content: [], details: { progress: { phase: 'searching' } } }) + return { content: [{ type: 'text', text: 'tool result' }], details: null } + }) + + await runAgentCore( + createOptions({ + tools: [tool], + streamFn: createScriptedStream([ + assistantMessage([{ type: 'toolCall', id: 'tool-progress', name: 'lookup', arguments: {} }], { + stopReason: 'toolUse', + }), + assistantMessage([{ type: 'text', text: 'Done' }]), + ]), + onEvent: (event) => events.push(event), + }) + ) + + assert.deepEqual( + events.find((event) => event.type === 'tool_update'), + { + type: 'tool_update', + toolCallId: 'tool-progress', + toolName: 'lookup', + progress: { phase: 'searching' }, + } + ) + }) + it('turns thrown tool errors into error tool results and keeps the agent loop recoverable', async () => { const events: AgentCoreEvent[] = [] const tool = createTool('broken_tool', async () => { diff --git a/packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts b/packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts index 05b81bc5..32377387 100644 --- a/packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts +++ b/packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts @@ -45,6 +45,34 @@ describe('AgentEventHandler', () => { assert.equal(startChunk?.toolCallId, 'call_1') }) + it('forwards tool progress without changing the result payload', () => { + const chunks: AgentStreamChunk[] = [] + const handler = new AgentEventHandler({ + onChunk: (chunk) => chunks.push(chunk), + context: {}, + systemPrompt: 'test', + }) + + handler.handleCoreEvent( + { + type: 'tool_update', + toolCallId: 'call_1', + toolName: 'retrieve_chat_evidence', + progress: { phase: 'semantic_search' }, + }, + [] + ) + + assert.deepEqual(chunks, [ + { + type: 'tool_update', + toolCallId: 'call_1', + toolName: 'retrieve_chat_evidence', + toolProgress: { phase: 'semantic_search' }, + }, + ]) + }) + it('updates tool rounds on turn_end', () => { const handler = new AgentEventHandler({ onChunk: () => {}, diff --git a/packages/node-runtime/src/ai/agent/core.ts b/packages/node-runtime/src/ai/agent/core.ts index 8d34ad19..a84048a5 100644 --- a/packages/node-runtime/src/ai/agent/core.ts +++ b/packages/node-runtime/src/ai/agent/core.ts @@ -9,6 +9,7 @@ import { Agent as PiAgentCore } from '@earendil-works/pi-agent-core' import type { AgentEvent as PiAgentEvent, AgentMessage as PiAgentMessage } from '@earendil-works/pi-agent-core' import { type Message as PiMessage, type Usage as PiUsage, clampThinkingLevel } from '@earendil-works/pi-ai' import { StreamingThinkTagParser, needsStreamingThinkParsing } from '@openchatlab/core' +import type { ToolProgress } from '@openchatlab/shared-types' import type { AgentCoreOptions, AgentCoreResult, AgentTokenUsage } from './types' import { initTokenizer } from '../tokenizer' @@ -197,6 +198,16 @@ export async function runAgentCore(options: AgentCoreOptions): Promise, }) + } else if (event.type === 'tool_execution_update') { + const details = event.partialResult?.details as { progress?: ToolProgress } | null + if (details?.progress) { + onEvent({ + type: 'tool_update', + toolCallId: event.toolCallId, + toolName: event.toolName, + progress: details.progress, + }) + } } else if (event.type === 'tool_execution_end') { onEvent({ type: 'tool_end', diff --git a/packages/node-runtime/src/ai/agent/event-handler.ts b/packages/node-runtime/src/ai/agent/event-handler.ts index c8fe0e9f..9471eaa2 100644 --- a/packages/node-runtime/src/ai/agent/event-handler.ts +++ b/packages/node-runtime/src/ai/agent/event-handler.ts @@ -6,6 +6,7 @@ */ import type { Message as PiMessage } from '@earendil-works/pi-ai' +import type { ToolProgress } from '@openchatlab/shared-types' import type { AgentCoreEvent } from './types' import type { PlanContentBlock } from './planning-types' import type { RouteDecision } from './routing-types' @@ -35,6 +36,7 @@ export interface AgentStreamChunk { | 'content' | 'think' | 'tool_start' + | 'tool_update' | 'tool_result' | 'status' | 'compression_done' @@ -50,6 +52,7 @@ export interface AgentStreamChunk { toolCallId?: string toolName?: string toolParams?: Record + toolProgress?: ToolProgress toolResult?: unknown toolIsError?: boolean error?: unknown @@ -187,6 +190,14 @@ export class AgentEventHandler { this.emitStatus('tool_running', messages, { currentTool: event.toolName, force: true }) break } + case 'tool_update': + this.onChunk({ + type: 'tool_update', + toolCallId: event.toolCallId, + toolName: event.toolName, + toolProgress: event.progress, + }) + break case 'tool_end': this.onChunk({ type: 'tool_result', diff --git a/packages/node-runtime/src/ai/agent/types.ts b/packages/node-runtime/src/ai/agent/types.ts index 19f06f32..37bdb0b4 100644 --- a/packages/node-runtime/src/ai/agent/types.ts +++ b/packages/node-runtime/src/ai/agent/types.ts @@ -8,6 +8,7 @@ import type { AgentTool } from '@earendil-works/pi-agent-core' import type { Model, Api, Message } from '@earendil-works/pi-ai' import type { ThinkingLevel } from '@openchatlab/core' +import type { ToolProgress } from '@openchatlab/shared-types' import type { ContentBlock } from '../chats' export interface AgentTokenUsage { @@ -31,6 +32,7 @@ export type AgentCoreEvent = | { type: 'thinking_delta'; content: string } | { type: 'thinking_end'; durationMs?: number } | { type: 'tool_start'; toolCallId: string; toolName: string; toolParams: Record } + | { type: 'tool_update'; toolCallId: string; toolName: string; progress: ToolProgress } | { type: 'tool_end'; toolCallId: string; toolName: string; toolResult: unknown; isError: boolean } | { type: 'turn_end'; round: number; hadToolCalls: boolean } | { type: 'usage_update'; usage: AgentTokenUsage } diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 5c763abb..0509ec1c 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -39,6 +39,15 @@ export type NavigationLayoutLoadResult = | { status: 'saved'; layout: NavigationLayout } | { status: 'missing' | 'invalid'; layout: null } +// ==================== AI tool progress ==================== + +/** Stable, transport-safe progress payload emitted by long-running AI tools. */ +export interface ToolProgress { + phase: string + current?: number + total?: number +} + // ==================== 时间筛选 ==================== export interface TimeFilter { diff --git a/packages/tools/src/definitions/retrieve-chat-evidence.test.ts b/packages/tools/src/definitions/retrieve-chat-evidence.test.ts index ce832244..2ce8f7f0 100644 --- a/packages/tools/src/definitions/retrieve-chat-evidence.test.ts +++ b/packages/tools/src/definitions/retrieve-chat-evidence.test.ts @@ -86,6 +86,7 @@ describe('retrieveChatEvidenceTool schema', () => { describe('retrieveChatEvidenceTool mode resolution', () => { it('auto resolves to hybrid and calls both semantic + keyword when available with keywords', async () => { let semanticCalled = false + const progressPhases: string[] = [] const capture: { args?: unknown[] } = {} const service = makeService({ searchForTool: async () => { @@ -96,12 +97,22 @@ describe('retrieveChatEvidenceTool mode resolution', () => { const dp = makeDataProvider([rawMsg(200, 1714521600, '我们去乐山玩了')], capture) const res = await retrieveChatEvidenceTool.handler( { query: '去过乐山几次', criteria: '实际出行', keywords: ['乐山'] }, - makeContext({ semanticIndexService: service, dataProvider: dp }) + makeContext({ + semanticIndexService: service, + dataProvider: dp, + reportProgress: (progress) => progressPhases.push(progress.phase), + }) ) assert.equal(semanticCalled, true) assert.ok(capture.args, 'keyword search should be called') const payload = getPayload(res.data) assert.equal(payload.mode, 'hybrid') + assert.deepEqual(progressPhases, [ + 'checking_capabilities', + 'semantic_search', + 'keyword_search', + 'assembling_evidence', + ]) }) it('semantic mode does not call keyword search', async () => { diff --git a/packages/tools/src/definitions/retrieve-chat-evidence.ts b/packages/tools/src/definitions/retrieve-chat-evidence.ts index 74121636..3874066e 100644 --- a/packages/tools/src/definitions/retrieve-chat-evidence.ts +++ b/packages/tools/src/definitions/retrieve-chat-evidence.ts @@ -347,6 +347,7 @@ async function handler(params: Record, context: ToolExecutionCo ? params.mode : 'auto' + context.reportProgress?.({ phase: 'checking_capabilities' }) const service = context.semanticIndexService const semanticAvailable = !!service && (await service.canSearch(context.sessionId)) const rangeMs = parseEvidenceTimeRange(params, context) @@ -367,6 +368,7 @@ async function handler(params: Record, context: ToolExecutionCo if (!service || !semanticAvailable) { warnings.add('semantic_unavailable') } else { + context.reportProgress?.({ phase: 'semantic_search' }) const result = await service.searchForTool(context.sessionId, query, { maxResults: clampMaxResults(params.max_results), preprocessConfig: context.preprocessConfig, @@ -408,6 +410,7 @@ async function handler(params: Record, context: ToolExecutionCo } else if (!context.dataProvider) { warnings.add('keyword_unavailable') } else { + context.reportProgress?.({ phase: 'keyword_search' }) const secFilter = toKeywordTimeFilter(rangeMs) const searchResult = await context.dataProvider.searchMessages(keywords, { timeFilter: secFilter, @@ -418,6 +421,7 @@ async function handler(params: Record, context: ToolExecutionCo } } + context.reportProgress?.({ phase: 'assembling_evidence' }) const merged = mergeCandidates(semanticCandidates, keywordCandidates) const groups = buildGroups(merged, !!criteria, locale) diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index b128e0ae..0c518fd0 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -53,6 +53,7 @@ export { parseExtendedTimeParams } from './utils/time-params' export type { ToolDefinition, ToolExecutionContext, + ToolProgress, ToolResult, JsonSchema, RawMessage, diff --git a/packages/tools/src/types.ts b/packages/tools/src/types.ts index 0212eb91..65d448c2 100644 --- a/packages/tools/src/types.ts +++ b/packages/tools/src/types.ts @@ -5,6 +5,9 @@ */ import type { ChartPayload, DatabaseAdapter, EvidenceTimeRangeMs } from '@openchatlab/core' +import type { ToolProgress } from '@openchatlab/shared-types' + +export type { ToolProgress } from '@openchatlab/shared-types' export type { EvidenceRetrievalMode, @@ -301,6 +304,8 @@ export interface ToolExecutionContext { locale?: string timeFilter?: ToolTimeRange abortSignal?: AbortSignal + /** Report meaningful phases for long-running tool execution. */ + reportProgress?: (progress: ToolProgress) => void /** 抽象查询接口 */ dataProvider?: ToolDataProvider /** 语义检索窄接口(仅当前会话可检索时由 adapter 注入) */ diff --git a/src/components/AIChat/chat/AIThinkingIndicator.vue b/src/components/AIChat/chat/AIThinkingIndicator.vue index 14f6d9da..763ec155 100644 --- a/src/components/AIChat/chat/AIThinkingIndicator.vue +++ b/src/components/AIChat/chat/AIThinkingIndicator.vue @@ -2,6 +2,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import type { AgentRuntimeStatus } from '@electron/shared/types' +import type { ToolProgress } from '@openchatlab/shared-types' const { t, te } = useI18n() @@ -11,6 +12,12 @@ function localizedToolName(name: string, fallback?: string): string { return te(key) ? t(key) : fallback || name } +function localizedToolProgress(progress?: ToolProgress): string { + if (!progress) return '' + const key = `ai.chat.thinking.toolProgress.${progress.phase}` + return te(key) ? t(key) : '' +} + // Props const props = defineProps<{ // 当前工具执行状态 @@ -18,6 +25,7 @@ const props = defineProps<{ name: string displayName: string status: 'running' | 'done' | 'error' + progress?: ToolProgress } | null // 当前轮次已使用的工具列表 toolsUsed: string[] @@ -83,6 +91,13 @@ const isCompressing = computed(() => props.agentStatus?.phase === 'compressing') + + {{ localizedToolProgress(currentToolStatus.progress) }} + + + toolProgress?: ToolProgress toolResult?: unknown toolIsError?: boolean error?: unknown diff --git a/src/stores/aiChat.ts b/src/stores/aiChat.ts index b39c6091..a614a287 100644 --- a/src/stores/aiChat.ts +++ b/src/stores/aiChat.ts @@ -20,7 +20,7 @@ import { useAgentStreamService } from '@/services/ai-stream/service' import { buildSerializablePreprocessConfig, shouldEnsureDesensitizeRulesBeforeSerialize } from './aiPreprocessConfig' import type { ChartPayload, ChatEvidencePayload } from '@openchatlab/core' import { extractToolResultText, truncateToolResultText } from '@openchatlab/core' -import { getDefaultGeneralAssistantId } from '@openchatlab/shared-types' +import { getDefaultGeneralAssistantId, type ToolProgress } from '@openchatlab/shared-types' import { createRenderOnlyToolPendingBlock, extractChartPayloads, @@ -129,6 +129,7 @@ export interface ToolStatus { name: string displayName: string status: 'running' | 'done' | 'error' + progress?: ToolProgress result?: unknown } @@ -1064,6 +1065,17 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { } break + case 'tool_update': + if ( + chunk.toolName && + chunk.toolProgress && + state.currentToolStatus?.name === chunk.toolName && + state.currentToolStatus.status === 'running' + ) { + state.currentToolStatus = { ...state.currentToolStatus, progress: chunk.toolProgress } + } + break + case 'tool_result': if (chunk.toolName) { const charts = extractChartPayloads(chunk.toolResult) @@ -1643,6 +1655,16 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { } } break + case 'tool_update': + if ( + chunk.toolName && + chunk.toolProgress && + state.currentToolStatus?.name === chunk.toolName && + state.currentToolStatus.status === 'running' + ) { + state.currentToolStatus = { ...state.currentToolStatus, progress: chunk.toolProgress } + } + break case 'tool_result': if (chunk.toolName) { const charts = extractChartPayloads(chunk.toolResult)