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.
- clink/__init__.py +19 -0
- clink/agents/__init__.py +30 -0
- clink/agents/base.py +70 -0
- clink/agents/claude.py +7 -0
- clink/agents/codex.py +7 -0
- clink/agents/gemini.py +7 -0
- clink/allowed_roots.py +356 -0
- clink/backend.py +765 -0
- clink/cli.py +594 -0
- clink/constants.py +78 -0
- clink/contracts.py +167 -0
- clink/files.py +70 -0
- clink/generation.py +274 -0
- clink/generation_capabilities.py +207 -0
- clink/generation_contracts.py +214 -0
- clink/generation_policy.py +174 -0
- clink/models.py +243 -0
- clink/native.py +409 -0
- clink/native_adapters.py +244 -0
- clink/native_contracts.py +68 -0
- clink/native_generation.py +421 -0
- clink/native_generation_claude_policy.py +59 -0
- clink/native_generation_contracts.py +264 -0
- clink/native_generation_families.py +218 -0
- clink/native_generation_family_policy.py +14 -0
- clink/native_generation_files.py +153 -0
- clink/native_generation_policy.py +82 -0
- clink/native_policy.py +86 -0
- clink/parsers/__init__.py +34 -0
- clink/parsers/agy.py +53 -0
- clink/parsers/agy_native.py +161 -0
- clink/parsers/agy_native_generation.py +189 -0
- clink/parsers/base.py +209 -0
- clink/parsers/claude.py +104 -0
- clink/parsers/claude_native.py +113 -0
- clink/parsers/claude_native_generation.py +346 -0
- clink/parsers/codex.py +172 -0
- clink/parsers/codex_generation.py +212 -0
- clink/parsers/codex_native.py +236 -0
- clink/parsers/codex_native_generation.py +367 -0
- clink/parsers/gemini.py +110 -0
- clink/parsers/grok.py +63 -0
- clink/parsers/grok_native.py +91 -0
- clink/parsers/grok_native_generation.py +157 -0
- clink/policy.py +180 -0
- clink/registry.py +544 -0
- clink/resources/CODEX-LICENSE +201 -0
- clink/resources/CODEX-NOTICE +6 -0
- clink/resources/PROVENANCE.md +16 -0
- clink/resources/codex-0.166.0-models-direct.json +1284 -0
- clink/resources/codex-0.166.0-models-generation.json +1269 -0
- clink/resources/native-agy-1.4.12-admission.json +746 -0
- clink/resources/native-claude-2.1.304-admission.json +99 -0
- clink/resources/native-generation-claude-2.1.304-admission.json +277 -0
- clink/resources/native-generation-codex-0.166.0-admission.json +309 -0
- clink/resources/native-generation-grok-1.0.70-admission.json +329 -0
- clink/resources/native-grok-1.0.70-admission.json +644 -0
- clink/runner.py +303 -0
- clink/setup.py +399 -0
- clink/tasks.py +695 -0
- clink_cli-0.2.1.dist-info/METADATA +225 -0
- clink_cli-0.2.1.dist-info/RECORD +141 -0
- clink_cli-0.2.1.dist-info/WHEEL +5 -0
- clink_cli-0.2.1.dist-info/entry_points.txt +3 -0
- clink_cli-0.2.1.dist-info/licenses/LICENSE +197 -0
- clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-LICENSE +201 -0
- clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-NOTICE +6 -0
- clink_cli-0.2.1.dist-info/top_level.txt +7 -0
- conf/__init__.py +1 -0
- conf/cli_clients/agy.json +40 -0
- conf/cli_clients/claude.json +39 -0
- conf/cli_clients/codex.json +40 -0
- conf/cli_clients/gemini.json +23 -0
- conf/cli_clients/grok.json +40 -0
- config.py +40 -0
- server.py +896 -0
- systemprompts/__init__.py +35 -0
- systemprompts/analyze_prompt.py +87 -0
- systemprompts/chat_prompt.py +57 -0
- systemprompts/clink/codex_codereviewer.txt +8 -0
- systemprompts/clink/default.txt +8 -0
- systemprompts/clink/default_codereviewer.txt +8 -0
- systemprompts/clink/default_planner.txt +7 -0
- systemprompts/clink/native_codereviewer.txt +1 -0
- systemprompts/clink/native_default.txt +1 -0
- systemprompts/clink/native_planner.txt +1 -0
- systemprompts/codereview_prompt.py +91 -0
- systemprompts/consensus_prompt.py +120 -0
- systemprompts/debug_prompt.py +150 -0
- systemprompts/docgen_prompt.py +252 -0
- systemprompts/generate_code_prompt.py +181 -0
- systemprompts/planner_prompt.py +127 -0
- systemprompts/precommit_prompt.py +179 -0
- systemprompts/refactor_prompt.py +323 -0
- systemprompts/secaudit_prompt.py +420 -0
- systemprompts/testgen_prompt.py +133 -0
- systemprompts/thinkdeep_prompt.py +55 -0
- systemprompts/tracer_prompt.py +145 -0
- tools/__init__.py +43 -0
- tools/analyze.py +583 -0
- tools/apilookup.py +97 -0
- tools/challenge.py +198 -0
- tools/chat.py +187 -0
- tools/clink.py +130 -0
- tools/codereview.py +724 -0
- tools/consensus.py +790 -0
- tools/debug.py +573 -0
- tools/docgen.py +577 -0
- tools/listmodels.py +48 -0
- tools/models.py +373 -0
- tools/planner.py +533 -0
- tools/precommit.py +776 -0
- tools/refactor.py +645 -0
- tools/secaudit.py +685 -0
- tools/shared/__init__.py +19 -0
- tools/shared/base_models.py +167 -0
- tools/shared/base_tool.py +659 -0
- tools/shared/exceptions.py +20 -0
- tools/shared/schema_builders.py +159 -0
- tools/simple/__init__.py +18 -0
- tools/simple/base.py +539 -0
- tools/testgen.py +513 -0
- tools/thinkdeep.py +586 -0
- tools/tracer.py +763 -0
- tools/version.py +53 -0
- tools/workflow/__init__.py +22 -0
- tools/workflow/base.py +448 -0
- tools/workflow/schema_builders.py +166 -0
- tools/workflow/workflow_mixin.py +1195 -0
- utils/__init__.py +21 -0
- utils/call_state.py +349 -0
- utils/client_info.py +293 -0
- utils/consensus_manifest.py +131 -0
- utils/conversation_memory.py +582 -0
- utils/env.py +111 -0
- utils/file_types.py +271 -0
- utils/file_utils.py +803 -0
- utils/image_utils.py +94 -0
- utils/security_config.py +163 -0
- utils/storage_backend.py +177 -0
- utils/token_utils.py +54 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core Tool Infrastructure for PAL MCP Tools
|
|
3
|
+
|
|
4
|
+
This module provides the fundamental base class for all tools:
|
|
5
|
+
- BaseTool: Abstract base class defining the tool interface
|
|
6
|
+
|
|
7
|
+
The BaseTool class defines the core contract that tools must implement and provides
|
|
8
|
+
common functionality for request validation, error handling, model management,
|
|
9
|
+
conversation handling, file processing, and response formatting.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
16
|
+
|
|
17
|
+
from mcp.types import TextContent
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from tools.models import ToolModelCategory
|
|
21
|
+
|
|
22
|
+
from config import MCP_PROMPT_SIZE_LIMIT
|
|
23
|
+
from utils import estimate_tokens
|
|
24
|
+
from utils.conversation_memory import (
|
|
25
|
+
ConversationTurn,
|
|
26
|
+
get_conversation_file_list,
|
|
27
|
+
get_thread,
|
|
28
|
+
)
|
|
29
|
+
from utils.env import get_env
|
|
30
|
+
|
|
31
|
+
# Import models from tools.models for compatibility
|
|
32
|
+
try:
|
|
33
|
+
from tools.models import ToolOutput
|
|
34
|
+
except ImportError:
|
|
35
|
+
# Fallback in case models haven't been set up yet
|
|
36
|
+
ToolOutput = None
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BaseTool(ABC):
|
|
42
|
+
"""
|
|
43
|
+
Abstract base class for all PAL MCP tools.
|
|
44
|
+
|
|
45
|
+
This class defines the interface that all tools must implement and provides
|
|
46
|
+
common functionality for request handling, CLI routing, and response formatting.
|
|
47
|
+
|
|
48
|
+
The server owns conversation transactions and bounded replay. Tools retain their
|
|
49
|
+
schemas, caller-driven protocols and prompt preparation. All delegated work uses
|
|
50
|
+
one immutable backend target and the shared bounded CLI runner. Context paths are
|
|
51
|
+
validated before file reads and revalidated before launch.
|
|
52
|
+
|
|
53
|
+
To create a new tool:
|
|
54
|
+
1. Create a new class that inherits from BaseTool
|
|
55
|
+
2. Implement all abstract methods
|
|
56
|
+
3. Define a request model that inherits from ToolRequest
|
|
57
|
+
4. Register the tool in server.py's TOOLS dictionary
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self):
|
|
61
|
+
# Cache tool metadata at initialization to avoid repeated calls
|
|
62
|
+
self.name = self.get_name()
|
|
63
|
+
self.description = self.get_description()
|
|
64
|
+
self.default_temperature = self.get_default_temperature()
|
|
65
|
+
self._effective_user_prompt: Optional[str] = None
|
|
66
|
+
self._current_arguments: dict[str, Any] = {}
|
|
67
|
+
# Tool initialization complete
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def get_name(self) -> str:
|
|
71
|
+
"""
|
|
72
|
+
Return the unique name identifier for this tool.
|
|
73
|
+
|
|
74
|
+
This name is used by MCP clients to invoke the tool and must be
|
|
75
|
+
unique across all registered tools.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
str: The tool's unique name (e.g., "review_code", "analyze")
|
|
79
|
+
"""
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
@abstractmethod
|
|
83
|
+
def get_description(self) -> str:
|
|
84
|
+
"""
|
|
85
|
+
Return a detailed description of what this tool does.
|
|
86
|
+
|
|
87
|
+
This description is shown to MCP clients (like Claude / Codex / Gemini) to help them
|
|
88
|
+
understand when and how to use the tool. It should be comprehensive
|
|
89
|
+
and include trigger phrases.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
str: Detailed tool description with usage examples
|
|
93
|
+
"""
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
@abstractmethod
|
|
97
|
+
def get_input_schema(self) -> dict[str, Any]:
|
|
98
|
+
"""
|
|
99
|
+
Return the JSON Schema that defines this tool's parameters.
|
|
100
|
+
|
|
101
|
+
This schema is used by MCP clients to validate inputs before
|
|
102
|
+
sending requests. It should match the tool's request model.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Dict[str, Any]: JSON Schema object defining required and optional parameters
|
|
106
|
+
"""
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
@abstractmethod
|
|
110
|
+
def get_system_prompt(self) -> str:
|
|
111
|
+
"""
|
|
112
|
+
Return the system prompt that configures the AI model's behavior.
|
|
113
|
+
|
|
114
|
+
This prompt sets the context and instructions for how the model
|
|
115
|
+
should approach the task. It's prepended to the user's request.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
str: System prompt with role definition and instructions
|
|
119
|
+
"""
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
def get_annotations(self) -> Optional[dict[str, Any]]:
|
|
123
|
+
"""
|
|
124
|
+
Return optional annotations for this tool.
|
|
125
|
+
|
|
126
|
+
Annotations provide hints about tool behavior without being security-critical.
|
|
127
|
+
They help MCP clients make better decisions about tool usage.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Optional[dict]: Dictionary with annotation fields like readOnlyHint, destructiveHint, etc.
|
|
131
|
+
Returns None if no annotations are needed.
|
|
132
|
+
"""
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
def requires_model(self) -> bool:
|
|
136
|
+
"""
|
|
137
|
+
Return whether this tool requires AI model access.
|
|
138
|
+
|
|
139
|
+
Tools that override execute() to do pure data processing (like planner)
|
|
140
|
+
should return False to skip model resolution at the MCP boundary.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
bool: True if tool needs AI model access (default), False for data-only tools
|
|
144
|
+
"""
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
def is_effective_auto_mode(self) -> bool:
|
|
148
|
+
"""Native CLI model defaults never require caller model selection."""
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
def get_model_field_schema(self) -> dict[str, Any]:
|
|
152
|
+
"""Describe native model selection without provider catalogs or aliases."""
|
|
153
|
+
from .base_models import COMMON_FIELD_DESCRIPTIONS
|
|
154
|
+
|
|
155
|
+
return {"type": "string", "description": COMMON_FIELD_DESCRIPTIONS["model"]}
|
|
156
|
+
|
|
157
|
+
def get_default_temperature(self) -> float:
|
|
158
|
+
"""
|
|
159
|
+
Return the default temperature setting for this tool.
|
|
160
|
+
|
|
161
|
+
Override this method to set tool-specific temperature defaults.
|
|
162
|
+
Lower values (0.0-0.3) for analytical tasks, higher (0.7-1.0) for creative tasks.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
float: Default temperature between 0.0 and 1.0
|
|
166
|
+
"""
|
|
167
|
+
return 0.5
|
|
168
|
+
|
|
169
|
+
def wants_line_numbers_by_default(self) -> bool:
|
|
170
|
+
"""
|
|
171
|
+
Return whether this tool wants line numbers added to code files by default.
|
|
172
|
+
|
|
173
|
+
By default, ALL tools get line numbers for precise code references.
|
|
174
|
+
Line numbers are essential for accurate communication about code locations.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
bool: True if line numbers should be added by default for this tool
|
|
178
|
+
"""
|
|
179
|
+
return True # All tools get line numbers by default for consistency
|
|
180
|
+
|
|
181
|
+
def get_default_thinking_mode(self) -> str:
|
|
182
|
+
"""
|
|
183
|
+
Return the default thinking mode for this tool.
|
|
184
|
+
|
|
185
|
+
Thinking mode controls computational budget for reasoning.
|
|
186
|
+
Override for tools that need more or less reasoning depth.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
str: One of "minimal", "low", "medium", "high", "max"
|
|
190
|
+
"""
|
|
191
|
+
return "medium" # Default to medium thinking for better reasoning
|
|
192
|
+
|
|
193
|
+
def get_model_category(self) -> "ToolModelCategory":
|
|
194
|
+
"""
|
|
195
|
+
Return the model category for this tool.
|
|
196
|
+
|
|
197
|
+
Model category influences which model is selected in auto mode.
|
|
198
|
+
Override to specify whether your tool needs extended reasoning,
|
|
199
|
+
fast response, or balanced capabilities.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
ToolModelCategory: Category that influences model selection
|
|
203
|
+
"""
|
|
204
|
+
from tools.models import ToolModelCategory
|
|
205
|
+
|
|
206
|
+
return ToolModelCategory.BALANCED
|
|
207
|
+
|
|
208
|
+
@abstractmethod
|
|
209
|
+
def get_request_model(self):
|
|
210
|
+
"""
|
|
211
|
+
Return the Pydantic model class used for validating requests.
|
|
212
|
+
|
|
213
|
+
This model should inherit from ToolRequest and define all
|
|
214
|
+
parameters specific to this tool.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Type[ToolRequest]: The request model class
|
|
218
|
+
"""
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
def validate_file_paths(self, request) -> Optional[str]:
|
|
222
|
+
"""
|
|
223
|
+
Validate that all file paths in the request are absolute.
|
|
224
|
+
|
|
225
|
+
This is a critical security function that prevents path traversal attacks
|
|
226
|
+
and ensures all file access is properly controlled. All file paths must
|
|
227
|
+
be absolute to avoid ambiguity and security issues.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
request: The validated request object
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Optional[str]: Error message if validation fails, None if all paths are valid
|
|
234
|
+
"""
|
|
235
|
+
# Only validate files/paths if they exist in the request
|
|
236
|
+
file_fields = [
|
|
237
|
+
"absolute_file_paths",
|
|
238
|
+
"file",
|
|
239
|
+
"path",
|
|
240
|
+
"directory",
|
|
241
|
+
"notebooks",
|
|
242
|
+
"test_examples",
|
|
243
|
+
"style_guide_examples",
|
|
244
|
+
"files_checked",
|
|
245
|
+
"relevant_files",
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
for field_name in file_fields:
|
|
249
|
+
if hasattr(request, field_name):
|
|
250
|
+
field_value = getattr(request, field_name)
|
|
251
|
+
if field_value is None:
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
# Handle both single paths and lists of paths
|
|
255
|
+
paths_to_check = field_value if isinstance(field_value, list) else [field_value]
|
|
256
|
+
|
|
257
|
+
for path in paths_to_check:
|
|
258
|
+
if path and not os.path.isabs(path):
|
|
259
|
+
return f"All file paths must be FULL absolute paths. Invalid path: '{path}'"
|
|
260
|
+
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
def _validate_token_limit(self, content: str, content_type: str = "Content") -> None:
|
|
264
|
+
"""
|
|
265
|
+
Validate that user-provided content doesn't exceed the MCP prompt size limit.
|
|
266
|
+
|
|
267
|
+
This enforcement is strictly for text crossing the MCP transport boundary
|
|
268
|
+
(i.e., user input). Internal prompt construction may exceed this size and is
|
|
269
|
+
governed by model-specific token limits.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
content: The user-originated content to validate
|
|
273
|
+
content_type: Description of the content type for error messages
|
|
274
|
+
|
|
275
|
+
Raises:
|
|
276
|
+
ValueError: If content exceeds the character size limit
|
|
277
|
+
"""
|
|
278
|
+
if not content:
|
|
279
|
+
logger.debug(f"{self.name} tool {content_type.lower()} validation skipped (no content)")
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
char_count = len(content.encode("utf-8"))
|
|
283
|
+
if char_count > MCP_PROMPT_SIZE_LIMIT:
|
|
284
|
+
token_estimate = estimate_tokens(content)
|
|
285
|
+
error_msg = (
|
|
286
|
+
f"{char_count:,} UTF-8 bytes (~{token_estimate:,} tokens). "
|
|
287
|
+
f"Maximum is {MCP_PROMPT_SIZE_LIMIT:,} UTF-8 bytes."
|
|
288
|
+
)
|
|
289
|
+
logger.error(f"{self.name} tool {content_type.lower()} validation failed: {error_msg}")
|
|
290
|
+
from clink.contracts import BackendError
|
|
291
|
+
|
|
292
|
+
raise BackendError("input_limit", f"{content_type} too large: {error_msg}", "Split or narrow the request.")
|
|
293
|
+
|
|
294
|
+
token_estimate = estimate_tokens(content)
|
|
295
|
+
logger.debug(
|
|
296
|
+
f"{self.name} tool {content_type.lower()} validation passed: "
|
|
297
|
+
f"{char_count:,} UTF-8 bytes (~{token_estimate:,} tokens)"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# === CONVERSATION AND FILE HANDLING METHODS ===
|
|
301
|
+
|
|
302
|
+
def get_conversation_embedded_files(self, continuation_id: Optional[str]) -> list[str]:
|
|
303
|
+
"""
|
|
304
|
+
Get list of files already embedded in conversation history.
|
|
305
|
+
|
|
306
|
+
This method returns the list of files that have already been embedded
|
|
307
|
+
in the conversation history for a given continuation thread. Tools can
|
|
308
|
+
use this to avoid re-embedding files that are already available in the
|
|
309
|
+
conversation context.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
continuation_id: Thread continuation ID, or None for new conversations
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
list[str]: List of file paths already embedded in conversation history
|
|
316
|
+
"""
|
|
317
|
+
if not continuation_id:
|
|
318
|
+
# New conversation, no files embedded yet
|
|
319
|
+
return []
|
|
320
|
+
|
|
321
|
+
thread_context = get_thread(continuation_id)
|
|
322
|
+
if not thread_context:
|
|
323
|
+
# Thread not found, no files embedded
|
|
324
|
+
return []
|
|
325
|
+
|
|
326
|
+
embedded_files = get_conversation_file_list(thread_context)
|
|
327
|
+
logger.debug(f"[FILES] {self.name}: Found {len(embedded_files)} embedded files")
|
|
328
|
+
return embedded_files
|
|
329
|
+
|
|
330
|
+
def filter_new_files(self, requested_files: list[str], continuation_id: Optional[str]) -> list[str]:
|
|
331
|
+
"""
|
|
332
|
+
Filter out files that are already embedded in conversation history.
|
|
333
|
+
|
|
334
|
+
This method prevents duplicate file embeddings by filtering out files that have
|
|
335
|
+
already been embedded in the conversation history. This optimizes token usage
|
|
336
|
+
while ensuring tools still have logical access to all requested files through
|
|
337
|
+
conversation history references.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
requested_files: List of files requested for current tool execution
|
|
341
|
+
continuation_id: Thread continuation ID, or None for new conversations
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
list[str]: List of files that need to be embedded (not already in history)
|
|
345
|
+
"""
|
|
346
|
+
logger.debug(f"[FILES] {self.name}: Filtering {len(requested_files)} requested files")
|
|
347
|
+
|
|
348
|
+
if not continuation_id:
|
|
349
|
+
# New conversation, all files are new
|
|
350
|
+
logger.debug(f"[FILES] {self.name}: New conversation, all {len(requested_files)} files are new")
|
|
351
|
+
return requested_files
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
embedded_files = set(self.get_conversation_embedded_files(continuation_id))
|
|
355
|
+
logger.debug(f"[FILES] {self.name}: Found {len(embedded_files)} embedded files in conversation")
|
|
356
|
+
|
|
357
|
+
# Safety check: If no files are marked as embedded but we have a continuation_id,
|
|
358
|
+
# this might indicate an issue with conversation history. Be conservative.
|
|
359
|
+
if not embedded_files:
|
|
360
|
+
logger.debug(f"{self.name} tool: No files found in conversation history for thread {continuation_id}")
|
|
361
|
+
logger.debug(
|
|
362
|
+
f"[FILES] {self.name}: No embedded files found, returning all {len(requested_files)} requested files"
|
|
363
|
+
)
|
|
364
|
+
return requested_files
|
|
365
|
+
|
|
366
|
+
# Return only files that haven't been embedded yet
|
|
367
|
+
new_files = [f for f in requested_files if f not in embedded_files]
|
|
368
|
+
logger.debug(
|
|
369
|
+
f"[FILES] {self.name}: After filtering: {len(new_files)} new files, {len(requested_files) - len(new_files)} already embedded"
|
|
370
|
+
)
|
|
371
|
+
logger.debug("new_context_files tool=%s count=%d", self.name, len(new_files))
|
|
372
|
+
|
|
373
|
+
# Log filtering results for debugging
|
|
374
|
+
if len(new_files) < len(requested_files):
|
|
375
|
+
skipped = [f for f in requested_files if f in embedded_files]
|
|
376
|
+
logger.debug("conversation_files_filtered tool=%s count=%d", self.name, len(skipped))
|
|
377
|
+
logger.debug("existing_context_files tool=%s count=%d", self.name, len(skipped))
|
|
378
|
+
|
|
379
|
+
return new_files
|
|
380
|
+
|
|
381
|
+
except Exception as e:
|
|
382
|
+
# If there's any issue with conversation history lookup, be conservative
|
|
383
|
+
# and include all files rather than risk losing access to needed files
|
|
384
|
+
logger.warning("conversation_file_lookup_failed tool=%s error_type=%s", self.name, type(e).__name__)
|
|
385
|
+
logger.warning(f"{self.name} tool: Including all requested files as fallback")
|
|
386
|
+
logger.debug(
|
|
387
|
+
f"[FILES] {self.name}: Returning {len(requested_files)} declared references after lookup failure"
|
|
388
|
+
)
|
|
389
|
+
return requested_files
|
|
390
|
+
|
|
391
|
+
def format_conversation_turn(self, turn: ConversationTurn) -> list[str]:
|
|
392
|
+
"""
|
|
393
|
+
Format a conversation turn for display in conversation history.
|
|
394
|
+
|
|
395
|
+
Tools can override this to provide custom formatting for their responses
|
|
396
|
+
while maintaining the standard structure for cross-tool compatibility.
|
|
397
|
+
|
|
398
|
+
This method is called by build_conversation_history when reconstructing
|
|
399
|
+
conversation context, allowing each tool to control how its responses
|
|
400
|
+
appear in subsequent conversation turns.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
turn: The conversation turn to format (from utils.conversation_memory)
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
list[str]: Lines of formatted content for this turn
|
|
407
|
+
|
|
408
|
+
Example:
|
|
409
|
+
Default implementation returns:
|
|
410
|
+
["Files used in this turn: file1.py, file2.py", "", "Response content..."]
|
|
411
|
+
|
|
412
|
+
Tools can override to add custom sections, formatting, or metadata display.
|
|
413
|
+
"""
|
|
414
|
+
parts = []
|
|
415
|
+
|
|
416
|
+
# Add files context if present
|
|
417
|
+
if turn.files:
|
|
418
|
+
parts.append(f"Files used in this turn: {', '.join(turn.files)}")
|
|
419
|
+
parts.append("") # Empty line for readability
|
|
420
|
+
|
|
421
|
+
# Add the actual content
|
|
422
|
+
parts.append(turn.content)
|
|
423
|
+
|
|
424
|
+
return parts
|
|
425
|
+
|
|
426
|
+
def handle_prompt_file(self, files: Optional[list[str]]) -> tuple[Optional[str], Optional[list[str]]]:
|
|
427
|
+
"""Read one admitted prompt.txt verbatim under the same required-input cap."""
|
|
428
|
+
from pathlib import Path
|
|
429
|
+
|
|
430
|
+
from clink.contracts import BackendError
|
|
431
|
+
from clink.files import read_context
|
|
432
|
+
|
|
433
|
+
if not files:
|
|
434
|
+
return None, files
|
|
435
|
+
prompt_content = None
|
|
436
|
+
updated_files = []
|
|
437
|
+
for file_path in files:
|
|
438
|
+
if os.path.basename(file_path) != "prompt.txt":
|
|
439
|
+
updated_files.append(file_path)
|
|
440
|
+
continue
|
|
441
|
+
if prompt_content is not None:
|
|
442
|
+
raise BackendError("invalid_request", "Only one prompt.txt may supply required prompt text.")
|
|
443
|
+
try:
|
|
444
|
+
if "_backend_target" not in self.__dict__:
|
|
445
|
+
raise BackendError("path_not_allowed", "Required prompt.txt must be admitted before reading.")
|
|
446
|
+
data = read_context(
|
|
447
|
+
Path(file_path), self._backend_target.client.allowed_roots, MCP_PROMPT_SIZE_LIMIT + 1
|
|
448
|
+
)
|
|
449
|
+
if len(data) > MCP_PROMPT_SIZE_LIMIT:
|
|
450
|
+
raise BackendError("input_limit", "Required prompt.txt exceeds the 1 MiB UTF-8 input limit.")
|
|
451
|
+
prompt_content = data.decode("utf-8")
|
|
452
|
+
except (OSError, UnicodeError) as exc:
|
|
453
|
+
raise BackendError("path_not_allowed", "Required prompt.txt could not be read as UTF-8.") from exc
|
|
454
|
+
return prompt_content, updated_files
|
|
455
|
+
|
|
456
|
+
def get_prompt_content_for_size_validation(self, user_content: str) -> str:
|
|
457
|
+
"""
|
|
458
|
+
Get the content that should be validated for MCP prompt size limits.
|
|
459
|
+
|
|
460
|
+
This hook method allows tools to specify what content should be checked
|
|
461
|
+
against the MCP transport size limit. By default, it returns the user content,
|
|
462
|
+
but can be overridden to exclude conversation history when needed.
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
user_content: The user content that would normally be validated
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
The content that should actually be validated for size limits
|
|
469
|
+
"""
|
|
470
|
+
# Default implementation: validate the full user content
|
|
471
|
+
return user_content
|
|
472
|
+
|
|
473
|
+
def check_prompt_size(self, text: str) -> Optional[dict[str, Any]]:
|
|
474
|
+
"""Reject required UTF-8 text that cannot fit the shared request ceiling."""
|
|
475
|
+
size = len(text.encode("utf-8")) if text else 0
|
|
476
|
+
if size > MCP_PROMPT_SIZE_LIMIT:
|
|
477
|
+
return {
|
|
478
|
+
"status": "error",
|
|
479
|
+
"content": "Required prompt text exceeds the 1 MiB UTF-8 input limit.",
|
|
480
|
+
"content_type": "text",
|
|
481
|
+
"metadata": {
|
|
482
|
+
"code": "input_limit",
|
|
483
|
+
"prompt_size": size,
|
|
484
|
+
"limit": MCP_PROMPT_SIZE_LIMIT,
|
|
485
|
+
"remedy": "Split the request or explicitly select less context; prompt.txt cannot bypass this limit.",
|
|
486
|
+
},
|
|
487
|
+
}
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
def get_websearch_instruction(self, tool_specific: Optional[str] = None) -> str:
|
|
491
|
+
"""
|
|
492
|
+
Generate standardized web search instruction.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
tool_specific: Optional tool-specific search guidance
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
str: Web search instruction to append to prompt
|
|
499
|
+
"""
|
|
500
|
+
|
|
501
|
+
base_instruction = """
|
|
502
|
+
|
|
503
|
+
WEB SEARCH CAPABILITY: You can request the calling agent to perform web searches to enhance your analysis with current information!
|
|
504
|
+
|
|
505
|
+
IMPORTANT: When you identify areas where web searches would significantly improve your response (such as checking current documentation, finding recent solutions, verifying best practices, or gathering community insights), you MUST explicitly instruct the agent to perform specific web searches and then respond back using the continuation_id from this response to continue the analysis.
|
|
506
|
+
|
|
507
|
+
Use clear, direct language based on the value of the search:
|
|
508
|
+
|
|
509
|
+
For valuable supplementary information: "Please perform a web search on '[specific topic/query]' and then continue this analysis using the continuation_id from this response if you find relevant information."
|
|
510
|
+
|
|
511
|
+
For important missing information: "Please search for '[specific topic/query]' and respond back with the findings using the continuation_id from this response - this information is needed to provide a complete analysis."
|
|
512
|
+
|
|
513
|
+
For critical/essential information: "SEARCH REQUIRED: Please immediately perform a web search on '[specific topic/query]' and respond back with the results using the continuation_id from this response. Cannot provide accurate analysis without this current information."
|
|
514
|
+
|
|
515
|
+
This ensures you get the most current and comprehensive information while maintaining conversation context through the continuation_id."""
|
|
516
|
+
|
|
517
|
+
if tool_specific:
|
|
518
|
+
return f"""{base_instruction}
|
|
519
|
+
|
|
520
|
+
{tool_specific}
|
|
521
|
+
|
|
522
|
+
When recommending searches, be specific about what information you need and why it would improve your analysis."""
|
|
523
|
+
|
|
524
|
+
# Default instruction for all tools
|
|
525
|
+
return f"""{base_instruction}
|
|
526
|
+
|
|
527
|
+
Consider requesting searches for:
|
|
528
|
+
- Current documentation and API references
|
|
529
|
+
- Recent best practices and patterns
|
|
530
|
+
- Known issues and community solutions
|
|
531
|
+
- Framework updates and compatibility
|
|
532
|
+
- Security advisories and patches
|
|
533
|
+
- Performance benchmarks and optimizations
|
|
534
|
+
|
|
535
|
+
When recommending searches, be specific about what information you need and why it would improve your analysis. Always remember to instruct agent to use the continuation_id from this response when providing search results."""
|
|
536
|
+
|
|
537
|
+
def get_language_instruction(self) -> str:
|
|
538
|
+
"""
|
|
539
|
+
Generate language instruction based on LOCALE configuration.
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
str: Language instruction to prepend to prompt, or empty string if
|
|
543
|
+
no locale set
|
|
544
|
+
"""
|
|
545
|
+
# Read LOCALE directly from environment to support dynamic changes
|
|
546
|
+
# Tests can monkeypatch LOCALE via the environment helper (or .env when override is enforced)
|
|
547
|
+
|
|
548
|
+
locale = (get_env("LOCALE", "") or "").strip()
|
|
549
|
+
|
|
550
|
+
if not locale:
|
|
551
|
+
return ""
|
|
552
|
+
|
|
553
|
+
# Simple language instruction
|
|
554
|
+
return f"Always respond in {locale}.\n\n"
|
|
555
|
+
|
|
556
|
+
# === ABSTRACT METHODS FOR SIMPLE TOOLS ===
|
|
557
|
+
|
|
558
|
+
@abstractmethod
|
|
559
|
+
async def prepare_prompt(self, request) -> str:
|
|
560
|
+
"""
|
|
561
|
+
Prepare the complete prompt for the AI model.
|
|
562
|
+
|
|
563
|
+
This method should construct the full prompt by combining:
|
|
564
|
+
- System prompt from get_system_prompt()
|
|
565
|
+
- Declared file references (the shared backend loads bounded contents)
|
|
566
|
+
- Conversation history from reconstruct_thread_context()
|
|
567
|
+
- User's request and any tool-specific context
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
request: The validated request object
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
str: Complete prompt ready for the AI model
|
|
574
|
+
"""
|
|
575
|
+
pass
|
|
576
|
+
|
|
577
|
+
def format_response(self, response: str, request, model_info: dict = None) -> str:
|
|
578
|
+
"""
|
|
579
|
+
Format the AI model's response for the user.
|
|
580
|
+
|
|
581
|
+
This method allows tools to post-process the model's response,
|
|
582
|
+
adding structure, validation, or additional context.
|
|
583
|
+
|
|
584
|
+
The default implementation returns the response unchanged.
|
|
585
|
+
Tools can override this method to add custom formatting.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
response: Raw response from the AI model
|
|
589
|
+
request: The original request object
|
|
590
|
+
model_info: Optional model information and metadata
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
str: Formatted response ready for the user
|
|
594
|
+
"""
|
|
595
|
+
return response
|
|
596
|
+
|
|
597
|
+
# === IMPLEMENTATION METHODS ===
|
|
598
|
+
# These will be provided in a full implementation but are inherited from current base.py
|
|
599
|
+
# for now to maintain compatibility.
|
|
600
|
+
|
|
601
|
+
async def execute(self, arguments: dict[str, Any]) -> list[TextContent]:
|
|
602
|
+
"""Execute the tool - will be inherited from existing base.py for now."""
|
|
603
|
+
# This will be implemented by importing from the current base.py
|
|
604
|
+
# for backward compatibility during the migration
|
|
605
|
+
raise NotImplementedError("Subclasses must implement execute method")
|
|
606
|
+
|
|
607
|
+
def _parse_response(self, raw_text: str, request, model_info: Optional[dict] = None):
|
|
608
|
+
"""Parse response - will be inherited for now."""
|
|
609
|
+
# Implementation inherited from current base.py
|
|
610
|
+
raise NotImplementedError("Subclasses must implement _parse_response method")
|
|
611
|
+
|
|
612
|
+
def get_request_working_directory(self, request) -> Optional[str]:
|
|
613
|
+
"""Use configured cwd unless a tool explicitly defines an invocation cwd."""
|
|
614
|
+
return None
|
|
615
|
+
|
|
616
|
+
def _resolve_cli_target(self, request, *, files=(), images=()):
|
|
617
|
+
"""Freeze routing and validate declared context before any host file read."""
|
|
618
|
+
from clink.backend import get_backend
|
|
619
|
+
|
|
620
|
+
backend = get_backend()
|
|
621
|
+
self._backend_target = backend.resolve(
|
|
622
|
+
cli_name=request.cli_name,
|
|
623
|
+
role=request.role,
|
|
624
|
+
model=request.model,
|
|
625
|
+
working_directory=self.get_request_working_directory(request),
|
|
626
|
+
images=tuple(images),
|
|
627
|
+
temperature=request.temperature,
|
|
628
|
+
thinking_mode=request.thinking_mode,
|
|
629
|
+
)
|
|
630
|
+
return backend.validate_files(self._backend_target, tuple(files), tuple(images), tool_name=self.get_name())
|
|
631
|
+
|
|
632
|
+
async def _run_cli(self, request, prompt: str, system_prompt: str, *, files=(), images=(), require_search=False):
|
|
633
|
+
"""Delegate one advisory request through the sole execution boundary."""
|
|
634
|
+
from clink.backend import get_backend
|
|
635
|
+
from clink.contracts import CLIRequest
|
|
636
|
+
from utils.call_state import get_current_call
|
|
637
|
+
|
|
638
|
+
if "_backend_target" not in self.__dict__:
|
|
639
|
+
self._resolve_cli_target(request, files=files, images=images)
|
|
640
|
+
timing = {}
|
|
641
|
+
call = get_current_call()
|
|
642
|
+
if call is not None:
|
|
643
|
+
timing = {"admitted_at": call.admitted_at, "deadline": call.deadline, "request_id": call.request_id}
|
|
644
|
+
result = await get_backend().run(
|
|
645
|
+
CLIRequest(
|
|
646
|
+
prompt=prompt,
|
|
647
|
+
target=self._backend_target,
|
|
648
|
+
system_prompt=system_prompt,
|
|
649
|
+
files=tuple(files),
|
|
650
|
+
images=tuple(images),
|
|
651
|
+
tool_name=self.get_name(),
|
|
652
|
+
require_search=require_search,
|
|
653
|
+
history_turns=tuple(self._current_arguments.get("_replay_history_turns", ())),
|
|
654
|
+
history_omitted_turns=self._current_arguments.get("_replay_omitted_turns", 0),
|
|
655
|
+
**timing,
|
|
656
|
+
)
|
|
657
|
+
)
|
|
658
|
+
self._backend_metadata = result.to_metadata()
|
|
659
|
+
return result
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for PAL MCP tools.
|
|
3
|
+
|
|
4
|
+
These exceptions allow tools to signal protocol-level errors that should be surfaced
|
|
5
|
+
to MCP clients using the `isError` flag on `CallToolResult`. Raising one of these
|
|
6
|
+
exceptions ensures the low-level server adapter marks the result as an error while
|
|
7
|
+
preserving the structured payload we pass through the exception message.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ToolExecutionError(RuntimeError):
|
|
12
|
+
"""Raised to indicate a tool-level failure that must set `isError=True`."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, payload: str):
|
|
15
|
+
"""
|
|
16
|
+
Args:
|
|
17
|
+
payload: Serialized error payload (typically JSON) to return to the client.
|
|
18
|
+
"""
|
|
19
|
+
super().__init__(payload)
|
|
20
|
+
self.payload = payload
|