abstractcore 2.6.8__py3-none-any.whl → 2.9.0__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 (45) hide show
  1. abstractcore/apps/summarizer.py +69 -27
  2. abstractcore/architectures/detection.py +190 -25
  3. abstractcore/assets/architecture_formats.json +129 -6
  4. abstractcore/assets/model_capabilities.json +789 -136
  5. abstractcore/config/main.py +2 -2
  6. abstractcore/config/manager.py +3 -1
  7. abstractcore/events/__init__.py +7 -1
  8. abstractcore/mcp/__init__.py +30 -0
  9. abstractcore/mcp/client.py +213 -0
  10. abstractcore/mcp/factory.py +64 -0
  11. abstractcore/mcp/naming.py +28 -0
  12. abstractcore/mcp/stdio_client.py +336 -0
  13. abstractcore/mcp/tool_source.py +164 -0
  14. abstractcore/processing/basic_deepsearch.py +1 -1
  15. abstractcore/processing/basic_summarizer.py +300 -83
  16. abstractcore/providers/anthropic_provider.py +91 -10
  17. abstractcore/providers/base.py +537 -16
  18. abstractcore/providers/huggingface_provider.py +17 -8
  19. abstractcore/providers/lmstudio_provider.py +170 -25
  20. abstractcore/providers/mlx_provider.py +13 -10
  21. abstractcore/providers/ollama_provider.py +42 -26
  22. abstractcore/providers/openai_compatible_provider.py +87 -22
  23. abstractcore/providers/openai_provider.py +12 -9
  24. abstractcore/providers/streaming.py +201 -39
  25. abstractcore/providers/vllm_provider.py +78 -21
  26. abstractcore/server/app.py +65 -28
  27. abstractcore/structured/retry.py +20 -7
  28. abstractcore/tools/__init__.py +5 -4
  29. abstractcore/tools/abstractignore.py +166 -0
  30. abstractcore/tools/arg_canonicalizer.py +61 -0
  31. abstractcore/tools/common_tools.py +2311 -772
  32. abstractcore/tools/core.py +109 -13
  33. abstractcore/tools/handler.py +17 -3
  34. abstractcore/tools/parser.py +798 -155
  35. abstractcore/tools/registry.py +107 -2
  36. abstractcore/tools/syntax_rewriter.py +68 -6
  37. abstractcore/tools/tag_rewriter.py +186 -1
  38. abstractcore/utils/jsonish.py +111 -0
  39. abstractcore/utils/version.py +1 -1
  40. {abstractcore-2.6.8.dist-info → abstractcore-2.9.0.dist-info}/METADATA +11 -2
  41. {abstractcore-2.6.8.dist-info → abstractcore-2.9.0.dist-info}/RECORD +45 -36
  42. {abstractcore-2.6.8.dist-info → abstractcore-2.9.0.dist-info}/WHEEL +0 -0
  43. {abstractcore-2.6.8.dist-info → abstractcore-2.9.0.dist-info}/entry_points.txt +0 -0
  44. {abstractcore-2.6.8.dist-info → abstractcore-2.9.0.dist-info}/licenses/LICENSE +0 -0
  45. {abstractcore-2.6.8.dist-info → abstractcore-2.9.0.dist-info}/top_level.txt +0 -0
@@ -59,7 +59,16 @@ class VLLMProvider(BaseProvider):
59
59
  self.client = httpx.Client(timeout=timeout_value)
60
60
  except Exception as e:
61
61
  try:
62
- self.client = httpx.Client(timeout=300.0)
62
+ fallback_timeout = None
63
+ try:
64
+ from ..config.manager import get_config_manager
65
+
66
+ fallback_timeout = float(get_config_manager().get_default_timeout())
67
+ except Exception:
68
+ fallback_timeout = 7200.0
69
+ if isinstance(fallback_timeout, (int, float)) and float(fallback_timeout) <= 0:
70
+ fallback_timeout = None
71
+ self.client = httpx.Client(timeout=fallback_timeout)
63
72
  except Exception:
64
73
  raise RuntimeError(f"Failed to create HTTP client for vLLM: {e}")
65
74
 
@@ -154,19 +163,24 @@ class VLLMProvider(BaseProvider):
154
163
  chat_messages = []
155
164
 
156
165
  # Add tools to system prompt if provided
157
- enhanced_system_prompt = system_prompt
158
- if tools and self.tool_handler.supports_prompted:
159
- tool_prompt = self.tool_handler.format_tools_prompt(tools)
160
- if enhanced_system_prompt:
161
- enhanced_system_prompt += f"\n\n{tool_prompt}"
166
+ final_system_prompt = system_prompt
167
+ # Prefer native tools when the model supports them. Only inject a prompted tool list
168
+ # when native tool calling is not available.
169
+ if tools and self.tool_handler.supports_prompted and not self.tool_handler.supports_native:
170
+ include_tool_list = True
171
+ if final_system_prompt and "## Tools (session)" in final_system_prompt:
172
+ include_tool_list = False
173
+ tool_prompt = self.tool_handler.format_tools_prompt(tools, include_tool_list=include_tool_list)
174
+ if final_system_prompt:
175
+ final_system_prompt += f"\n\n{tool_prompt}"
162
176
  else:
163
- enhanced_system_prompt = tool_prompt
177
+ final_system_prompt = tool_prompt
164
178
 
165
179
  # Add system message if provided
166
- if enhanced_system_prompt:
180
+ if final_system_prompt:
167
181
  chat_messages.append({
168
182
  "role": "system",
169
- "content": enhanced_system_prompt
183
+ "content": final_system_prompt
170
184
  })
171
185
 
172
186
  # Add conversation history
@@ -222,6 +236,11 @@ class VLLMProvider(BaseProvider):
222
236
  "top_p": kwargs.get("top_p", 0.9),
223
237
  }
224
238
 
239
+ # Native tools (OpenAI-compatible): send structured tools/tool_choice when supported.
240
+ if tools and self.tool_handler.supports_native:
241
+ payload["tools"] = self.tool_handler.prepare_tools_for_native(tools)
242
+ payload["tool_choice"] = kwargs.get("tool_choice", "auto")
243
+
225
244
  # Add additional generation parameters if provided
226
245
  if "frequency_penalty" in kwargs:
227
246
  payload["frequency_penalty"] = kwargs["frequency_penalty"]
@@ -283,8 +302,9 @@ class VLLMProvider(BaseProvider):
283
302
  raise ProviderAPIError("HTTP client not initialized")
284
303
 
285
304
  start_time = time.time()
305
+ request_url = f"{self.base_url}/chat/completions"
286
306
  response = self.client.post(
287
- f"{self.base_url}/chat/completions",
307
+ request_url,
288
308
  json=payload,
289
309
  headers=self._get_headers()
290
310
  )
@@ -296,10 +316,18 @@ class VLLMProvider(BaseProvider):
296
316
  # Extract response from OpenAI format
297
317
  if "choices" in result and len(result["choices"]) > 0:
298
318
  choice = result["choices"][0]
299
- content = choice.get("message", {}).get("content", "")
319
+ message = choice.get("message") or {}
320
+ if not isinstance(message, dict):
321
+ message = {}
322
+
323
+ content = message.get("content", "")
324
+ tool_calls = message.get("tool_calls")
325
+ if tool_calls is None:
326
+ tool_calls = choice.get("tool_calls")
300
327
  finish_reason = choice.get("finish_reason", "stop")
301
328
  else:
302
329
  content = "No response generated"
330
+ tool_calls = None
303
331
  finish_reason = "error"
304
332
 
305
333
  # Extract usage info
@@ -310,6 +338,13 @@ class VLLMProvider(BaseProvider):
310
338
  model=self.model,
311
339
  finish_reason=finish_reason,
312
340
  raw_response=result,
341
+ tool_calls=tool_calls if isinstance(tool_calls, list) else None,
342
+ metadata={
343
+ "_provider_request": {
344
+ "url": request_url,
345
+ "payload": payload,
346
+ }
347
+ },
313
348
  usage={
314
349
  "input_tokens": usage.get("prompt_tokens", 0),
315
350
  "output_tokens": usage.get("completion_tokens", 0),
@@ -335,7 +370,7 @@ class VLLMProvider(BaseProvider):
335
370
  except Exception:
336
371
  raise ModelNotFoundError(f"Model '{self.model}' not found in vLLM and could not fetch available models")
337
372
  else:
338
- raise ProviderAPIError(f"vLLM API error: {str(e)}")
373
+ raise
339
374
 
340
375
  def _stream_generate(self, payload: Dict[str, Any]) -> Iterator[GenerateResponse]:
341
376
  """Generate streaming response."""
@@ -366,13 +401,17 @@ class VLLMProvider(BaseProvider):
366
401
  if "choices" in chunk and len(chunk["choices"]) > 0:
367
402
  choice = chunk["choices"][0]
368
403
  delta = choice.get("delta", {})
404
+ if not isinstance(delta, dict):
405
+ delta = {}
369
406
  content = delta.get("content", "")
407
+ tool_calls = delta.get("tool_calls") or choice.get("tool_calls")
370
408
  finish_reason = choice.get("finish_reason")
371
409
 
372
410
  yield GenerateResponse(
373
411
  content=content,
374
412
  model=self.model,
375
413
  finish_reason=finish_reason,
414
+ tool_calls=tool_calls if isinstance(tool_calls, list) else None,
376
415
  raw_response=chunk
377
416
  )
378
417
 
@@ -408,16 +447,20 @@ class VLLMProvider(BaseProvider):
408
447
  # Build messages (same logic as sync)
409
448
  chat_messages = []
410
449
 
411
- enhanced_system_prompt = system_prompt
412
- if tools and self.tool_handler.supports_prompted:
413
- tool_prompt = self.tool_handler.format_tools_prompt(tools)
414
- if enhanced_system_prompt:
415
- enhanced_system_prompt += f"\n\n{tool_prompt}"
450
+ final_system_prompt = system_prompt
451
+ # Prefer native tools when available; only inject prompted tool syntax as fallback.
452
+ if tools and self.tool_handler.supports_prompted and not self.tool_handler.supports_native:
453
+ include_tool_list = True
454
+ if final_system_prompt and "## Tools (session)" in final_system_prompt:
455
+ include_tool_list = False
456
+ tool_prompt = self.tool_handler.format_tools_prompt(tools, include_tool_list=include_tool_list)
457
+ if final_system_prompt:
458
+ final_system_prompt += f"\n\n{tool_prompt}"
416
459
  else:
417
- enhanced_system_prompt = tool_prompt
460
+ final_system_prompt = tool_prompt
418
461
 
419
- if enhanced_system_prompt:
420
- chat_messages.append({"role": "system", "content": enhanced_system_prompt})
462
+ if final_system_prompt:
463
+ chat_messages.append({"role": "system", "content": final_system_prompt})
421
464
 
422
465
  if messages:
423
466
  chat_messages.extend(messages)
@@ -469,6 +512,11 @@ class VLLMProvider(BaseProvider):
469
512
  "top_p": kwargs.get("top_p", 0.9),
470
513
  }
471
514
 
515
+ # Native tools (OpenAI-compatible): send structured tools/tool_choice when supported.
516
+ if tools and self.tool_handler.supports_native:
517
+ payload["tools"] = self.tool_handler.prepare_tools_for_native(tools)
518
+ payload["tool_choice"] = kwargs.get("tool_choice", "auto")
519
+
472
520
  if "frequency_penalty" in kwargs:
473
521
  payload["frequency_penalty"] = kwargs["frequency_penalty"]
474
522
  if "presence_penalty" in kwargs:
@@ -700,7 +748,16 @@ class VLLMProvider(BaseProvider):
700
748
  if hasattr(self, 'logger'):
701
749
  self.logger.warning(f"Failed to update HTTP client timeout: {e}")
702
750
  try:
703
- self.client = httpx.Client(timeout=300.0)
751
+ fallback_timeout = None
752
+ try:
753
+ from ..config.manager import get_config_manager
754
+
755
+ fallback_timeout = float(get_config_manager().get_default_timeout())
756
+ except Exception:
757
+ fallback_timeout = 7200.0
758
+ if isinstance(fallback_timeout, (int, float)) and float(fallback_timeout) <= 0:
759
+ fallback_timeout = None
760
+ self.client = httpx.Client(timeout=fallback_timeout)
704
761
  except Exception:
705
762
  pass
706
763
 
@@ -517,6 +517,16 @@ class ChatCompletionRequest(BaseModel):
517
517
  example="http://localhost:1234/v1"
518
518
  )
519
519
 
520
+ # Runtime/orchestrator policy (AbstractCore-specific feature)
521
+ timeout_s: Optional[float] = Field(
522
+ default=None,
523
+ description="Per-request provider HTTP timeout in seconds (AbstractCore-specific feature). "
524
+ "Intended for orchestrators (e.g. AbstractRuntime) to enforce execution policy. "
525
+ "If omitted, the server uses its own defaults. "
526
+ "Values <= 0 are treated as unlimited.",
527
+ example=7200.0,
528
+ )
529
+
520
530
  class Config:
521
531
  schema_extra = {
522
532
  "examples": {
@@ -2064,6 +2074,10 @@ async def process_chat_completion(
2064
2074
  request_id=request_id,
2065
2075
  base_url=request.base_url
2066
2076
  )
2077
+ if request.timeout_s is not None:
2078
+ # Orchestrator policy: allow the caller to specify the provider timeout.
2079
+ # Note: BaseProvider treats non-positive values as "unlimited".
2080
+ provider_kwargs["timeout"] = request.timeout_s
2067
2081
 
2068
2082
  llm = create_llm(provider, model=model, **provider_kwargs)
2069
2083
 
@@ -2214,31 +2228,49 @@ def generate_streaming_response(
2214
2228
  }
2215
2229
  yield f"data: {json.dumps(openai_chunk)}\n\n"
2216
2230
 
2217
- # Tool calls - always convert to OpenAI format for streaming
2231
+ # Tool calls
2218
2232
  if hasattr(chunk, 'tool_calls') and chunk.tool_calls:
2219
2233
  has_tool_calls = True
2220
- openai_tool_calls = syntax_rewriter.convert_to_openai_format(chunk.tool_calls)
2221
-
2222
- for i, openai_tool_call in enumerate(openai_tool_calls):
2223
- tool_chunk = {
2224
- "id": chat_id,
2225
- "object": "chat.completion.chunk",
2226
- "created": created_time,
2227
- "model": f"{provider}/{model}",
2228
- "choices": [{
2229
- "index": 0,
2230
- "delta": {
2231
- "tool_calls": [{
2232
- "index": i, # Proper indexing for multiple tools
2233
- "id": openai_tool_call["id"],
2234
- "type": "function",
2235
- "function": openai_tool_call["function"]
2236
- }]
2237
- },
2238
- "finish_reason": "tool_calls" # Critical for Codex
2239
- }]
2240
- }
2241
- yield f"data: {json.dumps(tool_chunk)}\n\n"
2234
+ # OpenAI/Codex clients expect structured tool_calls deltas.
2235
+ if syntax_rewriter.target_format in [SyntaxFormat.OPENAI, SyntaxFormat.CODEX]:
2236
+ openai_tool_calls = syntax_rewriter.convert_to_openai_format(chunk.tool_calls)
2237
+
2238
+ for i, openai_tool_call in enumerate(openai_tool_calls):
2239
+ tool_chunk = {
2240
+ "id": chat_id,
2241
+ "object": "chat.completion.chunk",
2242
+ "created": created_time,
2243
+ "model": f"{provider}/{model}",
2244
+ "choices": [{
2245
+ "index": 0,
2246
+ "delta": {
2247
+ "tool_calls": [{
2248
+ "index": i, # Proper indexing for multiple tools
2249
+ "id": openai_tool_call["id"],
2250
+ "type": "function",
2251
+ "function": openai_tool_call["function"]
2252
+ }]
2253
+ },
2254
+ "finish_reason": "tool_calls" # Critical for Codex
2255
+ }]
2256
+ }
2257
+ yield f"data: {json.dumps(tool_chunk)}\n\n"
2258
+ # Tag-based clients parse tool calls from assistant content.
2259
+ elif syntax_rewriter.target_format != SyntaxFormat.PASSTHROUGH:
2260
+ tool_text = syntax_rewriter.rewrite_content("", detected_tool_calls=chunk.tool_calls)
2261
+ if tool_text and tool_text.strip():
2262
+ openai_chunk = {
2263
+ "id": chat_id,
2264
+ "object": "chat.completion.chunk",
2265
+ "created": created_time,
2266
+ "model": f"{provider}/{model}",
2267
+ "choices": [{
2268
+ "index": 0,
2269
+ "delta": {"content": tool_text},
2270
+ "finish_reason": None
2271
+ }]
2272
+ }
2273
+ yield f"data: {json.dumps(openai_chunk)}\n\n"
2242
2274
 
2243
2275
  # Final chunk
2244
2276
  final_chunk = {
@@ -2319,6 +2351,7 @@ def convert_to_openai_response(
2319
2351
 
2320
2352
  # Apply syntax rewriting to content
2321
2353
  content = response.content if hasattr(response, 'content') else str(response)
2354
+ tool_calls = getattr(response, "tool_calls", None) if hasattr(response, "tool_calls") else None
2322
2355
 
2323
2356
  # For OpenAI/Codex format: only clean if content contains tool calls
2324
2357
  # For other formats: apply syntax rewriting
@@ -2328,8 +2361,12 @@ def convert_to_openai_response(
2328
2361
  if any(pattern in content for pattern in ['<function_call>', '<tool_call>', '<|tool_call|>', '```tool_code']):
2329
2362
  content = syntax_rewriter.remove_tool_call_patterns(content)
2330
2363
  elif syntax_rewriter.target_format != SyntaxFormat.PASSTHROUGH:
2331
- # Apply format-specific rewriting for non-OpenAI formats
2332
- content = syntax_rewriter.rewrite_content(content)
2364
+ # Apply format-specific rewriting for non-OpenAI formats.
2365
+ # Prefer structured tool_calls when present (content may be cleaned upstream).
2366
+ if tool_calls:
2367
+ content = syntax_rewriter.rewrite_content(content, detected_tool_calls=tool_calls)
2368
+ else:
2369
+ content = syntax_rewriter.rewrite_content(content)
2333
2370
 
2334
2371
  response_dict = {
2335
2372
  "id": f"chatcmpl-{uuid.uuid4().hex[:8]}",
@@ -2349,15 +2386,15 @@ def convert_to_openai_response(
2349
2386
  }
2350
2387
 
2351
2388
  # Add tool calls if present
2352
- if hasattr(response, 'tool_calls') and response.tool_calls:
2353
- openai_tool_calls = syntax_rewriter.convert_to_openai_format(response.tool_calls)
2389
+ if tool_calls:
2390
+ openai_tool_calls = syntax_rewriter.convert_to_openai_format(tool_calls)
2354
2391
  response_dict["choices"][0]["message"]["tool_calls"] = openai_tool_calls
2355
2392
  response_dict["choices"][0]["finish_reason"] = "tool_calls"
2356
2393
 
2357
2394
  logger.info(
2358
2395
  "🔧 Tool calls converted",
2359
2396
  request_id=request_id,
2360
- tool_count=len(response.tool_calls),
2397
+ tool_count=len(tool_calls),
2361
2398
  target_format=syntax_rewriter.target_format.value
2362
2399
  )
2363
2400
 
@@ -62,17 +62,20 @@ class FeedbackRetry(Retry):
62
62
 
63
63
  def should_retry(self, attempt: int, error: Exception) -> bool:
64
64
  """
65
- Retry only for Pydantic ValidationErrors and within attempt limit.
65
+ Retry for structured-output errors that the model can often self-correct:
66
+ - Pydantic ValidationError (schema mismatch)
67
+ - JSONDecodeError (not valid JSON at all)
66
68
  """
67
- return isinstance(error, ValidationError) and attempt < self.max_attempts
69
+ return isinstance(error, (ValidationError, json.JSONDecodeError)) and attempt < self.max_attempts
68
70
 
69
- def prepare_retry_prompt(self, original_prompt: str, error: ValidationError, attempt: int) -> str:
71
+ def prepare_retry_prompt(self, original_prompt: str, error: Exception, attempt: int) -> str:
70
72
  """
71
73
  Create a retry prompt with validation error feedback.
72
74
  """
73
- error_feedback = self._format_validation_errors(error)
75
+ if isinstance(error, ValidationError):
76
+ error_feedback = self._format_validation_errors(error)
74
77
 
75
- retry_prompt = f"""{original_prompt}
78
+ retry_prompt = f"""{original_prompt}
76
79
 
77
80
  IMPORTANT: Your previous response (attempt {attempt}) had validation errors:
78
81
 
@@ -80,7 +83,17 @@ IMPORTANT: Your previous response (attempt {attempt}) had validation errors:
80
83
 
81
84
  Please correct these errors and provide a valid JSON response that matches the required schema exactly."""
82
85
 
83
- return retry_prompt
86
+ return retry_prompt
87
+
88
+ # JSONDecodeError (or other JSON parsing issues): retry with a simpler instruction.
89
+ return f"""{original_prompt}
90
+
91
+ IMPORTANT: Your previous response (attempt {attempt}) was not valid JSON:
92
+
93
+ {error}
94
+
95
+ Please respond again with a single valid JSON object that matches the required schema exactly.
96
+ Return ONLY the JSON object, with no additional text or formatting."""
84
97
 
85
98
  def _format_validation_errors(self, error: ValidationError) -> str:
86
99
  """
@@ -113,4 +126,4 @@ Please correct these errors and provide a valid JSON response that matches the r
113
126
  else:
114
127
  error_lines.append(f"• Field '{field_path}': {error_msg}")
115
128
 
116
- return "\n".join(error_lines)
129
+ return "\n".join(error_lines)
@@ -10,8 +10,9 @@ Tool Execution Modes
10
10
  AbstractCore supports two tool execution modes:
11
11
 
12
12
  **Passthrough Mode (Default)** - execute_tools=False
13
- The LLM returns raw tool call tags that downstream runtimes
14
- (AbstractRuntime, Codex, Claude Code) parse and execute.
13
+ AbstractCore detects/parses tool calls (native API fields or prompted tags)
14
+ and returns structured `GenerateResponse.tool_calls` plus cleaned `content`.
15
+ Downstream runtimes (AbstractRuntime, Codex, Claude Code) execute tools.
15
16
  Use case: Agent loops, custom orchestration, multi-step workflows.
16
17
 
17
18
  **Direct Execution Mode** - execute_tools=True
@@ -38,7 +39,7 @@ def get_weather(city: str) -> str:
38
39
 
39
40
  llm = create_llm("ollama", model="qwen3:4b")
40
41
  response = llm.generate("Weather in Paris?", tools=[get_weather])
41
- # response.content has tool call tags - parse with your runtime
42
+ # response.tool_calls contains structured tool calls; response.content is cleaned
42
43
  ```
43
44
 
44
45
  Example: Direct Execution Mode
@@ -119,4 +120,4 @@ __all__ = [
119
120
  "execute_tool",
120
121
  "execute_tools",
121
122
  "clear_registry",
122
- ]
123
+ ]
@@ -0,0 +1,166 @@
1
+ """`.abstractignore` support for filesystem tools.
2
+
3
+ This module provides a small, runtime-enforced ignore policy for AbstractCore
4
+ filesystem tools (search/list/read/write/edit/analyze).
5
+
6
+ Goals:
7
+ - Avoid accidental scanning/editing of runtime artifacts (e.g. JsonFileRunStore
8
+ directories ending in `.d/`).
9
+ - Allow users to define additional ignore rules via a `.abstractignore` file,
10
+ inspired by `.gitignore` (not full parity).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ import fnmatch
18
+ from typing import Iterable, List, Optional, Tuple
19
+
20
+
21
+ _DEFAULT_IGNORE_LINES: List[str] = [
22
+ # VCS + caches
23
+ ".git/",
24
+ ".hg/",
25
+ ".svn/",
26
+ "__pycache__/",
27
+ ".pytest_cache/",
28
+ ".mypy_cache/",
29
+ ".ruff_cache/",
30
+ # Common build/env dirs
31
+ "node_modules/",
32
+ "dist/",
33
+ "build/",
34
+ ".venv/",
35
+ "venv/",
36
+ "env/",
37
+ ".env/",
38
+ # Editor/host artifacts
39
+ ".cursor/",
40
+ # AbstractFramework runtime stores (AbstractCode/Runtime file-backed stores)
41
+ "*.d/",
42
+ ]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class AbstractIgnoreRule:
47
+ pattern: str
48
+ negate: bool = False
49
+ dir_only: bool = False
50
+ anchored: bool = False
51
+
52
+
53
+ def _parse_rules(lines: Iterable[str]) -> List[AbstractIgnoreRule]:
54
+ rules: List[AbstractIgnoreRule] = []
55
+ for raw in lines:
56
+ line = str(raw or "").strip()
57
+ if not line or line.startswith("#"):
58
+ continue
59
+ negate = False
60
+ if line.startswith("!"):
61
+ negate = True
62
+ line = line[1:].strip()
63
+ if not line:
64
+ continue
65
+ dir_only = line.endswith("/")
66
+ if dir_only:
67
+ line = line[:-1].strip()
68
+ anchored = line.startswith("/")
69
+ if anchored:
70
+ line = line[1:].strip()
71
+ if not line:
72
+ continue
73
+ rules.append(AbstractIgnoreRule(pattern=line, negate=negate, dir_only=dir_only, anchored=anchored))
74
+ return rules
75
+
76
+
77
+ def _find_nearest_abstractignore(start: Path) -> Optional[Path]:
78
+ """Find the nearest `.abstractignore` by walking upward from start."""
79
+ cur = start if start.is_dir() else start.parent
80
+ cur = cur.resolve()
81
+ for p in (cur, *cur.parents):
82
+ candidate = p / ".abstractignore"
83
+ if candidate.is_file():
84
+ return candidate
85
+ return None
86
+
87
+
88
+ class AbstractIgnore:
89
+ """A simple ignore matcher loaded from `.abstractignore` + defaults."""
90
+
91
+ def __init__(self, *, root: Path, rules: List[AbstractIgnoreRule], source: Optional[Path] = None):
92
+ self.root = root.resolve()
93
+ self.rules = list(rules)
94
+ self.source = source.resolve() if isinstance(source, Path) else None
95
+
96
+ @classmethod
97
+ def for_path(cls, path: Path) -> "AbstractIgnore":
98
+ """Create an ignore matcher for a given path (file or dir)."""
99
+ start = path if path.is_dir() else path.parent
100
+ ignore_file = _find_nearest_abstractignore(start)
101
+ root = ignore_file.parent if ignore_file is not None else start
102
+ file_rules: List[AbstractIgnoreRule] = []
103
+ if ignore_file is not None:
104
+ try:
105
+ file_rules = _parse_rules(ignore_file.read_text(encoding="utf-8").splitlines())
106
+ except Exception:
107
+ file_rules = []
108
+ # Defaults first; user file rules can override via negation.
109
+ rules = _parse_rules(_DEFAULT_IGNORE_LINES) + file_rules
110
+ return cls(root=root, rules=rules, source=ignore_file)
111
+
112
+ def _rel(self, path: Path) -> Tuple[str, List[str]]:
113
+ p = path.resolve()
114
+ try:
115
+ rel = p.relative_to(self.root)
116
+ rel_str = rel.as_posix()
117
+ parts = list(rel.parts)
118
+ except Exception:
119
+ # If the target is outside root, fall back to absolute matching.
120
+ rel_str = p.as_posix().lstrip("/")
121
+ parts = list(p.parts)
122
+ return rel_str, [str(x) for x in parts if str(x)]
123
+
124
+ def is_ignored(self, path: Path, *, is_dir: Optional[bool] = None) -> bool:
125
+ p = path.resolve()
126
+ if is_dir is None:
127
+ try:
128
+ is_dir = p.is_dir()
129
+ except Exception:
130
+ is_dir = False
131
+
132
+ rel_str, parts = self._rel(p)
133
+ name = parts[-1] if parts else p.name
134
+ dir_parts = parts if is_dir else parts[:-1]
135
+
136
+ ignored = False
137
+ for rule in self.rules:
138
+ pat = rule.pattern
139
+ if not pat:
140
+ continue
141
+
142
+ matched = False
143
+ if rule.dir_only:
144
+ # Match against any directory prefix.
145
+ for i in range(1, len(dir_parts) + 1):
146
+ prefix = "/".join(dir_parts[:i])
147
+ if fnmatch.fnmatchcase(prefix, pat) or fnmatch.fnmatchcase(dir_parts[i - 1], pat):
148
+ matched = True
149
+ break
150
+ if not matched and is_dir:
151
+ matched = fnmatch.fnmatchcase(rel_str, pat) or fnmatch.fnmatchcase(name, pat)
152
+ else:
153
+ if rule.anchored or ("/" in pat):
154
+ matched = fnmatch.fnmatchcase(rel_str, pat)
155
+ else:
156
+ matched = fnmatch.fnmatchcase(name, pat)
157
+
158
+ if matched:
159
+ ignored = not rule.negate
160
+
161
+ return ignored
162
+
163
+
164
+ __all__ = ["AbstractIgnore", "AbstractIgnoreRule"]
165
+
166
+
@@ -0,0 +1,61 @@
1
+ """Tool argument canonicalization (aliases -> canonical).
2
+
3
+ AbstractCore owns tool-call parsing/rewriting. This module provides a small,
4
+ central place to normalize common argument-name drift that appears in LLM
5
+ generated calls, while keeping the runtime/tool implementations stable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, Optional
11
+
12
+ from ..utils.jsonish import loads_dict_like
13
+
14
+
15
+ def _loads_dict(value: Any) -> Optional[Dict[str, Any]]:
16
+ if value is None:
17
+ return None
18
+ if isinstance(value, dict):
19
+ return dict(value)
20
+ if isinstance(value, str):
21
+ parsed = loads_dict_like(value)
22
+ return dict(parsed) if isinstance(parsed, dict) else None
23
+ return None
24
+
25
+
26
+ def canonicalize_tool_arguments(tool_name: str, arguments: Any) -> Dict[str, Any]:
27
+ """Return a canonical argument dict for a tool call (best-effort)."""
28
+ name = str(tool_name or "").strip()
29
+ args = _loads_dict(arguments) or {}
30
+
31
+ if not name or not args:
32
+ return args
33
+
34
+ if name == "read_file":
35
+ return _canonicalize_read_file_args(args)
36
+
37
+ return args
38
+
39
+
40
+ def _canonicalize_read_file_args(arguments: Dict[str, Any]) -> Dict[str, Any]:
41
+ out = dict(arguments)
42
+
43
+ if "start_line" not in out:
44
+ if "start_line_one_indexed" in out:
45
+ out["start_line"] = out.get("start_line_one_indexed")
46
+ elif "start" in out:
47
+ out["start_line"] = out.get("start")
48
+
49
+ if "end_line" not in out:
50
+ if "end_line_one_indexed_inclusive" in out:
51
+ out["end_line"] = out.get("end_line_one_indexed_inclusive")
52
+ elif "end" in out:
53
+ out["end_line"] = out.get("end")
54
+
55
+ for k in ("start_line_one_indexed", "end_line_one_indexed_inclusive", "start", "end"):
56
+ out.pop(k, None)
57
+
58
+ return out
59
+
60
+
61
+ __all__ = ["canonicalize_tool_arguments"]