Add project tools and strict provider execution
This commit is contained in:
+137
-13
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user