diff --git a/src/activity_core/rules/executor.py b/src/activity_core/rules/executor.py index 7c6894c..a9ecf0a 100644 --- a/src/activity_core/rules/executor.py +++ b/src/activity_core/rules/executor.py @@ -180,7 +180,10 @@ def _llm_run_config(instr: Any) -> dict[str, Any]: value = getattr(instr, field, None) if value is not None: config[field] = value - model_params = getattr(instr, "model_params", None) + model_params = dict(getattr(instr, "model_params", None) or {}) + schema = _load_output_schema(getattr(instr, "output_schema", "")) + if schema is not None: + model_params.setdefault("json_schema", schema) if model_params: config["model_params"] = model_params return config @@ -263,16 +266,33 @@ def _validate_against_schema(data: Any, schema_path: str) -> str | None: if not schema_path: return None + try: + schema = _load_output_schema(schema_path) + except (OSError, json.JSONDecodeError, TypeError) as exc: + return f"could not read output schema: {exc}" + if schema is None: + return None + + return _validate_schema_node(data, schema, "$") + + +def _load_output_schema(schema_path: str) -> dict[str, Any] | None: + """Load a JSON schema file when present. + + Missing schema files are intentionally tolerated for backward + compatibility with existing tests and definitions. + """ + if not schema_path: + return None + path = Path(schema_path) if not path.exists(): return None - try: - schema = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - return f"could not read output schema: {exc}" - - return _validate_schema_node(data, schema, "$") + schema = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(schema, dict): + raise TypeError("output schema must be a JSON object") + return schema def _validate_schema_node(data: Any, schema: dict[str, Any], path: str) -> str | None: diff --git a/tests/rules/test_executor.py b/tests/rules/test_executor.py index 558a68a..aa73b7d 100644 --- a/tests/rules/test_executor.py +++ b/tests/rules/test_executor.py @@ -275,6 +275,46 @@ def test_execute_instruction_forwards_llm_connect_run_config(): ] +def test_execute_instruction_forwards_output_schema_to_llm_connect(tmp_path, monkeypatch): + schema_dir = tmp_path / "schemas" + schema_dir.mkdir() + schema_path = schema_dir / "daily-triage-report.json" + schema = { + "type": "object", + "required": ["summary", "recommendations"], + "properties": { + "summary": {"type": "string"}, + "recommendations": {"type": "array", "items": {"type": "object"}}, + }, + } + schema_path.write_text(json.dumps(schema), encoding="utf-8") + monkeypatch.chdir(tmp_path) + + llm = _CountingLLM([ + json.dumps({"summary": "Review open work.", "recommendations": []}) + ]) + instr = _instr( + id="daily-triage-report", + prompt="Report.", + trusted_fields=[], + output_schema="schemas/daily-triage-report.json", + model_params={"reasoning_effort": "medium"}, + ) + + result = execute_instruction_with_audit(instr, _Event(), {}, llm) + + assert result.output_validated is True + assert llm.calls == [ + { + "model_name": "claude-sonnet-4-6", + "model_params": { + "reasoning_effort": "medium", + "json_schema": schema, + }, + } + ] + + def test_execute_instruction_with_audit_accepts_report_payload(): report_data = { "summary": "State Hub has loose ends.", diff --git a/tests/test_instruction_evaluation.py b/tests/test_instruction_evaluation.py index 41c3e9e..a32cf52 100644 --- a/tests/test_instruction_evaluation.py +++ b/tests/test_instruction_evaluation.py @@ -157,5 +157,18 @@ async def test_evaluate_instructions_forwards_llm_connect_depth_config(monkeypat "temperature": 0.2, "max_tokens": 1200, "max_depth": 2, - "model_params": {"reasoning_effort": "medium"}, + "model_params": { + "reasoning_effort": "medium", + "json_schema": { + "type": "object", + "required": ["summary", "recommendations"], + "properties": { + "summary": {"type": "string"}, + "recommendations": { + "type": "array", + "items": {"type": "object"}, + }, + }, + }, + }, }