feature/m6-estimation-actor-yaml #975
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
|
||||
provider-level structured-output enforcement is planned in a future follow-up.
|
||||
|
||||
---
|
||||
|
||||
## LLM Actors with Tools
|
||||
|
||||
### Strategist with File Access
|
||||
|
||||
@@ -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.
|
||||
|
brent.edwards
commented
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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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(
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
@@ -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,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:
|
||||
|
||||
Reference in New Issue
Block a user
P1 — Misleading claim: "defines
response_formatwith anEstimationReportJSON schema so outputs are consistently machine-readable" —response_formatis 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.