返回 oh-my-ppt
thinking-agent.ts
根目录 / src / main / thinking / thinking-agent.ts
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
884 lines TYPESCRIPT