alita-sdk 0.3.603__py3-none-any.whl → 0.3.611__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.

Potentially problematic release.


This version of alita-sdk might be problematic. Click here for more details.

Files changed (34) hide show
  1. alita_sdk/cli/agents.py +108 -826
  2. alita_sdk/cli/testcases/__init__.py +94 -0
  3. alita_sdk/cli/testcases/data_generation.py +119 -0
  4. alita_sdk/cli/testcases/discovery.py +96 -0
  5. alita_sdk/cli/testcases/executor.py +84 -0
  6. alita_sdk/cli/testcases/logger.py +85 -0
  7. alita_sdk/cli/testcases/parser.py +172 -0
  8. alita_sdk/cli/testcases/prompts.py +91 -0
  9. alita_sdk/cli/testcases/reporting.py +125 -0
  10. alita_sdk/cli/testcases/setup.py +108 -0
  11. alita_sdk/cli/testcases/test_runner.py +282 -0
  12. alita_sdk/cli/testcases/utils.py +39 -0
  13. alita_sdk/cli/testcases/validation.py +90 -0
  14. alita_sdk/cli/testcases/workflow.py +196 -0
  15. alita_sdk/configurations/openapi.py +2 -2
  16. alita_sdk/runtime/clients/artifact.py +1 -1
  17. alita_sdk/runtime/langchain/langraph_agent.py +21 -6
  18. alita_sdk/runtime/tools/artifact.py +253 -8
  19. alita_sdk/runtime/tools/function.py +25 -6
  20. alita_sdk/runtime/tools/llm.py +12 -11
  21. alita_sdk/runtime/utils/serialization.py +155 -0
  22. alita_sdk/tools/bitbucket/api_wrapper.py +31 -30
  23. alita_sdk/tools/bitbucket/cloud_api_wrapper.py +49 -35
  24. alita_sdk/tools/confluence/api_wrapper.py +8 -1
  25. alita_sdk/tools/elitea_base.py +40 -36
  26. alita_sdk/tools/figma/api_wrapper.py +140 -83
  27. alita_sdk/tools/github/graphql_client_wrapper.py +1 -0
  28. alita_sdk/tools/utils/text_operations.py +156 -52
  29. {alita_sdk-0.3.603.dist-info → alita_sdk-0.3.611.dist-info}/METADATA +1 -1
  30. {alita_sdk-0.3.603.dist-info → alita_sdk-0.3.611.dist-info}/RECORD +34 -20
  31. {alita_sdk-0.3.603.dist-info → alita_sdk-0.3.611.dist-info}/WHEEL +0 -0
  32. {alita_sdk-0.3.603.dist-info → alita_sdk-0.3.611.dist-info}/entry_points.txt +0 -0
  33. {alita_sdk-0.3.603.dist-info → alita_sdk-0.3.611.dist-info}/licenses/LICENSE +0 -0
  34. {alita_sdk-0.3.603.dist-info → alita_sdk-0.3.611.dist-info}/top_level.txt +0 -0
@@ -1,3 +1,4 @@
1
+ import base64
1
2
  import json
2
3
  import logging
3
4
  from copy import deepcopy
@@ -12,6 +13,7 @@ from langchain_core.utils.function_calling import convert_to_openai_tool
12
13
  from pydantic import ValidationError
13
14
 
14
15
  from ..langchain.utils import propagate_the_input_mapping
16
+ from ..utils.serialization import safe_serialize
15
17
 
16
18
  logger = logging.getLogger(__name__)
17
19
 
@@ -40,15 +42,32 @@ class FunctionTool(BaseTool):
40
42
  alita_client: Optional[Any] = None
41
43
 
42
44
  def _prepare_pyodide_input(self, state: Union[str, dict, ToolCall]) -> str:
43
- """Prepare input for PyodideSandboxTool by injecting state into the code block."""
44
- # add state into the code block here since it might be changed during the execution of the code
45
+ """Prepare input for PyodideSandboxTool by injecting state into the code block.
46
+
47
+ Uses base64 encoding to avoid string escaping issues when passing JSON
48
+ through multiple layers of parsing (Python -> Deno -> Pyodide).
49
+ """
45
50
  state_copy = replace_escaped_newlines(deepcopy(state))
46
51
 
47
- del state_copy['messages'] # remove messages to avoid issues with pickling without langchain-core
48
- # inject state into the code block as alita_state variable
49
- state_json = json.dumps(state_copy, ensure_ascii=False)
50
- pyodide_predata = f'#state dict\nimport json\nalita_state = json.loads({json.dumps(state_json)})\n'
52
+ # remove messages to avoid issues with pickling without langchain-core
53
+ if 'messages' in state_copy:
54
+ del state_copy['messages']
55
+
56
+ # Use safe_serialize to handle Pydantic models, datetime, and other non-JSON types
57
+ state_json = safe_serialize(state_copy)
51
58
 
59
+ # Use base64 encoding to avoid all string escaping issues
60
+ # This is more robust than repr() when the code passes through multiple parsers
61
+ state_json_b64 = base64.b64encode(state_json.encode('utf-8')).decode('ascii')
62
+
63
+ # Generate code that decodes base64 and parses JSON inside Pyodide
64
+ pyodide_predata = f'''#state dict
65
+ import json
66
+ import base64
67
+ _state_json_b64 = "{state_json_b64}"
68
+ _state_json = base64.b64decode(_state_json_b64).decode('utf-8')
69
+ alita_state = json.loads(_state_json)
70
+ '''
52
71
  return pyodide_predata
53
72
 
54
73
  def _handle_pyodide_output(self, tool_result: Any) -> dict:
@@ -1,7 +1,7 @@
1
1
  import asyncio
2
2
  import logging
3
3
  from traceback import format_exc
4
- from typing import Any, Optional, List, Union, Literal
4
+ from typing import Any, Optional, List, Union, Literal, Dict
5
5
 
6
6
  from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
7
7
  from langchain_core.runnables import RunnableConfig
@@ -67,7 +67,7 @@ class LLMNode(BaseTool):
67
67
  client: Any = Field(default=None, description='LLM client instance')
68
68
  return_type: str = Field(default="str", description='Return type')
69
69
  response_key: str = Field(default="messages", description='Response key')
70
- structured_output_dict: Optional[dict[str, str]] = Field(default=None, description='Structured output dictionary')
70
+ structured_output_dict: Optional[Dict[str, Any]] = Field(default=None, description='Structured output dictionary')
71
71
  output_variables: Optional[List[str]] = Field(default=None, description='Output variables')
72
72
  input_mapping: Optional[dict[str, dict]] = Field(default=None, description='Input mapping')
73
73
  input_variables: Optional[List[str]] = Field(default=None, description='Input variables')
@@ -82,7 +82,7 @@ class LLMNode(BaseTool):
82
82
  Prepare structured output parameters from structured_output_dict.
83
83
 
84
84
  Expected self.structured_output_dict formats:
85
- - {"field": "str"} / {"field": "list"} / {"field": "list[str]"} / {"field": "any"} ...
85
+ - {"field": "str"} / {"field": "list"} / {"field": "list[dict]"} / {"field": "any"} ...
86
86
  - OR {"field": {"type": "...", "description": "...", "default": ...}} (optional)
87
87
 
88
88
  Returns:
@@ -93,19 +93,20 @@ class LLMNode(BaseTool):
93
93
  for key, value in (self.structured_output_dict or {}).items():
94
94
  # Allow either a plain type string or a dict with details
95
95
  if isinstance(value, dict):
96
- type_str = (value.get("type") or "any")
96
+ type_str = str(value.get("type") or "any")
97
97
  desc = value.get("description", "") or ""
98
98
  entry: dict = {"type": type_str, "description": desc}
99
99
  if "default" in value:
100
100
  entry["default"] = value["default"]
101
101
  else:
102
- type_str = (value or "any") if isinstance(value, str) else "any"
103
- entry = {"type": type_str, "description": ""}
102
+ # Ensure we always have a string type
103
+ if isinstance(value, str):
104
+ type_str = value
105
+ else:
106
+ # If it's already a type object, convert to string representation
107
+ type_str = getattr(value, '__name__', 'any')
104
108
 
105
- # Normalize: only convert the *exact* "list" into "list[str]"
106
- # (avoid the old bug where "if 'list' in value" also hits "blacklist", etc.)
107
- if isinstance(entry.get("type"), str) and entry["type"].strip().lower() == "list":
108
- entry["type"] = "list[str]"
109
+ entry = {"type": type_str, "description": ""}
109
110
 
110
111
  struct_params[key] = entry
111
112
 
@@ -1146,5 +1147,5 @@ class LLMNode(BaseTool):
1146
1147
 
1147
1148
  return new_messages, current_completion
1148
1149
 
1149
- def __get_struct_output_model(self, llm_client, pydantic_model, method: Literal["function_calling", "json_mode", "json_schema"] = "json_schema"):
1150
+ def __get_struct_output_model(self, llm_client, pydantic_model, method: Literal["function_calling", "json_mode", "json_schema"] = "function_calling"):
1150
1151
  return llm_client.with_structured_output(pydantic_model, method=method)
@@ -0,0 +1,155 @@
1
+ """
2
+ Serialization utilities for safe JSON encoding of complex objects.
3
+
4
+ Handles Pydantic models, LangChain messages, datetime objects, and other
5
+ non-standard types that may appear in state variables.
6
+ """
7
+ import json
8
+ import logging
9
+ from datetime import datetime, date
10
+ from typing import Any
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def _convert_to_serializable(obj: Any, _seen: set = None) -> Any:
16
+ """
17
+ Recursively convert an object to JSON-serializable primitives.
18
+
19
+ Handles nested dicts and lists that may contain non-serializable objects.
20
+ Uses a seen set to prevent infinite recursion with circular references.
21
+
22
+ Args:
23
+ obj: Any object to convert
24
+ _seen: Internal set to track seen object ids (for circular reference detection)
25
+
26
+ Returns:
27
+ JSON-serializable representation of the object
28
+ """
29
+ # Initialize seen set for circular reference detection
30
+ if _seen is None:
31
+ _seen = set()
32
+
33
+ # Check for circular references (only for mutable objects)
34
+ obj_id = id(obj)
35
+ if isinstance(obj, (dict, list, set)) and obj_id in _seen:
36
+ return f"<circular reference: {type(obj).__name__}>"
37
+
38
+ # Primitives - return as-is
39
+ if obj is None or isinstance(obj, (str, int, float, bool)):
40
+ return obj
41
+
42
+ # Add to seen set for mutable containers
43
+ if isinstance(obj, (dict, list, set)):
44
+ _seen = _seen | {obj_id} # Create new set to avoid mutation issues
45
+
46
+ # Dict - recursively process all values
47
+ if isinstance(obj, dict):
48
+ return {
49
+ _convert_to_serializable(k, _seen): _convert_to_serializable(v, _seen)
50
+ for k, v in obj.items()
51
+ }
52
+
53
+ # List/tuple - recursively process all items
54
+ if isinstance(obj, (list, tuple)):
55
+ return [_convert_to_serializable(item, _seen) for item in obj]
56
+
57
+ # Set - convert to list and process
58
+ if isinstance(obj, set):
59
+ return [_convert_to_serializable(item, _seen) for item in obj]
60
+
61
+ # Bytes - decode to string
62
+ if isinstance(obj, bytes):
63
+ try:
64
+ return obj.decode('utf-8')
65
+ except UnicodeDecodeError:
66
+ return obj.decode('utf-8', errors='replace')
67
+
68
+ # Datetime objects
69
+ if isinstance(obj, datetime):
70
+ return obj.isoformat()
71
+ if isinstance(obj, date):
72
+ return obj.isoformat()
73
+
74
+ # Pydantic BaseModel (v2) - check for model_dump method
75
+ if hasattr(obj, 'model_dump') and callable(getattr(obj, 'model_dump')):
76
+ try:
77
+ return _convert_to_serializable(obj.model_dump(), _seen)
78
+ except Exception as e:
79
+ logger.debug(f"Failed to call model_dump on {type(obj).__name__}: {e}")
80
+
81
+ # Pydantic BaseModel (v1) - check for dict method
82
+ if hasattr(obj, 'dict') and callable(getattr(obj, 'dict')) and hasattr(obj, '__fields__'):
83
+ try:
84
+ return _convert_to_serializable(obj.dict(), _seen)
85
+ except Exception as e:
86
+ logger.debug(f"Failed to call dict on {type(obj).__name__}: {e}")
87
+
88
+ # LangChain BaseMessage - extract key fields
89
+ if hasattr(obj, 'type') and hasattr(obj, 'content'):
90
+ try:
91
+ result = {
92
+ "type": obj.type,
93
+ "content": _convert_to_serializable(obj.content, _seen),
94
+ }
95
+ if hasattr(obj, 'additional_kwargs') and obj.additional_kwargs:
96
+ result["additional_kwargs"] = _convert_to_serializable(obj.additional_kwargs, _seen)
97
+ if hasattr(obj, 'name') and obj.name:
98
+ result["name"] = obj.name
99
+ return result
100
+ except Exception as e:
101
+ logger.debug(f"Failed to extract message fields from {type(obj).__name__}: {e}")
102
+
103
+ # Objects with __dict__ attribute (custom classes)
104
+ if hasattr(obj, '__dict__'):
105
+ try:
106
+ return _convert_to_serializable(obj.__dict__, _seen)
107
+ except Exception as e:
108
+ logger.debug(f"Failed to serialize __dict__ of {type(obj).__name__}: {e}")
109
+
110
+ # UUID objects
111
+ if hasattr(obj, 'hex') and hasattr(obj, 'int'):
112
+ return str(obj)
113
+
114
+ # Enum objects
115
+ if hasattr(obj, 'value') and hasattr(obj, 'name') and hasattr(obj.__class__, '__members__'):
116
+ return obj.value
117
+
118
+ # Last resort - convert to string
119
+ try:
120
+ return str(obj)
121
+ except Exception:
122
+ return f"<non-serializable: {type(obj).__name__}>"
123
+
124
+
125
+ def safe_serialize(obj: Any, **kwargs) -> str:
126
+ """
127
+ Safely serialize any object to a JSON string.
128
+
129
+ Pre-processes the entire object tree to convert non-serializable
130
+ objects before passing to json.dumps. This ensures nested dicts
131
+ and lists with non-standard objects are handled correctly.
132
+
133
+ Args:
134
+ obj: Any object to serialize
135
+ **kwargs: Additional arguments passed to json.dumps
136
+ (e.g., indent, sort_keys)
137
+
138
+ Returns:
139
+ JSON string representation of the object
140
+
141
+ Example:
142
+ >>> from pydantic import BaseModel
143
+ >>> class User(BaseModel):
144
+ ... name: str
145
+ >>> state = {"user": User(name="Alice"), "count": 5}
146
+ >>> safe_serialize(state)
147
+ '{"user": {"name": "Alice"}, "count": 5}'
148
+ """
149
+ # Pre-process the entire object tree
150
+ serializable = _convert_to_serializable(obj)
151
+
152
+ # Set defaults
153
+ kwargs.setdefault('ensure_ascii', False)
154
+
155
+ return json.dumps(serializable, **kwargs)
@@ -270,11 +270,20 @@ class BitbucketAPIWrapper(CodeIndexerToolkit):
270
270
  >>>> NEW
271
271
  branch(str): branch name (by default: active_branch)
272
272
  Returns:
273
- str: A success or failure message
273
+ str | ToolException: A success message or a ToolException on failure.
274
274
  """
275
275
  try:
276
- result = self._bitbucket.update_file(file_path=file_path, update_query=update_query, branch=branch)
277
- return result if isinstance(result, ToolException) else f"File has been updated: {file_path}."
276
+ # Use the shared edit_file logic from BaseCodeToolApiWrapper, operating on
277
+ # this wrapper instance, which provides _read_file and _write_file.
278
+ result = self.edit_file(
279
+ file_path=file_path,
280
+ branch=branch,
281
+ file_query=update_query,
282
+ )
283
+ return result
284
+ except ToolException as e:
285
+ # Pass through ToolExceptions as-is so callers can handle them uniformly.
286
+ return e
278
287
  except Exception as e:
279
288
  return ToolException(f"File was not updated due to error: {str(e)}")
280
289
 
@@ -415,37 +424,29 @@ class BitbucketAPIWrapper(CodeIndexerToolkit):
415
424
  file_path: str,
416
425
  content: str,
417
426
  branch: str = None,
418
- commit_message: str = None
427
+ commit_message: str = None,
419
428
  ) -> str:
429
+ """Write content to a file (create or update) via the underlying Bitbucket client.
430
+
431
+ This delegates to the low-level BitbucketServerApi/BitbucketCloudApi `_write_file`
432
+ implementations, so all backend-specific commit behavior (server vs cloud) is
433
+ centralized there. Used by BaseCodeToolApiWrapper.edit_file.
420
434
  """
421
- Write content to a file (create or update).
422
-
423
- Parameters:
424
- file_path: Path to the file
425
- content: New file content
426
- branch: Branch name (uses active branch if None)
427
- commit_message: Commit message (not used by Bitbucket API)
428
-
429
- Returns:
430
- Success message
431
- """
435
+ branch = branch or self._active_branch
432
436
  try:
433
- branch = branch or self._active_branch
434
-
435
- # Check if file exists by attempting to read it
436
- try:
437
- self._read_file(file_path, branch)
438
- # File exists, update it using OLD/NEW format
439
- old_content = self._read_file(file_path, branch)
440
- update_query = f"OLD <<<<\n{old_content}\n>>>> OLD\nNEW <<<<\n{content}\n>>>> NEW"
441
- self._bitbucket.update_file(file_path=file_path, update_query=update_query, branch=branch)
442
- return f"Updated file {file_path}"
443
- except:
444
- # File doesn't exist, create it
445
- self._bitbucket.create_file(file_path=file_path, file_contents=content, branch=branch)
446
- return f"Created file {file_path}"
437
+ # Delegate actual write/commit to the underlying API wrapper, which
438
+ # implements _write_file(file_path, content, branch, commit_message).
439
+ self._bitbucket._write_file(
440
+ file_path=file_path,
441
+ content=content,
442
+ branch=branch,
443
+ commit_message=commit_message or f"Update {file_path}",
444
+ )
445
+ return f"Update {file_path}"
446
+ except ToolException:
447
+ raise
447
448
  except Exception as e:
448
- raise ToolException(f"Unable to write file {file_path}: {str(e)}")
449
+ raise ToolException(f"Unable to write file {file_path} on branch {branch}: {str(e)}")
449
450
 
450
451
  @extend_with_parent_available_tools
451
452
  @extend_with_file_operations
@@ -142,32 +142,28 @@ class BitbucketServerApi(BitbucketApiAbstract):
142
142
  filename=file_path
143
143
  )
144
144
 
145
- def update_file(self, file_path: str, update_query: str, branch: str) -> str:
146
- file_content = self.get_file(file_path=file_path, branch=branch)
147
- updated_file_content = file_content
148
- for old, new in parse_old_new_markers(update_query):
149
- if not old.strip():
150
- continue
151
- updated_file_content = updated_file_content.replace(old, new)
152
-
153
- if file_content == updated_file_content:
154
- raise ToolException(
155
- "File content was not updated because old content was not found or empty. "
156
- "It may be helpful to use the read_file action to get "
157
- "the current file contents."
158
- )
145
+ def _write_file(self, file_path: str, content: str, branch: str, commit_message: str) -> str:
146
+ """Write updated file content to Bitbucket Server.
159
147
 
148
+ it creates a new commit on the given branch that edits the existing file.
149
+ """
150
+ # Get the latest commit on the branch (used as source_commit_id)
160
151
  source_commit_generator = self.api_client.get_commits(project_key=self.project, repository_slug=self.repository,
161
152
  hash_newest=branch, limit=1)
162
- source_commit = next(source_commit_generator)
153
+ source_commit = next(source_commit_generator, None)
154
+ if not source_commit:
155
+ raise ToolException(
156
+ f"Unable to determine latest commit on branch '{branch}' for repository '{self.repository}'."
157
+ )
158
+
163
159
  return self.api_client.update_file(
164
160
  project_key=self.project,
165
161
  repository_slug=self.repository,
166
- content=updated_file_content,
167
- message=f"Update {file_path}",
162
+ content=content,
163
+ message=commit_message or f"Update {file_path}",
168
164
  branch=branch,
169
165
  filename=file_path,
170
- source_commit_id=source_commit['id']
166
+ source_commit_id=source_commit['id'],
171
167
  )
172
168
 
173
169
  def get_pull_request_commits(self, pr_id: str) -> List[Dict[str, Any]]:
@@ -294,7 +290,37 @@ class BitbucketCloudApi(BitbucketApiAbstract):
294
290
  return None
295
291
 
296
292
  def get_file(self, file_path: str, branch: str) -> str:
297
- return self.repository.get(path=f'src/{branch}/{file_path}')
293
+ """Fetch a file's content from Bitbucket Cloud and return it as text.
294
+
295
+ Uses the 'get' endpoint with advanced_mode to get a rich response object.
296
+ """
297
+ try:
298
+ file_response = self.repository.get(
299
+ path=f"src/{branch}/{file_path}",
300
+ advanced_mode=True,
301
+ )
302
+
303
+ # Prefer HTTP status when available
304
+ status = getattr(file_response, "status_code", None)
305
+ if status is not None and status != 200:
306
+ raise ToolException(
307
+ f"Failed to retrieve text from file '{file_path}' from branch '{branch}': "
308
+ f"HTTP {status}"
309
+ )
310
+
311
+ # Safely extract text content
312
+ file_text = getattr(file_response, "text", None)
313
+ if not isinstance(file_text, str) or not file_text:
314
+ raise ToolException(
315
+ f"File '{file_path}' from branch '{branch}' is empty or could not be retrieved."
316
+ )
317
+
318
+ return file_text
319
+ except Exception as e:
320
+ # Network/transport or client-level failure
321
+ raise ToolException(
322
+ f"Failed to retrieve text from file '{file_path}' from branch '{branch}': {e}"
323
+ )
298
324
 
299
325
  def get_files_list(self, file_path: str, branch: str) -> list:
300
326
  files_list = []
@@ -315,22 +341,10 @@ class BitbucketCloudApi(BitbucketApiAbstract):
315
341
  return self.repository.post(path='src', data=form_data, files={},
316
342
  headers={'Content-Type': 'application/x-www-form-urlencoded'})
317
343
 
318
- def update_file(self, file_path: str, update_query: str, branch: str) -> ToolException | str:
319
-
320
- file_content = self.get_file(file_path=file_path, branch=branch)
321
- updated_file_content = file_content
322
- for old, new in parse_old_new_markers(file_query=update_query):
323
- if not old.strip():
324
- continue
325
- updated_file_content = updated_file_content.replace(old, new)
326
-
327
- if file_content == updated_file_content:
328
- return ToolException(
329
- "File content was not updated because old content was not found or empty. "
330
- "It may be helpful to use the read_file action to get "
331
- "the current file contents."
332
- )
333
- return self.create_file(file_path, updated_file_content, branch)
344
+ def _write_file(self, file_path: str, content: str, branch: str, commit_message: str) -> str:
345
+ """Write updated file content to Bitbucket Cloud.
346
+ """
347
+ return self.create_file(file_path=file_path, file_contents=content, branch=branch)
334
348
 
335
349
  def get_pull_request_commits(self, pr_id: str) -> List[Dict[str, Any]]:
336
350
  """
@@ -620,11 +620,18 @@ class ConfluenceAPIWrapper(NonCodeIndexerToolkit):
620
620
  def _process_search(self, cql, skip_images: bool = False):
621
621
  start = 0
622
622
  pages_info = []
623
+ seen_ids: set = set() # Track seen page IDs to avoid duplicates
623
624
  for _ in range((self.max_pages + self.limit - 1) // self.limit):
624
625
  pages = self.client.cql(cql, start=start, limit=self.limit).get("results", [])
625
626
  if not pages:
626
627
  break
627
- page_ids = [page['content']['id'] for page in pages]
628
+ # Deduplicate page IDs before processing
629
+ page_ids = []
630
+ for page in pages:
631
+ page_id = page['content']['id']
632
+ if page_id not in seen_ids:
633
+ seen_ids.add(page_id)
634
+ page_ids.append(page_id)
628
635
  for page in self.get_pages_by_id(page_ids, skip_images):
629
636
  page_info = {
630
637
  'content': page.page_content,
@@ -837,10 +837,7 @@ class BaseCodeToolApiWrapper(BaseVectorStoreToolApiWrapper):
837
837
  commit_message: Commit message (VCS toolkits only)
838
838
 
839
839
  Returns:
840
- Success message or error
841
-
842
- Raises:
843
- ToolException: If file is not text-editable or edit fails
840
+ Success message or raises ToolException on failure.
844
841
  """
845
842
  from .utils.text_operations import parse_old_new_markers, is_text_editable, try_apply_edit
846
843
  from langchain_core.callbacks import dispatch_custom_event
@@ -868,45 +865,35 @@ class BaseCodeToolApiWrapper(BaseVectorStoreToolApiWrapper):
868
865
  raise current_content if isinstance(current_content, Exception) else ToolException(str(current_content))
869
866
  except Exception as e:
870
867
  raise ToolException(f"Failed to read file {file_path}: {e}")
871
-
872
- # Apply all edits (with tolerant fallback)
868
+
869
+ # Apply all edits (stop on first warning/error)
873
870
  updated_content = current_content
874
- fallbacks_used = 0
875
871
  edits_applied = 0
876
872
  for old_text, new_text in edits:
877
873
  if not old_text.strip():
878
874
  continue
879
875
 
880
- new_updated, used_fallback = try_apply_edit(
876
+ new_updated, error_message = try_apply_edit(
881
877
  content=updated_content,
882
878
  old_text=old_text,
883
879
  new_text=new_text,
884
880
  file_path=file_path,
885
881
  )
886
882
 
887
- if new_updated == updated_content:
888
- # No change applied for this pair (exact nor fallback)
889
- logger.warning(
890
- "Old content not found or could not be safely matched in %s. Snippet: %s...",
891
- file_path,
892
- old_text[:100].replace("\n", "\\n"),
893
- )
894
- continue
883
+ if error_message:
884
+ return error_message
895
885
 
896
886
  # A replacement was applied
897
887
  edits_applied += 1
898
- if used_fallback:
899
- fallbacks_used += 1
900
-
901
888
  updated_content = new_updated
902
889
 
903
890
  # Check if any changes were made
904
- if current_content == updated_content or edits_applied == 0:
905
- return (
906
- f"No changes made to {file_path}. "
907
- "Old content was not found or is empty. "
908
- "Use read_file or search_file to verify current content."
909
- )
891
+ if current_content == updated_content:
892
+ # At least one edit was applied, but the final content is identical.
893
+ # This usually means the sequence of OLD/NEW pairs is redundant or cancels out.
894
+ return (f"Edits for {file_path} were applied but the final content is identical to the original. "
895
+ "The sequence of OLD/NEW pairs appears to be redundant or self-cancelling. "
896
+ "Please simplify or review the update_query.")
910
897
 
911
898
  # Write updated content
912
899
  try:
@@ -1116,60 +1103,77 @@ def extend_with_file_operations(method):
1116
1103
  """
1117
1104
  Decorator to automatically add file operation tools to toolkits that implement
1118
1105
  _read_file and _write_file methods.
1119
-
1106
+
1120
1107
  Adds:
1121
1108
  - read_file_chunk: Read specific line ranges
1122
1109
  - read_multiple_files: Batch read files
1123
1110
  - search_file: Search for patterns in files
1124
1111
  - edit_file: Edit files using OLD/NEW markers
1112
+
1113
+ Custom Schema Support:
1114
+ Toolkits can provide custom schemas by implementing _get_file_operation_schemas() method
1115
+ that returns a dict mapping tool names to Pydantic models. This allows toolkits like
1116
+ ArtifactWrapper to use bucket_name instead of branch.
1117
+
1118
+ Example:
1119
+ def _get_file_operation_schemas(self):
1120
+ return {
1121
+ "read_file_chunk": MyCustomReadFileChunkInput,
1122
+ "read_multiple_files": MyCustomReadMultipleFilesInput,
1123
+ }
1125
1124
  """
1126
1125
  def wrapper(self, *args, **kwargs):
1127
1126
  tools = method(self, *args, **kwargs)
1128
-
1127
+
1129
1128
  # Only add file operations if toolkit has implemented the required methods
1130
1129
  # Check for both _read_file and _write_file methods
1131
1130
  has_file_ops = (hasattr(self, '_read_file') and callable(getattr(self, '_read_file')) and
1132
1131
  hasattr(self, '_write_file') and callable(getattr(self, '_write_file')))
1133
-
1132
+
1134
1133
  if has_file_ops:
1135
1134
  # Import schemas from elitea_base
1136
1135
  from . import elitea_base
1137
-
1136
+
1137
+ # Check for toolkit-specific custom schemas
1138
+ custom_schemas = {}
1139
+ if hasattr(self, '_get_file_operation_schemas') and callable(getattr(self, '_get_file_operation_schemas')):
1140
+ custom_schemas = self._get_file_operation_schemas() or {}
1141
+
1138
1142
  file_operation_tools = [
1139
1143
  {
1140
1144
  "name": "read_file_chunk",
1141
1145
  "mode": "read_file_chunk",
1142
1146
  "ref": self.read_file_chunk,
1143
1147
  "description": self.read_file_chunk.__doc__,
1144
- "args_schema": elitea_base.ReadFileChunkInput
1148
+ "args_schema": custom_schemas.get("read_file_chunk", elitea_base.ReadFileChunkInput)
1145
1149
  },
1146
1150
  {
1147
1151
  "name": "read_multiple_files",
1148
1152
  "mode": "read_multiple_files",
1149
1153
  "ref": self.read_multiple_files,
1150
1154
  "description": self.read_multiple_files.__doc__,
1151
- "args_schema": elitea_base.ReadMultipleFilesInput
1155
+ "args_schema": custom_schemas.get("read_multiple_files", elitea_base.ReadMultipleFilesInput)
1152
1156
  },
1153
1157
  {
1154
1158
  "name": "search_file",
1155
1159
  "mode": "search_file",
1156
1160
  "ref": self.search_file,
1157
1161
  "description": self.search_file.__doc__,
1158
- "args_schema": elitea_base.SearchFileInput
1162
+ "args_schema": custom_schemas.get("search_file", elitea_base.SearchFileInput)
1159
1163
  },
1160
1164
  {
1161
1165
  "name": "edit_file",
1162
1166
  "mode": "edit_file",
1163
1167
  "ref": self.edit_file,
1164
1168
  "description": self.edit_file.__doc__,
1165
- "args_schema": elitea_base.EditFileInput
1169
+ "args_schema": custom_schemas.get("edit_file", elitea_base.EditFileInput)
1166
1170
  },
1167
1171
  ]
1168
-
1172
+
1169
1173
  tools.extend(file_operation_tools)
1170
-
1174
+
1171
1175
  return tools
1172
-
1176
+
1173
1177
  return wrapper
1174
1178
 
1175
1179