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.
- abstractcore/apps/summarizer.py +69 -27
- abstractcore/architectures/detection.py +190 -25
- abstractcore/assets/architecture_formats.json +129 -6
- abstractcore/assets/model_capabilities.json +789 -136
- abstractcore/config/main.py +2 -2
- abstractcore/config/manager.py +3 -1
- abstractcore/events/__init__.py +7 -1
- abstractcore/mcp/__init__.py +30 -0
- abstractcore/mcp/client.py +213 -0
- abstractcore/mcp/factory.py +64 -0
- abstractcore/mcp/naming.py +28 -0
- abstractcore/mcp/stdio_client.py +336 -0
- abstractcore/mcp/tool_source.py +164 -0
- abstractcore/processing/basic_deepsearch.py +1 -1
- abstractcore/processing/basic_summarizer.py +300 -83
- abstractcore/providers/anthropic_provider.py +91 -10
- abstractcore/providers/base.py +537 -16
- abstractcore/providers/huggingface_provider.py +17 -8
- abstractcore/providers/lmstudio_provider.py +170 -25
- abstractcore/providers/mlx_provider.py +13 -10
- abstractcore/providers/ollama_provider.py +42 -26
- abstractcore/providers/openai_compatible_provider.py +87 -22
- abstractcore/providers/openai_provider.py +12 -9
- abstractcore/providers/streaming.py +201 -39
- abstractcore/providers/vllm_provider.py +78 -21
- abstractcore/server/app.py +65 -28
- abstractcore/structured/retry.py +20 -7
- abstractcore/tools/__init__.py +5 -4
- abstractcore/tools/abstractignore.py +166 -0
- abstractcore/tools/arg_canonicalizer.py +61 -0
- abstractcore/tools/common_tools.py +2311 -772
- abstractcore/tools/core.py +109 -13
- abstractcore/tools/handler.py +17 -3
- abstractcore/tools/parser.py +798 -155
- abstractcore/tools/registry.py +107 -2
- abstractcore/tools/syntax_rewriter.py +68 -6
- abstractcore/tools/tag_rewriter.py +186 -1
- abstractcore/utils/jsonish.py +111 -0
- abstractcore/utils/version.py +1 -1
- {abstractcore-2.6.8.dist-info → abstractcore-2.9.0.dist-info}/METADATA +11 -2
- {abstractcore-2.6.8.dist-info → abstractcore-2.9.0.dist-info}/RECORD +45 -36
- {abstractcore-2.6.8.dist-info → abstractcore-2.9.0.dist-info}/WHEEL +0 -0
- {abstractcore-2.6.8.dist-info → abstractcore-2.9.0.dist-info}/entry_points.txt +0 -0
- {abstractcore-2.6.8.dist-info → abstractcore-2.9.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
177
|
+
final_system_prompt = tool_prompt
|
|
164
178
|
|
|
165
179
|
# Add system message if provided
|
|
166
|
-
if
|
|
180
|
+
if final_system_prompt:
|
|
167
181
|
chat_messages.append({
|
|
168
182
|
"role": "system",
|
|
169
|
-
"content":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
460
|
+
final_system_prompt = tool_prompt
|
|
418
461
|
|
|
419
|
-
if
|
|
420
|
-
chat_messages.append({"role": "system", "content":
|
|
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
|
-
|
|
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
|
|
abstractcore/server/app.py
CHANGED
|
@@ -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
|
|
2231
|
+
# Tool calls
|
|
2218
2232
|
if hasattr(chunk, 'tool_calls') and chunk.tool_calls:
|
|
2219
2233
|
has_tool_calls = True
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
"
|
|
2230
|
-
"
|
|
2231
|
-
"
|
|
2232
|
-
|
|
2233
|
-
"
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
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
|
-
|
|
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
|
|
2353
|
-
openai_tool_calls = syntax_rewriter.convert_to_openai_format(
|
|
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(
|
|
2397
|
+
tool_count=len(tool_calls),
|
|
2361
2398
|
target_format=syntax_rewriter.target_format.value
|
|
2362
2399
|
)
|
|
2363
2400
|
|
abstractcore/structured/retry.py
CHANGED
|
@@ -62,17 +62,20 @@ class FeedbackRetry(Retry):
|
|
|
62
62
|
|
|
63
63
|
def should_retry(self, attempt: int, error: Exception) -> bool:
|
|
64
64
|
"""
|
|
65
|
-
Retry
|
|
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:
|
|
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
|
-
|
|
75
|
+
if isinstance(error, ValidationError):
|
|
76
|
+
error_feedback = self._format_validation_errors(error)
|
|
74
77
|
|
|
75
|
-
|
|
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
|
-
|
|
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)
|
abstractcore/tools/__init__.py
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
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.
|
|
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"]
|