clink-cli 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. clink/__init__.py +19 -0
  2. clink/agents/__init__.py +30 -0
  3. clink/agents/base.py +70 -0
  4. clink/agents/claude.py +7 -0
  5. clink/agents/codex.py +7 -0
  6. clink/agents/gemini.py +7 -0
  7. clink/allowed_roots.py +356 -0
  8. clink/backend.py +765 -0
  9. clink/cli.py +594 -0
  10. clink/constants.py +78 -0
  11. clink/contracts.py +167 -0
  12. clink/files.py +70 -0
  13. clink/generation.py +274 -0
  14. clink/generation_capabilities.py +207 -0
  15. clink/generation_contracts.py +214 -0
  16. clink/generation_policy.py +174 -0
  17. clink/models.py +243 -0
  18. clink/native.py +409 -0
  19. clink/native_adapters.py +244 -0
  20. clink/native_contracts.py +68 -0
  21. clink/native_generation.py +421 -0
  22. clink/native_generation_claude_policy.py +59 -0
  23. clink/native_generation_contracts.py +264 -0
  24. clink/native_generation_families.py +218 -0
  25. clink/native_generation_family_policy.py +14 -0
  26. clink/native_generation_files.py +153 -0
  27. clink/native_generation_policy.py +82 -0
  28. clink/native_policy.py +86 -0
  29. clink/parsers/__init__.py +34 -0
  30. clink/parsers/agy.py +53 -0
  31. clink/parsers/agy_native.py +161 -0
  32. clink/parsers/agy_native_generation.py +189 -0
  33. clink/parsers/base.py +209 -0
  34. clink/parsers/claude.py +104 -0
  35. clink/parsers/claude_native.py +113 -0
  36. clink/parsers/claude_native_generation.py +346 -0
  37. clink/parsers/codex.py +172 -0
  38. clink/parsers/codex_generation.py +212 -0
  39. clink/parsers/codex_native.py +236 -0
  40. clink/parsers/codex_native_generation.py +367 -0
  41. clink/parsers/gemini.py +110 -0
  42. clink/parsers/grok.py +63 -0
  43. clink/parsers/grok_native.py +91 -0
  44. clink/parsers/grok_native_generation.py +157 -0
  45. clink/policy.py +180 -0
  46. clink/registry.py +544 -0
  47. clink/resources/CODEX-LICENSE +201 -0
  48. clink/resources/CODEX-NOTICE +6 -0
  49. clink/resources/PROVENANCE.md +16 -0
  50. clink/resources/codex-0.166.0-models-direct.json +1284 -0
  51. clink/resources/codex-0.166.0-models-generation.json +1269 -0
  52. clink/resources/native-agy-1.4.12-admission.json +746 -0
  53. clink/resources/native-claude-2.1.304-admission.json +99 -0
  54. clink/resources/native-generation-claude-2.1.304-admission.json +277 -0
  55. clink/resources/native-generation-codex-0.166.0-admission.json +309 -0
  56. clink/resources/native-generation-grok-1.0.70-admission.json +329 -0
  57. clink/resources/native-grok-1.0.70-admission.json +644 -0
  58. clink/runner.py +303 -0
  59. clink/setup.py +399 -0
  60. clink/tasks.py +695 -0
  61. clink_cli-0.2.1.dist-info/METADATA +225 -0
  62. clink_cli-0.2.1.dist-info/RECORD +141 -0
  63. clink_cli-0.2.1.dist-info/WHEEL +5 -0
  64. clink_cli-0.2.1.dist-info/entry_points.txt +3 -0
  65. clink_cli-0.2.1.dist-info/licenses/LICENSE +197 -0
  66. clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-LICENSE +201 -0
  67. clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-NOTICE +6 -0
  68. clink_cli-0.2.1.dist-info/top_level.txt +7 -0
  69. conf/__init__.py +1 -0
  70. conf/cli_clients/agy.json +40 -0
  71. conf/cli_clients/claude.json +39 -0
  72. conf/cli_clients/codex.json +40 -0
  73. conf/cli_clients/gemini.json +23 -0
  74. conf/cli_clients/grok.json +40 -0
  75. config.py +40 -0
  76. server.py +896 -0
  77. systemprompts/__init__.py +35 -0
  78. systemprompts/analyze_prompt.py +87 -0
  79. systemprompts/chat_prompt.py +57 -0
  80. systemprompts/clink/codex_codereviewer.txt +8 -0
  81. systemprompts/clink/default.txt +8 -0
  82. systemprompts/clink/default_codereviewer.txt +8 -0
  83. systemprompts/clink/default_planner.txt +7 -0
  84. systemprompts/clink/native_codereviewer.txt +1 -0
  85. systemprompts/clink/native_default.txt +1 -0
  86. systemprompts/clink/native_planner.txt +1 -0
  87. systemprompts/codereview_prompt.py +91 -0
  88. systemprompts/consensus_prompt.py +120 -0
  89. systemprompts/debug_prompt.py +150 -0
  90. systemprompts/docgen_prompt.py +252 -0
  91. systemprompts/generate_code_prompt.py +181 -0
  92. systemprompts/planner_prompt.py +127 -0
  93. systemprompts/precommit_prompt.py +179 -0
  94. systemprompts/refactor_prompt.py +323 -0
  95. systemprompts/secaudit_prompt.py +420 -0
  96. systemprompts/testgen_prompt.py +133 -0
  97. systemprompts/thinkdeep_prompt.py +55 -0
  98. systemprompts/tracer_prompt.py +145 -0
  99. tools/__init__.py +43 -0
  100. tools/analyze.py +583 -0
  101. tools/apilookup.py +97 -0
  102. tools/challenge.py +198 -0
  103. tools/chat.py +187 -0
  104. tools/clink.py +130 -0
  105. tools/codereview.py +724 -0
  106. tools/consensus.py +790 -0
  107. tools/debug.py +573 -0
  108. tools/docgen.py +577 -0
  109. tools/listmodels.py +48 -0
  110. tools/models.py +373 -0
  111. tools/planner.py +533 -0
  112. tools/precommit.py +776 -0
  113. tools/refactor.py +645 -0
  114. tools/secaudit.py +685 -0
  115. tools/shared/__init__.py +19 -0
  116. tools/shared/base_models.py +167 -0
  117. tools/shared/base_tool.py +659 -0
  118. tools/shared/exceptions.py +20 -0
  119. tools/shared/schema_builders.py +159 -0
  120. tools/simple/__init__.py +18 -0
  121. tools/simple/base.py +539 -0
  122. tools/testgen.py +513 -0
  123. tools/thinkdeep.py +586 -0
  124. tools/tracer.py +763 -0
  125. tools/version.py +53 -0
  126. tools/workflow/__init__.py +22 -0
  127. tools/workflow/base.py +448 -0
  128. tools/workflow/schema_builders.py +166 -0
  129. tools/workflow/workflow_mixin.py +1195 -0
  130. utils/__init__.py +21 -0
  131. utils/call_state.py +349 -0
  132. utils/client_info.py +293 -0
  133. utils/consensus_manifest.py +131 -0
  134. utils/conversation_memory.py +582 -0
  135. utils/env.py +111 -0
  136. utils/file_types.py +271 -0
  137. utils/file_utils.py +803 -0
  138. utils/image_utils.py +94 -0
  139. utils/security_config.py +163 -0
  140. utils/storage_backend.py +177 -0
  141. utils/token_utils.py +54 -0
@@ -0,0 +1,1195 @@
1
+ """
2
+ Workflow Mixin for PAL MCP Tools
3
+
4
+ This module provides a sophisticated workflow-based pattern that enables tools to
5
+ perform multi-step work with structured findings and expert analysis.
6
+
7
+ Key Components:
8
+ - BaseWorkflowMixin: Abstract base class providing comprehensive workflow functionality
9
+
10
+ The workflow pattern enables tools like debug, precommit, and codereview to perform
11
+ systematic multi-step work with pause/resume capabilities, context-aware file embedding,
12
+ and seamless integration with external AI models for expert analysis.
13
+
14
+ Features:
15
+ - Multi-step workflow orchestration with pause/resume
16
+ - Context-aware file embedding optimization
17
+ - Expert analysis integration with token budgeting
18
+ - Conversation memory and threading support
19
+ - Proper inheritance-based architecture (no hasattr/getattr)
20
+ - Comprehensive type annotations for IDE support
21
+ """
22
+
23
+ import json
24
+ import logging
25
+ import os
26
+ import re
27
+ from abc import ABC, abstractmethod
28
+ from typing import Any, Optional
29
+
30
+ from mcp.types import TextContent
31
+ from pydantic import ValidationError
32
+
33
+ from clink.contracts import BackendError
34
+ from config import MCP_PROMPT_SIZE_LIMIT
35
+ from utils.call_state import StateError
36
+
37
+ from ..shared.base_models import ConsolidatedFindings
38
+ from ..shared.exceptions import ToolExecutionError
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ class BaseWorkflowMixin(ABC):
44
+ """
45
+ Abstract base class providing guided workflow functionality for tools.
46
+
47
+ This class implements a sophisticated workflow pattern where the CLI performs
48
+ systematic local work before calling external models for expert analysis.
49
+ Tools can inherit from this class to gain comprehensive workflow capabilities.
50
+
51
+ Architecture:
52
+ - Uses proper inheritance patterns instead of hasattr/getattr
53
+ - Provides hook methods with default implementations
54
+ - Requires abstract methods to be implemented by subclasses
55
+ - Fully type-annotated for excellent IDE support
56
+
57
+ Context-Aware File Embedding:
58
+ - Intermediate steps: Only reference file names (saves the CLI's context)
59
+ - Final steps: Embed full file content for expert analysis
60
+ - Integrates with existing token budgeting infrastructure
61
+
62
+ Requirements:
63
+ This class expects to be used with BaseTool and requires implementation of:
64
+ - get_system_prompt()
65
+ - get_default_temperature()
66
+ """
67
+
68
+ def __init__(self) -> None:
69
+ super().__init__()
70
+ self.work_history: list[dict[str, Any]] = []
71
+ self.consolidated_findings: ConsolidatedFindings = ConsolidatedFindings()
72
+ self.initial_request: Optional[str] = None
73
+
74
+ # ================================================================================
75
+ # Abstract Methods - Required Implementation by BaseTool or Subclasses
76
+ # ================================================================================
77
+
78
+ @abstractmethod
79
+ def get_name(self) -> str:
80
+ """Return the name of this tool. Usually provided by BaseTool."""
81
+ pass
82
+
83
+ @abstractmethod
84
+ def get_workflow_request_model(self) -> type:
85
+ """Return the request model class for this workflow tool."""
86
+ pass
87
+
88
+ @abstractmethod
89
+ def get_system_prompt(self) -> str:
90
+ """Return the system prompt for this tool. Usually provided by BaseTool."""
91
+ pass
92
+
93
+ @abstractmethod
94
+ def get_language_instruction(self) -> str:
95
+ """Return the language instruction for localization. Usually provided by BaseTool."""
96
+ pass
97
+
98
+ @abstractmethod
99
+ def get_default_temperature(self) -> float:
100
+ """Return the default temperature for this tool. Usually provided by BaseTool."""
101
+ pass
102
+
103
+ # ================================================================================
104
+ # Abstract Methods - Tool-Specific Implementation Required
105
+ # ================================================================================
106
+
107
+ @abstractmethod
108
+ def get_work_steps(self, request: Any) -> list[str]:
109
+ """Define tool-specific work steps and criteria"""
110
+ pass
111
+
112
+ @abstractmethod
113
+ def get_required_actions(
114
+ self, step_number: int, confidence: str, findings: str, total_steps: int, request=None
115
+ ) -> list[str]:
116
+ """Define required actions for each work phase.
117
+
118
+ Args:
119
+ step_number: Current step (1-based)
120
+ confidence: Current confidence level (exploring, low, medium, high, certain)
121
+ findings: Current findings text
122
+ total_steps: Total estimated steps for this work
123
+ request: Optional request object for continuation-aware decisions
124
+
125
+ Returns:
126
+ List of specific actions the CLI should take before calling tool again
127
+ """
128
+ pass
129
+
130
+ # ================================================================================
131
+ # Hook Methods - Default Implementations with Override Capability
132
+ # ================================================================================
133
+
134
+ def should_call_expert_analysis(self, consolidated_findings: ConsolidatedFindings, request=None) -> bool:
135
+ """
136
+ Decide when to call external model based on tool-specific criteria.
137
+
138
+ Default implementation for tools that don't use expert analysis.
139
+ Override this for tools that do use expert analysis.
140
+
141
+ Args:
142
+ consolidated_findings: Findings from workflow steps
143
+ request: Current request object (optional for backwards compatibility)
144
+ """
145
+ if not self.requires_expert_analysis():
146
+ return False
147
+
148
+ # Check if user requested to skip assistant model
149
+ if request and not self.get_request_use_assistant_model(request):
150
+ return False
151
+
152
+ # Default logic for tools that support expert analysis
153
+ return (
154
+ len(consolidated_findings.relevant_files) > 0
155
+ or len(consolidated_findings.findings) >= 2
156
+ or len(consolidated_findings.issues_found) > 0
157
+ )
158
+
159
+ def prepare_expert_analysis_context(self, consolidated_findings: ConsolidatedFindings) -> str:
160
+ """
161
+ Prepare context for external model call.
162
+
163
+ Default implementation for tools that don't use expert analysis.
164
+ Override this for tools that do use expert analysis.
165
+ """
166
+ if not self.requires_expert_analysis():
167
+ return ""
168
+
169
+ # Default context preparation
170
+ context_parts = [
171
+ f"=== {self.get_name().upper()} WORK SUMMARY ===",
172
+ f"Total steps: {len(consolidated_findings.findings)}",
173
+ f"Files examined: {len(consolidated_findings.files_checked)}",
174
+ f"Relevant files: {len(consolidated_findings.relevant_files)}",
175
+ "",
176
+ "=== WORK PROGRESSION ===",
177
+ ]
178
+
179
+ for finding in consolidated_findings.findings:
180
+ context_parts.append(finding)
181
+
182
+ return "\n".join(context_parts)
183
+
184
+ def requires_expert_analysis(self) -> bool:
185
+ """
186
+ Override this to completely disable expert analysis for the tool.
187
+
188
+ Returns True if the tool supports expert analysis (default).
189
+ Returns False if the tool is self-contained (like planner).
190
+ """
191
+ return True
192
+
193
+ def should_include_files_in_expert_prompt(self) -> bool:
194
+ """
195
+ Whether to include file content in the expert analysis prompt.
196
+ Override this to return True if your tool needs files in the prompt.
197
+ """
198
+ return False
199
+
200
+ def should_embed_system_prompt(self) -> bool:
201
+ """
202
+ Whether to embed the system prompt in the main prompt.
203
+ Override this to return True if your tool needs the system prompt embedded.
204
+ """
205
+ return False
206
+
207
+ def get_expert_thinking_mode(self) -> str:
208
+ """
209
+ Get the thinking mode for expert analysis.
210
+ Override this to customize the thinking mode.
211
+ """
212
+ return "high"
213
+
214
+ def get_request_temperature(self, request) -> float:
215
+ """Get temperature from request. Override for custom temperature handling."""
216
+ try:
217
+ return request.temperature if request.temperature is not None else self.get_default_temperature()
218
+ except AttributeError:
219
+ return self.get_default_temperature()
220
+
221
+ def get_request_thinking_mode(self, request) -> str:
222
+ """Get thinking mode from request. Override for custom thinking mode handling."""
223
+ try:
224
+ return request.thinking_mode if request.thinking_mode is not None else self.get_expert_thinking_mode()
225
+ except AttributeError:
226
+ return self.get_expert_thinking_mode()
227
+
228
+ def get_expert_analysis_instruction(self) -> str:
229
+ """
230
+ Get the instruction to append after the expert context.
231
+ Override this to provide tool-specific instructions.
232
+ """
233
+ return "Please provide expert analysis based on the investigation findings."
234
+
235
+ def get_request_use_assistant_model(self, request) -> bool:
236
+ """
237
+ Get use_assistant_model from request. Override for custom assistant model handling.
238
+
239
+ Args:
240
+ request: Current request object
241
+
242
+ Returns:
243
+ True if assistant model should be used, False otherwise
244
+ """
245
+ try:
246
+ return request.use_assistant_model if request.use_assistant_model is not None else True
247
+ except AttributeError:
248
+ return True
249
+
250
+ def get_step_guidance_message(self, request) -> str:
251
+ """
252
+ Get step guidance message. Override for tool-specific guidance.
253
+ Default implementation uses required actions.
254
+ """
255
+ required_actions = self.get_required_actions(
256
+ request.step_number, self.get_request_confidence(request), request.findings, request.total_steps, request
257
+ )
258
+
259
+ next_step_number = request.step_number + 1
260
+ return (
261
+ f"MANDATORY: DO NOT call the {self.get_name()} tool again immediately. "
262
+ f"You MUST first work using appropriate tools. "
263
+ f"REQUIRED ACTIONS before calling {self.get_name()} step {next_step_number}:\n"
264
+ + "\n".join(f"{i + 1}. {action}" for i, action in enumerate(required_actions))
265
+ + f"\n\nOnly call {self.get_name()} again with step_number: {next_step_number} "
266
+ f"AFTER completing this work."
267
+ )
268
+
269
+ def wants_line_numbers_by_default(self) -> bool:
270
+ """
271
+ Whether this tool wants line numbers in file content by default.
272
+ Override this to customize line number behavior.
273
+ """
274
+ return True # Most workflow tools benefit from line numbers for analysis
275
+
276
+ # ================================================================================
277
+ # Context-Aware File Embedding - Core Implementation
278
+ # ================================================================================
279
+
280
+ def _handle_workflow_file_context(self, request: Any, arguments: dict[str, Any]) -> None:
281
+ """Keep investigation steps local; expert calls embed after path admission."""
282
+ self._embedded_file_content = ""
283
+ self._file_reference_note = ""
284
+ self._actually_processed_files = []
285
+ self._reference_workflow_files(request)
286
+
287
+ def _reference_workflow_files(self, request: Any) -> None:
288
+ """
289
+ Reference file names without embedding content for intermediate steps.
290
+ Saves the CLI's context while still providing file awareness.
291
+ """
292
+ # Workflow tools use relevant_files, not files
293
+ request_files = self.get_request_relevant_files(request)
294
+ logger.debug(
295
+ f"[WORKFLOW_FILES] {self.get_name()}: _reference_workflow_files called with {len(request_files)} relevant_files"
296
+ )
297
+
298
+ if not request_files:
299
+ logger.debug(f"[WORKFLOW_FILES] {self.get_name()}: No files to reference, skipping")
300
+ return
301
+
302
+ # Store file references for conversation context
303
+ self._referenced_files = request_files
304
+
305
+ # Create a simple reference note
306
+ file_names = [os.path.basename(f) for f in request_files]
307
+ reference_note = f"Files referenced in this step: {', '.join(file_names)}\n"
308
+
309
+ self._file_reference_note = reference_note
310
+ logger.debug("workflow_files_referenced tool=%s count=%d", self.get_name(), len(request_files))
311
+
312
+ logger.info(
313
+ f"[WORKFLOW_FILES] {self.get_name()}: Referenced {len(request_files)} files without embedding content"
314
+ )
315
+
316
+ # ================================================================================
317
+ # Main Workflow Orchestration
318
+ # ================================================================================
319
+
320
+ async def execute_workflow(self, arguments: dict[str, Any]) -> list[TextContent]:
321
+ """
322
+ Main workflow orchestration following debug tool pattern.
323
+
324
+ Comprehensive workflow implementation that handles all common patterns:
325
+ 1. Request validation and step management
326
+ 2. Continuation and backtracking support
327
+ 3. Step data processing and consolidation
328
+ 4. Tool-specific field mapping and customization
329
+ 5. Completion logic with optional expert analysis
330
+ 6. Generic "certain confidence" handling
331
+ 7. Step guidance and required actions
332
+ 8. Conversation memory integration
333
+ """
334
+
335
+ try:
336
+ # Store arguments for access by helper methods
337
+ self._current_arguments = arguments
338
+
339
+ # Validate request using tool-specific model
340
+ request = self.get_workflow_request_model()(**arguments)
341
+
342
+ # Validate step field size (basic validation for workflow instructions)
343
+ # If step is too large, user should use shorter instructions and put details in files
344
+ step_content = request.step
345
+ if step_content and len(step_content.encode("utf-8")) > MCP_PROMPT_SIZE_LIMIT:
346
+ from tools.models import ToolOutput
347
+
348
+ error_output = ToolOutput(
349
+ status="error",
350
+ metadata={"code": "input_limit", "remedy": "Split or narrow the workflow step."},
351
+ content="Step instructions are too long. Please use shorter instructions and provide detailed context via file paths instead.",
352
+ content_type="text",
353
+ )
354
+ raise ToolExecutionError(error_output.model_dump_json())
355
+
356
+ # Validate file paths for security (same as base tool)
357
+ # Use try/except instead of hasattr as per coding standards
358
+ try:
359
+ path_error = self.validate_file_paths(request)
360
+ if path_error:
361
+ from tools.models import ToolOutput
362
+
363
+ error_output = ToolOutput(
364
+ status="error",
365
+ content=path_error,
366
+ content_type="text",
367
+ )
368
+ logger.error("workflow_path_validation_failed tool=%s", self.get_name())
369
+ raise ToolExecutionError(error_output.model_dump_json())
370
+ except AttributeError:
371
+ # validate_file_paths method not available - skip validation
372
+ pass
373
+
374
+ from utils.call_state import get_current_call
375
+
376
+ call = get_current_call()
377
+ continuation_id = call.continuation_id if call is not None else request.continuation_id
378
+ state = call.workflow_state if call is not None else {}
379
+ self.work_history = list(state.get("work_history", []))
380
+ self.initial_request = state.get("initial_request")
381
+ for name in self.get_workflow_state_fields():
382
+ if name in state:
383
+ setattr(self, name, state[name])
384
+ self._reprocess_consolidated_findings()
385
+
386
+ # Adjust total steps if needed
387
+ if request.step_number > request.total_steps:
388
+ request.total_steps = request.step_number
389
+
390
+ if not self.work_history:
391
+ self.initial_request = request.step
392
+ self.store_initial_issue(request.step)
393
+
394
+ # Process work step - allow tools to customize field mapping
395
+ step_data = self.prepare_step_data(request)
396
+
397
+ # Store in history
398
+ self.work_history.append(step_data)
399
+
400
+ # Update consolidated findings
401
+ self._update_consolidated_findings(step_data)
402
+
403
+ # Handle file context appropriately based on workflow phase
404
+ self._handle_workflow_file_context(request, arguments)
405
+
406
+ # Build response with tool-specific customization
407
+ response_data = self.build_base_response(request, continuation_id)
408
+
409
+ # If work is complete, handle completion logic
410
+ if not request.next_step_required:
411
+ response_data = await self.handle_work_completion(response_data, request, arguments)
412
+ else:
413
+ # Force CLI to work before calling tool again
414
+ response_data = self.handle_work_continuation(response_data, request)
415
+
416
+ # Allow tools to customize the final response
417
+ response_data = self.customize_workflow_response(response_data, request)
418
+
419
+ # Add bounded backend metadata to the workflow response
420
+ self._add_workflow_metadata(response_data, arguments)
421
+
422
+ # Store in conversation memory
423
+ if continuation_id:
424
+ self.store_conversation_turn(continuation_id, response_data, request)
425
+
426
+ payload = json.dumps(response_data, indent=2, ensure_ascii=False)
427
+ if response_data.get("status") == "error":
428
+ raise ToolExecutionError(payload)
429
+ return [TextContent(type="text", text=payload)]
430
+
431
+ except (ToolExecutionError, StateError):
432
+ raise
433
+ except BackendError as exc:
434
+ error_data = {"status": "error", "content": str(exc), "metadata": exc.to_dict()}
435
+ raise ToolExecutionError(json.dumps(error_data, ensure_ascii=False)) from None
436
+ except (ValidationError, ValueError):
437
+ error_data = {
438
+ "status": "error",
439
+ "content": "Request validation failed; check the declared fields.",
440
+ "metadata": {"code": "invalid_request", "tool_name": self.get_name()},
441
+ }
442
+ raise ToolExecutionError(json.dumps(error_data, ensure_ascii=False)) from None
443
+ except Exception as exc:
444
+ logger.error("workflow_execution_failed tool=%s error_type=%s", self.get_name(), type(exc).__name__)
445
+ error_data = {
446
+ "status": "error",
447
+ "content": "The workflow could not complete this request.",
448
+ "metadata": {"code": "tool_error", "tool_name": self.get_name(), "error_type": type(exc).__name__},
449
+ }
450
+ raise ToolExecutionError(json.dumps(error_data, ensure_ascii=False)) from None
451
+
452
+ # Hook methods for tool customization
453
+
454
+ def prepare_step_data(self, request) -> dict:
455
+ """
456
+ Prepare step data from request. Tools can override to customize field mapping.
457
+ """
458
+ step_data = {
459
+ "step": request.step,
460
+ "step_number": request.step_number,
461
+ "findings": request.findings,
462
+ "files_checked": self.get_request_files_checked(request),
463
+ "relevant_files": self.get_request_relevant_files(request),
464
+ "relevant_context": self.get_request_relevant_context(request),
465
+ "issues_found": self.get_request_issues_found(request),
466
+ "confidence": self.get_request_confidence(request),
467
+ "hypothesis": self.get_request_hypothesis(request),
468
+ "images": self.get_request_images(request),
469
+ }
470
+ return step_data
471
+
472
+ def build_base_response(self, request, continuation_id: str = None) -> dict:
473
+ """
474
+ Build the base response structure. Tools can override for custom response fields.
475
+ """
476
+ response_data = {
477
+ "status": f"{self.get_name()}_in_progress",
478
+ "step_number": request.step_number,
479
+ "total_steps": request.total_steps,
480
+ "next_step_required": request.next_step_required,
481
+ f"{self.get_name()}_status": {
482
+ "files_checked": len(self.consolidated_findings.files_checked),
483
+ "relevant_files": len(self.consolidated_findings.relevant_files),
484
+ "relevant_context": len(self.consolidated_findings.relevant_context),
485
+ "issues_found": len(self.consolidated_findings.issues_found),
486
+ "images_collected": len(self.consolidated_findings.images),
487
+ "current_confidence": self.get_request_confidence(request),
488
+ },
489
+ }
490
+
491
+ if continuation_id:
492
+ response_data["continuation_id"] = continuation_id
493
+
494
+ # Add file context information based on workflow phase
495
+ embedded_content = self.get_embedded_file_content()
496
+ reference_note = self.get_file_reference_note()
497
+ processed_files = self.get_actually_processed_files()
498
+
499
+ logger.debug(
500
+ f"[WORKFLOW_FILES] {self.get_name()}: Building response - has embedded_content: {bool(embedded_content)}, has reference_note: {bool(reference_note)}"
501
+ )
502
+
503
+ # Prioritize embedded content over references for final steps
504
+ if embedded_content:
505
+ # Final step - include embedded file information
506
+ logger.debug(f"[WORKFLOW_FILES] {self.get_name()}: Adding fully_embedded file context")
507
+ response_data["file_context"] = {
508
+ "type": "fully_embedded",
509
+ "files_embedded": len(processed_files),
510
+ "context_optimization": "Full file content embedded for expert analysis",
511
+ }
512
+ elif reference_note:
513
+ # Intermediate step - include file reference note
514
+ logger.debug(f"[WORKFLOW_FILES] {self.get_name()}: Adding reference_only file context")
515
+ response_data["file_context"] = {
516
+ "type": "reference_only",
517
+ "note": reference_note,
518
+ "context_optimization": "Files referenced but not embedded to preserve the context window",
519
+ }
520
+
521
+ return response_data
522
+
523
+ def should_skip_expert_analysis(self, request, consolidated_findings) -> bool:
524
+ """
525
+ Determine if expert analysis should be skipped due to high certainty.
526
+
527
+ Default: False (always call expert analysis)
528
+ Override in tools like debug to check for "certain" confidence.
529
+ """
530
+ return False
531
+
532
+ def handle_completion_without_expert_analysis(self, request, consolidated_findings) -> dict:
533
+ """
534
+ Handle completion when skipping expert analysis.
535
+
536
+ Tools can override this for custom high-confidence completion handling.
537
+ Default implementation provides generic response.
538
+ """
539
+ work_summary = self.prepare_work_summary()
540
+ continuation_id = self.get_request_continuation_id(request)
541
+
542
+ response_data = {
543
+ "status": self.get_completion_status(),
544
+ f"complete_{self.get_name()}": {
545
+ "initial_request": self.get_initial_request(request.step),
546
+ "steps_taken": len(consolidated_findings.findings),
547
+ "files_examined": list(consolidated_findings.files_checked),
548
+ "relevant_files": list(consolidated_findings.relevant_files),
549
+ "relevant_context": list(consolidated_findings.relevant_context),
550
+ "work_summary": work_summary,
551
+ "final_analysis": self.get_final_analysis_from_request(request),
552
+ "confidence_level": self.get_confidence_level(request),
553
+ },
554
+ "next_steps": self.get_completion_message(),
555
+ "skip_expert_analysis": True,
556
+ "expert_analysis": {
557
+ "status": self.get_skip_expert_analysis_status(),
558
+ "reason": self.get_skip_reason(),
559
+ },
560
+ }
561
+
562
+ if continuation_id:
563
+ response_data["continuation_id"] = continuation_id
564
+
565
+ return response_data
566
+
567
+ # ================================================================================
568
+ # Inheritance Hook Methods - Replace hasattr/getattr Anti-patterns
569
+ # ================================================================================
570
+
571
+ def get_request_confidence(self, request: Any) -> str:
572
+ """Get confidence from request. Override for custom confidence handling."""
573
+ try:
574
+ return request.confidence or "low"
575
+ except AttributeError:
576
+ return "low"
577
+
578
+ def get_request_relevant_context(self, request: Any) -> list[str]:
579
+ """Get relevant context from request. Override for custom field mapping."""
580
+ try:
581
+ return request.relevant_context or []
582
+ except AttributeError:
583
+ return []
584
+
585
+ def get_request_issues_found(self, request: Any) -> list[str]:
586
+ """Get issues found from request. Override for custom field mapping."""
587
+ try:
588
+ return request.issues_found or []
589
+ except AttributeError:
590
+ return []
591
+
592
+ def get_request_hypothesis(self, request: Any) -> Optional[str]:
593
+ """Get hypothesis from request. Override for custom field mapping."""
594
+ try:
595
+ return request.hypothesis
596
+ except AttributeError:
597
+ return None
598
+
599
+ def get_request_images(self, request: Any) -> list[str]:
600
+ """Get images from request. Override for custom field mapping."""
601
+ try:
602
+ return request.images or []
603
+ except AttributeError:
604
+ return []
605
+
606
+ # File Context Access Methods
607
+
608
+ def get_embedded_file_content(self) -> str:
609
+ """Get embedded file content. Returns empty string if not available."""
610
+ try:
611
+ return self._embedded_file_content or ""
612
+ except AttributeError:
613
+ return ""
614
+
615
+ def get_file_reference_note(self) -> str:
616
+ """Get file reference note. Returns empty string if not available."""
617
+ try:
618
+ return self._file_reference_note or ""
619
+ except AttributeError:
620
+ return ""
621
+
622
+ def get_actually_processed_files(self) -> list[str]:
623
+ """Get list of actually processed files. Returns empty list if not available."""
624
+ try:
625
+ return self._actually_processed_files or []
626
+ except AttributeError:
627
+ return []
628
+
629
+ def get_request_model_name(self, request: Any) -> Optional[str]:
630
+ return request.model
631
+
632
+ def get_request_continuation_id(self, request: Any) -> Optional[str]:
633
+ """Get continuation ID from request. Override for custom continuation handling."""
634
+ try:
635
+ return request.continuation_id
636
+ except AttributeError:
637
+ return None
638
+
639
+ def get_request_next_step_required(self, request: Any) -> bool:
640
+ """Get next step required from request. Override for custom step handling."""
641
+ try:
642
+ return request.next_step_required
643
+ except AttributeError:
644
+ return True
645
+
646
+ def get_request_step_number(self, request: Any) -> int:
647
+ """Get step number from request. Override for custom step handling."""
648
+ try:
649
+ return request.step_number or 1
650
+ except AttributeError:
651
+ return 1
652
+
653
+ def get_request_relevant_files(self, request: Any) -> list[str]:
654
+ """Get relevant files from request. Override for custom file handling."""
655
+ try:
656
+ return request.relevant_files or []
657
+ except AttributeError:
658
+ return []
659
+
660
+ def get_request_files_checked(self, request: Any) -> list[str]:
661
+ """Get files checked from request. Override for custom file handling."""
662
+ try:
663
+ return request.files_checked or []
664
+ except AttributeError:
665
+ return []
666
+
667
+ def get_current_arguments(self) -> dict[str, Any]:
668
+ """Get current arguments. Returns empty dict if not available."""
669
+ try:
670
+ return self._current_arguments or {}
671
+ except AttributeError:
672
+ return {}
673
+
674
+ def store_initial_issue(self, step_description: str):
675
+ """Store initial issue description. Override for custom storage."""
676
+ # Default implementation - tools can override to store differently
677
+ self.initial_issue = step_description
678
+
679
+ def get_initial_request(self, fallback_step: str) -> str:
680
+ """Get initial request description. Override for custom retrieval."""
681
+ try:
682
+ return self.initial_request or fallback_step
683
+ except AttributeError:
684
+ return fallback_step
685
+
686
+ # Default implementations for inheritance hooks
687
+
688
+ def prepare_work_summary(self) -> str:
689
+ """Prepare work summary. Override for custom implementation."""
690
+ return f"Completed {len(self.consolidated_findings.findings)} work steps"
691
+
692
+ def get_completion_status(self) -> str:
693
+ """Get completion status. Override for tool-specific status."""
694
+ return "high_confidence_completion"
695
+
696
+ def get_final_analysis_from_request(self, request):
697
+ """Extract final analysis from request. Override for tool-specific fields."""
698
+ return self.get_request_hypothesis(request)
699
+
700
+ def get_confidence_level(self, request) -> str:
701
+ """Get confidence level. Override for tool-specific confidence handling."""
702
+ return self.get_request_confidence(request) or "high"
703
+
704
+ def get_completion_message(self) -> str:
705
+ """Get completion message. Override for tool-specific messaging."""
706
+ return (
707
+ f"{self.get_name().capitalize()} complete with high confidence. Present results "
708
+ "and proceed with implementation without requiring further consultation."
709
+ )
710
+
711
+ def get_skip_reason(self) -> str:
712
+ """Get reason for skipping expert analysis. Override for tool-specific reasons."""
713
+ return f"{self.get_name()} completed with sufficient confidence"
714
+
715
+ def get_skip_expert_analysis_status(self) -> str:
716
+ """Get status for skipped expert analysis. Override for tool-specific status."""
717
+ return "skipped_by_tool_design"
718
+
719
+ def get_completion_next_steps_message(self, expert_analysis_used: bool = False) -> str:
720
+ """
721
+ Get the message to show when work is complete.
722
+ Tools can override for custom messaging.
723
+
724
+ Args:
725
+ expert_analysis_used: True if expert analysis was successfully executed
726
+ """
727
+ base_message = (
728
+ f"{self.get_name().upper()} IS COMPLETE. You MUST now summarize and present ALL key findings, confirmed "
729
+ "hypotheses, and exact recommended solutions. Clearly identify the most likely root cause and "
730
+ "provide concrete, actionable implementation guidance. Highlight affected code paths and display "
731
+ "reasoning that led to this conclusion—make it easy for a developer to understand exactly where "
732
+ "the problem lies."
733
+ )
734
+
735
+ # Add expert analysis guidance only when expert analysis was actually used
736
+ if expert_analysis_used:
737
+ expert_guidance = self.get_expert_analysis_guidance()
738
+ if expert_guidance:
739
+ return f"{base_message}\n\n{expert_guidance}"
740
+
741
+ return base_message
742
+
743
+ def get_expert_analysis_guidance(self) -> str:
744
+ """
745
+ Get additional guidance for handling expert analysis results.
746
+
747
+ Subclasses can override this to provide specific instructions about how
748
+ to validate and use expert analysis findings. Returns empty string by default.
749
+
750
+ When expert analysis is called, this guidance will be:
751
+ 1. Appended to the completion next steps message
752
+ 2. Added as "important_considerations" field in the response data
753
+
754
+ Example implementation:
755
+ ```python
756
+ def get_expert_analysis_guidance(self) -> str:
757
+ return (
758
+ "IMPORTANT: Expert analysis provided above. You MUST validate "
759
+ "the expert findings rather than accepting them blindly. "
760
+ "Cross-reference with your own investigation and ensure "
761
+ "recommendations align with the codebase context."
762
+ )
763
+ ```
764
+
765
+ Returns:
766
+ Additional guidance text or empty string if no guidance needed
767
+ """
768
+ return ""
769
+
770
+ def customize_workflow_response(self, response_data: dict, request) -> dict:
771
+ """
772
+ Allow tools to customize the workflow response before returning.
773
+
774
+ Tools can override this to add tool-specific fields, modify status names,
775
+ customize field mapping, etc. Default implementation returns unchanged.
776
+ """
777
+ # Ensure file context information is preserved in all response paths
778
+ if not response_data.get("file_context"):
779
+ embedded_content = self.get_embedded_file_content()
780
+ reference_note = self.get_file_reference_note()
781
+ processed_files = self.get_actually_processed_files()
782
+
783
+ # Prioritize embedded content over references for final steps
784
+ if embedded_content:
785
+ response_data["file_context"] = {
786
+ "type": "fully_embedded",
787
+ "files_embedded": len(processed_files),
788
+ "context_optimization": "Full file content embedded for expert analysis",
789
+ }
790
+ elif reference_note:
791
+ response_data["file_context"] = {
792
+ "type": "reference_only",
793
+ "note": reference_note,
794
+ "context_optimization": "Files referenced but not embedded to preserve the context window",
795
+ }
796
+
797
+ return response_data
798
+
799
+ def store_conversation_turn(self, continuation_id: str, response_data: dict, request):
800
+ """Snapshot workflow data; the outer call transaction records history once."""
801
+ from utils.call_state import get_current_call
802
+
803
+ call = get_current_call()
804
+ if call is not None:
805
+ fields = self.get_workflow_state_fields()
806
+ state = {name: getattr(self, name) for name in fields if name in self.__dict__}
807
+ state["work_history"] = self.work_history
808
+ state["initial_request"] = self.initial_request
809
+ call.set_workflow_state(state)
810
+
811
+ def get_workflow_state_fields(self) -> tuple[str, ...]:
812
+ """Explicit persisted fields for existing workflows, including branches/configuration."""
813
+ return (
814
+ "branches",
815
+ "trace_config",
816
+ "security_config",
817
+ "analysis_config",
818
+ "refactor_config",
819
+ "git_config",
820
+ "review_config",
821
+ "stored_request_params",
822
+ "initial_issue",
823
+ "initial_planning_description",
824
+ "initial_tracing_description",
825
+ )
826
+
827
+ def _add_workflow_metadata(self, response_data: dict, arguments: dict[str, Any]) -> None:
828
+ metadata = response_data.setdefault("metadata", {})
829
+ metadata["tool_name"] = self.get_name()
830
+ metadata.update(getattr(self, "_backend_metadata", {}))
831
+
832
+ def _extract_clean_workflow_content_for_history(self, response_data: dict) -> str:
833
+ """
834
+ Extract clean content from workflow response suitable for conversation history.
835
+
836
+ This method removes internal workflow metadata, continuation offers, and
837
+ status information that should not appear when the conversation is
838
+ reconstructed for expert models or other tools.
839
+
840
+ Args:
841
+ response_data: The full workflow response data
842
+
843
+ Returns:
844
+ str: Clean content suitable for conversation history storage
845
+ """
846
+ # Create a clean copy with only essential content for conversation history
847
+ clean_data = {}
848
+
849
+ # Include core content if present
850
+ if "content" in response_data:
851
+ clean_data["content"] = response_data["content"]
852
+
853
+ # Include expert analysis if present (but clean it)
854
+ if "expert_analysis" in response_data:
855
+ expert_analysis = response_data["expert_analysis"]
856
+ if isinstance(expert_analysis, dict):
857
+ # Only include the actual analysis content, not metadata
858
+ clean_expert = {}
859
+ if "raw_analysis" in expert_analysis:
860
+ clean_expert["analysis"] = expert_analysis["raw_analysis"]
861
+ elif "content" in expert_analysis:
862
+ clean_expert["analysis"] = expert_analysis["content"]
863
+ if clean_expert:
864
+ clean_data["expert_analysis"] = clean_expert
865
+
866
+ # Include findings/issues if present (core workflow output)
867
+ if "complete_analysis" in response_data:
868
+ complete_analysis = response_data["complete_analysis"]
869
+ if isinstance(complete_analysis, dict):
870
+ clean_complete = {}
871
+ # Include essential analysis data without internal metadata
872
+ for key in ["findings", "issues_found", "relevant_context", "insights"]:
873
+ if key in complete_analysis:
874
+ clean_complete[key] = complete_analysis[key]
875
+ if clean_complete:
876
+ clean_data["analysis_summary"] = clean_complete
877
+
878
+ # Include step information for context but remove internal workflow metadata
879
+ if "step_number" in response_data:
880
+ clean_data["step_info"] = {
881
+ "step": response_data.get("step", ""),
882
+ "step_number": response_data.get("step_number", 1),
883
+ "total_steps": response_data.get("total_steps", 1),
884
+ }
885
+
886
+ # Exclude problematic fields that should never appear in conversation history:
887
+ # - continuation_id (confuses LLMs with old IDs)
888
+ # - status (internal workflow state)
889
+ # - next_step_required (internal control flow)
890
+ # - analysis_status (internal tracking)
891
+ # - file_context (internal optimization info)
892
+ # - required_actions (internal workflow instructions)
893
+
894
+ return json.dumps(clean_data, indent=2, ensure_ascii=False)
895
+
896
+ # Core workflow logic methods
897
+
898
+ async def handle_work_completion(self, response_data: dict, request, arguments: dict) -> dict:
899
+ """
900
+ Handle work completion logic - expert analysis decision and response building.
901
+ """
902
+ response_data[f"{self.get_name()}_complete"] = True
903
+
904
+ # Check if tool wants to skip expert analysis due to high certainty
905
+ if self.requires_expert_analysis() and not self.get_request_use_assistant_model(request):
906
+ response_data.update(
907
+ {
908
+ "status": "local_work_complete",
909
+ "skip_expert_analysis": True,
910
+ "expert_analysis": {"status": "skipped_by_user", "reason": "use_assistant_model=false"},
911
+ "next_steps": self.get_completion_next_steps_message(expert_analysis_used=False),
912
+ }
913
+ )
914
+ elif self.should_skip_expert_analysis(request, self.consolidated_findings):
915
+ # Handle completion without expert analysis
916
+ completion_response = self.handle_completion_without_expert_analysis(request, self.consolidated_findings)
917
+ response_data.update(completion_response)
918
+ elif self.requires_expert_analysis() and self.should_call_expert_analysis(self.consolidated_findings, request):
919
+ # Standard expert analysis path
920
+ response_data["status"] = "calling_expert_analysis"
921
+
922
+ # Call expert analysis
923
+ expert_analysis = await self._call_expert_analysis(arguments, request)
924
+ response_data["expert_analysis"] = expert_analysis
925
+
926
+ # Handle special expert analysis statuses
927
+ if isinstance(expert_analysis, dict) and expert_analysis.get("status") in [
928
+ "files_required_to_continue",
929
+ "investigation_paused",
930
+ "refactoring_paused",
931
+ ]:
932
+ # Promote the special status to the main response
933
+ special_status = expert_analysis["status"]
934
+ response_data["status"] = special_status
935
+ response_data["content"] = expert_analysis.get(
936
+ "raw_analysis", json.dumps(expert_analysis, ensure_ascii=False)
937
+ )
938
+ del response_data["expert_analysis"]
939
+
940
+ # Update next steps for special status
941
+ if special_status == "files_required_to_continue":
942
+ response_data["next_steps"] = "Provide the requested files and continue the analysis."
943
+ else:
944
+ response_data["next_steps"] = expert_analysis.get(
945
+ "next_steps", "Continue based on expert analysis."
946
+ )
947
+ elif isinstance(expert_analysis, dict) and expert_analysis.get("status") == "analysis_error":
948
+ # Expert analysis failed - promote error status
949
+ response_data["status"] = "error"
950
+ response_data["content"] = expert_analysis.get("error", "Expert analysis failed")
951
+ response_data["content_type"] = "text"
952
+ response_data.setdefault("metadata", {}).update(expert_analysis.get("metadata", {}))
953
+ del response_data["expert_analysis"]
954
+ else:
955
+ # Expert analysis was successfully executed - include expert guidance
956
+ response_data["next_steps"] = self.get_completion_next_steps_message(expert_analysis_used=True)
957
+
958
+ # Add expert analysis guidance as important considerations
959
+ expert_guidance = self.get_expert_analysis_guidance()
960
+ if expert_guidance:
961
+ response_data["important_considerations"] = expert_guidance
962
+
963
+ # Prepare complete work summary
964
+ work_summary = self._prepare_work_summary()
965
+ response_data[f"complete_{self.get_name()}"] = {
966
+ "initial_request": self.get_initial_request(request.step),
967
+ "steps_taken": len(self.work_history),
968
+ "files_examined": list(self.consolidated_findings.files_checked),
969
+ "relevant_files": list(self.consolidated_findings.relevant_files),
970
+ "relevant_context": list(self.consolidated_findings.relevant_context),
971
+ "issues_found": self.consolidated_findings.issues_found,
972
+ "work_summary": work_summary,
973
+ }
974
+ else:
975
+ # Tool doesn't require expert analysis or local work was sufficient
976
+ if not self.requires_expert_analysis():
977
+ # Tool is self-contained (like planner)
978
+ response_data["status"] = f"{self.get_name()}_complete"
979
+ response_data["next_steps"] = (
980
+ f"{self.get_name().capitalize()} work complete. Present results to the user."
981
+ )
982
+ else:
983
+ # Local work was sufficient for tools that support expert analysis
984
+ response_data["status"] = "local_work_complete"
985
+ response_data["next_steps"] = (
986
+ f"Local {self.get_name()} complete with sufficient confidence. Present findings "
987
+ "and recommendations to the user based on the work results."
988
+ )
989
+
990
+ return response_data
991
+
992
+ def handle_work_continuation(self, response_data: dict, request) -> dict:
993
+ """
994
+ Handle work continuation - force pause and provide guidance.
995
+ """
996
+ response_data["status"] = f"pause_for_{self.get_name()}"
997
+ response_data[f"{self.get_name()}_required"] = True
998
+
999
+ # Get tool-specific required actions
1000
+ required_actions = self.get_required_actions(
1001
+ request.step_number, self.get_request_confidence(request), request.findings, request.total_steps, request
1002
+ )
1003
+ response_data["required_actions"] = required_actions
1004
+
1005
+ # Generate step guidance
1006
+ response_data["next_steps"] = self.get_step_guidance_message(request)
1007
+
1008
+ return response_data
1009
+
1010
+ def _update_consolidated_findings(self, step_data: dict):
1011
+ """Update consolidated findings with new step data"""
1012
+ self.consolidated_findings.files_checked.update(step_data.get("files_checked", []))
1013
+ self.consolidated_findings.relevant_files.update(step_data.get("relevant_files", []))
1014
+ self.consolidated_findings.relevant_context.update(step_data.get("relevant_context", []))
1015
+ self.consolidated_findings.findings.append(f"Step {step_data['step_number']}: {step_data['findings']}")
1016
+ if step_data.get("hypothesis"):
1017
+ self.consolidated_findings.hypotheses.append(
1018
+ {
1019
+ "step": step_data["step_number"],
1020
+ "hypothesis": step_data["hypothesis"],
1021
+ "confidence": step_data["confidence"],
1022
+ }
1023
+ )
1024
+ if step_data.get("issues_found"):
1025
+ self.consolidated_findings.issues_found.extend(step_data["issues_found"])
1026
+ if step_data.get("images"):
1027
+ self.consolidated_findings.images.extend(step_data["images"])
1028
+ # Update confidence to latest value from this step
1029
+ if step_data.get("confidence"):
1030
+ self.consolidated_findings.confidence = step_data["confidence"]
1031
+
1032
+ def _reprocess_consolidated_findings(self):
1033
+ """Reprocess consolidated findings after backtracking"""
1034
+ self.consolidated_findings = ConsolidatedFindings()
1035
+ for step in self.work_history:
1036
+ self._update_consolidated_findings(step)
1037
+
1038
+ def _prepare_work_summary(self) -> str:
1039
+ """Prepare a comprehensive summary of the work"""
1040
+ summary_parts = [
1041
+ f"=== {self.get_name().upper()} WORK SUMMARY ===",
1042
+ f"Total steps: {len(self.work_history)}",
1043
+ f"Files examined: {len(self.consolidated_findings.files_checked)}",
1044
+ f"Relevant files identified: {len(self.consolidated_findings.relevant_files)}",
1045
+ f"Methods/functions involved: {len(self.consolidated_findings.relevant_context)}",
1046
+ f"Issues found: {len(self.consolidated_findings.issues_found)}",
1047
+ "",
1048
+ "=== WORK PROGRESSION ===",
1049
+ ]
1050
+
1051
+ for finding in self.consolidated_findings.findings:
1052
+ summary_parts.append(finding)
1053
+
1054
+ if self.consolidated_findings.hypotheses:
1055
+ summary_parts.extend(
1056
+ [
1057
+ "",
1058
+ "=== HYPOTHESIS EVOLUTION ===",
1059
+ ]
1060
+ )
1061
+ for hyp in self.consolidated_findings.hypotheses:
1062
+ summary_parts.append(f"Step {hyp['step']} ({hyp['confidence']} confidence): {hyp['hypothesis']}")
1063
+
1064
+ if self.consolidated_findings.issues_found:
1065
+ summary_parts.extend(
1066
+ [
1067
+ "",
1068
+ "=== ISSUES IDENTIFIED ===",
1069
+ ]
1070
+ )
1071
+ for issue in self.consolidated_findings.issues_found:
1072
+ severity = issue.get("severity", "unknown")
1073
+ description = issue.get("description", "No description")
1074
+ summary_parts.append(f"[{severity.upper()}] {description}")
1075
+
1076
+ return "\n".join(summary_parts)
1077
+
1078
+ def get_expert_initial_request(self, initial: Optional[str], fallback: str) -> str:
1079
+ """Reference an identical current step without duplicating required input."""
1080
+ description = initial or fallback
1081
+ if description == self._current_arguments.get("step"):
1082
+ return "See the current caller step supplied above."
1083
+ return description
1084
+
1085
+ async def _call_expert_analysis(self, arguments: dict, request) -> dict:
1086
+ """Launch one final advisory analysis, preserving caller-controlled workflow gates."""
1087
+ try:
1088
+ files = self._resolve_cli_target(
1089
+ request,
1090
+ files=tuple(sorted(self.consolidated_findings.relevant_files)),
1091
+ images=tuple(dict.fromkeys(self.consolidated_findings.images)),
1092
+ )
1093
+ self.consolidated_findings.relevant_files = set(files)
1094
+ context = self.prepare_expert_analysis_context(self.consolidated_findings)
1095
+ instruction = self.get_expert_analysis_instruction()
1096
+ prompt = (
1097
+ f"=== CURRENT CALLER STEP ===\n{request.step}\n=== END CURRENT CALLER STEP ===\n\n"
1098
+ f"=== INVESTIGATION DATA ===\n{context}\n=== END INVESTIGATION DATA ===\n\n{instruction}"
1099
+ )
1100
+ result = await self._run_cli(
1101
+ request,
1102
+ prompt,
1103
+ self.get_system_prompt() + "\n\n" + self.get_language_instruction(),
1104
+ files=tuple(sorted(self.consolidated_findings.relevant_files)),
1105
+ images=tuple(dict.fromkeys(self.consolidated_findings.images)),
1106
+ )
1107
+ content = result.content.strip()
1108
+ match = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", content, re.DOTALL)
1109
+ if match:
1110
+ content = match.group(1).strip()
1111
+ try:
1112
+ analysis = json.loads(content)
1113
+ except json.JSONDecodeError:
1114
+ analysis = None
1115
+ if isinstance(analysis, dict):
1116
+ return analysis
1117
+ return {"status": "analysis_complete", "raw_analysis": result.content, "format": "text"}
1118
+ except BackendError as exc:
1119
+ return {"status": "analysis_error", "error": str(exc), "metadata": exc.to_dict()}
1120
+
1121
+ def _process_work_step(self, step_data: dict):
1122
+ """
1123
+ Process a single work step and update internal state.
1124
+
1125
+ This method is useful for testing and manual step processing.
1126
+ It adds the step to work history and updates consolidated findings.
1127
+
1128
+ Args:
1129
+ step_data: Dictionary containing step information including:
1130
+ step, step_number, findings, files_checked, etc.
1131
+ """
1132
+ # Store in history
1133
+ self.work_history.append(step_data)
1134
+
1135
+ # Update consolidated findings
1136
+ self._update_consolidated_findings(step_data)
1137
+
1138
+ # Common execute method for workflow-based tools
1139
+
1140
+ async def execute(self, arguments: dict[str, Any]) -> list[TextContent]:
1141
+ """
1142
+ Common execute logic for workflow-based tools.
1143
+
1144
+ This method provides common validation and delegates to execute_workflow.
1145
+ Tools that need custom execute logic can override this method.
1146
+ """
1147
+ try:
1148
+ # Common validation
1149
+ if not arguments:
1150
+ error_data = {"status": "error", "content": "No arguments provided"}
1151
+ # Add basic metadata even for validation errors
1152
+ error_data["metadata"] = {"tool_name": self.get_name()}
1153
+ raise ToolExecutionError(json.dumps(error_data, ensure_ascii=False))
1154
+
1155
+ # Delegate to execute_workflow
1156
+ return await self.execute_workflow(arguments)
1157
+
1158
+ except (ToolExecutionError, StateError):
1159
+ raise
1160
+ except BackendError as exc:
1161
+ error_data = {"status": "error", "content": str(exc), "metadata": exc.to_dict()}
1162
+ raise ToolExecutionError(json.dumps(error_data, ensure_ascii=False)) from None
1163
+ except Exception as exc:
1164
+ logger.error("workflow_execution_failed tool=%s error_type=%s", self.get_name(), type(exc).__name__)
1165
+ error_data = {
1166
+ "status": "error",
1167
+ "content": "The workflow could not complete this request.",
1168
+ "metadata": {"code": "tool_error", "tool_name": self.get_name(), "error_type": type(exc).__name__},
1169
+ }
1170
+ raise ToolExecutionError(json.dumps(error_data, ensure_ascii=False)) from None
1171
+
1172
+ # Default implementations for methods that workflow-based tools typically don't need
1173
+
1174
+ async def prepare_prompt(self, request) -> str:
1175
+ """
1176
+ Base implementation for workflow tools - compatible with BaseTool signature.
1177
+
1178
+ Workflow tools typically don't need to return a prompt since they handle
1179
+ their own prompt preparation internally through the workflow execution.
1180
+
1181
+ Args:
1182
+ request: The validated request object
1183
+
1184
+ Returns:
1185
+ Empty string since workflow tools manage prompts internally
1186
+ """
1187
+ # Workflow tools handle their prompts internally during workflow execution
1188
+ return ""
1189
+
1190
+ def format_response(self, response: str, request, model_info=None):
1191
+ """
1192
+ Workflow tools handle their own response formatting.
1193
+ The BaseWorkflowMixin formats responses internally.
1194
+ """
1195
+ return response