Add project tools and strict provider execution

This commit is contained in:
rob
2026-07-20 22:27:36 -03:00
parent c289e812fb
commit 6a88fe2e0f
19 changed files with 1504 additions and 84 deletions
+137 -13
View File
@@ -12,7 +12,9 @@ from typing import Optional, List
import yaml
from .tool import Tool, PromptStep, CodeStep, ToolStep, McpStep
from .providers import call_provider, mock_provider
from .providers import (
call_provider, mock_provider, ProviderExecutionPolicy,
)
from .resolver import resolve_tool, ToolNotFoundError, ToolSpec, install_from_registry
from .manifest import load_manifest
from .profiles import load_profile
@@ -508,7 +510,9 @@ def execute_prompt_step(
variables: dict,
provider_override: str = None,
verbose: bool = False,
base_dir: Optional[Path] = None
base_dir: Optional[Path] = None,
execution_policy: Optional[ProviderExecutionPolicy] = None,
provenance_log: Optional[list[dict]] = None,
) -> tuple[str, bool]:
"""
Execute a prompt step.
@@ -525,6 +529,24 @@ def execute_prompt_step(
Otherwise, enforces structured JSON output with schema validation.
"""
import re
from dataclasses import replace
effective_policy = execution_policy or ProviderExecutionPolicy()
if step.fallback_policy == "deny" and effective_policy.allow_fallback:
effective_policy = replace(effective_policy, allow_fallback=False)
def record_result(result, selected_provider: str) -> None:
# Mock calls bypass call_provider for deterministic tests, so attach
# the same runtime-owned identity here.
if selected_provider.lower() == "mock" and not result.actual_provider:
result.requested_provider = "mock"
result.actual_provider = "mock"
result.attempted_providers = ["mock"]
result.model = "mock"
result.locality = "local"
result.model_identity_source = "built-in"
if provenance_log is not None:
provenance_log.append(result.provenance())
# Build prompt with variable substitution
try:
@@ -557,7 +579,12 @@ def execute_prompt_step(
if provider.lower() == "mock":
result = mock_provider(prompt)
else:
result = call_provider(provider, prompt, max_tokens=step.max_tokens)
result = call_provider(
provider, prompt, max_tokens=step.max_tokens,
execution_policy=effective_policy,
)
record_result(result, provider)
if not result.success:
print(f"Error in prompt step: {result.error}", file=sys.stderr)
@@ -601,7 +628,12 @@ Please try again with valid JSON matching the schema exactly."""
if provider.lower() == "mock":
result = mock_provider(current_prompt)
else:
result = call_provider(provider, current_prompt, max_tokens=step.max_tokens)
result = call_provider(
provider, current_prompt, max_tokens=step.max_tokens,
execution_policy=effective_policy,
)
record_result(result, provider)
if not result.success:
print(f"Error in prompt step: {result.error}", file=sys.stderr)
@@ -743,6 +775,8 @@ def execute_tool_step(
agent_profile: Optional[str] = None,
agent_skills: Optional[List[str]] = None,
agent_tool_policies: Optional[List[List[str]]] = None,
execution_policy: Optional[ProviderExecutionPolicy] = None,
provenance_log: Optional[list[dict]] = None,
) -> tuple[str, bool]:
"""
Execute a tool step by calling another tool.
@@ -815,6 +849,8 @@ def execute_tool_step(
agent_tool_policies=_extend_agent_tool_policies(
agent_tool_policies, step.tools
),
execution_policy=execution_policy,
provenance_log=provenance_log,
)
return output, exit_code == 0
@@ -834,6 +870,8 @@ def run_tool(
agent_profile: Optional[str] = None,
agent_skills: Optional[List[str]] = None,
agent_tool_policies: Optional[List[List[str]]] = None,
execution_policy: Optional[ProviderExecutionPolicy] = None,
provenance_log: Optional[list[dict]] = None,
) -> tuple[str, int]:
"""
Execute a tool.
@@ -969,7 +1007,9 @@ def run_tool(
variables,
provider_override,
verbose=verbose,
base_dir=tool_base_dir
base_dir=tool_base_dir,
execution_policy=execution_policy,
provenance_log=provenance_log,
)
if not success:
return "", 2
@@ -1018,6 +1058,8 @@ def run_tool(
agent_profile=agent_profile,
agent_skills=agent_skills,
agent_tool_policies=agent_tool_policies,
execution_policy=execution_policy,
provenance_log=provenance_log,
)
if not success:
return "", 3
@@ -1133,14 +1175,65 @@ def create_argument_parser(tool: Tool) -> argparse.ArgumentParser:
help="Show debug information")
parser.add_argument("--auto-install", action="store_true",
help="Automatically install missing tool dependencies")
parser.add_argument("--no-fallback", action="store_true",
help="Fail instead of trying configured fallbacks")
parser.add_argument("--require-local", action="store_true",
help="Allow only providers declared local")
parser.add_argument("--require-capability", action="append", default=[],
help="Required provider capability (repeatable)")
parser.add_argument("--data-classification",
choices=["public", "internal", "private"])
parser.add_argument("--require-model-identity", action="store_true")
parser.add_argument("--require-model-digest", action="store_true")
parser.add_argument("--result-envelope", choices=["json"])
# Tool-specific flags from arguments
for arg in tool.arguments:
existing_action = parser._option_string_actions.get(arg.flag)
if existing_action is not None:
# Preserve the legacy and useful case where a boolean tool option
# intentionally shares a universal flag (for example --verbose).
# Both consumers then read the same parsed boolean.
if arg.type == "boolean" and existing_action.dest == arg.variable:
continue
raise ValueError(
f"Tool argument {arg.flag!r} conflicts with CmdForge's "
"universal run options"
)
def parse_boolean(value):
if isinstance(value, bool):
return value
normalized = str(value).strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
raise argparse.ArgumentTypeError(
"expected true/false, yes/no, on/off, or 1/0"
)
value_type = {
"string": str,
"integer": int,
"number": float,
"boolean": parse_boolean,
}[arg.type]
options = {
"dest": arg.variable,
"default": arg.default,
"help": arg.description or f"{arg.variable} (default: {arg.default})",
"required": arg.required,
"type": value_type,
}
if arg.enum is not None:
options["choices"] = [value_type(value) for value in arg.enum]
if arg.type == "boolean":
options["nargs"] = "?"
options["const"] = True
parser.add_argument(
arg.flag,
dest=arg.variable,
default=arg.default,
help=arg.description or f"{arg.variable} (default: {arg.default})"
**options,
)
return parser
@@ -1215,6 +1308,16 @@ def main():
# Determine provider override (CLI flag takes precedence over manifest)
effective_provider = args.provider or provider_override_from_manifest
execution_policy = ProviderExecutionPolicy(
allow_fallback=not args.no_fallback,
required_locality="local" if args.require_local else None,
required_capabilities=tuple(args.require_capability or []),
data_classification=args.data_classification or "public",
require_model_identity=args.require_model_identity,
require_model_digest=args.require_model_digest,
)
provider_calls = []
# Run tool
output, exit_code = run_tool(
tool=tool,
@@ -1224,15 +1327,36 @@ def main():
dry_run=args.dry_run,
show_prompt=args.show_prompt,
verbose=args.verbose,
auto_install=args.auto_install
auto_install=args.auto_install,
execution_policy=execution_policy,
provenance_log=provider_calls,
)
# Write output
if exit_code == 0 and output:
if args.output_file:
Path(args.output_file).write_text(output)
if args.result_envelope == "json" or (exit_code == 0 and output):
if args.result_envelope == "json":
import json
rendered_output = json.dumps({
"output": output,
"execution": {
"exit_code": exit_code,
"provider_calls": provider_calls,
"fallback_used": any(
item.get("fallback_used", False)
for item in provider_calls
),
"actual_providers": list(dict.fromkeys(
item["actual_provider"] for item in provider_calls
if item.get("actual_provider")
)),
},
}, indent=2)
else:
print(output)
rendered_output = output
if args.output_file:
Path(args.output_file).write_text(rendered_output)
else:
print(rendered_output)
if exit_code == 0 and not args.dry_run:
try: