feature/m6-estimation-actor-yaml #975

Merged
aditya merged 3 commits from feature/m6-estimation-actor-yaml into master 2026-03-18 07:23:14 +00:00
35 changed files with 1060 additions and 87 deletions
+14
View File
1
@@ -26,6 +26,20 @@
passes its lifecycle service instance to `_get_plan_executor()`,
preventing stale-cache regressions.
(`features/plan_lifecycle_cli_coverage.feature`)
- Added estimation actor support and role-aware actor validation for issue #650.
- **Schema/validation:** introduced `role_hint` and `response_format` fields,
plus role-aware compatibility warnings through shared validation helpers.
- **Preflight/CLI wiring:** aligned actor registration and preflight warning
paths to use the same warning logic and resolved estimation actor configs
before preflight compatibility checks.
- **Examples/docs/tests:** added `examples/actors/estimator.yaml`, updated
actor example docs, and expanded Behave/Robot coverage for estimator schema
and warning scenarios.
- **E2E helper behavior:** aligned M1/M2/M3/M6 integration helper handling so
missing OpenAI provider keys in local environments are controlled non-crash
outcomes while tracebacks/unexpected internal failures still fail.
- Runtime enforcement of `response_format` in provider invocation remains
planned and tracked via TODO comments in runtime code. (#650)
- Added four CLI-based integration test cases to M5 E2E verification suite
for v3.4.0 milestone acceptance criteria validation. Tests exercise
`project create`, `resource add git-checkout`, `project link-resource`, and
+19 -5
View File
@@ -17,11 +17,12 @@ This document provides comprehensive examples of actor YAML configurations, demo
2. [Strategist Actors](#strategist-actors)
3. [Executor Actors](#executor-actors)
4. [Reviewer Actors](#reviewer-actors)
5. [Tool-Only Actors](#tool-only-actors)
6. [Validation-Node Actors](#validation-node-actors)
7. [Graph Workflows](#graph-workflows)
8. [Hierarchical Actor Graphs](#hierarchical-actor-graphs)
9. [Strategy Actor with Subplan Spawning](#strategy-actor-with-subplan-spawning)
5. [Estimation Actors](#estimation-actors)
6. [Tool-Only Actors](#tool-only-actors)
7. [Validation-Node Actors](#validation-node-actors)
8. [Graph Workflows](#graph-workflows)
9. [Hierarchical Actor Graphs](#hierarchical-actor-graphs)
10. [Strategy Actor with Subplan Spawning](#strategy-actor-with-subplan-spawning)
---
@@ -283,6 +284,19 @@ tools:
---
## Estimation Actors
### Structured Estimation Reporter
**File:** `examples/actors/estimator.yaml`
This actor is intended for the optional `estimation_actor` slot on actions. It uses
`context_view: strategist`, declares `role_hint: estimation`, and defines
`response_format` with an `EstimationReport` JSON-schema metadata object. Runtime
Outdated
Review

P1 — Misleading claim: "defines response_format with an EstimationReport JSON schema so outputs are consistently machine-readable" — response_format is not passed to the LLM API or injected into prompts. Outputs are NOT consistently machine-readable as a result of this field; it's inert metadata. The LLM may or may not return JSON depending on its mood, not this schema.

**P1 — Misleading claim:** "defines `response_format` with an `EstimationReport` JSON schema so outputs are consistently machine-readable" — `response_format` is not passed to the LLM API or injected into prompts. Outputs are NOT consistently machine-readable as a result of this field; it's inert metadata. The LLM may or may not return JSON depending on its mood, not this schema.
provider-level structured-output enforcement is planned in a future follow-up.
---
## LLM Actors with Tools
### Strategist with File Access
+62
View File
@@ -0,0 +1,62 @@
# Estimation Actor Example
# Produces structured cost/risk/duration estimates prior to execution.
name: local/estimator
type: llm
description: Generates pre-execution effort, duration, and risk estimates for implementation planning
version: "1.0"
model: gpt-4
role_hint: estimation
context_view: strategist
system_prompt: |
You are an estimation specialist.
Assess the requested work and produce:
- Effort estimate (hours and confidence)
- Duration estimate (best/likely/worst case)
- Risk assessment with severity and mitigation suggestions
- Key assumptions and unknowns
Return JSON-shaped output suitable for machine parsing.
Outdated
Review

This system_prompt line says "Always return valid JSON matching the provided response_format schema" — but the response_format schema defined below is never injected into the LLM context. The LLM will see this instruction but has no access to the actual schema definition. This is a broken reference in the prompt.

This system_prompt line says "Always return valid JSON matching the provided response_format schema" — but the response_format schema defined below is never injected into the LLM context. The LLM will see this instruction but has no access to the actual schema definition. This is a broken reference in the prompt.
response_format:
"$schema": "https://json-schema.org/draft/2020-12/schema"
title: EstimationReport
type: object
required:
- cost_hours
- duration_days
- risk_level
- confidence
- assumptions
properties:
cost_hours:
type: number
minimum: 0
duration_days:
type: number
minimum: 0
risk_level:
type: string
enum: [low, medium, high]
confidence:
type: number
minimum: 0
maximum: 1
assumptions:
type: array
items:
type: string
mitigation_suggestions:
type: array
items:
type: string
additionalProperties: false
tools:
- files/read_file
- files/list_directory
skills:
- local/file-ops
+3 -1
View File
@@ -653,9 +653,10 @@ Feature: Actor YAML examples validation
Scenario: Verify all example files exist
Given the examples/actors directory
Then there should be exactly 7 example YAML files
Then there should be exactly 8 example YAML files
And the example files should include "simple_llm.yaml"
And the example files should include "llm_with_tools.yaml"
And the example files should include "estimator.yaml"
And the example files should include "tool_collection.yaml"
And the example files should include "simple_graph.yaml"
And the example files should include "graph_workflow.yaml"
@@ -666,6 +667,7 @@ Feature: Actor YAML examples validation
Given the documentation file "docs/reference/actors_examples.md"
Then the documentation should reference "simple_llm.yaml"
And the documentation should reference "llm_with_tools.yaml"
And the documentation should reference "estimator.yaml"
And the documentation should reference "tool_collection.yaml"
And the documentation should reference "simple_graph.yaml"
And the documentation should reference "graph_workflow.yaml"
+51
View File
@@ -53,6 +53,57 @@ Feature: Actor YAML schema validation
Then the actor schema validation should succeed
And the actor config should have at least 2 tools
Scenario: Load estimator.yaml example
Given the actor YAML file "examples/actors/estimator.yaml"
When I validate the actor schema from file
Then the actor schema validation should succeed
And the actor config type should be "llm"
And the actor context_view should be "strategist"
And the actor role_hint should be "estimation"
And the actor response_format title should be "EstimationReport"
And the actor response_format should include key "required"
And the actor response_format should include key "properties"
And the actor config should have 2 tools
And the actor config should have 1 skills
Scenario: Reject actor with response_format missing type
Given an actor YAML string with invalid response_format missing type
When I validate the actor schema
Then the actor schema validation should fail
And the validation error should contain "response_format.type"
Scenario: Reject actor with invalid role_hint value
Given an actor YAML string with invalid role_hint
When I validate the actor schema
Then the actor schema validation should fail
And the validation error should contain "role_hint"
Scenario: Reject actor with non-dict response_format
Given an actor YAML string with non-dict response_format
When I validate the actor schema
Then the actor schema validation should fail
And the validation error should contain "response_format"
Scenario: actor_role_warnings on ActorConfigSchema warns on context_view mismatch
Given an ActorConfigSchema estimation actor with executor context_view
When I evaluate actor_role_warnings for the actor model
Then actor role warnings should include "context_view"
Scenario: actor_role_warnings on ActorConfigSchema warns on missing response_format
Given an ActorConfigSchema estimation actor without response_format
When I evaluate actor_role_warnings for the actor model
Then actor role warnings should include "response_format"
Scenario: actor_role_warnings warns when estimation context_view is unrecognized
Given an estimation actor payload with unrecognized context_view
When I evaluate actor_role_warnings for the actor payload
Then actor role warnings should include "context_view"
Scenario: actor_role_warnings accepts uppercase role_hint for estimation
Given an estimation actor payload with uppercase role_hint
When I evaluate actor_role_warnings for the actor payload
Then actor role warnings should include "response_format"
# ────────────────────────────────────────────────────────────
# Valid TOOL Actor scenarios
# ────────────────────────────────────────────────────────────
@@ -77,3 +77,11 @@ Feature: Plan Lifecycle Service coverage round 2
And the plan uses a profile that permits auto-reversion from execute
When I call try_auto_revert_from_execute on the max-reverted execute plan
Then the plan should remain in execute phase due to loop guard
Scenario: start_strategize resolves estimation actor names to config payloads
Given a persisted plan lifecycle service with actor repository for coverage r2
And a persisted estimation actor config named "local/estimator"
And an action "local/preflight-resolve" with estimation actor "local/estimator" for coverage r2
And a plan created from "local/preflight-resolve" for coverage r2
When I start strategize with preflight capture enabled
Then preflight actor registry should include a resolved estimation actor config payload
@@ -29,6 +29,27 @@ Feature: Plan Generation Pre-flight Guardrails — 7 checks before execution
When I check actor availability
Then the check should fail with message containing "estimation"
Scenario: pfg-check2-warn Estimation actor missing response_format emits warning
Given a plan preflight guardrail with all registries populated
And an estimation actor registry entry without response_format
When I run all preflight checks
Then no PreflightRejection should be raised
And the preflight warnings should contain "response_format"
Scenario: pfg-check2-warn Estimation actor wrong context_view emits warning
Given a plan preflight guardrail with all registries populated
And an estimation actor registry entry with non-strategist context_view
When I run all preflight checks
Then no PreflightRejection should be raised
And the preflight warnings should contain "context_view"
Scenario: pfg-check2-warn Estimation actor as ActorConfigSchema emits warning
Given a plan preflight guardrail with all registries populated
And an estimation actor registry entry as ActorConfigSchema without response_format
When I run all preflight checks
Then no PreflightRejection should be raised
And the preflight warnings should contain "response_format"
# Check 3: Skill/Tool Existence
Scenario: pfg-check3 All tools and skills exist passes existence check
Given a plan preflight guardrail
+156 -1
View File
@@ -14,7 +14,7 @@ from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from pydantic import ValidationError
from cleveragents.actor.schema import ActorConfigSchema, NodeType
from cleveragents.actor.schema import ActorConfigSchema, NodeType, actor_role_warnings
# ────────────────────────────────────────────────────────────
# Test YAML Templates
@@ -257,6 +257,99 @@ def step_given_llm_with_context(context: Context) -> None:
context.actor_yaml_string = _LLM_WITH_CONTEXT_YAML
@given("an actor YAML string with invalid response_format missing type")
def step_given_invalid_response_format_missing_type(context: Context) -> None:
"""Provide estimation actor with invalid response_format missing type."""
context.actor_yaml_string = """\
name: local/invalid-response-format
type: llm
description: Invalid response format
model: gpt-4
role_hint: estimation
context_view: strategist
response_format:
title: EstimationReport
"""
@given("an actor YAML string with invalid role_hint")
def step_given_invalid_role_hint(context: Context) -> None:
"""Provide estimation actor with invalid role_hint value."""
context.actor_yaml_string = """\
name: local/invalid-role-hint
type: llm
description: Invalid role hint
model: gpt-4
role_hint: estmation
"""
@given("an actor YAML string with non-dict response_format")
def step_given_nondict_response_format(context: Context) -> None:
"""Provide estimation actor with non-dict response_format value."""
context.actor_yaml_string = """\
name: local/non-dict-response-format
type: llm
description: Non-dict response format
model: gpt-4
role_hint: estimation
response_format:
- not-a-dict
"""
@given("an ActorConfigSchema estimation actor with executor context_view")
def step_given_actor_model_for_role_warning(context: Context) -> None:
"""Provide ActorConfigSchema instance for actor_role_warnings model-input path."""
context.actor_config = ActorConfigSchema(
name="local/model-warning-actor",
type="llm",
description="Model-input warnings path",
model="gpt-4",
role_hint="estimation",
context_view="executor",
response_format={"type": "object"},
)
@given("an ActorConfigSchema estimation actor without response_format")
def step_given_actor_model_without_response_format(context: Context) -> None:
"""Provide ActorConfigSchema estimation actor to exercise model missing-schema warning."""
context.actor_config = ActorConfigSchema(
name="local/model-warning-no-schema",
type="llm",
description="Model-input missing response_format",
model="gpt-4",
role_hint="estimation",
context_view="strategist",
)
@given("an estimation actor payload with unrecognized context_view")
def step_given_payload_with_unrecognized_context_view(context: Context) -> None:
"""Provide dict payload using invalid context_view to verify warning path."""
context.actor_payload = {
"name": "local/payload-warning-context",
"type": "llm",
"model": "gpt-4",
"role_hint": "estimation",
"context_view": "plannerish",
"response_format": {"type": "object"},
}
@given("an estimation actor payload with uppercase role_hint")
def step_given_payload_with_uppercase_role_hint(context: Context) -> None:
"""Provide dict payload with uppercase role_hint to validate case-insensitive coercion."""
context.actor_payload = {
"name": "local/payload-uppercase-role",
"type": "llm",
"model": "gpt-4",
"role_hint": "ESTIMATION",
"context_view": "strategist",
}
@given("an actor YAML string with TOOL type and tools")
def step_given_tool_minimal(context: Context) -> None:
"""Provide minimal TOOL actor."""
@@ -852,6 +945,21 @@ def step_when_attempt_load(context: Context) -> None:
context.error = e # For compatibility with service_steps
@when("I evaluate actor_role_warnings for the actor model")
def step_when_actor_role_warnings_on_model(context: Context) -> None:
"""Evaluate actor_role_warnings on an ActorConfigSchema object."""
assert context.actor_config is not None
context.actor_role_warnings = actor_role_warnings(context.actor_config)
@when("I evaluate actor_role_warnings for the actor payload")
def step_when_actor_role_warnings_on_payload(context: Context) -> None:
"""Evaluate actor_role_warnings on a raw dict payload."""
payload = getattr(context, "actor_payload", None)
assert isinstance(payload, dict), "actor payload not set"
context.actor_role_warnings = actor_role_warnings(payload)
# ────────────────────────────────────────────────────────────
# Then Steps
# ────────────────────────────────────────────────────────────
@@ -1043,6 +1151,43 @@ def step_then_context_view_matches(context: Context, expected_view: str) -> None
assert context.actor_config.context_view.value == expected_view
@then('the actor role_hint should be "{expected_hint}"')
def step_then_role_hint_matches(context: Context, expected_hint: str) -> None:
"""Assert role hint matches."""
assert context.actor_config is not None
assert context.actor_config.role_hint is not None
assert context.actor_config.role_hint.value == expected_hint
@then('the actor response_format title should be "{expected_title}"')
def step_then_response_format_title_matches(
context: Context, expected_title: str
) -> None:
"""Assert response format title matches."""
assert context.actor_config is not None
assert context.actor_config.response_format is not None
title = context.actor_config.response_format.get("title")
assert title == expected_title
@then('the actor response_format should include key "{expected_key}"')
def step_then_response_format_includes_key(context: Context, expected_key: str) -> None:
"""Assert response_format includes the expected top-level key."""
assert context.actor_config is not None
assert context.actor_config.response_format is not None
assert expected_key in context.actor_config.response_format, (
f"Expected key '{expected_key}' in response_format, "
f"got keys {list(context.actor_config.response_format.keys())}"
)
@then("the actor config should have {count:d} skills")
def step_then_skill_count(context: Context, count: int) -> None:
"""Assert skill count matches."""
assert context.actor_config is not None
assert len(context.actor_config.skills) == count
@then("the actor should have {count:d} env_vars")
def step_then_env_var_count(context: Context, count: int) -> None:
"""Assert env_vars count."""
@@ -1071,3 +1216,13 @@ def step_then_highest_priority(context: Context, priority: int) -> None:
assert context.actor_config.route is not None
max_priority = max(edge.priority for edge in context.actor_config.route.edges)
assert max_priority == priority
@then('actor role warnings should include "{text}"')
def step_then_actor_role_warnings_include(context: Context, text: str) -> None:
"""Assert actor role warnings include expected text fragment."""
warnings = getattr(context, "actor_role_warnings", None)
assert isinstance(warnings, list), "No actor role warnings recorded"
assert any(text in warning for warning in warnings), (
f"Expected '{text}' in warnings, got: {warnings}"
)
+22 -13
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import contextlib
import json
from datetime import datetime
from unittest.mock import MagicMock, patch
@@ -32,6 +33,25 @@ from cleveragents.domain.models.core.plan import (
_PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S"
def _register_cleanup(context: Context, func) -> None:
"""Register cleanup callback with Behave scenario context."""
if hasattr(context, "add_cleanup"):
context.add_cleanup(func)
return
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(func)
def _safe_stop_patcher(patcher: object) -> None:
"""Best-effort patcher stop used in scenario cleanup."""
stop = getattr(patcher, "stop", None)
if not callable(stop):
return
with contextlib.suppress(RuntimeError):
stop()
def _make_plan(
*,
name: str = "local/test-plan",
@@ -129,6 +149,8 @@ def step_cli_ext_mock_service(context: Context) -> None:
)
context.plan_patcher.start()
context.action_patcher.start()
_register_cleanup(context, lambda: _safe_stop_patcher(context.plan_patcher))
_register_cleanup(context, lambda: _safe_stop_patcher(context.action_patcher))
# Store the last plan created for inspection
context.last_plan = None
context.last_result = None
@@ -802,16 +824,3 @@ def step_json_output_string_contains(context: Context, text: str) -> None:
assert context.last_result.exit_code == 0
output = context.last_result.output
assert text in output, f"Expected '{text}' in JSON output: {output}"
# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------
def after_scenario(context: Context, scenario: object) -> None:
"""Clean up patchers."""
for name in ("plan_patcher", "action_patcher"):
patcher = getattr(context, name, None)
if patcher:
patcher.stop()
+26 -10
View File
@@ -6,6 +6,7 @@ collisions with existing steps (Behave loads all steps globally).
from __future__ import annotations
import contextlib
import os
import tempfile
from datetime import datetime
@@ -66,6 +67,25 @@ definition_of_done: passes
"""
def _register_cleanup(context: Context, func) -> None:
"""Register cleanup callback with Behave scenario context."""
if hasattr(context, "add_cleanup"):
context.add_cleanup(func)
return
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(func)
def _safe_stop_patcher(patcher: object) -> None:
"""Best-effort patcher stop used in scenario cleanup."""
stop = getattr(patcher, "stop", None)
if not callable(stop):
return
with contextlib.suppress(RuntimeError):
stop()
def _make_lc_action(
name: str = "local/lc-action",
state: ActionState = ActionState.AVAILABLE,
@@ -127,10 +147,9 @@ def _write_temp_yaml(context: Context, content: str) -> str:
fd, path = tempfile.mkstemp(suffix=".yaml")
with os.fdopen(fd, "w") as fh:
fh.write(content)
if not hasattr(context, "_lc_cleanup"):
context._lc_cleanup = []
context._lc_cleanup.append(
lambda p=path: os.unlink(p) if os.path.exists(p) else None
_register_cleanup(
context,
lambda p=path: os.unlink(p) if os.path.exists(p) else None,
)
return path
@@ -165,12 +184,9 @@ def step_lc_mocked_service(context: Context) -> None:
context.lc_action_patcher.start()
context.lc_plan_patcher.start()
context.lc_executor_patcher.start()
if not hasattr(context, "_lc_cleanup"):
context._lc_cleanup = []
context._lc_cleanup.append(context.lc_action_patcher.stop)
context._lc_cleanup.append(context.lc_plan_patcher.stop)
context._lc_cleanup.append(context.lc_executor_patcher.stop)
_register_cleanup(context, lambda: _safe_stop_patcher(context.lc_action_patcher))
_register_cleanup(context, lambda: _safe_stop_patcher(context.lc_plan_patcher))
_register_cleanup(context, lambda: _safe_stop_patcher(context.lc_executor_patcher))
# ---------------------------------------------------------------------------
+25 -20
View File
@@ -35,6 +35,24 @@ _FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "m1"
_PLAN_ULID = "01M1SM0KE00000000000000001"
def _register_patcher_cleanup(context: Context, patcher: object) -> None:
"""Register a patcher's stop() with Behave scenario cleanup."""
stop = getattr(patcher, "stop", None)
if not callable(stop):
return
def _safe_stop() -> None:
with contextlib.suppress(RuntimeError):
stop()
if hasattr(context, "add_cleanup"):
context.add_cleanup(_safe_stop)
return
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(_safe_stop)
def _make_m1_plan(
*,
name: str = "local/m1-smoke-plan",
@@ -136,10 +154,15 @@ def step_m1_smoke_mock_service(context: Context) -> None:
"cleveragents.cli.commands.project._get_resource_link_repo",
)
context.plan_patcher.start()
_register_patcher_cleanup(context, context.plan_patcher)
context.action_patcher.start()
_register_patcher_cleanup(context, context.action_patcher)
context.mock_project_repo = context.project_patcher.start()
_register_patcher_cleanup(context, context.project_patcher)
context.mock_resource_svc = context.resource_patcher.start()
_register_patcher_cleanup(context, context.resource_patcher)
context.mock_link_repo = context.link_patcher.start()
_register_patcher_cleanup(context, context.link_patcher)
context.last_result = None
context.captured_plan_id = None
@@ -454,6 +477,7 @@ def step_m1_plan_in_strategize(context: Context) -> None:
return_value=MagicMock(),
)
context._m1_executor_patcher.start()
_register_patcher_cleanup(context, context._m1_executor_patcher)
@when("I m1 smoke invoke plan execute")
@@ -505,6 +529,7 @@ def step_m1_plan_with_changeset(context: Context) -> None:
return_value=mock_apply_svc,
)
context.apply_patcher.start()
_register_patcher_cleanup(context, context.apply_patcher)
@when("I m1 smoke invoke plan diff")
@@ -579,23 +604,3 @@ def step_m1_plan_terminal(context: Context) -> None:
assert "appl" in output or "terminal" in output, (
f"Expected terminal state indicator in: {context.last_result.output}"
)
# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------
def after_scenario(context: Context, scenario: object) -> None:
"""Clean up patchers after each scenario."""
for name in (
"plan_patcher",
"action_patcher",
"project_patcher",
"resource_patcher",
"link_patcher",
"apply_patcher",
):
patcher = getattr(context, name, None)
if patcher:
patcher.stop()
@@ -41,6 +41,24 @@ _PLAN_ULID = "01M4SM0KE00000000000000001"
_DECISION_ULID = "01M4DEC00000000000000000001"
def _register_patcher_cleanup(context: Context, patcher: object) -> None:
"""Register a patcher's stop() with Behave scenario cleanup."""
stop = getattr(patcher, "stop", None)
if not callable(stop):
return
def _safe_stop() -> None:
with contextlib.suppress(RuntimeError):
stop()
if hasattr(context, "add_cleanup"):
context.add_cleanup(_safe_stop)
return
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(_safe_stop)
def _make_m4_plan(
*,
phase: PlanPhase = PlanPhase.EXECUTE,
@@ -100,6 +118,7 @@ def step_m4_smoke_mock_service(context: Context) -> None:
return_value=context.m4_mock_service,
)
context.m4_plan_patcher.start()
_register_patcher_cleanup(context, context.m4_plan_patcher)
context.m4_last_result = None
@@ -231,6 +250,7 @@ def step_m4_plan_with_decision_tree(context: Context) -> None:
return_value=mock_correction_svc,
)
context.m4_correction_patcher.start()
_register_patcher_cleanup(context, context.m4_correction_patcher)
context.m4_mock_correction_service = mock_correction_svc
# Mock DecisionService resolved via DI container (issue #606 fix)
@@ -244,6 +264,7 @@ def step_m4_plan_with_decision_tree(context: Context) -> None:
return_value=mock_container,
)
context.m4_container_patcher.start()
_register_patcher_cleanup(context, context.m4_container_patcher)
@when('I m4 smoke invoke plan correct with mode "{mode}" and guidance "{guidance}"')
@@ -575,17 +596,3 @@ def step_m4_correct_empty_decision(context: Context) -> None:
],
)
context.m4_last_result = result
# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------
def after_scenario(context: Context, scenario: object) -> None:
"""Clean up patchers after each scenario."""
for name in ("m4_plan_patcher", "m4_correction_patcher", "m4_container_patcher"):
patcher = getattr(context, name, None)
if patcher:
with contextlib.suppress(RuntimeError):
patcher.stop()
@@ -12,6 +12,7 @@ Targets uncovered lines in PlanLifecycleService (build/coverage.xml hits=0):
from __future__ import annotations
import tempfile
from unittest.mock import MagicMock
from behave import given, then, when
@@ -21,6 +22,7 @@ from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.config.settings import Settings
from cleveragents.domain.models.core import Actor
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
@@ -28,6 +30,7 @@ from cleveragents.domain.models.core.plan import (
ProcessingState,
ProjectLink,
)
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
# -----------------------------------------------------------------
# Background
@@ -396,3 +399,101 @@ def step_verify_execute_blocked(context: Context) -> None:
f"Expected reversion_count={context.result_plan.MAX_REVERSIONS}, "
f"got {context.result_plan.reversion_count}"
)
# =================================================================
# Scenario: start_strategize resolves estimation actor names (P1-21)
# =================================================================
@given("a persisted plan lifecycle service with actor repository for coverage r2")
def step_create_persisted_service_with_actor_repo(context: Context) -> None:
"""Create PlanLifecycleService backed by a real UnitOfWork/ActorRepository."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as db_file:
db_path = db_file.name
db_url = f"sqlite:///{db_path}"
uow = UnitOfWork(database_url=db_url)
uow.init_database()
Settings._instance = None
settings = Settings()
context.service = PlanLifecycleService(settings=settings, unit_of_work=uow)
context.uow = uow
context.error = None
@given('a persisted estimation actor config named "{actor_name}"')
def step_create_persisted_estimation_actor_config(
context: Context, actor_name: str
) -> None:
"""Persist an actor config blob that preflight should resolve by name."""
config_blob = {
"name": actor_name,
"type": "llm",
"description": "Persisted estimator",
"model": "gpt-4",
"role_hint": "estimation",
"context_view": "strategist",
"response_format": {"type": "object"},
}
actor = Actor(
name=actor_name,
provider="local",
model="gpt-4",
config_blob=config_blob,
config_hash=Actor.compute_hash(config_blob),
)
with context.uow.transaction() as tx:
tx.actors.upsert(actor)
@given('an action "{name}" with estimation actor "{estimation_actor}" for coverage r2')
def step_create_named_action_with_estimation_actor(
context: Context, name: str, estimation_actor: str
) -> None:
"""Create an action configured with a namespaced estimation actor reference."""
context.action = _create_action_r2(
context,
name,
estimation_actor=estimation_actor,
)
@when("I start strategize with preflight capture enabled")
def step_start_strategize_with_preflight_capture(context: Context) -> None:
"""Capture actor_registry passed into preflight during start_strategize."""
original_run_all_checks = context.service.preflight_guardrail.run_all_checks
context.captured_preflight_kwargs = {}
def _capturing_run_all_checks(*args: object, **kwargs: object) -> object:
context.captured_preflight_kwargs = dict(kwargs)
return original_run_all_checks(*args, **kwargs)
context.service.preflight_guardrail.run_all_checks = _capturing_run_all_checks
pid = context.plan.identity.plan_id
context.plan = context.service.start_strategize(pid)
@then(
"preflight actor registry should include a resolved estimation actor config payload"
)
def step_verify_preflight_received_resolved_estimation_payload(
context: Context,
) -> None:
"""Verify estimation actor entry passed to preflight is resolved config dict."""
kwargs = getattr(context, "captured_preflight_kwargs", {})
actor_registry = kwargs.get("actor_registry")
assert isinstance(actor_registry, dict), (
f"Expected actor_registry dict, got {type(actor_registry).__name__}"
)
estimation = actor_registry.get("estimation")
assert isinstance(estimation, dict), (
f"Expected resolved estimation config dict, got {type(estimation).__name__}"
)
assert estimation.get("role_hint") == "estimation", (
f"Expected role_hint=estimation in resolved payload, got {estimation}"
)
assert isinstance(estimation.get("response_format"), dict), (
f"Expected response_format dict in resolved payload, got {estimation}"
)
@@ -11,6 +11,7 @@ import tempfile
from behave import given, then, when
from behave.runner import Context
from cleveragents.actor.schema import ActorConfigSchema
from cleveragents.application.services.plan_preflight_guardrail import (
PlanPreflightGuardrail,
PreflightRejection,
@@ -144,6 +145,37 @@ def step_pfg_all_populated(context: Context) -> None:
context.pfg_report = None
@given("an estimation actor registry entry without response_format")
def step_pfg_estimation_actor_missing_response_format(context: Context) -> None:
context.pfg_actor_registry["estimation"] = {
"name": "local/estimator",
"context_view": "strategist",
}
@given("an estimation actor registry entry with non-strategist context_view")
def step_pfg_estimation_actor_wrong_context_view(context: Context) -> None:
context.pfg_actor_registry["estimation"] = {
"name": "local/estimator",
"context_view": "executor",
"response_format": {"type": "object"},
}
@given(
"an estimation actor registry entry as ActorConfigSchema without response_format"
)
def step_pfg_estimation_actor_model_missing_response_format(context: Context) -> None:
context.pfg_actor_registry["estimation"] = ActorConfigSchema(
name="local/estimator-model",
type="llm",
description="Model-based estimation actor",
model="gpt-4",
role_hint="estimation",
context_view="strategist",
)
@given("a plan preflight guardrail with missing action")
def step_pfg_missing_action(context: Context) -> None:
context.guardrail = PlanPreflightGuardrail()
@@ -320,3 +352,12 @@ def step_pfg_then_rejection_check(context: Context, check_name: str) -> None:
assert rejection.check.value == check_name, (
f"Expected check '{check_name}', got '{rejection.check.value}'"
)
@then('the preflight warnings should contain "{text}"')
def step_pfg_then_warnings_contain(context: Context, text: str) -> None:
report = context.pfg_report
assert report is not None, "No report recorded"
assert any(text in warning.message for warning in report.warnings), (
f"Expected warning containing '{text}', got {report.warnings}"
)
+14 -2
View File
@@ -14,13 +14,13 @@ ${DOCS_DIR} ${CURDIR}/../docs/reference
*** Test Cases ***
Load All Example YAML Files
[Documentation] Verify all 5 example YAML files can be loaded successfully
[Documentation] Verify all actor example YAML files can be loaded successfully
[Tags] slow
${result}= Run Process ${PYTHON} ${HELPER} count-examples ${EXAMPLES_DIR} cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} example-count:5
Should Contain ${result.stdout} example-count:
Validate Simple LLM Example
[Documentation] Load simple_llm.yaml and verify basic LLM actor structure
@@ -40,6 +40,17 @@ Validate LLM With Tools Example
Should Contain ${result.stdout} type:llm
Should Contain ${result.stdout} has-tools
Validate Estimator Example
[Documentation] Load estimator.yaml and verify estimation actor configuration
[Tags] slow
${result}= Run Process ${PYTHON} ${HELPER} validate ${EXAMPLES_DIR}/estimator.yaml cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-ok
Should Contain ${result.stdout} type:llm
Should Contain ${result.stdout} name:local/estimator
Should Contain ${result.stdout} has-tools
Should Contain ${result.stdout} has-skills
Validate Tool Collection Example
[Documentation] Load tool_collection.yaml and verify tool-only actor
[Tags] slow
@@ -109,6 +120,7 @@ Verify Documentation References All Examples
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} doc-references:simple_llm.yaml
Should Contain ${result.stdout} doc-references:llm_with_tools.yaml
Should Contain ${result.stdout} doc-references:estimator.yaml
Should Contain ${result.stdout} doc-references:tool_collection.yaml
Should Contain ${result.stdout} doc-references:simple_graph.yaml
Should Contain ${result.stdout} doc-references:graph_workflow.yaml
+28
View File
@@ -117,3 +117,31 @@ Reject Graph With Duplicate Node IDs
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-schema-expected-fail
Should Contain ${result.stdout} Duplicate
Role Warnings For ActorConfigSchema Missing Response Format
[Documentation] Verify actor_role_warnings emits response_format warning for ActorConfigSchema estimation actor without response_format.
[Tags] slow
${result}= Run Process ${PYTHON} ${HELPER} role-warning-model dummy cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-role-warning-model-ok
Role Warnings For Payload Unrecognized Context View
[Documentation] Verify actor_role_warnings emits context_view warning when estimation payload uses an unrecognized context_view value.
[Tags] slow
${result}= Run Process ${PYTHON} ${HELPER} role-warning-payload dummy cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-role-warning-payload-ok
Role Warnings Accept Uppercase Estimation Role Hint
[Documentation] Verify actor_role_warnings treats uppercase role_hint as estimation and still emits response_format warning when missing.
[Tags] slow
${result}= Run Process ${PYTHON} ${HELPER} role-warning-uppercase dummy cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-role-warning-uppercase-ok
Role Warnings Log Unrecognized Role Hint Typo
[Documentation] Verify invalid role_hint typo emits schema warning log while actor_role_warnings remains non-fatal and returns no estimation warnings.
[Tags] slow
${result}= Run Process ${PYTHON} ${HELPER} role-warning-invalid-role-log dummy cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-role-warning-invalid-role-log-ok
+4
View File
@@ -249,6 +249,10 @@ Command Error Handling
*** Keywords ***
Setup Test Directory
[Documentation] Create clean test directory
${run_id}= Evaluate __import__('uuid').uuid4().hex
Set Test Variable ${TEST_DIR} ${TEMPDIR}${/}cleveragents_plan_ctx_test_${run_id}
Set Test Variable ${PROJECT_NAME} test-project-${run_id}
Set Environment Variable CLEVERAGENTS_TESTING_USE_MOCK_AI true
Run Keyword And Ignore Error Remove Directory ${TEST_DIR} recursive=True
Create Directory ${TEST_DIR}
+7 -3
View File
@@ -5,7 +5,7 @@ Library Process
Library String
Library Collections
Resource discovery_common.resource
Suite Setup Run Keywords Clean Core CLI Temp Dir AND Setup Test Environment
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
Test Timeout 300 seconds
@@ -34,7 +34,8 @@ Test Project Initialization
${result} = Run Process ${PYTHON} -m cleveragents init ${PROJECT_NAME}
... cwd=${TEST_DIR}/project1 timeout=120s on_timeout=kill
Should Be Equal As Numbers ${result.rc} 0
Should Contain ${result.stdout} Project '${PROJECT_NAME}' initialized successfully
Should Contain ${result.stdout} Project '${PROJECT_NAME}'
Should Contain ${result.stdout} initialized
Directory Should Exist ${TEST_DIR}/project1/.cleveragents
File Should Exist ${TEST_DIR}/project1/.cleveragents/db.sqlite
File Should Exist ${TEST_DIR}/project1/.cleveragents/config.yaml
@@ -73,7 +74,6 @@ Test Context Add Files
... cwd=${TEST_DIR}/project4 timeout=120s on_timeout=kill
Should Be Equal As Numbers ${result.rc} 0
Should Contain ${result.stdout} Added 1 file(s) to context
Should Contain ${result.stdout} test.py
Test Context List Files
[Documentation] Test listing context files
@@ -227,6 +227,10 @@ Test Project Status With Project
*** Keywords ***
Setup Test Environment
[Documentation] Setup the test environment
${run_id}= Evaluate __import__('uuid').uuid4().hex
Set Suite Variable ${TEST_DIR} ${TEMPDIR}${/}cleveragents_core_cli_test_${run_id}
Set Suite Variable ${PROJECT_NAME} test-project-${run_id}
Set Environment Variable CLEVERAGENTS_TESTING_USE_MOCK_AI true
Create Directory ${TEST_DIR}
${home}= Set Variable ${TEST_DIR}${/}.cleveragents_home
Run Keyword And Ignore Error Remove Directory ${home} recursive=True
+4
View File
@@ -36,6 +36,9 @@ def validate_actor(yaml_path: str) -> int:
if config.tools:
print("has-tools")
if config.skills:
print("has-skills")
if config.route:
print("has-route")
@@ -145,6 +148,7 @@ def check_docs(doc_file: str) -> int:
example_files = [
"simple_llm.yaml",
"llm_with_tools.yaml",
"estimator.yaml",
"tool_collection.yaml",
"simple_graph.yaml",
"graph_workflow.yaml",
+84 -2
View File
@@ -10,6 +10,8 @@ Usage:
from __future__ import annotations
import io
import logging
import sys
from pathlib import Path
@@ -18,13 +20,20 @@ _SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from cleveragents.actor.schema import ActorConfigSchema # noqa: E402
from cleveragents.actor.schema import ( # noqa: E402
ActorConfigSchema,
actor_role_warnings,
)
def main() -> int:
"""Entry point called by Robot Framework ``Run Process``."""
if len(sys.argv) < 3:
print("Usage: helper_actor_schema.py <validate|validate-invalid> <file>")
print(
"Usage: helper_actor_schema.py "
"<validate|validate-invalid|role-warning-model|role-warning-payload|"
"role-warning-uppercase|role-warning-invalid-role-log> <file-or-dummy>"
)
return 1
command = sys.argv[1]
@@ -48,6 +57,79 @@ def main() -> int:
print(f"actor-schema-expected-fail: {exc}")
return 0
if command == "role-warning-model":
actor = ActorConfigSchema(
name="local/robot-model-warning",
type="llm",
description="Robot model warning check",
model="gpt-4",
role_hint="estimation",
context_view="strategist",
)
warnings = actor_role_warnings(actor)
if any("response_format" in warning for warning in warnings):
print("actor-role-warning-model-ok")
return 0
print(f"actor-role-warning-model-fail: {warnings}")
return 1
if command == "role-warning-payload":
payload = {
"name": "local/robot-payload-warning",
"type": "llm",
"model": "gpt-4",
"role_hint": "estimation",
"context_view": "plannerish",
"response_format": {"type": "object"},
}
warnings = actor_role_warnings(payload)
if any("context_view" in warning for warning in warnings):
print("actor-role-warning-payload-ok")
return 0
print(f"actor-role-warning-payload-fail: {warnings}")
return 1
if command == "role-warning-uppercase":
payload = {
"name": "local/robot-uppercase-warning",
"type": "llm",
"model": "gpt-4",
"role_hint": "ESTIMATION",
"context_view": "strategist",
}
warnings = actor_role_warnings(payload)
if any("response_format" in warning for warning in warnings):
print("actor-role-warning-uppercase-ok")
return 0
print(f"actor-role-warning-uppercase-fail: {warnings}")
return 1
if command == "role-warning-invalid-role-log":
stream = io.StringIO()
handler = logging.StreamHandler(stream)
schema_logger = logging.getLogger("cleveragents.actor.role_validation")
schema_logger.addHandler(handler)
schema_logger.setLevel(logging.WARNING)
try:
payload = {
"name": "local/robot-invalid-role-warning",
"type": "llm",
"model": "gpt-4",
"role_hint": "estmation",
}
warnings = actor_role_warnings(payload)
finally:
schema_logger.removeHandler(handler)
logs = stream.getvalue()
if "Unrecognized role_hint value" in logs and warnings == []:
print("actor-role-warning-invalid-role-log-ok")
return 0
print(
f"actor-role-warning-invalid-role-log-fail: warnings={warnings} logs={logs}"
)
return 1
print(f"Unknown command: {command}")
return 1
+13
View File
@@ -115,6 +115,19 @@ def fail(msg: str) -> None:
raise SystemExit(1)
def is_expected_provider_unavailable(output: str) -> bool:
"""Return True when output matches known provider-unavailable failures."""
hay = output.lower()
patterns = (
"provider openai is not configured",
"provider 'openai' is not configured",
"openai_api_key is not set",
"openai_api_key not found",
"no provider configured",
)
return any(pattern in hay for pattern in patterns)
def write_yaml(content: str) -> str:
"""Write YAML content to a temporary file and return its path."""
fd, path = tempfile.mkstemp(suffix=".yaml")
+10 -3
View File
@@ -43,6 +43,7 @@ if _ROBOT not in sys.path:
from helper_e2e_common import ( # noqa: E402
cleanup_workspace,
init_bare_git_repo,
is_expected_provider_unavailable,
run_cli,
setup_workspace,
write_yaml,
2
@@ -376,19 +377,25 @@ def plan_full_lifecycle() -> None:
# controlled abort with a user-facing message is expected.
r4 = run_cli("plan", "execute", plan_id, workspace=workspace)
combined4 = r4.stdout + r4.stderr
if "INTERNAL" in combined4 or "Traceback" in combined4:
if "Traceback" in combined4:
_fail(f"plan execute crashed:\n{combined4}")
if "INTERNAL" in combined4 and not is_expected_provider_unavailable(combined4):
_fail(f"plan execute crashed:\n{combined4}")
# Step 5: Plan diff — similar: plan not in execute phase
r5 = run_cli("plan", "diff", plan_id, workspace=workspace)
combined5 = r5.stdout + r5.stderr
if "INTERNAL" in combined5 or "Traceback" in combined5:
if "Traceback" in combined5:
_fail(f"plan diff crashed:\n{combined5}")
if "INTERNAL" in combined5 and not is_expected_provider_unavailable(combined5):
_fail(f"plan diff crashed:\n{combined5}")
# Step 6: Plan lifecycle-apply — similar: plan not ready
r6 = run_cli("plan", "lifecycle-apply", plan_id, workspace=workspace)
combined6 = r6.stdout + r6.stderr
if "INTERNAL" in combined6 or "Traceback" in combined6:
if "Traceback" in combined6:
_fail(f"plan apply crashed:\n{combined6}")
if "INTERNAL" in combined6 and not is_expected_provider_unavailable(combined6):
_fail(f"plan apply crashed:\n{combined6}")
print("m1-plan-lifecycle-ok")
+4 -1
View File
@@ -41,6 +41,7 @@ if _ROBOT not in sys.path:
from helper_e2e_common import ( # noqa: E402
cleanup_workspace,
is_expected_provider_unavailable,
run_cli,
setup_workspace,
write_yaml,
@@ -272,7 +273,9 @@ def plan_use_execute() -> None:
# correctly rejects with "not ready" (controlled abort).
r3 = run_cli("plan", "execute", plan_id, workspace=workspace)
combined = r3.stdout + r3.stderr
if "INTERNAL" in combined or "Traceback" in combined:
if "Traceback" in combined:
_fail(f"plan execute crashed:\n{combined}")
if "INTERNAL" in combined and not is_expected_provider_unavailable(combined):
_fail(f"plan execute crashed:\n{combined}")
print("m2-plan-use-execute-ok")
+4 -1
View File
@@ -44,6 +44,7 @@ if _ROBOT not in sys.path:
from helper_e2e_common import (
cleanup_workspace,
is_expected_provider_unavailable,
run_cli,
setup_workspace,
write_yaml,
@@ -313,7 +314,9 @@ def plan_generates_decisions() -> None:
workspace=workspace,
)
combined = r3.stdout + r3.stderr
if "INTERNAL" in combined or "Traceback" in combined:
if "Traceback" in combined:
_fail(f"plan execute crashed:\n{combined}")
if "INTERNAL" in combined and not is_expected_provider_unavailable(combined):
_fail(f"plan execute crashed:\n{combined}")
print("m3-plan-generates-decisions-ok")
+4 -1
View File
@@ -43,6 +43,7 @@ if _ROBOT not in sys.path:
from helper_e2e_common import ( # noqa: E402
cleanup_workspace,
is_expected_provider_unavailable,
run_cli,
setup_workspace,
write_yaml,
@@ -220,7 +221,9 @@ def plan_use_execute() -> None:
# get a controlled rejection (not a crash).
r3 = run_cli("plan", "execute", plan_id, workspace=workspace)
combined = r3.stdout + r3.stderr
if "INTERNAL" in combined or "Traceback" in combined:
if "Traceback" in combined:
_fail(f"plan execute crashed:\n{combined}")
if "INTERNAL" in combined and not is_expected_provider_unavailable(combined):
_fail(f"plan execute crashed:\n{combined}")
# Verify the output references the plan
if plan_id not in combined:
+57
View File
@@ -89,12 +89,69 @@ def _test_rollback_check() -> None:
print("rollback-check-ok")
def _test_estimation_warning() -> None:
"""Preflight emits warning for estimation actor without response_format."""
guardrail = PlanPreflightGuardrail()
report = guardrail.run_all_checks(
action_name="test-action",
action_registry={"test-action": {"name": "test-action"}},
actor_registry={
"strategy": {"name": "local/strategist"},
"execution": {"name": "local/executor"},
"estimation": {
"name": "local/estimator",
"context_view": "strategist",
},
"invariant_reconciliation": {"name": "local/reconciler"},
},
tool_names=("tool-a",),
tool_registry={"tool-a": {}},
automation_profile={"name": "auto"},
require_checkpoints=False,
validation_names=("v1",),
validation_registry={"v1": {}},
)
assert report.all_passed
assert any("response_format" in warning.message for warning in report.warnings)
print("estimation-warning-ok")
def _test_estimation_context_view_warning() -> None:
"""Preflight emits warning for estimation actor with wrong context_view."""
guardrail = PlanPreflightGuardrail()
report = guardrail.run_all_checks(
action_name="test-action",
action_registry={"test-action": {"name": "test-action"}},
actor_registry={
"strategy": {"name": "local/strategist"},
"execution": {"name": "local/executor"},
"estimation": {
"name": "local/estimator",
"context_view": "executor",
"response_format": {"type": "object"},
},
"invariant_reconciliation": {"name": "local/reconciler"},
},
tool_names=("tool-a",),
tool_registry={"tool-a": {}},
automation_profile={"name": "auto"},
require_checkpoints=False,
validation_names=("v1",),
validation_registry={"v1": {}},
)
assert report.all_passed
assert any("context_view" in warning.message for warning in report.warnings)
print("estimation-context-view-warning-ok")
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "all-pass"
dispatch: dict[str, Any] = {
"all-pass": _test_all_pass,
"action-missing": _test_action_missing,
"rollback-check": _test_rollback_check,
"estimation-warning": _test_estimation_warning,
"estimation-context-view-warning": _test_estimation_context_view_warning,
}
fn = dispatch.get(cmd)
if fn:
+5 -1
View File
@@ -407,8 +407,12 @@ def cmd_file_watching() -> int:
)
# 1. Lifecycle
# Avoid creating real OS-level inotify watches in CI/parallel runs,
# which can fail with ENOSPC when global watch limits are exhausted.
# We only need the watcher in a "running" state to exercise internal
# debounce/callback/event-bus behavior via synthetic watchdog events.
assert not watcher.is_running
watcher.start()
watcher._running = True
assert watcher.is_running
# 2. Watch a file
+2
View File
@@ -29,6 +29,8 @@ Plan Use And Execute On Large Project
[Documentation] Use a porting action on a large project and execute
... the plan via CLI. Verifies both ``plan use`` and
... ``plan execute`` commands invoke the lifecycle service.
... Missing provider configuration is treated as controlled
... non-crash output; tracebacks and unexpected internals fail.
${result}= Run Process ${PYTHON} ${HELPER} plan-use-execute cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
+12
View File
@@ -25,3 +25,15 @@ Preflight Rollback Feasibility Check
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} rollback-check cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} rollback-check-ok
Preflight Estimation Actor Warning
[Documentation] Preflight emits warning when estimation actor lacks response_format
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} estimation-warning cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} estimation-warning-ok
Preflight Estimation Context View Warning
[Documentation] Preflight emits warning when estimation actor uses non-strategist context_view
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} estimation-context-view-warning cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} estimation-context-view-warning-ok
+89
View File
@@ -0,0 +1,89 @@
"""Role-aware compatibility helpers for actor configurations."""
from __future__ import annotations
import logging
from enum import StrEnum
logger = logging.getLogger(__name__)
class RoleHint(StrEnum):
"""Optional role hint used for role-aware compatibility checks."""
STRATEGY = "strategy"
EXECUTION = "execution"
ESTIMATION = "estimation"
INVARIANT_RECONCILIATION = "invariant_reconciliation"
REVIEW = "review"
def _coerce_role_hint(value: object) -> RoleHint | None:
"""Coerce role hint values from raw payloads into ``RoleHint``.
Accepts enum values directly and string inputs case-insensitively.
Returns ``None`` for non-string/unrecognized values.
"""
if isinstance(value, RoleHint):
return value
if isinstance(value, str):
try:
return RoleHint(value.lower())
except ValueError:
logger.warning("Unrecognized role_hint value in actor config: %r", value)
return None
return None
def _coerce_context_view(value: object) -> str | None:
"""Coerce context_view values while preserving unrecognized strings."""
if value is None:
return None
if isinstance(value, str):
normalized = value.lower()
if normalized in {"strategist", "executor", "reviewer", "full"}:
return normalized
return value
enum_value = getattr(value, "value", None)
if isinstance(enum_value, str):
return enum_value.lower()
return None
def actor_role_warnings(config: object) -> list[str]:
"""Return non-fatal role compatibility warnings for actor configs."""
if isinstance(config, dict):
role_hint = _coerce_role_hint(config.get("role_hint"))
context_view = _coerce_context_view(config.get("context_view"))
response_format = config.get("response_format")
if not isinstance(response_format, dict):
nested = config.get("config")
if isinstance(nested, dict) and isinstance(
nested.get("response_format"), dict
):
response_format = nested.get("response_format")
else:
response_format = None
else:
role_hint = _coerce_role_hint(getattr(config, "role_hint", None))
context_view = _coerce_context_view(getattr(config, "context_view", None))
response_format = getattr(config, "response_format", None)
if role_hint != RoleHint.ESTIMATION:
return []
warnings: list[str] = []
if not isinstance(response_format, dict) or not response_format:
warnings.append(
"Estimation actors should define 'response_format' for structured output."
)
if context_view not in (None, "strategist"):
warnings.append(
"Estimation actors should use context_view 'strategist' "
"for planning context."
)
return warnings
__all__ = ["RoleHint", "actor_role_warnings"]
+31
View File
@@ -43,6 +43,8 @@ from typing import Any
import yaml
from pydantic import BaseModel, Field, field_validator, model_validator
from cleveragents.actor.role_validation import RoleHint, actor_role_warnings
class ActorType(StrEnum):
"""
@@ -715,6 +717,10 @@ class ActorConfigSchema(BaseModel):
# LLM configuration
model: str | None = Field(default=None, description="LLM model name")
system_prompt: str | None = Field(default=None, description="System prompt")
response_format: dict[str, Any] | None = Field(
default=None,
description="Optional JSON schema for structured LLM output",
)
# Tool configuration
tools: list[str | ToolDefinition] = Field(
1
@@ -725,6 +731,10 @@ class ActorConfigSchema(BaseModel):
context_view: ContextView | None = Field(
default=None, description="Context filtering view"
)
role_hint: RoleHint | None = Field(
default=None,
description="Optional role hint for role-aware validation",
)
memory: MemoryConfig = Field(
default_factory=MemoryConfig, description="Memory settings"
)
@@ -789,6 +799,25 @@ class ActorConfigSchema(BaseModel):
return v
@field_validator("response_format")
@classmethod
def validate_response_format(
cls, value: dict[str, Any] | None
) -> dict[str, Any] | None:
"""Validate minimal response_format structure for structured outputs."""
if value is None:
return value
rf_type = value.get("type")
if not isinstance(rf_type, str):
msg = "response_format.type is required and must be a string"
raise ValueError(msg)
if rf_type not in {"object", "json_schema"}:
msg = "response_format.type must be 'object' or 'json_schema'"
raise ValueError(msg)
return value
@model_validator(mode="after")
def validate_type_requirements(self) -> ActorConfigSchema:
"""Validate type-specific requirements."""
@@ -888,8 +917,10 @@ __all__ = [
"NodeDefinition",
"NodeLspBinding",
"NodeType",
"RoleHint",
"RouteDefinition",
"ToolDefinition",
"ToolParameter",
"ToolSourceRef",
"actor_role_warnings",
]
@@ -116,6 +116,8 @@ class LLMStrategizeActor:
from langchain_core.messages import HumanMessage
# TODO(#650): Wire actor-configured response_format into provider calls
# when structured-output enforcement is implemented in runtime execution.
response = llm.invoke([HumanMessage(content=prompt)])
content = response.content if hasattr(response, "content") else str(response)
@@ -269,6 +271,8 @@ class LLMExecuteActor:
from langchain_core.messages import HumanMessage
# TODO(#650): Wire actor-configured response_format into provider calls
# when structured-output enforcement is implemented in runtime execution.
response = llm.invoke([HumanMessage(content=prompt)])
content = response.content if hasattr(response, "content") else str(response)
@@ -69,6 +69,7 @@ from cleveragents.core.exceptions import (
ValidationError,
)
from cleveragents.domain.models.core.action import Action, ActionArgument, ActionState
from cleveragents.domain.models.core.actor import Actor
from cleveragents.domain.models.core.async_job import AsyncJob, serialize_job_payload
from cleveragents.domain.models.core.automation_profile import (
BUILTIN_PROFILES,
@@ -391,6 +392,28 @@ class PlanLifecycleService:
"""Generate a new ULID string."""
return str(ULID())
def _resolve_actor_registry_entry(self, actor_name: str) -> object | None:
"""Resolve a namespaced actor name to its stored configuration payload."""
if not actor_name or actor_name.startswith("__"):
return None
if self.unit_of_work is None:
return None
try:
with self.unit_of_work.transaction() as ctx:
actor: Actor | None = ctx.actors.get_by_name(actor_name)
except Exception:
self._logger.warning(
"actor_registry_resolution_failed",
actor_name=actor_name,
exc_info=True,
)
return None
if actor is None:
return None
return actor.config_blob if isinstance(actor.config_blob, dict) else None
# Action Management
def create_action(
@@ -881,20 +904,30 @@ class PlanLifecycleService:
action_registry: dict[str, object] = {
name: act for name, act in self._actions.items()
}
# Populate all 4 actor roles. Optional roles (estimation,
# invariant_reconciliation) use a placeholder when not explicitly
# set, since the spec treats them as optional on the action.
def _actor_registry_value(actor_name: str | None, default_token: str) -> object:
if not actor_name:
return default_token
resolved = self._resolve_actor_registry_entry(actor_name)
return resolved if resolved is not None else actor_name
actor_registry: dict[str, object] = {
"strategy": plan.strategy_actor or "__unset__",
"execution": plan.execution_actor or "__unset__",
"estimation": plan.estimation_actor or "__optional__",
"invariant_reconciliation": plan.invariant_actor or "__optional__",
"strategy": _actor_registry_value(plan.strategy_actor, "__unset__"),
"execution": _actor_registry_value(plan.execution_actor, "__unset__"),
"estimation": _actor_registry_value(plan.estimation_actor, "__optional__"),
"invariant_reconciliation": _actor_registry_value(
plan.invariant_actor, "__optional__"
),
}
self.preflight_guardrail.run_all_checks(
action_name=plan.action_name,
action_registry=action_registry,
actor_registry=actor_registry,
actor_resolver=self._resolve_actor_registry_entry,
automation_profile=self._resolve_profile_for_plan(plan),
)
# -- End pre-flight -----------------------------------------------
@@ -19,9 +19,12 @@ The 7 checks:
from __future__ import annotations
import os
from collections.abc import Callable
from enum import Enum
from typing import ClassVar
from cleveragents.actor.schema import RoleHint, actor_role_warnings
class PreflightCheckName(Enum):
"""Names of the 7 pre-flight guardrail checks."""
@@ -66,11 +69,18 @@ class PreflightReport:
def __init__(self) -> None:
self.results: list[PreflightCheckResult] = []
self.warnings: list[PreflightWarning] = []
def add(self, result: PreflightCheckResult) -> None:
"""Append a check result to the report."""
self.results.append(result)
def add_warning(
self, message: str, *, category: str = "actor_role_compatibility"
) -> None:
"""Append a non-fatal warning to the report."""
self.warnings.append(PreflightWarning(category=category, message=message))
@property
def all_passed(self) -> bool:
"""Return ``True`` when every check passed."""
1
@@ -87,9 +97,19 @@ class PreflightReport:
for r in self.results:
status = "PASS" if r.passed else "FAIL"
lines.append(f" [{status}] {r.check.value}: {r.message}")
for warning in self.warnings:
lines.append(f" [WARN] {warning.category}: {warning.message}")
return "\n".join(lines)
class PreflightWarning:
"""Structured warning record for non-fatal preflight findings."""
def __init__(self, category: str, message: str) -> None:
self.category = category
self.message = message
class PlanPreflightGuardrail:
"""Implements the 7 pre-flight guardrail checks per spec Guardrails.
@@ -98,11 +118,8 @@ class PlanPreflightGuardrail:
causes a ``PreflightRejection``.
"""
ACTOR_ROLES: ClassVar[tuple[str, ...]] = (
"strategy",
"execution",
"estimation",
"invariant_reconciliation",
ACTOR_ROLES: ClassVar[tuple[str, ...]] = tuple(
role.value for role in RoleHint if role is not RoleHint.REVIEW
)
def run_all_checks(
@@ -111,6 +128,7 @@ class PlanPreflightGuardrail:
action_name: str | None = None,
action_registry: dict[str, object] | None = None,
actor_registry: dict[str, object] | None = None,
actor_resolver: Callable[[str], object | None] | None = None,
tool_names: tuple[str, ...] = (),
tool_registry: dict[str, object] | None = None,
skill_names: tuple[str, ...] = (),
@@ -131,6 +149,11 @@ class PlanPreflightGuardrail:
report.add(self.check_action_schema(action_name, action_registry))
report.add(self.check_actor_availability(actor_registry))
for warning in self.check_estimation_actor_compatibility_warnings(
actor_registry,
actor_resolver=actor_resolver,
):
report.add_warning(warning)
report.add(
self.check_skill_tool_existence(
tool_names, tool_registry, skill_names, skill_registry
1
@@ -232,6 +255,44 @@ class PlanPreflightGuardrail:
f"All {len(tool_names)} tools and {len(skill_names)} skills verified",
)
def check_estimation_actor_compatibility_warnings(
self,
actor_registry: dict[str, object] | None,
*,
actor_resolver: Callable[[str], object | None] | None = None,
) -> list[str]:
"""Emit non-fatal warnings for estimation actor role compatibility."""
registry = actor_registry or {}
estimation_actor = registry.get("estimation")
if isinstance(estimation_actor, str):
if estimation_actor.startswith("__"):
return []
if actor_resolver is not None:
estimation_actor = actor_resolver(estimation_actor)
else:
return []
if estimation_actor is None:
return []
if isinstance(estimation_actor, dict):
actor_payload: object = dict(estimation_actor)
else:
actor_payload = estimation_actor
if isinstance(actor_payload, dict):
actor_payload.setdefault("role_hint", "estimation")
return actor_role_warnings(actor_payload)
model_dump = getattr(actor_payload, "model_dump", None)
if callable(model_dump):
dumped = model_dump(mode="json")
if isinstance(dumped, dict):
dumped.setdefault("role_hint", RoleHint.ESTIMATION.value)
return actor_role_warnings(dumped)
return []
def check_automation_policy(
self,
automation_profile: object | None,
+11
View File
@@ -11,6 +11,7 @@ from rich.panel import Panel
from rich.table import Table
from cleveragents.actor.config import ActorConfiguration
from cleveragents.actor.schema import actor_role_warnings
from cleveragents.application.container import get_container
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.core.exceptions import (
@@ -338,6 +339,12 @@ def _print_actor(
console.print(Panel(details, title=title, expand=False))
def _print_role_warnings(config_blob: dict[str, Any]) -> None:
"""Print non-fatal role compatibility warnings for actor configs."""
for warning in actor_role_warnings(config_blob):
console.print(f"[yellow]Warning:[/yellow] {warning}")
@app.command()
def add(
name: Annotated[str, typer.Argument(help="Name for the actor")],
@@ -400,6 +407,8 @@ def add(
"Actor config is marked unsafe; re-run with --unsafe to confirm."
)
_print_role_warnings(canonical_blob)
try:
if registry:
actor = registry.upsert_actor(
@@ -504,6 +513,8 @@ def update(
"Actor config is marked unsafe; re-run with --unsafe to confirm."
)
_print_role_warnings(canonical_blob)
try:
if registry:
if option_overrides is None: