Implement structured output for prompt steps (Phase 1)

Add schema validation and retry logic for AI responses:

- Add output_schema, max_retries, plain_text fields to PromptStep
- Add DEFAULT_OUTPUT_SCHEMA with output/reasoning fields
- Implement append_schema_instructions() for prompt augmentation
- Implement validate_schema() using jsonschema library
- Add retry logic with error feedback on validation failure
- Enhance substitute_variables() for nested field access ({var.field})
- Add jsonschema to dependencies
- Update GUI dialog with plain_text checkbox and max_retries spinner
- Add comprehensive tests for structured output and nested access

Tools using plain_text=True bypass validation (current behavior).
Tools without plain_text get structured JSON output with schema enforcement.

Migration Phase 1 complete. Next: Apply plain_text=True to all 72 tools.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-02-17 17:34:48 -04:00
parent f980fe05f1
commit 8581775002
6 changed files with 566 additions and 46 deletions

View File

@ -35,6 +35,7 @@ dependencies = [
"PySide6>=6.5", "PySide6>=6.5",
"NodeGraphQt>=0.6.0", "NodeGraphQt>=0.6.0",
"setuptools", # Required for distutils compatibility (Python 3.12+) "setuptools", # Required for distutils compatibility (Python 3.12+)
"jsonschema>=4.0", # JSON schema validation for structured output
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@ -6,7 +6,7 @@ from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QFormLayout, QLineEdit, QDialog, QVBoxLayout, QFormLayout, QLineEdit,
QComboBox, QPushButton, QHBoxLayout, QLabel, QComboBox, QPushButton, QHBoxLayout, QLabel,
QPlainTextEdit, QSplitter, QGroupBox, QTextEdit, QMessageBox, QPlainTextEdit, QSplitter, QGroupBox, QTextEdit, QMessageBox,
QCheckBox QCheckBox, QSpinBox
) )
from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtCore import Qt, QThread, Signal
@ -73,6 +73,31 @@ class PromptStepDialog(QDialog):
self.strip_fences_check = QCheckBox("Strip markdown code fences from output") self.strip_fences_check = QCheckBox("Strip markdown code fences from output")
form.addRow("", self.strip_fences_check) form.addRow("", self.strip_fences_check)
# Structured output options
form.addRow(QLabel("")) # Spacer
structured_label = QLabel("<b>Structured Output</b>")
form.addRow(structured_label)
# Plain text checkbox (bypass structured output)
self.plain_text_check = QCheckBox("Plain text mode (bypass JSON validation)")
self.plain_text_check.setToolTip(
"When checked, output is returned as-is without JSON parsing.\n"
"When unchecked, output must be valid JSON matching a schema."
)
self.plain_text_check.stateChanged.connect(self._on_plain_text_changed)
form.addRow("", self.plain_text_check)
# Max retries (only visible when structured output is enabled)
self.retries_spin = QSpinBox()
self.retries_spin.setRange(0, 5)
self.retries_spin.setValue(1)
self.retries_spin.setToolTip(
"Number of retries if JSON validation fails.\n"
"The AI will receive error feedback and try again."
)
self.retries_label = QLabel("Max retries:")
form.addRow(self.retries_label, self.retries_spin)
layout.addLayout(form) layout.addLayout(form)
# Prompt text # Prompt text
@ -103,6 +128,12 @@ class PromptStepDialog(QDialog):
layout.addLayout(buttons) layout.addLayout(buttons)
def _on_plain_text_changed(self, state):
"""Show/hide structured output options based on plain_text checkbox."""
is_plain = bool(state)
self.retries_label.setVisible(not is_plain)
self.retries_spin.setVisible(not is_plain)
def _load_step(self, step: PromptStep): def _load_step(self, step: PromptStep):
"""Load step data into form.""" """Load step data into form."""
# Load name # Load name
@ -125,6 +156,11 @@ class PromptStepDialog(QDialog):
self.prompt_input.setPlainText(step.prompt) self.prompt_input.setPlainText(step.prompt)
self.strip_fences_check.setChecked(step.strip_fences) self.strip_fences_check.setChecked(step.strip_fences)
# Structured output fields
self.plain_text_check.setChecked(step.plain_text)
self.retries_spin.setValue(step.max_retries)
self._on_plain_text_changed(step.plain_text)
def _validate_and_accept(self): def _validate_and_accept(self):
"""Validate and accept.""" """Validate and accept."""
prompt = self.prompt_input.toPlainText().strip() prompt = self.prompt_input.toPlainText().strip()
@ -153,7 +189,9 @@ class PromptStepDialog(QDialog):
output_var=self.output_input.text().strip(), output_var=self.output_input.text().strip(),
profile=profile, profile=profile,
name=name, name=name,
strip_fences=self.strip_fences_check.isChecked() strip_fences=self.strip_fences_check.isChecked(),
plain_text=self.plain_text_check.isChecked(),
max_retries=self.retries_spin.value()
) )

View File

@ -1,6 +1,7 @@
"""Tool execution engine.""" """Tool execution engine."""
import argparse import argparse
import json
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -16,6 +17,80 @@ from .profiles import load_profile
# Maximum recursion depth for nested tool calls # Maximum recursion depth for nested tool calls
MAX_TOOL_DEPTH = 10 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 append_schema_instructions(prompt: str, schema: dict) -> str:
"""
Append schema instructions to a prompt.
Args:
prompt: Original prompt text
schema: JSON schema the response must match
Returns:
Augmented prompt with schema instructions
"""
schema_json = json.dumps(schema, indent=2)
return f"""{prompt}
---
RESPONSE FORMAT:
You must respond with ONLY valid JSON matching this exact schema:
{schema_json}
CRITICAL RULES:
- Output ONLY the JSON object, no other text before or after
- Put any thinking, reasoning, or analysis in the "reasoning" field
- Put your actual answer/response in the "output" field
- Do not include markdown code fences
- Do not include any preamble like "Here is my response:"
"""
def check_system_dependencies(tool: Tool) -> list: def check_system_dependencies(tool: Tool) -> list:
""" """
@ -114,14 +189,52 @@ def check_dependencies(tool: Tool, checked: set = None) -> list[str]:
return missing 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: def substitute_variables(template: str, variables: dict, warn_non_scalar: bool = False) -> str:
""" """
Substitute {variable} placeholders in a template. Substitute {variable} placeholders in a template.
Supports escaping: use {{ for literal { and }} for literal } Supports:
- Simple: {varname}
- Nested: {varname.field} or {varname.field.subfield}
- Settings: {settings.key}
- Escaping: {{ for literal { and }} for literal }
Also supports {settings.key} syntax for accessing top-level scalar For nested access, if the variable is a JSON string, it will be parsed.
values from the settings dict.
Args: Args:
template: String with {var} placeholders template: String with {var} placeholders
@ -137,6 +250,8 @@ def substitute_variables(template: str, variables: dict, warn_non_scalar: bool =
'Use {braces}' 'Use {braces}'
>>> substitute_variables("Backend: {settings.backend}", {"settings": {"backend": "piper"}}) >>> substitute_variables("Backend: {settings.backend}", {"settings": {"backend": "piper"}})
'Backend: piper' 'Backend: piper'
>>> substitute_variables("Answer: {result.output}", {"result": '{"output": "Hello"}'})
'Answer: Hello'
""" """
import re import re
@ -147,34 +262,60 @@ def substitute_variables(template: str, variables: dict, warn_non_scalar: bool =
# First, replace escaped braces with placeholders # First, replace escaped braces with placeholders
result = template.replace("{{", ESCAPE_OPEN).replace("}}", ESCAPE_CLOSE) result = template.replace("{{", ESCAPE_OPEN).replace("}}", ESCAPE_CLOSE)
# Handle settings.key syntax (top-level scalar values only) # Pattern for {var} or {var.field.subfield}
settings = variables.get("settings", {}) pattern = r'\{([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\}'
if settings and isinstance(settings, dict):
def replace_settings(match): def replace_var(match):
key = match.group(1) 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)
# Replace with empty string for None, str() for other 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: if key in settings:
value = settings[key] value = settings[key]
if isinstance(value, (str, int, float, bool)): if isinstance(value, (str, int, float, bool)):
return str(value) if value is not None else "" return str(value) if value is not None else ""
else: else:
# Non-scalar value - leave placeholder, warn if requested
if warn_non_scalar: if warn_non_scalar:
print( print(
f"Warning: {{settings.{key}}} is not a scalar value, " f"Warning: {{settings.{key}}} is not a scalar value, "
f"access it in code steps instead", f"access it in code steps instead",
file=sys.stderr file=sys.stderr
) )
return match.group(0) # Return unchanged return match.group(0)
return match.group(0) # Key not found, leave unchanged return match.group(0)
result = re.sub(r'\{settings\.([^}]+)\}', replace_settings, result) # 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
# Now do regular variable substitution (skip 'settings' since we handled it) # Convert to string for template
for name, value in variables.items(): if isinstance(value, (dict, list)):
if name == "settings": return json.dumps(value)
continue # Already handled above return str(value)
# Use 'is not None' to preserve falsey values like 0 and False
result = result.replace(f"{{{name}}}", "" if value is None else str(value)) result = re.sub(pattern, replace_var, result)
# Finally, restore escaped braces as single braces # Finally, restore escaped braces as single braces
result = result.replace(ESCAPE_OPEN, "{").replace(ESCAPE_CLOSE, "}") result = result.replace(ESCAPE_OPEN, "{").replace(ESCAPE_CLOSE, "}")
@ -198,7 +339,12 @@ def execute_prompt_step(
Returns: Returns:
Tuple of (output_value, success) 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 # Build prompt with variable substitution
prompt = substitute_variables(step.prompt, variables, warn_non_scalar=verbose) prompt = substitute_variables(step.prompt, variables, warn_non_scalar=verbose)
@ -209,9 +355,11 @@ def execute_prompt_step(
# Prepend system prompt to user prompt # Prepend system prompt to user prompt
prompt = f"{profile.system_prompt}\n\n---\n\n{prompt}" prompt = f"{profile.system_prompt}\n\n---\n\n{prompt}"
# Call provider (support variable substitution in provider name, e.g. {settings.provider}) # Determine provider
provider = provider_override or substitute_variables(step.provider, variables, warn_non_scalar=verbose) 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": if provider.lower() == "mock":
result = mock_provider(prompt) result = mock_provider(prompt)
else: else:
@ -223,12 +371,86 @@ def execute_prompt_step(
text = result.text text = result.text
if step.strip_fences: if step.strip_fences:
import re
text = re.sub(r'^```\w*\n', '', text.strip()) text = re.sub(r'^```\w*\n', '', text.strip())
text = re.sub(r'\n```\s*$', '', text) text = re.sub(r'\n```\s*$', '', text)
return text, True return text, True
# Structured output mode - enforce JSON with schema validation
schema = step.output_schema or DEFAULT_OUTPUT_SCHEMA
# Augment prompt with schema instructions
augmented_prompt = append_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)
if not result.success:
print(f"Error in prompt step: {result.error}", file=sys.stderr)
return "", False
last_response = result.text
# Strip markdown code fences if present (common model behavior)
text = result.text.strip()
if text.startswith("```"):
text = re.sub(r'^```\w*\n', '', text)
text = re.sub(r'\n```\s*$', '', text)
# Try to parse as JSON
try:
parsed = json.loads(text)
except json.JSONDecodeError as e:
last_error = f"Invalid JSON: {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] 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 normalized JSON string
return json.dumps(parsed), True
# Should not reach here, but just in case
return "", False
def execute_code_step( def execute_code_step(
step: CodeStep, step: CodeStep,

View File

@ -101,6 +101,10 @@ class PromptStep:
profile: Optional[str] = None # Optional AI persona profile name profile: Optional[str] = None # Optional AI persona profile name
name: Optional[str] = None # Optional display name for the step name: Optional[str] = None # Optional display name for the step
strip_fences: bool = False # Strip markdown code fences from output strip_fences: bool = False # Strip markdown code fences from output
# Structured output fields
output_schema: Optional[dict] = None # JSON schema for validation (None = use default)
max_retries: int = 1 # Retry count on validation failure
plain_text: bool = False # Bypass structured output enforcement
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = { d = {
@ -117,6 +121,12 @@ class PromptStep:
d["name"] = self.name d["name"] = self.name
if self.strip_fences: if self.strip_fences:
d["strip_fences"] = self.strip_fences d["strip_fences"] = self.strip_fences
if self.output_schema:
d["output_schema"] = self.output_schema
if self.max_retries != 1:
d["max_retries"] = self.max_retries
if self.plain_text:
d["plain_text"] = self.plain_text
return d return d
@classmethod @classmethod
@ -128,7 +138,10 @@ class PromptStep:
prompt_file=data.get("prompt_file"), prompt_file=data.get("prompt_file"),
profile=data.get("profile"), profile=data.get("profile"),
name=data.get("name"), name=data.get("name"),
strip_fences=data.get("strip_fences", False) strip_fences=data.get("strip_fences", False),
output_schema=data.get("output_schema"),
max_retries=data.get("max_retries", 1),
plain_text=data.get("plain_text", False)
) )

View File

@ -184,7 +184,7 @@ class TestRunCommand:
tool = Tool( tool = Tool(
name="summarize", name="summarize",
steps=[ steps=[
PromptStep(prompt="Summarize: {input}", provider="mock", output_var="summary") PromptStep(prompt="Summarize: {input}", provider="mock", output_var="summary", plain_text=True)
], ],
output="{summary}" output="{summary}"
) )
@ -223,7 +223,7 @@ class TestTestCommand:
tool = Tool( tool = Tool(
name="test-me", name="test-me",
steps=[ steps=[
PromptStep(prompt="Test: {input}", provider="claude", output_var="result") PromptStep(prompt="Test: {input}", provider="claude", output_var="result", plain_text=True)
], ],
output="{result}" output="{result}"
) )

View File

@ -108,6 +108,82 @@ Line 3: {var1} again"""
assert result == "Enabled: True" assert result == "Enabled: True"
class TestNestedFieldAccess:
"""Tests for nested field access in variable substitution."""
def test_nested_dict_access(self):
"""Access nested dict field via dot notation."""
result = substitute_variables(
"Answer: {result.output}",
{"result": {"output": "Hello", "reasoning": "User said hi"}}
)
assert result == "Answer: Hello"
def test_nested_json_string_access(self):
"""Access field in JSON string via dot notation."""
result = substitute_variables(
"Answer: {result.output}",
{"result": '{"output": "Hello", "reasoning": "User said hi"}'}
)
assert result == "Answer: Hello"
def test_nested_deep_access(self):
"""Access deeply nested fields."""
result = substitute_variables(
"Value: {data.nested.deep}",
{"data": {"nested": {"deep": "found"}}}
)
assert result == "Value: found"
def test_nested_missing_field_unchanged(self):
"""Missing nested field leaves placeholder unchanged."""
result = substitute_variables(
"Value: {result.missing}",
{"result": {"output": "Hello"}}
)
assert result == "Value: {result.missing}"
def test_nested_on_non_dict_unchanged(self):
"""Nested access on non-dict value leaves placeholder unchanged."""
result = substitute_variables(
"Value: {result.field}",
{"result": "just a string"}
)
assert result == "Value: {result.field}"
def test_nested_invalid_json_unchanged(self):
"""Invalid JSON string leaves placeholder unchanged."""
result = substitute_variables(
"Value: {result.field}",
{"result": "not valid json {"}
)
assert result == "Value: {result.field}"
def test_nested_array_in_json(self):
"""Nested array values should be JSON-serialized."""
result = substitute_variables(
"Items: {result.items}",
{"result": '{"items": [1, 2, 3]}'}
)
assert result == "Items: [1, 2, 3]"
def test_nested_object_in_json(self):
"""Nested object values should be JSON-serialized."""
result = substitute_variables(
"Data: {result.data}",
{"result": '{"data": {"key": "value"}}'}
)
assert result == 'Data: {"key": "value"}'
def test_settings_access_unchanged(self):
"""Settings access should use existing special handling."""
result = substitute_variables(
"Backend: {settings.backend}",
{"settings": {"backend": "piper"}}
)
assert result == "Backend: piper"
class TestExecutePromptStep: class TestExecutePromptStep:
"""Tests for prompt step execution.""" """Tests for prompt step execution."""
@ -118,10 +194,12 @@ class TestExecutePromptStep:
success=True success=True
) )
# Use plain_text=True to test legacy behavior (no schema enforcement)
step = PromptStep( step = PromptStep(
prompt="Summarize: {input}", prompt="Summarize: {input}",
provider="claude", provider="claude",
output_var="summary" output_var="summary",
plain_text=True
) )
variables = {"input": "Some text to summarize"} variables = {"input": "Some text to summarize"}
@ -142,7 +220,7 @@ class TestExecutePromptStep:
error="Provider error" error="Provider error"
) )
step = PromptStep(prompt="Test", provider="claude", output_var="out") step = PromptStep(prompt="Test", provider="claude", output_var="out", plain_text=True)
output, success = execute_prompt_step(step, {"input": ""}) output, success = execute_prompt_step(step, {"input": ""})
assert success is False assert success is False
@ -152,7 +230,7 @@ class TestExecutePromptStep:
def test_mock_provider_used(self, mock_mock): def test_mock_provider_used(self, mock_mock):
mock_mock.return_value = ProviderResult(text="mock response", success=True) mock_mock.return_value = ProviderResult(text="mock response", success=True)
step = PromptStep(prompt="Test", provider="mock", output_var="out") step = PromptStep(prompt="Test", provider="mock", output_var="out", plain_text=True)
output, success = execute_prompt_step(step, {"input": ""}) output, success = execute_prompt_step(step, {"input": ""})
assert success is True assert success is True
@ -162,13 +240,180 @@ class TestExecutePromptStep:
def test_provider_override(self, mock_call): def test_provider_override(self, mock_call):
mock_call.return_value = ProviderResult(text="response", success=True) mock_call.return_value = ProviderResult(text="response", success=True)
step = PromptStep(prompt="Test", provider="claude", output_var="out") step = PromptStep(prompt="Test", provider="claude", output_var="out", plain_text=True)
execute_prompt_step(step, {"input": ""}, provider_override="gpt4") execute_prompt_step(step, {"input": ""}, provider_override="gpt4")
# Should use override, not step's provider # Should use override, not step's provider
assert mock_call.call_args[0][0] == "gpt4" assert mock_call.call_args[0][0] == "gpt4"
class TestStructuredOutput:
"""Tests for structured output enforcement."""
@patch('cmdforge.runner.call_provider')
def test_structured_output_valid_json(self, mock_call):
"""Valid JSON matching default schema should succeed."""
mock_call.return_value = ProviderResult(
text='{"output": "Hello, world!", "reasoning": "Greeting requested"}',
success=True
)
step = PromptStep(
prompt="Say hello",
provider="claude",
output_var="result"
# plain_text defaults to False - structured output enabled
)
output, success = execute_prompt_step(step, {"input": ""})
assert success is True
# Output should be normalized JSON
import json
parsed = json.loads(output)
assert parsed["output"] == "Hello, world!"
assert parsed["reasoning"] == "Greeting requested"
@patch('cmdforge.runner.call_provider')
def test_structured_output_strips_markdown_fences(self, mock_call):
"""Markdown code fences should be stripped before parsing."""
mock_call.return_value = ProviderResult(
text='```json\n{"output": "Hello"}\n```',
success=True
)
step = PromptStep(
prompt="Say hello",
provider="claude",
output_var="result"
)
output, success = execute_prompt_step(step, {"input": ""})
assert success is True
import json
parsed = json.loads(output)
assert parsed["output"] == "Hello"
@patch('cmdforge.runner.call_provider')
def test_structured_output_retry_on_invalid_json(self, mock_call):
"""Invalid JSON should trigger retry with error feedback."""
# First call returns invalid JSON, second returns valid
mock_call.side_effect = [
ProviderResult(text="Not JSON at all", success=True),
ProviderResult(text='{"output": "Fixed!"}', success=True),
]
step = PromptStep(
prompt="Test",
provider="claude",
output_var="result",
max_retries=1
)
output, success = execute_prompt_step(step, {"input": ""})
assert success is True
assert mock_call.call_count == 2
# Second call should include error feedback
second_call_prompt = mock_call.call_args_list[1][0][1]
assert "failed validation" in second_call_prompt.lower()
@patch('cmdforge.runner.call_provider')
def test_structured_output_fails_after_retries(self, mock_call):
"""Should fail after exhausting retries."""
mock_call.return_value = ProviderResult(
text="Still not JSON",
success=True
)
step = PromptStep(
prompt="Test",
provider="claude",
output_var="result",
max_retries=1 # 1 retry = 2 total attempts
)
output, success = execute_prompt_step(step, {"input": ""})
assert success is False
assert mock_call.call_count == 2
@patch('cmdforge.runner.call_provider')
def test_structured_output_custom_schema(self, mock_call):
"""Custom schema should be validated."""
mock_call.return_value = ProviderResult(
text='{"score": 0.8, "reasoning": "Good match"}',
success=True
)
custom_schema = {
"type": "object",
"properties": {
"score": {"type": "number", "minimum": 0, "maximum": 1},
"reasoning": {"type": "string"}
},
"required": ["score"]
}
step = PromptStep(
prompt="Score this",
provider="claude",
output_var="result",
output_schema=custom_schema
)
output, success = execute_prompt_step(step, {"input": ""})
assert success is True
import json
parsed = json.loads(output)
assert parsed["score"] == 0.8
@patch('cmdforge.runner.call_provider')
def test_structured_output_schema_violation_triggers_retry(self, mock_call):
"""Schema violation should trigger retry."""
# First call missing required 'output' field
mock_call.side_effect = [
ProviderResult(text='{"reasoning": "thinking..."}', success=True),
ProviderResult(text='{"output": "Fixed!", "reasoning": "done"}', success=True),
]
step = PromptStep(
prompt="Test",
provider="claude",
output_var="result",
max_retries=1
)
output, success = execute_prompt_step(step, {"input": ""})
assert success is True
assert mock_call.call_count == 2
@patch('cmdforge.runner.call_provider')
def test_plain_text_bypasses_validation(self, mock_call):
"""plain_text=True should skip all JSON validation."""
mock_call.return_value = ProviderResult(
text="Just plain text, no JSON here!",
success=True
)
step = PromptStep(
prompt="Write prose",
provider="claude",
output_var="result",
plain_text=True
)
output, success = execute_prompt_step(step, {"input": ""})
assert success is True
assert output == "Just plain text, no JSON here!"
# Should only call provider once (no retries for plain text)
assert mock_call.call_count == 1
class TestExecuteCodeStep: class TestExecuteCodeStep:
"""Tests for code step execution.""" """Tests for code step execution."""
@ -387,7 +632,8 @@ class TestRunTool:
PromptStep( PromptStep(
prompt="Summarize: {input}", prompt="Summarize: {input}",
provider="claude", provider="claude",
output_var="summary" output_var="summary",
plain_text=True # Use plain text mode for legacy behavior
) )
], ],
output="{summary}" output="{summary}"
@ -423,7 +669,7 @@ class TestRunTool:
name="multi-step", name="multi-step",
steps=[ steps=[
CodeStep(code="preprocessed = input.strip().upper()", output_var="preprocessed"), CodeStep(code="preprocessed = input.strip().upper()", output_var="preprocessed"),
PromptStep(prompt="Process: {preprocessed}", provider="claude", output_var="response"), PromptStep(prompt="Process: {preprocessed}", provider="claude", output_var="response", plain_text=True),
CodeStep(code="final = f'Result: {response}'", output_var="final") CodeStep(code="final = f'Result: {response}'", output_var="final")
], ],
output="{final}" output="{final}"
@ -439,7 +685,7 @@ class TestRunTool:
tool = Tool( tool = Tool(
name="test", name="test",
steps=[ steps=[
PromptStep(prompt="Test", provider="claude", output_var="response") PromptStep(prompt="Test", provider="claude", output_var="response", plain_text=True)
], ],
output="{response}" output="{response}"
) )
@ -458,7 +704,7 @@ class TestRunTool:
tool = Tool( tool = Tool(
name="test", name="test",
steps=[ steps=[
PromptStep(prompt="Test", provider="claude", output_var="response") PromptStep(prompt="Test", provider="claude", output_var="response", plain_text=True)
], ],
output="{response}" output="{response}"
) )
@ -475,7 +721,7 @@ class TestRunTool:
tool = Tool( tool = Tool(
name="test", name="test",
steps=[ steps=[
PromptStep(prompt="Test", provider="claude", output_var="response") PromptStep(prompt="Test", provider="claude", output_var="response", plain_text=True)
], ],
output="{response}" output="{response}"
) )