Skip to content

Commit c6db02f

Browse files
zerob13KirinJin2046zhangmo8
authored
feat: add question tools (#1298)
* feat(agent): add question tool flow * feat(mcp): implement real Apple Maps search using URL scheme (#1289) * feat: support voice.ai (#1291) * feat: remove custome tiptap (#1295) * feat: settings auto scroll toggle (#1293) * feat: settings auto scroll toggle * feat: i18n support * fix(renderer): remove specific event listeners instead of all * feat: add tooltip for filling default API URL in settings (#1296) * refactor(question): simplify question request UI to single-choice interface * fix(chat): restore pending question state * fix: review issues --------- Co-authored-by: Qi Jin <jin.qi1@northeastern.edu> Co-authored-by: xiaomo <wegi866@gmail.com>
1 parent 7a90f8f commit c6db02f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+1188
-33
lines changed

src/main/presenter/agentPresenter/acp/agentToolManager.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { presenter } from '@/presenter'
99
import { AgentFileSystemHandler } from './agentFileSystemHandler'
1010
import { AgentBashHandler } from './agentBashHandler'
1111
import { SkillTools } from '../../skillPresenter/skillTools'
12+
import { questionToolSchema, QUESTION_TOOL_NAME } from '../tools/questionTool'
1213
import {
1314
ChatSettingsToolHandler,
1415
buildChatSettingsToolDefinitions,
@@ -278,6 +279,9 @@ export class AgentToolManager {
278279
defs.push(...fsDefs)
279280
}
280281

282+
// 2. Built-in question tool (all modes)
283+
defs.push(...this.getQuestionToolDefinitions())
284+
281285
// 3. Skill tools (agent mode only)
282286
if (isAgentMode && this.isSkillsEnabled()) {
283287
const skillDefs = this.getSkillToolDefinitions()
@@ -331,6 +335,21 @@ export class AgentToolManager {
331335
args: Record<string, unknown>,
332336
conversationId?: string
333337
): Promise<AgentToolCallResult | string> {
338+
if (toolName === QUESTION_TOOL_NAME) {
339+
const validationResult = questionToolSchema.safeParse(args)
340+
if (!validationResult.success) {
341+
throw new Error(`Invalid arguments for question: ${validationResult.error.message}`)
342+
}
343+
return {
344+
content: 'question_requested',
345+
rawData: {
346+
content: 'question_requested',
347+
isError: false,
348+
toolResult: validationResult.data
349+
}
350+
}
351+
}
352+
334353
// Route to FileSystem tools
335354
if (this.isFileSystemTool(toolName)) {
336355
if (!this.fileSystemHandler) {
@@ -610,6 +629,29 @@ export class AgentToolManager {
610629
]
611630
}
612631

632+
private getQuestionToolDefinitions(): MCPToolDefinition[] {
633+
return [
634+
{
635+
type: 'function',
636+
function: {
637+
name: QUESTION_TOOL_NAME,
638+
description:
639+
'Ask the user a structured question and pause the agent loop until the user responds.',
640+
parameters: zodToJsonSchema(questionToolSchema) as {
641+
type: string
642+
properties: Record<string, unknown>
643+
required?: string[]
644+
}
645+
},
646+
server: {
647+
name: 'agent-core',
648+
icons: '❓',
649+
description: 'Agent core tools'
650+
}
651+
}
652+
]
653+
}
654+
613655
private isFileSystemTool(toolName: string): boolean {
614656
const filesystemTools = [
615657
'read_file',

src/main/presenter/agentPresenter/index.ts

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
ISQLitePresenter,
88
MESSAGE_METADATA
99
} from '@shared/presenter'
10-
import type { AssistantMessage } from '@shared/chat'
10+
import type { AssistantMessage, AssistantMessageBlock, UserMessageContent } from '@shared/chat'
1111
import { eventBus, SendTarget } from '@/eventbus'
1212
import { STREAM_EVENTS } from '@/events'
1313
import { presenter } from '@/presenter'
@@ -161,6 +161,12 @@ export class AgentPresenter implements IAgentPresenter {
161161
this.buildMessageMetadata(conversation)
162162
)
163163

164+
try {
165+
await this.resolvePendingQuestionIfNeeded(agentId, userMessage.id, content)
166+
} catch (error) {
167+
console.warn('[AgentPresenter] Failed to auto-resolve pending question:', error)
168+
}
169+
164170
const assistantMessage = await this.streamGenerationHandler.generateAIResponse(
165171
agentId,
166172
userMessage.id
@@ -301,6 +307,25 @@ export class AgentPresenter implements IAgentPresenter {
301307
)
302308
}
303309

310+
async resolveQuestion(
311+
messageId: string,
312+
toolCallId: string,
313+
answerText: string,
314+
answerMessageId?: string
315+
): Promise<void> {
316+
await this.handleQuestionResolution(messageId, toolCallId, {
317+
resolution: 'replied',
318+
answerText,
319+
answerMessageId
320+
})
321+
}
322+
323+
async rejectQuestion(messageId: string, toolCallId: string): Promise<void> {
324+
await this.handleQuestionResolution(messageId, toolCallId, {
325+
resolution: 'rejected'
326+
})
327+
}
328+
304329
async getMessageRequestPreview(agentId: string, messageId?: string): Promise<unknown> {
305330
if (!messageId) {
306331
return null
@@ -309,6 +334,115 @@ export class AgentPresenter implements IAgentPresenter {
309334
return this.utilityHandler.getMessageRequestPreview(messageId)
310335
}
311336

337+
private async handleQuestionResolution(
338+
messageId: string,
339+
toolCallId: string,
340+
payload: {
341+
resolution: 'replied' | 'rejected'
342+
answerText?: string
343+
answerMessageId?: string
344+
}
345+
): Promise<void> {
346+
if (!messageId || !toolCallId) {
347+
return
348+
}
349+
350+
const message = await this.messageManager.getMessage(messageId)
351+
if (!message || message.role !== 'assistant') {
352+
throw new Error(`Message not found or not assistant (${messageId})`)
353+
}
354+
355+
const content = message.content as AssistantMessageBlock[]
356+
const questionBlock = content.find(
357+
(block) =>
358+
block.type === 'action' &&
359+
block.action_type === 'question_request' &&
360+
block.tool_call?.id === toolCallId
361+
)
362+
363+
if (!questionBlock) {
364+
throw new Error(
365+
`Question block not found (messageId: ${messageId}, toolCallId: ${toolCallId})`
366+
)
367+
}
368+
369+
if (questionBlock.status !== 'pending') {
370+
return
371+
}
372+
373+
const isReplied = payload.resolution === 'replied'
374+
questionBlock.status = isReplied ? 'success' : 'denied'
375+
questionBlock.extra = {
376+
...questionBlock.extra,
377+
needsUserAction: false,
378+
questionResolution: payload.resolution,
379+
...(isReplied && payload.answerText ? { answerText: payload.answerText } : {}),
380+
...(isReplied && payload.answerMessageId ? { answerMessageId: payload.answerMessageId } : {})
381+
}
382+
383+
const generatingState = this.generatingMessages.get(messageId)
384+
if (generatingState) {
385+
const questionIndex = generatingState.message.content.findIndex(
386+
(block) =>
387+
block.type === 'action' &&
388+
block.action_type === 'question_request' &&
389+
block.tool_call?.id === toolCallId
390+
)
391+
if (questionIndex !== -1) {
392+
const stateBlock = generatingState.message.content[questionIndex]
393+
generatingState.message.content[questionIndex] = {
394+
...stateBlock,
395+
...questionBlock,
396+
extra: questionBlock.extra ? { ...questionBlock.extra } : undefined,
397+
tool_call: questionBlock.tool_call ? { ...questionBlock.tool_call } : undefined
398+
}
399+
}
400+
}
401+
402+
await this.messageManager.editMessage(messageId, JSON.stringify(content))
403+
presenter.sessionManager.clearPendingQuestion(message.conversationId)
404+
presenter.sessionManager.setStatus(message.conversationId, 'idle')
405+
}
406+
407+
private async resolvePendingQuestionIfNeeded(
408+
conversationId: string,
409+
userMessageId: string,
410+
rawContent: string
411+
): Promise<void> {
412+
const session = await this.sessionManager.getSession(conversationId)
413+
const pendingQuestion = session.runtime?.pendingQuestion
414+
if (!pendingQuestion?.messageId || !pendingQuestion.toolCallId) {
415+
return
416+
}
417+
418+
const answerText = this.extractUserMessageText(rawContent)
419+
if (!answerText.trim()) {
420+
return
421+
}
422+
423+
await this.handleQuestionResolution(pendingQuestion.messageId, pendingQuestion.toolCallId, {
424+
resolution: 'replied',
425+
answerText,
426+
answerMessageId: userMessageId
427+
})
428+
}
429+
430+
private extractUserMessageText(rawContent: string): string {
431+
if (!rawContent) return ''
432+
try {
433+
const parsed = JSON.parse(rawContent) as UserMessageContent
434+
if (typeof parsed.text === 'string') {
435+
return parsed.text
436+
}
437+
if (Array.isArray(parsed.content)) {
438+
return parsed.content.map((block) => block.content || '').join('')
439+
}
440+
} catch (error) {
441+
console.warn('[AgentPresenter] Failed to parse user message content:', error)
442+
}
443+
return rawContent
444+
}
445+
312446
private buildMessageMetadata(conversation: CONVERSATION): MESSAGE_METADATA {
313447
const { providerId, modelId } = conversation.settings
314448
return {
@@ -416,6 +550,7 @@ export class AgentPresenter implements IAgentPresenter {
416550
this.sessionManager.updateRuntime(state.conversationId, { userStopRequested: true })
417551
this.sessionManager.setStatus(state.conversationId, 'paused')
418552
this.sessionManager.clearPendingPermission(state.conversationId)
553+
this.sessionManager.clearPendingQuestion(state.conversationId)
419554
state.isCancelled = true
420555

421556
if (state.adaptiveBuffer) {

src/main/presenter/agentPresenter/loop/toolCallHandler.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AssistantMessageBlock } from '@shared/chat'
2+
import type { QuestionInfo } from '@shared/types/core/question'
23
import { finalizeAssistantMessageBlocks } from '@shared/chat/messageBlocks'
34
import type {
45
LLMAgentEventData,
@@ -215,6 +216,44 @@ export class ToolCallHandler {
215216
}
216217
}
217218

219+
async processQuestionRequest(
220+
state: GeneratingMessageState,
221+
event: LLMAgentEventData,
222+
currentTime: number
223+
): Promise<void> {
224+
const payload = event.question_request as QuestionInfo | undefined
225+
if (!payload) return
226+
227+
this.finalizeLastBlock(state)
228+
229+
state.message.content.push({
230+
type: 'action',
231+
content: '',
232+
status: 'pending',
233+
timestamp: currentTime,
234+
action_type: 'question_request',
235+
tool_call: {
236+
id: event.tool_call_id,
237+
name: event.tool_call_name,
238+
params: event.tool_call_params || '',
239+
server_name: event.tool_call_server_name,
240+
server_icons: event.tool_call_server_icons,
241+
server_description: event.tool_call_server_description
242+
},
243+
extra: {
244+
needsUserAction: true,
245+
questionHeader: payload.header ?? '',
246+
questionText: payload.question,
247+
questionOptions: payload.options,
248+
questionMultiple: Boolean(payload.multiple),
249+
questionCustom: payload.custom !== false,
250+
questionResolution: 'asked'
251+
}
252+
})
253+
254+
state.pendingToolCall = undefined
255+
}
256+
218257
async processMcpUiResourcesFromToolCall(
219258
state: GeneratingMessageState,
220259
event: LLMAgentEventData,

src/main/presenter/agentPresenter/loop/toolCallProcessor.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import fs from 'fs/promises'
1010
import path from 'path'
1111
import { isNonRetryableError } from './errorClassification'
1212
import { resolveToolOffloadPath } from '../../sessionPresenter/sessionPaths'
13+
import { parseQuestionToolArgs, QUESTION_TOOL_NAME } from '../tools/questionTool'
1314

1415
interface ToolCallProcessorOptions {
1516
getAllToolDefinitions: (context: ToolCallExecutionContext) => Promise<MCPToolDefinition[]>
@@ -42,6 +43,7 @@ interface ToolCallProcessResult {
4243

4344
const TOOL_OUTPUT_OFFLOAD_THRESHOLD = 5000
4445
const TOOL_OUTPUT_PREVIEW_LENGTH = 1024
46+
const QUESTION_ERROR_KEY = 'common.error.invalidQuestionRequest'
4547

4648
// Tools that require offload when output exceeds threshold
4749
// Tools not in this list will never trigger offload (e.g., read_file has its own pagination)
@@ -75,7 +77,7 @@ export class ToolCallProcessor {
7577
return toolDefinitions.find((tool) => tool.function.name === toolName)
7678
}
7779

78-
for (const toolCall of context.toolCalls) {
80+
for (const [index, toolCall] of context.toolCalls.entries()) {
7981
if (context.abortSignal.aborted) break
8082

8183
if (toolCallCount >= context.maxToolCalls) {
@@ -145,6 +147,70 @@ export class ToolCallProcessor {
145147
conversationId: context.conversationId
146148
}
147149

150+
if (toolCall.name === QUESTION_TOOL_NAME) {
151+
const isStandalone = context.toolCalls.length === 1
152+
const isLast = index === context.toolCalls.length - 1
153+
if (!isStandalone || !isLast) {
154+
notifyToolCallFinished('error')
155+
this.appendToolError(
156+
context.conversationMessages,
157+
context.modelConfig,
158+
toolCall,
159+
'Question tool must be the only tool call in a turn.'
160+
)
161+
yield {
162+
type: 'response',
163+
data: {
164+
eventId: context.eventId,
165+
question_error: QUESTION_ERROR_KEY,
166+
tool_call_id: toolCall.id,
167+
tool_call_name: toolCall.name
168+
}
169+
}
170+
continue
171+
}
172+
173+
const parsedQuestion = parseQuestionToolArgs(toolCall.arguments || '')
174+
if (!parsedQuestion.success) {
175+
notifyToolCallFinished('error')
176+
this.appendToolError(
177+
context.conversationMessages,
178+
context.modelConfig,
179+
toolCall,
180+
`Invalid question tool arguments: ${parsedQuestion.error}`
181+
)
182+
yield {
183+
type: 'response',
184+
data: {
185+
eventId: context.eventId,
186+
question_error: QUESTION_ERROR_KEY,
187+
tool_call_id: toolCall.id,
188+
tool_call_name: toolCall.name
189+
}
190+
}
191+
continue
192+
}
193+
194+
notifyToolCallFinished('success')
195+
yield {
196+
type: 'response',
197+
data: {
198+
eventId: context.eventId,
199+
tool_call: 'question-required',
200+
tool_call_id: toolCall.id,
201+
tool_call_name: toolCall.name,
202+
tool_call_params: toolCall.arguments,
203+
tool_call_server_name: toolDef.server.name,
204+
tool_call_server_icons: toolDef.server.icons,
205+
tool_call_server_description: toolDef.server.description,
206+
question_request: parsedQuestion.data
207+
}
208+
}
209+
210+
needContinueConversation = false
211+
break
212+
}
213+
148214
yield {
149215
type: 'response',
150216
data: {

0 commit comments

Comments
 (0)