| 1 | import { LRUCache } from 'lru-cache' |
| 2 | import log from 'electron-log/main.js' |
| 3 | import { createMiddleware } from 'langchain' |
| 4 | import { resolveModel } from '../agent' |
| 5 | import type { ModelRuntimeConfig } from '../agent' |
| 6 | import { FilesystemBackend, createDeepAgent } from 'deepagents' |
| 7 | import { extractModelText } from '../ipc/utils' |
| 8 | import { resolveModelTimeoutMs } from '@shared/model-timeout' |
| 9 | import { logAgentToolEvents } from '../utils/agent-tool-logger' |
| 10 | import { buildThinkingContext, type ThinkingContextArgs } from './context-builder' |
| 11 | import { findUnsupportedPrecisionClaims, type CredibilityIssue } from './content-credibility' |
| 12 | import { routeThinkingIntent, type ThinkingIntentRoute } from './intent-router' |
| 13 | import { normalizeThinkingAssistantReply } from './reply-normalizer' |
| 14 | import { createThinkingToolInputRecoveryMiddleware } from './tool-error-recovery' |
| 15 | import { |
| 16 | checkStageTransition, |
| 17 | isValidTransition, |
| 18 | isRestartRequest, |
| 19 | resolveRequestedStage |
| 20 | } from './stage-manager' |
| 21 | import { buildInitialContextMd, buildInitialThinkingMd, writeContextMd, writeThinkingMd } from './workspace' |
| 22 | import { |
| 23 | createThinkingWorkflowTools, |
| 24 | type StagedThinkingFinalizeResult, |
| 25 | type ThinkingWorkflowState |
| 26 | } from './thinking-tools' |
| 27 | import type { ThinkingStage, ThinkingChatResult } from '@shared/thinking' |
| 28 | |
| 29 | interface ThinkingRuntime { |
| 30 | agent: ReturnType<typeof createDeepAgent> |
| 31 | workflowState: ThinkingWorkflowState |
| 32 | finalizeStagedThinkingDocument: (options?: { |
| 33 | discardIncomplete?: boolean |
| 34 | }) => Promise<StagedThinkingFinalizeResult> |
| 35 | } |
| 36 | |
| 37 | const THINKING_WORKFLOW_TOOL_NAMES = new Set([ |
| 38 | 'update_context_document', |
| 39 | 'update_thinking_document' |
| 40 | ]) |
| 41 | |
| 42 | const SOURCE_READ_TOOL_NAMES = ['read_file', 'grep'] as const |
| 43 | |
| 44 | function createThinkingToolAllowlistMiddleware(allowedToolNames: Set<string>) { |
| 45 | return createMiddleware({ |
| 46 | name: 'thinkingToolAllowlist', |
| 47 | wrapModelCall: async (request, handler) => { |
| 48 | const tools = request.tools?.filter((tool) => allowedToolNames.has(String(tool.name || ''))) |
| 49 | return handler({ ...request, tools }) |
| 50 | } |
| 51 | }) |
| 52 | } |
| 53 | |
| 54 | function getThinkingAllowedToolNames(hasSources: boolean): Set<string> { |
| 55 | return new Set([ |
| 56 | ...THINKING_WORKFLOW_TOOL_NAMES, |
| 57 | ...(hasSources ? SOURCE_READ_TOOL_NAMES : []) |
| 58 | ]) |
| 59 | } |
| 60 | |
| 61 | function getObject(value: unknown): Record<string, unknown> | null { |
| 62 | return value && typeof value === 'object' && !Array.isArray(value) |
| 63 | ? (value as Record<string, unknown>) |
| 64 | : null |
| 65 | } |
| 66 | |
| 67 | function isAssistantMessage(record: Record<string, unknown>): boolean { |
| 68 | const role = String(record.role || '').toLowerCase() |
| 69 | const type = String(record.type || '').toLowerCase() |
| 70 | const constructorName = String( |
| 71 | getObject(record.lc_kwargs)?.type ?? getObject(record.kwargs)?.type ?? '' |
| 72 | ).toLowerCase() |
| 73 | const isAssistant = |
| 74 | role === 'assistant' || type === 'ai' || type === 'assistant' || constructorName === 'ai' |
| 75 | const isToolOrHuman = |
| 76 | role === 'tool' || |
| 77 | role === 'user' || |
| 78 | role === 'system' || |
| 79 | type === 'tool' || |
| 80 | type === 'human' || |
| 81 | type === 'system' |
| 82 | return isAssistant && !isToolOrHuman |
| 83 | } |
| 84 | |
| 85 | function hasToolCalls(record: Record<string, unknown>): boolean { |
| 86 | const additional = getObject(record.additional_kwargs) |
| 87 | const toolCalls = record.tool_calls ?? additional?.tool_calls |
| 88 | return Array.isArray(toolCalls) && toolCalls.length > 0 |
| 89 | } |
| 90 | |
| 91 | function buildFallbackReply(args: { |
| 92 | contextUpdated: boolean |
| 93 | thinkingUpdated: boolean |
| 94 | stage: ThinkingStage |
| 95 | }): string { |
| 96 | if (args.thinkingUpdated) { |
| 97 | return '我已经更新了内容方案。你可以继续补充要求,或确认后进入生成。' |
| 98 | } |
| 99 | if (args.contextUpdated) { |
| 100 | return '我已经记录了这些需求。你可以继续补充信息,或让我开始整理页面大纲。' |
| 101 | } |
| 102 | if (args.stage === 'collect') { |
| 103 | return '我已收到。请继续补充目标受众、使用场景或素材,我会帮你整理成清晰的大纲。' |
| 104 | } |
| 105 | return '我已收到,会继续基于当前方案推进。' |
| 106 | } |
| 107 | |
| 108 | function extractAssistantTextsFromState(data: unknown): string[] { |
| 109 | const texts: string[] = [] |
| 110 | const seen = new Set<object>() |
| 111 | |
| 112 | const visit = (value: unknown): void => { |
| 113 | if (!value || typeof value !== 'object') return |
| 114 | if (seen.has(value as object)) return |
| 115 | seen.add(value as object) |
| 116 | |
| 117 | if (Array.isArray(value)) { |
| 118 | value.forEach(visit) |
| 119 | return |
| 120 | } |
| 121 | |
| 122 | const record = value as Record<string, unknown> |
| 123 | if (isAssistantMessage(record) && !hasToolCalls(record)) { |
| 124 | const text = normalizeThinkingAssistantReply(extractModelText(record)) |
| 125 | if (text) texts.push(text) |
| 126 | } |
| 127 | |
| 128 | for (const nested of Object.values(record)) { |
| 129 | if (nested && typeof nested === 'object') visit(nested) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | visit(data) |
| 134 | return texts |
| 135 | } |
| 136 | |
| 137 | const runtimeCache = new LRUCache<string, ThinkingRuntime>({ |
| 138 | max: 20, |
| 139 | ttl: 60 * 60 * 1000 |
| 140 | }) |
| 141 | |
| 142 | const SECTION_ORDER = [ |
| 143 | 'Stage', |
| 144 | 'Topic', |
| 145 | 'Audience', |
| 146 | 'Setting', |
| 147 | 'Tone', |
| 148 | 'Key Decisions', |
| 149 | 'User Intent', |
| 150 | 'Confirmed Decisions', |
| 151 | 'Open Questions', |
| 152 | 'Source Notes', |
| 153 | 'Latest Direction', |
| 154 | 'Style', |
| 155 | 'Font', |
| 156 | 'Page Count' |
| 157 | ] |
| 158 | |
| 159 | function upsertSection(markdown: string, heading: string, content: string): string { |
| 160 | const normalizedContent = content.trim() |
| 161 | if (!normalizedContent) return markdown |
| 162 | const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') |
| 163 | const sectionRegex = new RegExp(`^##\\s*${escaped}\\s*\\n[\\s\\S]*?(?=^##\\s+|(?![\\s\\S]))`, 'm') |
| 164 | const nextSection = `## ${heading}\n${normalizedContent}\n\n` |
| 165 | if (sectionRegex.test(markdown)) { |
| 166 | return markdown.replace(sectionRegex, nextSection.trimEnd() + '\n\n') |
| 167 | } |
| 168 | |
| 169 | for (let index = SECTION_ORDER.indexOf(heading) - 1; index >= 0; index -= 1) { |
| 170 | const previous = SECTION_ORDER[index] |
| 171 | const previousRegex = new RegExp(`^##\\s*${previous.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\n[\\s\\S]*?(?=^##\\s+|(?![\\s\\S]))`, 'm') |
| 172 | const match = markdown.match(previousRegex) |
| 173 | if (match?.[0]) { |
| 174 | const insertAt = (match.index || 0) + match[0].length |
| 175 | return `${markdown.slice(0, insertAt).trimEnd()}\n\n${nextSection}${markdown.slice(insertAt).trimStart()}` |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | const titleMatch = markdown.match(/^# .+$/m) |
| 180 | if (titleMatch) { |
| 181 | const insertAt = (titleMatch.index || 0) + titleMatch[0].length |
| 182 | return `${markdown.slice(0, insertAt).trimEnd()}\n\n${nextSection}${markdown.slice(insertAt).trimStart()}` |
| 183 | } |
| 184 | return `# Thinking Brief\n\n${nextSection}${markdown.trim()}` |
| 185 | } |
| 186 | |
| 187 | function mergeLatestDirectionIntoContextMd(args: { |
| 188 | contextMd: string |
| 189 | currentStage: ThinkingStage |
| 190 | userMessage: string |
| 191 | }): string { |
| 192 | let next = args.contextMd.trim() || `# Rolling Context\n\n## Stage: ${args.currentStage}\n` |
| 193 | if (!/^##\s*Stage:/m.test(next)) { |
| 194 | next = upsertSection(next, 'Stage', args.currentStage) |
| 195 | } |
| 196 | |
| 197 | const latestDirection = args.userMessage.trim() |
| 198 | ? `Latest user input:\n${args.userMessage.trim()}` |
| 199 | : '' |
| 200 | if (latestDirection) { |
| 201 | next = upsertSection(next, 'Latest Direction', latestDirection) |
| 202 | } |
| 203 | |
| 204 | return next.trimEnd() + '\n' |
| 205 | } |
| 206 | |
| 207 | async function collectAgentReply( |
| 208 | stream: AsyncIterable<unknown>, |
| 209 | onThinkingEvent?: (event: { type: 'tool_call' | 'tool_result'; toolName: string; summary: string }) => void |
| 210 | ): Promise<{ |
| 211 | replyText: string |
| 212 | latestAssistantStateText: string |
| 213 | }> { |
| 214 | let replyText = '' |
| 215 | let latestAssistantStateText = '' |
| 216 | const seenToolEvents = new Set<string>() |
| 217 | |
| 218 | for await (const chunk of stream as AsyncIterable<unknown>) { |
| 219 | if (!Array.isArray(chunk) || chunk.length < 3) continue |
| 220 | const mode = chunk[1] as string |
| 221 | const data = chunk[2] |
| 222 | if (mode === 'updates') { |
| 223 | extractAndEmitToolEvents(data, seenToolEvents, onThinkingEvent) |
| 224 | logAgentToolEvents(data, seenToolEvents, { tag: 'thinking:agent', source: 'updates' }) |
| 225 | const assistantTexts = extractAssistantTextsFromState(data) |
| 226 | const longestText = assistantTexts.sort((a, b) => b.length - a.length)[0] || '' |
| 227 | if (longestText.length >= latestAssistantStateText.length) { |
| 228 | latestAssistantStateText = longestText |
| 229 | } |
| 230 | continue |
| 231 | } |
| 232 | if (mode !== 'messages' || !Array.isArray(data)) continue |
| 233 | extractAndEmitToolEvents(data, seenToolEvents, onThinkingEvent) |
| 234 | logAgentToolEvents(data, seenToolEvents, { tag: 'thinking:agent', source: 'messages' }) |
| 235 | for (const message of data as Array<Record<string, unknown>>) { |
| 236 | if (!message || typeof message !== 'object') continue |
| 237 | if (!isAssistantMessage(message) || hasToolCalls(message)) continue |
| 238 | const content = normalizeThinkingAssistantReply(extractModelText(message)) |
| 239 | if (content) { |
| 240 | replyText += content |
| 241 | } |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | return { replyText, latestAssistantStateText } |
| 246 | } |
| 247 | |
| 248 | /** Extract tool call/result events and emit them for the thinking process UI. */ |
| 249 | function extractAndEmitToolEvents( |
| 250 | data: unknown, |
| 251 | seen: Set<string>, |
| 252 | onThinkingEvent?: (event: { type: 'tool_call' | 'tool_result'; toolName: string; summary: string }) => void |
| 253 | ): void { |
| 254 | if (!onThinkingEvent) return |
| 255 | const visit = (value: unknown): void => { |
| 256 | if (!value || typeof value !== 'object') return |
| 257 | if (Array.isArray(value)) { |
| 258 | value.forEach(visit) |
| 259 | return |
| 260 | } |
| 261 | const record = value as Record<string, unknown> |
| 262 | // Check for tool calls |
| 263 | const additional = record.additional_kwargs as Record<string, unknown> | undefined |
| 264 | const toolCalls = record.tool_calls ?? additional?.tool_calls |
| 265 | if (Array.isArray(toolCalls)) { |
| 266 | for (const call of toolCalls) { |
| 267 | const callRecord = call && typeof call === 'object' ? call as Record<string, unknown> : null |
| 268 | if (!callRecord) continue |
| 269 | const fnRecord = callRecord.function && typeof callRecord.function === 'object' |
| 270 | ? callRecord.function as Record<string, unknown> : null |
| 271 | const name = String(callRecord.name ?? fnRecord?.name ?? '').trim() |
| 272 | const id = String(callRecord.id ?? '').trim() |
| 273 | const rawArgs = callRecord.args ?? fnRecord?.arguments ?? '' |
| 274 | const key = `tc:${id}:${name}` |
| 275 | if (name && id && !seen.has(key)) { |
| 276 | seen.add(key) |
| 277 | const summary = summarizeToolCall(name, rawArgs) |
| 278 | if (!summary) continue |
| 279 | onThinkingEvent({ type: 'tool_call', toolName: name, summary }) |
| 280 | } |
| 281 | } |
| 282 | } |
| 283 | for (const nested of Object.values(record)) { |
| 284 | if (nested && typeof nested === 'object') visit(nested) |
| 285 | } |
| 286 | } |
| 287 | visit(data) |
| 288 | } |
| 289 | |
| 290 | function summarizeToolCall(toolName: string, rawArgs: unknown): string { |
| 291 | if (toolName === 'read_file') { |
| 292 | const args = typeof rawArgs === 'string' ? safeParseJson(rawArgs) : rawArgs |
| 293 | const path = getNestedField(args, 'path') as string | undefined |
| 294 | if (path) return `正在阅读资料:${path.replace(/^\/sources\//, '')}` |
| 295 | return '正在阅读资料' |
| 296 | } |
| 297 | if (toolName === 'grep') { |
| 298 | const args = typeof rawArgs === 'string' ? safeParseJson(rawArgs) : rawArgs |
| 299 | const pattern = getNestedField(args, 'pattern') as string | undefined |
| 300 | if (pattern) return `正在定位相关内容:${String(pattern).slice(0, 24)}` |
| 301 | return '正在定位相关内容' |
| 302 | } |
| 303 | if (toolName === 'update_thinking_document') { |
| 304 | const args = typeof rawArgs === 'string' ? safeParseJson(rawArgs) : rawArgs |
| 305 | const pages = getNestedField(args, 'pages') as Array<Record<string, unknown>> | undefined |
| 306 | const topic = getNestedField(args, 'topic') as string | undefined |
| 307 | if (pages && Array.isArray(pages) && pages.length > 0) { |
| 308 | const titles = pages.map((p) => getNestedField(p, 'title') as string || '').filter(Boolean) |
| 309 | if (titles.length > 0) return `正在整理 ${pages.length} 页方案:${titles.slice(0, 2).join('、')}${titles.length > 2 ? '…' : ''}` |
| 310 | return `正在整理 ${pages.length} 页方案` |
| 311 | } |
| 312 | if (topic) return `正在确认主题:${String(topic).slice(0, 24)}` |
| 313 | return '正在更新方案' |
| 314 | } |
| 315 | if (toolName === 'update_context_document') { |
| 316 | return '正在整理需求和关键信息' |
| 317 | } |
| 318 | return '' |
| 319 | } |
| 320 | |
| 321 | function safeParseJson(text: string): unknown { |
| 322 | try { return JSON.parse(text) } catch { return null } |
| 323 | } |
| 324 | |
| 325 | function getNestedField(obj: unknown, key: string): unknown { |
| 326 | if (!obj || typeof obj !== 'object') return undefined |
| 327 | return (obj as Record<string, unknown>)[key] |
| 328 | } |
| 329 | |
| 330 | function getThinkingRepairTarget(args: { |
| 331 | currentStage: ThinkingStage |
| 332 | rawAgentRequestedStage: ThinkingStage | null |
| 333 | rawRequestedStage: ThinkingStage | null |
| 334 | routedIntent: ThinkingIntentRoute |
| 335 | userMessage: string |
| 336 | }): ThinkingStage | null { |
| 337 | if (args.rawRequestedStage === 'collect') { |
| 338 | return null |
| 339 | } |
| 340 | if (args.currentStage === 'collect' && args.routedIntent.intent === 'plan_outline') { |
| 341 | return 'outline' |
| 342 | } |
| 343 | if ( |
| 344 | args.rawAgentRequestedStage && |
| 345 | args.rawAgentRequestedStage !== 'collect' && |
| 346 | isValidTransition(args.currentStage, args.rawAgentRequestedStage) |
| 347 | ) { |
| 348 | return args.rawAgentRequestedStage |
| 349 | } |
| 350 | if ( |
| 351 | args.rawRequestedStage && |
| 352 | isValidTransition(args.currentStage, args.rawRequestedStage) |
| 353 | ) { |
| 354 | return args.rawRequestedStage |
| 355 | } |
| 356 | return null |
| 357 | } |
| 358 | |
| 359 | function buildForcedThinkingUpdateMessage(targetStage: ThinkingStage, fullUserMessage: string): string { |
| 360 | return [ |
| 361 | `Internal repair task. The previous response did not persist /thinking.md in a form that can enter stage "${targetStage}".`, |
| 362 | 'You must now call update_thinking_document to persist a complete page-by-page thinking brief into /thinking.md.', |
| 363 | 'For large outlines, call update_thinking_document in batches with pageStart and 5-10 pages per call; set commit=true on the final batch only.', |
| 364 | 'Every page must include title, role, objective, summary, and substantive keyPoints.', |
| 365 | `Then call update_context_document with stage set to "${targetStage}".`, |
| 366 | 'Use read_file/grep first if sources are available and needed.', |
| 367 | 'Do not use write_file or edit_file.', |
| 368 | 'Do not ask the user a new question in this repair task.', |
| 369 | 'After the tool calls, return one concise user-facing reply describing what is ready.', |
| 370 | '', |
| 371 | fullUserMessage |
| 372 | ].join('\n') |
| 373 | } |
| 374 | |
| 375 | function buildCredibilityRepairMessage( |
| 376 | issues: CredibilityIssue[], |
| 377 | fullUserMessage: string |
| 378 | ): string { |
| 379 | const issueList = issues |
| 380 | .slice(0, 12) |
| 381 | .map((issue) => `- Line ${issue.line}: ${issue.text} (${issue.reason})`) |
| 382 | .join('\n') |
| 383 | |
| 384 | return [ |
| 385 | 'Internal repair task. The current /thinking.md contains exact metrics or benchmark claims that are not supported by user-provided text or source files.', |
| 386 | 'You must call update_thinking_document. For large outlines, update affected page ranges in batches with pageStart and set commit=true on the final batch only.', |
| 387 | 'Replace unsupported exact numbers with qualitative, source-needed wording. Preserve user-provided structural values such as topic year, duration, and page count.', |
| 388 | 'Do not add new exact metrics, benchmark scores, prices, percentages, rankings, or version-specific claims.', |
| 389 | 'Then call update_context_document to record that unsupported exact metrics were downgraded or marked as needing sources.', |
| 390 | 'Do not use write_file or edit_file.', |
| 391 | 'After the tool calls, return one concise user-facing reply in the user language.', |
| 392 | '', |
| 393 | 'Unsupported claims detected:', |
| 394 | issueList, |
| 395 | '', |
| 396 | fullUserMessage |
| 397 | ].join('\n') |
| 398 | } |
| 399 | |
| 400 | async function runAgentMessage(args: { |
| 401 | runtime: ThinkingRuntime |
| 402 | message: string |
| 403 | modelTimeoutMs: number |
| 404 | onThinkingEvent?: (event: { type: 'tool_call' | 'tool_result'; toolName: string; summary: string }) => void |
| 405 | }): Promise<{ replyText: string; latestAssistantStateText: string }> { |
| 406 | const stream = await args.runtime.agent.stream( |
| 407 | { |
| 408 | messages: [ |
| 409 | { role: 'user', content: args.message } |
| 410 | ] |
| 411 | }, |
| 412 | { |
| 413 | streamMode: ['updates', 'messages'], |
| 414 | subgraphs: true, |
| 415 | signal: AbortSignal.timeout(resolveModelTimeoutMs(args.modelTimeoutMs, 'agent')) |
| 416 | } |
| 417 | ) |
| 418 | return collectAgentReply(stream as AsyncIterable<unknown>, args.onThinkingEvent) |
| 419 | } |
| 420 | |
| 421 | function getOrCreateRuntime( |
| 422 | thinkingId: string, |
| 423 | thinkingDir: string, |
| 424 | args: { |
| 425 | provider: string |
| 426 | apiKey: string |
| 427 | model: string |
| 428 | baseUrl: string |
| 429 | maxTokens?: number |
| 430 | modelRuntime?: ModelRuntimeConfig |
| 431 | systemPrompt: string |
| 432 | currentStage: ThinkingStage |
| 433 | hasSources: boolean |
| 434 | } |
| 435 | ): ThinkingRuntime { |
| 436 | const cached = runtimeCache.get(thinkingId) |
| 437 | if (cached) return cached |
| 438 | |
| 439 | const model = resolveModel( |
| 440 | args.provider, |
| 441 | args.apiKey, |
| 442 | args.model, |
| 443 | args.baseUrl, |
| 444 | 0.3, |
| 445 | args.maxTokens, |
| 446 | args.modelRuntime |
| 447 | ) |
| 448 | const workflowTools = createThinkingWorkflowTools({ |
| 449 | thinkingDir, |
| 450 | currentStage: args.currentStage |
| 451 | }) |
| 452 | |
| 453 | const allowedToolNames = getThinkingAllowedToolNames(args.hasSources) |
| 454 | |
| 455 | const agent = createDeepAgent({ |
| 456 | model, |
| 457 | backend: new FilesystemBackend({ |
| 458 | rootDir: thinkingDir, |
| 459 | virtualMode: true |
| 460 | }), |
| 461 | permissions: [ |
| 462 | { operations: ['read'], paths: ['/**'] }, |
| 463 | { operations: ['write'], paths: ['/**'], mode: 'deny' } |
| 464 | ], |
| 465 | systemPrompt: args.systemPrompt, |
| 466 | tools: workflowTools.tools as any, |
| 467 | middleware: [ |
| 468 | createThinkingToolInputRecoveryMiddleware() as any, |
| 469 | createThinkingToolAllowlistMiddleware(allowedToolNames) as any |
| 470 | ] |
| 471 | }) |
| 472 | |
| 473 | const runtime: ThinkingRuntime = { |
| 474 | agent, |
| 475 | workflowState: workflowTools.state, |
| 476 | finalizeStagedThinkingDocument: workflowTools.finalizeStagedThinkingDocument |
| 477 | } |
| 478 | runtimeCache.set(thinkingId, runtime) |
| 479 | return runtime |
| 480 | } |
| 481 | |
| 482 | async function finalizeStagedThinkingIfNeeded(args: { |
| 483 | runtime: ThinkingRuntime |
| 484 | thinkingId: string |
| 485 | reason: string |
| 486 | }): Promise<void> { |
| 487 | if (!args.runtime.workflowState.thinkingStaged) return |
| 488 | const result = await args.runtime.finalizeStagedThinkingDocument() |
| 489 | if (result.status === 'committed') { |
| 490 | log.info('[thinking:agent] staged thinking auto-committed', { |
| 491 | thinkingId: args.thinkingId, |
| 492 | reason: args.reason, |
| 493 | pageCount: result.pageCount, |
| 494 | length: result.length |
| 495 | }) |
| 496 | } else if (result.status === 'discarded') { |
| 497 | log.warn('[thinking:agent] staged thinking discarded', { |
| 498 | thinkingId: args.thinkingId, |
| 499 | reason: args.reason, |
| 500 | discardReason: result.reason, |
| 501 | pageCount: result.pageCount, |
| 502 | expectedPageCount: result.expectedPageCount |
| 503 | }) |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | export interface RunThinkingChatArgs extends ThinkingContextArgs { |
| 508 | thinkingId: string |
| 509 | thinkingDir: string |
| 510 | provider: string |
| 511 | apiKey: string |
| 512 | model: string |
| 513 | baseUrl: string |
| 514 | maxTokens?: number |
| 515 | modelRuntime?: ModelRuntimeConfig |
| 516 | modelTimeoutMs: number |
| 517 | onThinkingEvent?: (event: { type: 'tool_call' | 'tool_result'; toolName: string; summary: string }) => void |
| 518 | } |
| 519 | |
| 520 | export async function runThinkingChat(args: RunThinkingChatArgs): Promise<ThinkingChatResult> { |
| 521 | const { |
| 522 | thinkingId, |
| 523 | thinkingDir, |
| 524 | stage: currentStage, |
| 525 | thinkingMd, |
| 526 | contextMd, |
| 527 | sourcesDir, |
| 528 | userMessage, |
| 529 | recentMessages, |
| 530 | provider, |
| 531 | apiKey, |
| 532 | model, |
| 533 | baseUrl, |
| 534 | maxTokens, |
| 535 | modelRuntime, |
| 536 | modelTimeoutMs, |
| 537 | onThinkingEvent |
| 538 | } = args |
| 539 | |
| 540 | const restartRequested = isRestartRequest(userMessage) |
| 541 | const inputStage: ThinkingStage = restartRequested ? 'collect' : currentStage |
| 542 | const inputThinkingMd = restartRequested ? buildInitialThinkingMd() : thinkingMd |
| 543 | const inputContextMd = restartRequested ? buildInitialContextMd('collect') : contextMd |
| 544 | |
| 545 | if (restartRequested) { |
| 546 | await writeThinkingMd(thinkingDir, inputThinkingMd) |
| 547 | await writeContextMd(thinkingDir, inputContextMd) |
| 548 | runtimeCache.delete(thinkingId) |
| 549 | } |
| 550 | |
| 551 | const initialContext = await buildThinkingContext({ |
| 552 | stage: inputStage, |
| 553 | thinkingMd: inputThinkingMd, |
| 554 | contextMd: inputContextMd, |
| 555 | sourcesDir, |
| 556 | userMessage, |
| 557 | recentMessages |
| 558 | }) |
| 559 | |
| 560 | const { systemPrompt, userMessage: fullUserMessage } = initialContext |
| 561 | const hasSources = initialContext.sourceContent.trim().length > 0 |
| 562 | |
| 563 | // Invalidate cached runtime so system prompt is fresh |
| 564 | runtimeCache.delete(thinkingId) |
| 565 | |
| 566 | const runtime = getOrCreateRuntime(thinkingId, thinkingDir, { |
| 567 | provider, |
| 568 | apiKey, |
| 569 | model, |
| 570 | baseUrl, |
| 571 | maxTokens, |
| 572 | modelRuntime, |
| 573 | systemPrompt, |
| 574 | currentStage: inputStage, |
| 575 | hasSources |
| 576 | }) |
| 577 | |
| 578 | const routedIntent = routeThinkingIntent({ |
| 579 | userMessage, |
| 580 | currentStage: inputStage |
| 581 | }) |
| 582 | |
| 583 | log.info('[thinking:agent] running chat', { |
| 584 | thinkingId, |
| 585 | stage: currentStage, |
| 586 | inputStage, |
| 587 | intent: routedIntent.intent, |
| 588 | intentStage: routedIntent.requestedStage, |
| 589 | messageLength: fullUserMessage.length |
| 590 | }) |
| 591 | |
| 592 | let replyText = '' |
| 593 | let latestAssistantStateText = '' |
| 594 | |
| 595 | try { |
| 596 | const result = await runAgentMessage({ |
| 597 | runtime, |
| 598 | message: fullUserMessage, |
| 599 | modelTimeoutMs, |
| 600 | onThinkingEvent |
| 601 | }) |
| 602 | replyText = result.replyText |
| 603 | latestAssistantStateText = result.latestAssistantStateText |
| 604 | await finalizeStagedThinkingIfNeeded({ |
| 605 | runtime, |
| 606 | thinkingId, |
| 607 | reason: 'main-run-complete' |
| 608 | }) |
| 609 | } catch (err) { |
| 610 | log.error('[thinking:agent] stream error', { |
| 611 | thinkingId, |
| 612 | error: err instanceof Error ? err.message : String(err) |
| 613 | }) |
| 614 | throw err |
| 615 | } |
| 616 | |
| 617 | // Prefer latestAssistantStateText (complete final response from updates stream, |
| 618 | // excludes pre-tool-call narration). Fall back to concatenated messages stream. |
| 619 | if (latestAssistantStateText) { |
| 620 | log.info('[thinking:agent] using assistant state text as reply', { |
| 621 | thinkingId, |
| 622 | stateLength: latestAssistantStateText.length, |
| 623 | streamLength: replyText.length |
| 624 | }) |
| 625 | replyText = latestAssistantStateText |
| 626 | } |
| 627 | |
| 628 | replyText = normalizeThinkingAssistantReply(replyText) |
| 629 | |
| 630 | if (!replyText) { |
| 631 | replyText = buildFallbackReply({ |
| 632 | contextUpdated: runtime.workflowState.contextUpdated, |
| 633 | thinkingUpdated: runtime.workflowState.thinkingUpdated, |
| 634 | stage: inputStage |
| 635 | }) |
| 636 | } |
| 637 | |
| 638 | // Workflow tools may have persisted files — read them back before validation. |
| 639 | const fs = await import('fs') |
| 640 | const path = await import('path') |
| 641 | const thinkingMdPath = path.join(thinkingDir, 'thinking.md') |
| 642 | const contextMdPath = path.join(thinkingDir, 'context.md') |
| 643 | let updatedThinkingMd = inputThinkingMd |
| 644 | let updatedContextMd = inputContextMd |
| 645 | try { |
| 646 | if (fs.existsSync(thinkingMdPath)) { |
| 647 | updatedThinkingMd = await fs.promises.readFile(thinkingMdPath, 'utf-8') |
| 648 | } |
| 649 | if (fs.existsSync(contextMdPath)) { |
| 650 | updatedContextMd = await fs.promises.readFile(contextMdPath, 'utf-8') |
| 651 | } |
| 652 | } catch { |
| 653 | // If read fails, keep the original |
| 654 | } |
| 655 | |
| 656 | if (!runtime.workflowState.contextUpdated && updatedContextMd === inputContextMd) { |
| 657 | log.warn('[thinking:agent] context.md was not updated by workflow tool; retrying forced context update', { |
| 658 | thinkingId, |
| 659 | stage: inputStage |
| 660 | }) |
| 661 | const forcedMessage = [ |
| 662 | 'Internal repair task. Your previous response did not call update_context_document.', |
| 663 | 'You must now call update_context_document to persist the confirmed user intent, decisions, and open questions into /context.md.', |
| 664 | 'Do not use write_file or edit_file.', |
| 665 | 'Do not ask the user a new question in this repair task.', |
| 666 | '', |
| 667 | fullUserMessage |
| 668 | ].join('\n') |
| 669 | try { |
| 670 | await runAgentMessage({ |
| 671 | runtime, |
| 672 | message: forcedMessage, |
| 673 | modelTimeoutMs |
| 674 | }) |
| 675 | if (fs.existsSync(contextMdPath)) { |
| 676 | updatedContextMd = await fs.promises.readFile(contextMdPath, 'utf-8') |
| 677 | } |
| 678 | } catch (err) { |
| 679 | log.warn('[thinking:agent] forced context write retry failed; latest-direction fallback will run', { |
| 680 | thinkingId, |
| 681 | error: err instanceof Error ? err.message : String(err) |
| 682 | }) |
| 683 | } |
| 684 | } |
| 685 | |
| 686 | if (!runtime.workflowState.contextUpdated) { |
| 687 | const mergedContextMd = mergeLatestDirectionIntoContextMd({ |
| 688 | contextMd: updatedContextMd, |
| 689 | currentStage: inputStage, |
| 690 | userMessage |
| 691 | }) |
| 692 | if (mergedContextMd !== updatedContextMd) { |
| 693 | await writeContextMd(thinkingDir, mergedContextMd) |
| 694 | updatedContextMd = mergedContextMd |
| 695 | } |
| 696 | } |
| 697 | |
| 698 | // Stage resolution: 1) agent-requested (via tool) 2) keyword fallback 3) structural check. |
| 699 | // All explicit requests still pass through the same transition and content-readiness rules. |
| 700 | let rawAgentRequestedStage = runtime.workflowState.requestedStage |
| 701 | const rawRequestedStage = routedIntent.requestedStage |
| 702 | let agentRequestedStage = resolveRequestedStage({ |
| 703 | currentStage: inputStage, |
| 704 | requestedStage: rawAgentRequestedStage, |
| 705 | thinkingMd: updatedThinkingMd |
| 706 | }) |
| 707 | let resolvedRequestedStage = resolveRequestedStage({ |
| 708 | currentStage: inputStage, |
| 709 | requestedStage: rawRequestedStage, |
| 710 | thinkingMd: updatedThinkingMd |
| 711 | }) |
| 712 | const repairTarget = getThinkingRepairTarget({ |
| 713 | currentStage: inputStage, |
| 714 | rawAgentRequestedStage, |
| 715 | rawRequestedStage, |
| 716 | routedIntent, |
| 717 | userMessage |
| 718 | }) |
| 719 | |
| 720 | if ( |
| 721 | repairTarget && |
| 722 | !resolveRequestedStage({ |
| 723 | currentStage: inputStage, |
| 724 | requestedStage: repairTarget, |
| 725 | thinkingMd: updatedThinkingMd |
| 726 | }) |
| 727 | ) { |
| 728 | log.warn('[thinking:agent] thinking.md is not ready for requested stage; retrying forced thinking update', { |
| 729 | thinkingId, |
| 730 | stage: inputStage, |
| 731 | repairTarget, |
| 732 | rawAgentRequestedStage, |
| 733 | rawRequestedStage |
| 734 | }) |
| 735 | try { |
| 736 | const repairResult = await runAgentMessage({ |
| 737 | runtime, |
| 738 | message: buildForcedThinkingUpdateMessage(repairTarget, fullUserMessage), |
| 739 | modelTimeoutMs, |
| 740 | onThinkingEvent |
| 741 | }) |
| 742 | await finalizeStagedThinkingIfNeeded({ |
| 743 | runtime, |
| 744 | thinkingId, |
| 745 | reason: 'forced-thinking-repair-complete' |
| 746 | }) |
| 747 | const repairReply = normalizeThinkingAssistantReply( |
| 748 | repairResult.latestAssistantStateText || repairResult.replyText |
| 749 | ) |
| 750 | if (repairReply.trim()) { |
| 751 | replyText = repairReply.trim() |
| 752 | } |
| 753 | if (fs.existsSync(thinkingMdPath)) { |
| 754 | updatedThinkingMd = await fs.promises.readFile(thinkingMdPath, 'utf-8') |
| 755 | } |
| 756 | if (fs.existsSync(contextMdPath)) { |
| 757 | updatedContextMd = await fs.promises.readFile(contextMdPath, 'utf-8') |
| 758 | } |
| 759 | rawAgentRequestedStage = runtime.workflowState.requestedStage |
| 760 | agentRequestedStage = resolveRequestedStage({ |
| 761 | currentStage: inputStage, |
| 762 | requestedStage: rawAgentRequestedStage, |
| 763 | thinkingMd: updatedThinkingMd |
| 764 | }) |
| 765 | resolvedRequestedStage = resolveRequestedStage({ |
| 766 | currentStage: inputStage, |
| 767 | requestedStage: rawRequestedStage, |
| 768 | thinkingMd: updatedThinkingMd |
| 769 | }) |
| 770 | } catch (err) { |
| 771 | log.warn('[thinking:agent] forced thinking update retry failed; leaving thinking.md unchanged', { |
| 772 | thinkingId, |
| 773 | repairTarget, |
| 774 | error: err instanceof Error ? err.message : String(err) |
| 775 | }) |
| 776 | } |
| 777 | } |
| 778 | |
| 779 | const credibilityIssues = findUnsupportedPrecisionClaims({ |
| 780 | markdown: updatedThinkingMd, |
| 781 | hasSources |
| 782 | }) |
| 783 | |
| 784 | if (runtime.workflowState.thinkingUpdated && credibilityIssues.length > 0) { |
| 785 | log.warn('[thinking:agent] unsupported exact claims detected; retrying credibility repair', { |
| 786 | thinkingId, |
| 787 | stage: inputStage, |
| 788 | issueCount: credibilityIssues.length |
| 789 | }) |
| 790 | try { |
| 791 | const repairResult = await runAgentMessage({ |
| 792 | runtime, |
| 793 | message: buildCredibilityRepairMessage(credibilityIssues, fullUserMessage), |
| 794 | modelTimeoutMs, |
| 795 | onThinkingEvent |
| 796 | }) |
| 797 | await finalizeStagedThinkingIfNeeded({ |
| 798 | runtime, |
| 799 | thinkingId, |
| 800 | reason: 'credibility-repair-complete' |
| 801 | }) |
| 802 | const repairReply = normalizeThinkingAssistantReply( |
| 803 | repairResult.latestAssistantStateText || repairResult.replyText |
| 804 | ) |
| 805 | if (repairReply.trim()) { |
| 806 | replyText = repairReply.trim() |
| 807 | } |
| 808 | if (fs.existsSync(thinkingMdPath)) { |
| 809 | updatedThinkingMd = await fs.promises.readFile(thinkingMdPath, 'utf-8') |
| 810 | } |
| 811 | if (fs.existsSync(contextMdPath)) { |
| 812 | updatedContextMd = await fs.promises.readFile(contextMdPath, 'utf-8') |
| 813 | } |
| 814 | rawAgentRequestedStage = runtime.workflowState.requestedStage |
| 815 | agentRequestedStage = resolveRequestedStage({ |
| 816 | currentStage: inputStage, |
| 817 | requestedStage: rawAgentRequestedStage, |
| 818 | thinkingMd: updatedThinkingMd |
| 819 | }) |
| 820 | resolvedRequestedStage = resolveRequestedStage({ |
| 821 | currentStage: inputStage, |
| 822 | requestedStage: rawRequestedStage, |
| 823 | thinkingMd: updatedThinkingMd |
| 824 | }) |
| 825 | } catch (err) { |
| 826 | log.warn('[thinking:agent] credibility repair failed; leaving thinking.md unchanged', { |
| 827 | thinkingId, |
| 828 | error: err instanceof Error ? err.message : String(err) |
| 829 | }) |
| 830 | } |
| 831 | } |
| 832 | |
| 833 | const autoStage = checkStageTransition(inputStage, updatedThinkingMd) |
| 834 | let newStage = agentRequestedStage || resolvedRequestedStage || autoStage |
| 835 | |
| 836 | if ( |
| 837 | rawRequestedStage === 'collect' && |
| 838 | resolvedRequestedStage === 'collect' && |
| 839 | restartRequested |
| 840 | ) { |
| 841 | updatedThinkingMd = buildInitialThinkingMd() |
| 842 | updatedContextMd = buildInitialContextMd('collect') |
| 843 | await writeThinkingMd(thinkingDir, updatedThinkingMd) |
| 844 | await writeContextMd(thinkingDir, updatedContextMd) |
| 845 | newStage = 'collect' |
| 846 | } |
| 847 | |
| 848 | // Update context.md with new stage |
| 849 | updatedContextMd = updateContextStage(updatedContextMd, newStage) |
| 850 | await writeContextMd(thinkingDir, updatedContextMd) |
| 851 | |
| 852 | log.info('[thinking:agent] chat complete', { |
| 853 | thinkingId, |
| 854 | replyLength: replyText.length, |
| 855 | thinkingMdChanged: updatedThinkingMd !== inputThinkingMd, |
| 856 | contextToolCalls: runtime.workflowState.contextUpdateCount, |
| 857 | thinkingToolCalls: runtime.workflowState.thinkingUpdateCount, |
| 858 | agentRequestedStage: rawAgentRequestedStage, |
| 859 | resolvedAgentRequestedStage: agentRequestedStage, |
| 860 | stageTransition: currentStage !== newStage ? `${currentStage} → ${newStage}` : 'none' |
| 861 | }) |
| 862 | |
| 863 | return { |
| 864 | reply: replyText, |
| 865 | thinkingMd: updatedThinkingMd, |
| 866 | contextMd: updatedContextMd, |
| 867 | stage: newStage |
| 868 | } |
| 869 | } |
| 870 | |
| 871 | function updateContextStage(contextMd: string, newStage: ThinkingStage): string { |
| 872 | if (/^## Stage:\s*\S+/m.test(contextMd)) { |
| 873 | return contextMd.replace( |
| 874 | /^## Stage:\s*\S+/m, |
| 875 | `## Stage: ${newStage}` |
| 876 | ) |
| 877 | } |
| 878 | return upsertSection(contextMd, 'Stage', newStage) |
| 879 | } |
| 880 | |
| 881 | export function invalidateRuntime(thinkingId: string): void { |
| 882 | runtimeCache.delete(thinkingId) |
| 883 | } |
| 884 |