1198 lines
40 KiB
Python
1198 lines
40 KiB
Python
"""Tool execution engine."""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import yaml
|
|
|
|
from .tool import Tool, PromptStep, CodeStep, ToolStep, McpStep
|
|
from .providers import call_provider, mock_provider
|
|
from .resolver import resolve_tool, ToolNotFoundError, ToolSpec, install_from_registry
|
|
from .manifest import load_manifest
|
|
from .profiles import load_profile
|
|
from .mcp_client import McpClientManager
|
|
|
|
# Maximum recursion depth for nested tool calls
|
|
MAX_TOOL_DEPTH = 10
|
|
|
|
# Default schema for structured output
|
|
# Separates AI reasoning from actual output
|
|
DEFAULT_OUTPUT_SCHEMA = {
|
|
"type": "object",
|
|
"properties": {
|
|
"output": {
|
|
"type": "string",
|
|
"description": "The actual response content"
|
|
},
|
|
"reasoning": {
|
|
"type": "string",
|
|
"description": "Internal thoughts, analysis, or step-by-step reasoning"
|
|
}
|
|
},
|
|
"required": ["output"]
|
|
}
|
|
|
|
|
|
class SchemaValidationError(Exception):
|
|
"""Raised when response doesn't match expected schema."""
|
|
pass
|
|
|
|
|
|
def validate_schema(data: dict, schema: dict) -> None:
|
|
"""
|
|
Validate data against a JSON schema.
|
|
|
|
Args:
|
|
data: Parsed JSON data
|
|
schema: JSON schema to validate against
|
|
|
|
Raises:
|
|
SchemaValidationError: If validation fails
|
|
"""
|
|
try:
|
|
from jsonschema import validate, ValidationError
|
|
validate(instance=data, schema=schema)
|
|
except ValidationError as e:
|
|
# Extract path info for clearer error
|
|
path = ".".join(str(p) for p in e.absolute_path) if e.absolute_path else "root"
|
|
raise SchemaValidationError(f"Field '{path}': {e.message}")
|
|
except ImportError:
|
|
# jsonschema not installed - skip validation with warning
|
|
print("Warning: jsonschema not installed, skipping schema validation", file=sys.stderr)
|
|
|
|
|
|
def generate_schema_example(schema: dict) -> str:
|
|
"""
|
|
Generate a simple example JSON from a schema.
|
|
|
|
Small models work better with concrete examples than abstract JSON schemas.
|
|
|
|
Args:
|
|
schema: JSON schema to generate example from
|
|
|
|
Returns:
|
|
Example JSON string
|
|
"""
|
|
def build_example(schema_part: dict) -> any:
|
|
"""Recursively build example from schema."""
|
|
type_ = schema_part.get("type", "string")
|
|
|
|
if type_ == "string":
|
|
if "enum" in schema_part:
|
|
return schema_part["enum"][0]
|
|
desc = schema_part.get("description", "value")
|
|
return f"<{desc}>"
|
|
elif type_ == "number":
|
|
return 0.0
|
|
elif type_ == "integer":
|
|
return 0
|
|
elif type_ == "boolean":
|
|
return True
|
|
elif type_ == "array":
|
|
items = schema_part.get("items", {})
|
|
if items.get("type") == "object" and "properties" in items:
|
|
# Nested object array - generate one example item
|
|
return [build_example(items)]
|
|
elif items.get("type") == "string":
|
|
return ["<item1>", "<item2>"]
|
|
else:
|
|
return []
|
|
elif type_ == "object":
|
|
if "properties" in schema_part:
|
|
obj = {}
|
|
for key, spec in schema_part["properties"].items():
|
|
obj[key] = build_example(spec)
|
|
return obj
|
|
return {"...": "..."}
|
|
else:
|
|
return f"<{type_}>"
|
|
|
|
# Handle top-level schema
|
|
if schema.get("type") == "array":
|
|
example = build_example(schema)
|
|
elif schema.get("type") == "object" or "properties" in schema:
|
|
example = {}
|
|
for key, spec in schema.get("properties", {}).items():
|
|
example[key] = build_example(spec)
|
|
else:
|
|
example = build_example(schema)
|
|
|
|
return json.dumps(example, indent=2)
|
|
|
|
|
|
def prepend_schema_instructions(prompt: str, schema: dict) -> str:
|
|
"""
|
|
Prepend schema instructions to a prompt using example-based format.
|
|
|
|
Small models work better when format instructions come FIRST and use
|
|
concrete examples rather than abstract JSON schemas. This prevents
|
|
models from echoing the schema as content.
|
|
|
|
Args:
|
|
prompt: Original prompt text
|
|
schema: JSON schema the response must match
|
|
|
|
Returns:
|
|
Augmented prompt with schema instructions prepended
|
|
"""
|
|
example = generate_schema_example(schema)
|
|
|
|
# Build field guidance based on schema
|
|
props = schema.get("properties", {})
|
|
required = schema.get("required", [])
|
|
|
|
field_notes = []
|
|
if "reasoning" in props and "output" in props:
|
|
field_notes.append("Put your thinking in 'reasoning' and your answer in 'output'.")
|
|
if required:
|
|
field_notes.append(f"Required: {', '.join(required)}.")
|
|
|
|
field_guidance = " ".join(field_notes)
|
|
|
|
return f"""Respond with ONLY valid JSON in this format:
|
|
{example}
|
|
|
|
No text before or after the JSON. No markdown fences.{' ' + field_guidance if field_guidance else ''}
|
|
|
|
---
|
|
{prompt}"""
|
|
|
|
|
|
def check_system_dependencies(tool: Tool) -> list:
|
|
"""
|
|
Check if system dependencies are satisfied.
|
|
|
|
Args:
|
|
tool: Tool to check system dependencies for
|
|
|
|
Returns:
|
|
List of missing SystemDependency objects
|
|
"""
|
|
from .system_deps import check_system_dep
|
|
|
|
if not tool.system_dependencies:
|
|
return []
|
|
|
|
return [d for d in tool.system_dependencies if not check_system_dep(d)]
|
|
|
|
|
|
def auto_install_dependencies(tool: Tool, verbose: bool = False) -> list[str]:
|
|
"""
|
|
Automatically install missing dependencies for a tool.
|
|
|
|
Args:
|
|
tool: Tool to check and install dependencies for
|
|
verbose: Show installation progress
|
|
|
|
Returns:
|
|
List of successfully installed tool references
|
|
"""
|
|
missing = check_dependencies(tool)
|
|
if not missing:
|
|
return []
|
|
|
|
installed = []
|
|
for dep in missing:
|
|
try:
|
|
if verbose:
|
|
print(f"[auto-install] Installing {dep}...", file=sys.stderr)
|
|
install_from_registry(dep)
|
|
installed.append(dep)
|
|
if verbose:
|
|
print(f"[auto-install] Installed {dep}", file=sys.stderr)
|
|
except Exception as e:
|
|
print(f"Warning: Failed to auto-install {dep}: {e}", file=sys.stderr)
|
|
|
|
return installed
|
|
|
|
|
|
def check_dependencies(tool: Tool, checked: set = None) -> list[str]:
|
|
"""
|
|
Check if all dependencies for a tool are available.
|
|
|
|
Args:
|
|
tool: Tool to check dependencies for
|
|
checked: Set of already checked tools (prevents infinite loops)
|
|
|
|
Returns:
|
|
List of missing dependency tool references
|
|
"""
|
|
if checked is None:
|
|
checked = set()
|
|
|
|
missing = []
|
|
|
|
# Check explicit dependencies
|
|
for dep in tool.dependencies:
|
|
if dep in checked:
|
|
continue
|
|
checked.add(dep)
|
|
|
|
try:
|
|
resolved = resolve_tool(dep)
|
|
# Recursively check nested dependencies
|
|
nested_missing = check_dependencies(resolved.tool, checked)
|
|
missing.extend(nested_missing)
|
|
except ToolNotFoundError:
|
|
missing.append(dep)
|
|
|
|
# Also check tool steps for implicit dependencies
|
|
for step in tool.steps:
|
|
if isinstance(step, ToolStep):
|
|
tool_ref = step.tool
|
|
if tool_ref in checked:
|
|
continue
|
|
checked.add(tool_ref)
|
|
|
|
try:
|
|
resolved = resolve_tool(tool_ref)
|
|
# Recursively check nested dependencies
|
|
nested_missing = check_dependencies(resolved.tool, checked)
|
|
missing.extend(nested_missing)
|
|
except ToolNotFoundError:
|
|
missing.append(tool_ref)
|
|
|
|
return missing
|
|
|
|
|
|
def _get_nested_value(obj, path_parts: list):
|
|
"""
|
|
Navigate nested dict/JSON to get a value.
|
|
|
|
Args:
|
|
obj: Starting object (dict or JSON string)
|
|
path_parts: List of keys to navigate
|
|
|
|
Returns:
|
|
Value at path, or None if not found
|
|
"""
|
|
value = obj
|
|
|
|
for part in path_parts:
|
|
if isinstance(value, dict):
|
|
value = value.get(part)
|
|
elif isinstance(value, str):
|
|
# Try parsing as JSON
|
|
try:
|
|
parsed = json.loads(value)
|
|
if isinstance(parsed, dict):
|
|
value = parsed.get(part)
|
|
else:
|
|
return None
|
|
except json.JSONDecodeError:
|
|
return None
|
|
else:
|
|
return None
|
|
|
|
if value is None:
|
|
return None
|
|
|
|
return value
|
|
|
|
|
|
def substitute_variables(template: str, variables: dict, warn_non_scalar: bool = False) -> str:
|
|
"""
|
|
Substitute {variable} placeholders in a template.
|
|
|
|
Supports:
|
|
- Simple: {varname}
|
|
- Nested: {varname.field} or {varname.field.subfield}
|
|
- Settings: {settings.key}
|
|
- Escaping: {{ for literal { and }} for literal }
|
|
|
|
For nested access, if the variable is a JSON string, it will be parsed.
|
|
|
|
Args:
|
|
template: String with {var} placeholders
|
|
variables: Dict of variable name -> value
|
|
|
|
Returns:
|
|
String with placeholders replaced
|
|
|
|
Examples:
|
|
>>> substitute_variables("Hello {name}", {"name": "World"})
|
|
'Hello World'
|
|
>>> substitute_variables("Use {{braces}}", {"braces": "nope"})
|
|
'Use {braces}'
|
|
>>> substitute_variables("Backend: {settings.backend}", {"settings": {"backend": "piper"}})
|
|
'Backend: piper'
|
|
>>> substitute_variables("Answer: {result.output}", {"result": '{"output": "Hello"}'})
|
|
'Answer: Hello'
|
|
"""
|
|
import re
|
|
|
|
# Use unique placeholders for escaped braces
|
|
ESCAPE_OPEN = "\x00\x01OPEN\x01\x00"
|
|
ESCAPE_CLOSE = "\x00\x01CLOSE\x01\x00"
|
|
|
|
# First, replace escaped braces with placeholders
|
|
result = template.replace("{{", ESCAPE_OPEN).replace("}}", ESCAPE_CLOSE)
|
|
|
|
# Pattern for {var} or {var.field.subfield}
|
|
pattern = r'\{([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\}'
|
|
|
|
def replace_var(match):
|
|
path = match.group(1)
|
|
parts = path.split('.')
|
|
|
|
# Get base variable
|
|
base_name = parts[0]
|
|
|
|
# Check if variable exists in dict (distinguish None value from missing key)
|
|
if base_name not in variables:
|
|
return match.group(0) # Leave unchanged for missing variables
|
|
|
|
base_value = variables[base_name]
|
|
|
|
# If no nested parts, simple substitution
|
|
if len(parts) == 1:
|
|
if base_name == "settings":
|
|
# Settings dict - leave unchanged (handled specially)
|
|
return match.group(0)
|
|
if isinstance(base_value, (dict, list)):
|
|
return json.dumps(base_value)
|
|
# Replace with empty string for None, str() for other scalar values
|
|
return "" if base_value is None else str(base_value)
|
|
|
|
# Handle nested access
|
|
if base_name == "settings":
|
|
# Settings dict - only allow scalar values in templates
|
|
settings = base_value if isinstance(base_value, dict) else {}
|
|
key = parts[1] # Only support one level: settings.key
|
|
if key in settings:
|
|
value = settings[key]
|
|
if isinstance(value, (str, int, float, bool)):
|
|
return str(value) if value is not None else ""
|
|
else:
|
|
if warn_non_scalar:
|
|
print(
|
|
f"Warning: {{settings.{key}}} is not a scalar value, "
|
|
f"access it in code steps instead",
|
|
file=sys.stderr
|
|
)
|
|
return match.group(0)
|
|
return match.group(0)
|
|
|
|
# Navigate nested path for other variables
|
|
value = _get_nested_value(base_value, parts[1:])
|
|
if value is None:
|
|
return match.group(0) # Leave unchanged if not found
|
|
|
|
# Convert to string for template
|
|
if isinstance(value, (dict, list)):
|
|
return json.dumps(value)
|
|
return str(value)
|
|
|
|
result = re.sub(pattern, replace_var, result)
|
|
|
|
# Finally, restore escaped braces as single braces
|
|
result = result.replace(ESCAPE_OPEN, "{").replace(ESCAPE_CLOSE, "}")
|
|
|
|
return result
|
|
|
|
|
|
def _extract_json(text: str) -> dict | list | None:
|
|
"""
|
|
Extract JSON from LLM response using multiple strategies.
|
|
|
|
LLMs often wrap JSON in markdown, add explanations before/after,
|
|
or include other text. This function tries several approaches
|
|
to find and extract valid JSON.
|
|
|
|
Returns parsed JSON (dict or list) or None if extraction fails.
|
|
"""
|
|
if not text:
|
|
return None
|
|
|
|
text = text.strip()
|
|
|
|
# Strategy 1: Direct parse (already clean JSON)
|
|
try:
|
|
return json.loads(text)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
# Strategy 2: Strip markdown code fences
|
|
if text.startswith("```"):
|
|
cleaned = re.sub(r'^```\w*\n?', '', text)
|
|
cleaned = re.sub(r'\n?```\s*$', '', cleaned)
|
|
try:
|
|
return json.loads(cleaned.strip())
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
# Strategy 3: Find first { or [ and match to last } or ]
|
|
# This handles "Here's the JSON:\n{...}" or "{...}\nExplanation"
|
|
first_brace = text.find('{')
|
|
first_bracket = text.find('[')
|
|
|
|
if first_brace == -1 and first_bracket == -1:
|
|
return None
|
|
|
|
# Determine which comes first and what we're looking for
|
|
if first_bracket == -1 or (first_brace != -1 and first_brace < first_bracket):
|
|
start_char, end_char = '{', '}'
|
|
start_pos = first_brace
|
|
else:
|
|
start_char, end_char = '[', ']'
|
|
start_pos = first_bracket
|
|
|
|
# Find matching end by counting braces/brackets
|
|
depth = 0
|
|
end_pos = -1
|
|
in_string = False
|
|
escape_next = False
|
|
|
|
for i in range(start_pos, len(text)):
|
|
char = text[i]
|
|
|
|
if escape_next:
|
|
escape_next = False
|
|
continue
|
|
|
|
if char == '\\':
|
|
escape_next = True
|
|
continue
|
|
|
|
if char == '"' and not escape_next:
|
|
in_string = not in_string
|
|
continue
|
|
|
|
if in_string:
|
|
continue
|
|
|
|
if char == start_char:
|
|
depth += 1
|
|
elif char == end_char:
|
|
depth -= 1
|
|
if depth == 0:
|
|
end_pos = i
|
|
break
|
|
|
|
if end_pos != -1:
|
|
candidate = text[start_pos:end_pos + 1]
|
|
try:
|
|
return json.loads(candidate)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
return None
|
|
|
|
|
|
def _read_step_file(filename: str, base_dir: Optional[Path], step_type: str) -> str:
|
|
"""Read a prompt/code file relative to the tool directory."""
|
|
if not base_dir:
|
|
raise ValueError(f"{step_type}_file requires the tool to have a filesystem path")
|
|
|
|
relative_path = Path(filename)
|
|
if relative_path.is_absolute():
|
|
raise ValueError(f"{step_type}_file must be relative to the tool directory")
|
|
|
|
base = base_dir.resolve()
|
|
path = (base / relative_path).resolve()
|
|
if path == base or base not in path.parents:
|
|
raise ValueError(f"{step_type}_file cannot reference files outside the tool directory")
|
|
if not path.is_file():
|
|
raise FileNotFoundError(f"{step_type}_file not found: {filename}")
|
|
return path.read_text()
|
|
|
|
|
|
def execute_prompt_step(
|
|
step: PromptStep,
|
|
variables: dict,
|
|
provider_override: str = None,
|
|
verbose: bool = False,
|
|
base_dir: Optional[Path] = None
|
|
) -> tuple[str, bool]:
|
|
"""
|
|
Execute a prompt step.
|
|
|
|
Args:
|
|
step: The prompt step to execute
|
|
variables: Current variable values
|
|
provider_override: Override the step's provider
|
|
|
|
Returns:
|
|
Tuple of (output_value, success)
|
|
|
|
If plain_text=True, returns raw provider output.
|
|
Otherwise, enforces structured JSON output with schema validation.
|
|
"""
|
|
import re
|
|
|
|
# Build prompt with variable substitution
|
|
try:
|
|
prompt_template = _read_step_file(step.prompt_file, base_dir, "prompt") if step.prompt_file else step.prompt
|
|
except (OSError, ValueError) as e:
|
|
print(f"Error in prompt step: {e}", file=sys.stderr)
|
|
return "", False
|
|
|
|
prompt = substitute_variables(prompt_template, variables, warn_non_scalar=verbose)
|
|
|
|
# Inject profile system prompt if specified
|
|
if step.profile:
|
|
profile = load_profile(step.profile)
|
|
if profile and profile.system_prompt:
|
|
# Prepend system prompt to user prompt
|
|
prompt = f"{profile.system_prompt}\n\n---\n\n{prompt}"
|
|
|
|
# Determine provider
|
|
provider = provider_override or substitute_variables(step.provider, variables, warn_non_scalar=verbose)
|
|
|
|
# Plain text mode - bypass structured output
|
|
if step.plain_text:
|
|
if provider.lower() == "mock":
|
|
result = mock_provider(prompt)
|
|
else:
|
|
result = call_provider(provider, prompt, max_tokens=step.max_tokens)
|
|
|
|
if not result.success:
|
|
print(f"Error in prompt step: {result.error}", file=sys.stderr)
|
|
return "", False
|
|
|
|
text = result.text
|
|
if step.strip_fences:
|
|
text = re.sub(r'^```\w*\n', '', text.strip())
|
|
text = re.sub(r'\n```\s*$', '', text)
|
|
|
|
return text, True
|
|
|
|
# Structured output mode - enforce JSON with schema validation
|
|
schema = step.output_schema or DEFAULT_OUTPUT_SCHEMA
|
|
|
|
# Augment prompt with schema instructions (prepended for small model compatibility)
|
|
augmented_prompt = prepend_schema_instructions(prompt, schema)
|
|
|
|
max_attempts = step.max_retries + 1
|
|
last_response = None
|
|
last_error = None
|
|
|
|
for attempt in range(max_attempts):
|
|
current_prompt = augmented_prompt
|
|
|
|
if attempt > 0 and last_response:
|
|
# Retry with error feedback
|
|
current_prompt = f"""{augmented_prompt}
|
|
|
|
Your previous response was:
|
|
{last_response[:1000]}{"..." if len(last_response) > 1000 else ""}
|
|
|
|
This failed validation: {last_error}
|
|
Please try again with valid JSON matching the schema exactly."""
|
|
|
|
if verbose:
|
|
if attempt > 0:
|
|
print(f"[verbose] Retry {attempt}/{step.max_retries} after validation failure", file=sys.stderr)
|
|
|
|
# Call provider
|
|
if provider.lower() == "mock":
|
|
result = mock_provider(current_prompt)
|
|
else:
|
|
result = call_provider(provider, current_prompt, max_tokens=step.max_tokens)
|
|
|
|
if not result.success:
|
|
print(f"Error in prompt step: {result.error}", file=sys.stderr)
|
|
return "", False
|
|
|
|
last_response = result.text
|
|
|
|
# Extract and parse JSON with multiple strategies
|
|
parsed = _extract_json(result.text)
|
|
if parsed is None:
|
|
last_error = f"Invalid JSON: Could not extract valid JSON from response"
|
|
if attempt == max_attempts - 1:
|
|
print(f"Prompt step failed after {step.max_retries} retry(s): {last_error}", file=sys.stderr)
|
|
if verbose:
|
|
print(f"[verbose] Last response: {last_response[:500]}...", file=sys.stderr)
|
|
return "", False
|
|
continue
|
|
|
|
# Validate against schema
|
|
try:
|
|
validate_schema(parsed, schema)
|
|
except SchemaValidationError as e:
|
|
last_error = str(e)
|
|
if attempt == max_attempts - 1:
|
|
print(f"Prompt step failed after {step.max_retries} retry(s): {last_error}", file=sys.stderr)
|
|
if verbose:
|
|
print(f"[verbose] Parsed response: {json.dumps(parsed)[:500]}...", file=sys.stderr)
|
|
return "", False
|
|
continue
|
|
|
|
# Success - return parsed dict for code step compatibility
|
|
# Template substitution handles dicts via _get_nested_value
|
|
return parsed, True
|
|
|
|
# Should not reach here, but just in case
|
|
return "", False
|
|
|
|
|
|
def execute_code_step(
|
|
step: CodeStep,
|
|
variables: dict,
|
|
step_num: int = 0,
|
|
verbose: bool = False,
|
|
base_dir: Optional[Path] = None
|
|
) -> tuple[dict, bool]:
|
|
"""
|
|
Execute a code step.
|
|
|
|
Args:
|
|
step: The code step to execute
|
|
variables: Current variable values (available in code)
|
|
step_num: Step number for error reporting
|
|
|
|
Returns:
|
|
Tuple of (output_vars_dict, success)
|
|
"""
|
|
# Substitute variables in code (like {outputfile} -> actual value)
|
|
try:
|
|
code_template = _read_step_file(step.code_file, base_dir, "code") if step.code_file else step.code
|
|
except (OSError, ValueError) as e:
|
|
print(f"Error in code step (step {step_num}):", file=sys.stderr)
|
|
print(f" {e}", file=sys.stderr)
|
|
return {}, False
|
|
|
|
code = substitute_variables(code_template, variables, warn_non_scalar=verbose)
|
|
|
|
# Create execution environment with variables
|
|
# IMPORTANT: Use the same dict for both globals and locals.
|
|
# When exec() gets separate dicts, nested functions can't access
|
|
# module-level imports (they look in globals, not the exec's locals).
|
|
local_vars = {"__builtins__": __builtins__}
|
|
local_vars.update(variables)
|
|
|
|
try:
|
|
# Execute the code with substituted variables
|
|
exec(code, local_vars, local_vars)
|
|
|
|
# Support comma-separated output vars (e.g., "a, b, c")
|
|
output_vars = [v.strip() for v in step.output_var.split(',')]
|
|
outputs = {}
|
|
for var in output_vars:
|
|
# Preserve original types for code→code workflows
|
|
# Template substitution will convert to string when needed
|
|
outputs[var] = local_vars.get(var)
|
|
|
|
return outputs, True
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
code_lines = code.split('\n')
|
|
|
|
# Extract line number from traceback
|
|
error_line = None
|
|
tb = traceback.extract_tb(e.__traceback__)
|
|
for frame in tb:
|
|
if frame.filename == '<string>': # exec'd code
|
|
error_line = frame.lineno
|
|
break
|
|
|
|
print(f"Error in code step (step {step_num}):", file=sys.stderr)
|
|
print(f" {type(e).__name__}: {e}", file=sys.stderr)
|
|
|
|
if error_line and 0 < error_line <= len(code_lines):
|
|
print(file=sys.stderr)
|
|
# Show context: line before, error line, line after
|
|
start = max(0, error_line - 2)
|
|
end = min(len(code_lines), error_line + 1)
|
|
for i in range(start, end):
|
|
marker = ">>>" if i == error_line - 1 else " "
|
|
print(f" {marker} {i+1}: {code_lines[i]}", file=sys.stderr)
|
|
|
|
print(file=sys.stderr)
|
|
print(f" Available variables: {list(variables.keys())}", file=sys.stderr)
|
|
return {}, False
|
|
|
|
|
|
def _print_call_stack(call_stack: list, error_msg: str) -> None:
|
|
"""Print the tool call stack for error reporting."""
|
|
if len(call_stack) > 1:
|
|
print("Error in tool chain:", file=sys.stderr)
|
|
for i, (name, step_num) in enumerate(call_stack):
|
|
indent = " " * i
|
|
arrow = "-> " if i > 0 else ""
|
|
step_info = f" (step {step_num})" if step_num else ""
|
|
print(f"{indent}{arrow}{name}{step_info}", file=sys.stderr)
|
|
print(f"{' ' * len(call_stack)}{error_msg}", file=sys.stderr)
|
|
else:
|
|
print(f"Error: {error_msg}", file=sys.stderr)
|
|
|
|
|
|
def execute_tool_step(
|
|
step: ToolStep,
|
|
variables: dict,
|
|
depth: int = 0,
|
|
provider_override: Optional[str] = None,
|
|
dry_run: bool = False,
|
|
verbose: bool = False,
|
|
call_stack: Optional[list] = None
|
|
) -> tuple[str, bool]:
|
|
"""
|
|
Execute a tool step by calling another tool.
|
|
|
|
Args:
|
|
step: The tool step to execute
|
|
variables: Current variable values
|
|
depth: Current recursion depth
|
|
provider_override: Override provider for nested calls
|
|
dry_run: Just show what would happen
|
|
verbose: Show debug info
|
|
call_stack: List of (tool_name, step_num) tuples for error reporting
|
|
|
|
Returns:
|
|
Tuple of (output_value, success)
|
|
"""
|
|
if call_stack is None:
|
|
call_stack = []
|
|
|
|
if depth >= MAX_TOOL_DEPTH:
|
|
_print_call_stack(call_stack, f"Maximum tool nesting depth ({MAX_TOOL_DEPTH}) exceeded")
|
|
return "", False
|
|
|
|
# Resolve the tool reference
|
|
try:
|
|
resolved = resolve_tool(step.tool)
|
|
nested_tool = resolved.tool
|
|
except ToolNotFoundError as e:
|
|
_print_call_stack(call_stack, f"Tool '{step.tool}' not found")
|
|
print(f"Hint: Install it with: cmdforge install {step.tool}", file=sys.stderr)
|
|
return "", False
|
|
|
|
# Prepare input by substituting variables
|
|
input_text = substitute_variables(step.input_template, variables, warn_non_scalar=verbose)
|
|
|
|
# Prepare arguments by substituting variables in arg values
|
|
custom_args = {}
|
|
for key, value in step.args.items():
|
|
custom_args[key] = substitute_variables(str(value), variables, warn_non_scalar=verbose)
|
|
|
|
# Determine effective provider (step override > parent override)
|
|
effective_provider = step.provider or provider_override
|
|
|
|
if verbose:
|
|
print(f"[verbose] Tool step: calling {step.tool}", file=sys.stderr)
|
|
print(f"[verbose] Input length: {len(input_text)} chars", file=sys.stderr)
|
|
print(f"[verbose] Args: {list(custom_args.keys())}", file=sys.stderr)
|
|
|
|
if dry_run:
|
|
return f"[DRY RUN - would call tool {step.tool}]", True
|
|
|
|
# Run the nested tool
|
|
output, exit_code = run_tool(
|
|
tool=nested_tool,
|
|
input_text=input_text,
|
|
custom_args=custom_args,
|
|
provider_override=effective_provider,
|
|
dry_run=dry_run,
|
|
show_prompt=False,
|
|
verbose=verbose,
|
|
_depth=depth + 1,
|
|
_call_stack=call_stack
|
|
)
|
|
|
|
return output, exit_code == 0
|
|
|
|
|
|
def run_tool(
|
|
tool: Tool,
|
|
input_text: str,
|
|
custom_args: dict,
|
|
provider_override: Optional[str] = None,
|
|
dry_run: bool = False,
|
|
show_prompt: bool = False,
|
|
verbose: bool = False,
|
|
auto_install: bool = False,
|
|
_depth: int = 0,
|
|
_call_stack: Optional[list] = None
|
|
) -> tuple[str, int]:
|
|
"""
|
|
Execute a tool.
|
|
|
|
Args:
|
|
tool: Tool definition
|
|
input_text: Input content
|
|
custom_args: Custom argument values
|
|
provider_override: Override all providers
|
|
dry_run: Just show what would happen
|
|
show_prompt: Show prompts in addition to output
|
|
verbose: Show debug info
|
|
auto_install: Automatically install missing dependencies
|
|
_depth: Internal recursion depth tracker
|
|
_call_stack: Internal call stack for error reporting
|
|
|
|
Returns:
|
|
Tuple of (output_text, exit_code)
|
|
"""
|
|
def _format_tool_ref(current_tool: Tool) -> str:
|
|
"""Best-effort tool reference for messages (owner/name if available)."""
|
|
if current_tool.path:
|
|
tool_dir = current_tool.path.parent
|
|
# Handle both global and local ~/.cmdforge/<owner>/<name> layouts
|
|
if tool_dir.parent.name == ".cmdforge":
|
|
return tool_dir.name
|
|
if tool_dir.parent.parent.name == ".cmdforge":
|
|
return f"{tool_dir.parent.name}/{tool_dir.name}"
|
|
return current_tool.name
|
|
|
|
# Initialize call stack
|
|
if _call_stack is None:
|
|
_call_stack = []
|
|
current_stack = _call_stack + [(tool.name, None)]
|
|
|
|
# Check dependencies on first level only (not for nested calls)
|
|
if _depth == 0 and (tool.dependencies or any(isinstance(s, ToolStep) for s in tool.steps)):
|
|
missing = check_dependencies(tool)
|
|
if missing:
|
|
if auto_install:
|
|
# Try to auto-install missing dependencies
|
|
installed = auto_install_dependencies(tool, verbose=verbose)
|
|
# Re-check after installation
|
|
still_missing = check_dependencies(tool)
|
|
if still_missing:
|
|
print(f"Warning: Could not install all dependencies. Missing: {', '.join(still_missing)}", file=sys.stderr)
|
|
elif installed:
|
|
print(f"Auto-installed dependencies: {', '.join(installed)}", file=sys.stderr)
|
|
else:
|
|
print(f"Warning: Missing dependencies: {', '.join(missing)}", file=sys.stderr)
|
|
print(f"Install with: cmdforge install {' '.join(missing)}", file=sys.stderr)
|
|
print(f"Or use --auto-install to install automatically", file=sys.stderr)
|
|
# Continue anyway - the actual step execution will fail with a better error
|
|
|
|
# Check system dependencies on first level only
|
|
if _depth == 0 and tool.system_dependencies:
|
|
missing_sys = check_system_dependencies(tool)
|
|
if missing_sys:
|
|
names = ", ".join(d.name for d in missing_sys)
|
|
print(f"Warning: Missing system packages: {names}", file=sys.stderr)
|
|
print(f"Run: cmdforge system-deps {_format_tool_ref(tool)} install", file=sys.stderr)
|
|
# Continue execution - let it fail naturally if dep is actually needed
|
|
|
|
# Initialize variables with input and arguments
|
|
variables = {"input": input_text}
|
|
|
|
# Add argument values (with defaults)
|
|
for arg in tool.arguments:
|
|
value = custom_args.get(arg.variable, arg.default)
|
|
variables[arg.variable] = value
|
|
|
|
# Load user settings if exists
|
|
settings = {}
|
|
if tool.path:
|
|
settings_path = tool.path.parent / "settings.yaml"
|
|
if settings_path.exists():
|
|
try:
|
|
settings = yaml.safe_load(settings_path.read_text()) or {}
|
|
except yaml.YAMLError:
|
|
print(f"Warning: Failed to load settings.yaml", file=sys.stderr)
|
|
settings = {}
|
|
variables["settings"] = settings
|
|
|
|
if verbose:
|
|
print(f"[verbose] Tool: {tool.name}", file=sys.stderr)
|
|
print(f"[verbose] Variables: {list(variables.keys())}", file=sys.stderr)
|
|
print(f"[verbose] Steps: {len(tool.steps)}", file=sys.stderr)
|
|
|
|
tool_base_dir = tool.path.parent if tool.path else None
|
|
|
|
# If no steps, just substitute output template
|
|
if not tool.steps:
|
|
output = substitute_variables(tool.output, variables, warn_non_scalar=verbose)
|
|
return output, 0
|
|
|
|
# Reuse MCP configuration and schema caches across all MCP steps in this run.
|
|
mcp_manager = None
|
|
|
|
# Execute each step
|
|
for i, step in enumerate(tool.steps):
|
|
if verbose:
|
|
if isinstance(step, PromptStep):
|
|
step_type = "PROMPT"
|
|
elif isinstance(step, CodeStep):
|
|
step_type = "CODE"
|
|
elif isinstance(step, ToolStep):
|
|
step_type = f"TOOL({step.tool})"
|
|
elif isinstance(step, McpStep):
|
|
step_type = f"MCP({step.server}/{step.tool})"
|
|
else:
|
|
step_type = "UNKNOWN"
|
|
print(f"[verbose] Step {i+1}: {step_type} -> {{{step.output_var}}}", file=sys.stderr)
|
|
|
|
if isinstance(step, PromptStep):
|
|
# Show prompt if requested
|
|
if show_prompt or dry_run:
|
|
prompt = substitute_variables(step.prompt, variables, warn_non_scalar=verbose)
|
|
print(f"=== PROMPT (Step {i+1}, provider={step.provider}) ===", file=sys.stderr)
|
|
print(prompt, file=sys.stderr)
|
|
print("=== END PROMPT ===", file=sys.stderr)
|
|
|
|
if dry_run:
|
|
variables[step.output_var] = f"[DRY RUN - would call {step.provider}]"
|
|
else:
|
|
output, success = execute_prompt_step(
|
|
step,
|
|
variables,
|
|
provider_override,
|
|
verbose=verbose,
|
|
base_dir=tool_base_dir
|
|
)
|
|
if not success:
|
|
return "", 2
|
|
variables[step.output_var] = output
|
|
|
|
elif isinstance(step, CodeStep):
|
|
if verbose or dry_run:
|
|
print(f"=== CODE (Step {i+1}) -> {{{step.output_var}}} ===", file=sys.stderr)
|
|
print(step.code, file=sys.stderr)
|
|
print("=== END CODE ===", file=sys.stderr)
|
|
|
|
if dry_run:
|
|
# Handle comma-separated output vars for dry run
|
|
for var in [v.strip() for v in step.output_var.split(',')]:
|
|
variables[var] = "[DRY RUN - would execute code]"
|
|
else:
|
|
outputs, success = execute_code_step(
|
|
step,
|
|
variables,
|
|
step_num=i+1,
|
|
verbose=verbose,
|
|
base_dir=tool_base_dir
|
|
)
|
|
if not success:
|
|
return "", 1
|
|
# Merge all output vars into variables
|
|
variables.update(outputs)
|
|
|
|
elif isinstance(step, ToolStep):
|
|
if verbose or dry_run:
|
|
print(f"=== TOOL (Step {i+1}) -> {{{step.output_var}}} ===", file=sys.stderr)
|
|
print(f" Calling: {step.tool}", file=sys.stderr)
|
|
print(f" Args: {step.args}", file=sys.stderr)
|
|
print("=== END TOOL ===", file=sys.stderr)
|
|
|
|
# Update call stack with current step number
|
|
step_stack = _call_stack + [(tool.name, i + 1)]
|
|
output, success = execute_tool_step(
|
|
step,
|
|
variables,
|
|
depth=_depth,
|
|
provider_override=provider_override,
|
|
dry_run=dry_run,
|
|
verbose=verbose,
|
|
call_stack=step_stack
|
|
)
|
|
if not success:
|
|
return "", 3
|
|
variables[step.output_var] = output
|
|
|
|
elif isinstance(step, McpStep):
|
|
if verbose or dry_run:
|
|
print(f"=== MCP (Step {i+1}) -> {{{step.output_var}}} ===", file=sys.stderr)
|
|
print(f" Server: {step.server} Tool: {step.tool}", file=sys.stderr)
|
|
print(f" Args: {step.arguments}", file=sys.stderr)
|
|
print("=== END MCP ===", file=sys.stderr)
|
|
|
|
if dry_run:
|
|
variables[step.output_var] = f"[DRY RUN - would call mcp:{step.server}/{step.tool}]"
|
|
else:
|
|
args = _substitute_mcp_args(step.arguments, variables)
|
|
try:
|
|
if mcp_manager is None:
|
|
mcp_manager = McpClientManager()
|
|
result = mcp_manager.call_tool(
|
|
step.server, step.tool, args,
|
|
result_mode=step.result_mode,
|
|
)
|
|
variables[step.output_var] = result
|
|
except ImportError as e:
|
|
print(f"{e}", file=sys.stderr)
|
|
return "", 4
|
|
except Exception as e:
|
|
print(f"MCP step failed (server={step.server}, tool={step.tool}): {e}", file=sys.stderr)
|
|
return "", 4
|
|
|
|
# Generate final output
|
|
output = substitute_variables(tool.output, variables, warn_non_scalar=verbose)
|
|
|
|
return output, 0
|
|
|
|
|
|
def _substitute_mcp_args(arguments: dict, variables: dict) -> dict:
|
|
"""Substitute variables in MCP arguments, preserving types."""
|
|
result = {}
|
|
for key, value in arguments.items():
|
|
result[key] = _deep_substitute(value, variables)
|
|
return result
|
|
|
|
|
|
def _deep_substitute(value, variables: dict):
|
|
if isinstance(value, str):
|
|
import re
|
|
|
|
exact_reference = re.fullmatch(
|
|
r"\s*\{([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\}\s*",
|
|
value,
|
|
)
|
|
if exact_reference:
|
|
path = exact_reference.group(1).split(".")
|
|
if path[0] in variables:
|
|
resolved = variables[path[0]]
|
|
found = True
|
|
for key in path[1:]:
|
|
if isinstance(resolved, dict) and key in resolved:
|
|
resolved = resolved[key]
|
|
elif hasattr(resolved, key):
|
|
resolved = getattr(resolved, key)
|
|
else:
|
|
found = False
|
|
break
|
|
if found:
|
|
return resolved
|
|
|
|
substituted = substitute_variables(value, variables)
|
|
return substituted if substituted != value else value
|
|
elif isinstance(value, dict):
|
|
return {k: _deep_substitute(v, variables) for k, v in value.items()}
|
|
elif isinstance(value, list):
|
|
return [_deep_substitute(v, variables) for v in value]
|
|
return value
|
|
|
|
|
|
def create_argument_parser(tool: Tool) -> argparse.ArgumentParser:
|
|
"""
|
|
Create an argument parser for a tool.
|
|
|
|
Args:
|
|
tool: Tool definition
|
|
|
|
Returns:
|
|
Configured ArgumentParser
|
|
"""
|
|
parser = argparse.ArgumentParser(
|
|
prog=tool.name,
|
|
description=tool.description or f"CmdForge: {tool.name}"
|
|
)
|
|
|
|
# Universal flags
|
|
parser.add_argument("-i", "--input", dest="input_file",
|
|
help="Input file (reads from stdin if piped)")
|
|
parser.add_argument("--stdin", action="store_true",
|
|
help="Read input interactively from stdin (type then Ctrl+D)")
|
|
parser.add_argument("-o", "--output", dest="output_file",
|
|
help="Output file (writes to stdout if omitted)")
|
|
parser.add_argument("--dry-run", action="store_true",
|
|
help="Show what would happen without executing")
|
|
parser.add_argument("--show-prompt", action="store_true",
|
|
help="Show prompts in addition to output")
|
|
parser.add_argument("-p", "--provider",
|
|
help="Override provider (e.g., --provider mock)")
|
|
parser.add_argument("-v", "--verbose", action="store_true",
|
|
help="Show debug information")
|
|
parser.add_argument("--auto-install", action="store_true",
|
|
help="Automatically install missing tool dependencies")
|
|
|
|
# Tool-specific flags from arguments
|
|
for arg in tool.arguments:
|
|
parser.add_argument(
|
|
arg.flag,
|
|
dest=arg.variable,
|
|
default=arg.default,
|
|
help=arg.description or f"{arg.variable} (default: {arg.default})"
|
|
)
|
|
|
|
return parser
|
|
|
|
|
|
def collect_custom_args(tool: Tool, args: argparse.Namespace) -> dict:
|
|
"""Collect tool-specific arguments from a parsed argparse namespace."""
|
|
custom_args = {}
|
|
for arg in tool.arguments:
|
|
value = getattr(args, arg.variable, None)
|
|
if value is not None:
|
|
custom_args[arg.variable] = value
|
|
return custom_args
|
|
|
|
|
|
def main():
|
|
"""Entry point for tool execution via wrapper script."""
|
|
if len(sys.argv) < 2:
|
|
print("Usage: python -m cmdforge.runner <tool_name> [args...]", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
tool_spec = sys.argv[1]
|
|
|
|
# Resolve tool using new resolution order
|
|
try:
|
|
resolved = resolve_tool(tool_spec)
|
|
tool = resolved.tool
|
|
except ToolNotFoundError as e:
|
|
print(f"Error: Tool '{tool_spec}' not found", file=sys.stderr)
|
|
print(f"Searched: {', '.join(e.searched_paths[:3])}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Check for manifest overrides
|
|
manifest = load_manifest()
|
|
provider_override_from_manifest = None
|
|
if manifest:
|
|
override = manifest.get_override(tool_spec)
|
|
if override and override.provider:
|
|
provider_override_from_manifest = override.provider
|
|
|
|
# Parse remaining arguments
|
|
parser = create_argument_parser(tool)
|
|
args = parser.parse_args(sys.argv[2:])
|
|
|
|
# Read input
|
|
if args.input_file:
|
|
# Read from file
|
|
input_path = Path(args.input_file)
|
|
if not input_path.exists():
|
|
print(f"Error: Input file not found: {args.input_file}", file=sys.stderr)
|
|
sys.exit(1)
|
|
input_text = input_path.read_text()
|
|
elif args.stdin:
|
|
# Explicit interactive input requested
|
|
print("Reading from stdin (Ctrl+D to end):", file=sys.stderr)
|
|
input_text = sys.stdin.read()
|
|
elif not sys.stdin.isatty():
|
|
# Stdin is piped - read it
|
|
input_text = sys.stdin.read()
|
|
else:
|
|
# No input provided - use empty string
|
|
input_text = ""
|
|
|
|
# Collect custom args
|
|
custom_args = collect_custom_args(tool, args)
|
|
|
|
# Determine provider override (CLI flag takes precedence over manifest)
|
|
effective_provider = args.provider or provider_override_from_manifest
|
|
|
|
# Run tool
|
|
output, exit_code = run_tool(
|
|
tool=tool,
|
|
input_text=input_text,
|
|
custom_args=custom_args,
|
|
provider_override=effective_provider,
|
|
dry_run=args.dry_run,
|
|
show_prompt=args.show_prompt,
|
|
verbose=args.verbose,
|
|
auto_install=args.auto_install
|
|
)
|
|
|
|
# Write output
|
|
if exit_code == 0 and output:
|
|
if args.output_file:
|
|
Path(args.output_file).write_text(output)
|
|
else:
|
|
print(output)
|
|
|
|
sys.exit(exit_code)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|