fix(auto_debug): return partial state updates from nodes per LangGraph contract
Closes #10496 Fixed four node functions in auto_debug.py that were violating LangGraph's node contract by mutating state in-place and returning full state objects. Now all four return dict[str, Any] with only the keys they update.
This commit is contained in:
+2
-18
@@ -5,8 +5,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
Changed `wf10_batch.robot` to be less likely to create files, and
|
||||
`plan_generation_graph.robot` to give more test answers.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Hardened the TDD bug-fix quality gate for issue #629: PR parsing now
|
||||
requires whole-word closing keywords (avoids false positives like
|
||||
"prefixes #12"), TDD bug tag discovery now uses exact token matching
|
||||
@@ -42,20 +40,6 @@ Changed `wf10_batch.robot` to be less likely to create files, and
|
||||
cleanup conflict with `cli_init_yes_flag_steps.py`); 2 new Behave
|
||||
scenarios covering multi-line PR description parsing and non-string
|
||||
`pr_diff` type guard.
|
||||
|
||||
- Added `TokenAuthMiddleware` and DI wiring to emit missing auth domain
|
||||
events (`AUTH_SUCCESS`, `AUTH_FAILURE`) during token checks, including
|
||||
spec-aligned audit details (`user_identity`, `attempted_identity`,
|
||||
`ip_address`, `token_prefix`, `failure_reason`) and best-effort publish
|
||||
behavior. Auth token prefixes now persist to audit logs without
|
||||
redaction-key collisions, and short tokens are masked (`***...`) to
|
||||
prevent full token disclosure. Added Behave coverage and Robot
|
||||
audit-pipeline integration tests for auth event persistence. (#714)
|
||||
|
||||
- Added TDD bug-capture E2E tests for bug #1028 — ACMS indexing pipeline not
|
||||
wired into CLI. Four Robot Framework E2E tests prove ContextTierService starts
|
||||
empty on every CLI invocation. Tests use ``@tdd_expected_fail`` until the bug
|
||||
fix is merged. (#1029)
|
||||
- Added Fix-then-Revalidate orchestration loop for required validations:
|
||||
bounded retry with configurable limits (0--100 per Safety Profile),
|
||||
strategy revision escalation via `auto_strategy_revision` float
|
||||
@@ -68,6 +52,7 @@ Changed `wf10_batch.robot` to be less likely to create files, and
|
||||
counter, spec-required `validation_summary` and
|
||||
`final_validation_results` fields on the result model, DI container
|
||||
|
||||
## [Unreleased]
|
||||
- **fix(tui): rename ActorSelectionOverlay._render to _refresh_display (issue #11039)** — `ActorSelectionOverlay._render()` shadows Textual's `Widget._render()` which must return a `Strip`. In textual >=1.0, layout calls `get_content_height()` `self._render()` gets `None` `AttributeError: 'NoneType' object has no attribute 'get_height'`. Renamed the method to `_refresh_display()` and updated all four internal call sites (`show()`, `move_up()`, `move_down()`, `set_search()`) to use the new name.
|
||||
|
||||
- **Structural Component Output Validation** (#8164): Replaces exact character matching with structural component checking for output validation. Implements three validators covering plan tree output, decision CLI dicts, and structured session snapshots. The `validate_plan_tree` function validates node dicts for required keys (`decision_id`, `type`, `sequence`, `question`, `children`), ULID format, correct types, and sibling ordering. The `validate_decision_dict` function validates decision CLI output against the `Decision.as_cli_dict()` schema with field presence, type, ULID pattern, confidence range [0..1], and boolean field checks. The `validate_structured_output` function validates the StructuredOutput envelope for `command`, `session_id` (ULID), status membership, `exit_code`, and elements integrity. A unified dispatcher (`validate_structured_component_output`) enables routing by target_type. BDD test coverage added in `features/structural_validation.feature`. [Epic #8137](https://git.cleverthis.com/cleveragents/cleveragents-core/issues/8137)
|
||||
@@ -201,8 +186,7 @@ Changed `wf10_batch.robot` to be less likely to create files, and
|
||||
were cleaned up in `features/actor_add_update_enforcement.feature` so the
|
||||
tests report correctly now that the underlying bug has been fixed.
|
||||
|
||||
- **Fixed `merge_invariants()` missing ACTION scope — 4-tier precedence restored** (#9126): Updated ``merge_invariants()`` and ``InvariantSet.merge()`` to accept a fourth parameter ``action_invariants`` alongside plan, project, and global tiers. The module docstring, ``InvariantScope`` docstring, and ``InvariantService.get_effective_invariants()`` now all reflect the correct precedence chain: ``plan > action > project > global``. Added ``action_name`` parameter to ``get_effective_invariants()`` so action-scoped invariants are collected and passed through the merge pipeline instead of silently dropped. All docstrings across both files were corrected from ``plan > project > global`` to ``plan > action > project > global``. Comprehensive Behave scenarios added covering four-tier merge precedence, action-before-project ordering, action override of project with same text, and effective invariant computation with all four scopes.
|
||||
|
||||
- **Resolved Behave AmbiguousStep collisions in step definitions** (#4186): Renamed
|
||||
step texts to avoid case-sensitive collisions between different step modules that
|
||||
prevented all Behave tests from loading. Renamed steps in
|
||||
`edge_case_plan_steps.py`, `plan_executor_coverage_boost_steps.py`,
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
Below are some of the specific details of various contributions.
|
||||
|
||||
* Jeffrey Phillips Freeman has acted as Lead Developer, daily contributor, and Project Owner.
|
||||
* Jeffrey Phillips Freeman has contributed the invariant merge precedence fix (#9126): restored the missing ACTION scope in ``merge_invariants()`` and ``InvariantSet.merge()``, corrected all module docstrings from ``plan > project > global`` to the spec-compliant ``plan > action > project > global``, and added comprehensive BDD test coverage for four-tier merge precedence.
|
||||
* Jeffrey Phillips Freeman has contributed an implementation for the invariant propagation fix (PR #10881 / issue #9131): added `_propagate_invariant_decisions()` to `SubplanService` to propagate all `invariant_enforced` decisions from parent plans to child plan decision trees during subplan spawn, satisfying the specification requirement for invariant propagation across hierarchical plan execution.
|
||||
* Brent E. Edwards has contributed quality assurance, test coverage, and CI pipeline improvements.
|
||||
* HAL 9000 has contributed automated implementation, bug fixes, and feature development as part of the CleverAgents automation pool.
|
||||
* HAL 9000 has contributed concurrency safety improvements, including thread-safe context tier management (issue #7547) for parallel plan execution.
|
||||
|
||||
@@ -74,7 +74,7 @@ class MergeSmallSuite:
|
||||
|
||||
def time_merge_small(self) -> None:
|
||||
"""Benchmark merge with ~13 invariants."""
|
||||
merge_invariants(self.plan, [], self.project, self.global_invs)
|
||||
merge_invariants(self.plan, self.project, self.global_invs)
|
||||
|
||||
|
||||
class MergeMediumSuite:
|
||||
@@ -88,7 +88,7 @@ class MergeMediumSuite:
|
||||
|
||||
def time_merge_medium(self) -> None:
|
||||
"""Benchmark merge with ~50 invariants."""
|
||||
merge_invariants(self.plan, [], self.project, self.global_invs)
|
||||
merge_invariants(self.plan, self.project, self.global_invs)
|
||||
|
||||
|
||||
class MergeLargeSuite:
|
||||
@@ -102,7 +102,7 @@ class MergeLargeSuite:
|
||||
|
||||
def time_merge_large(self) -> None:
|
||||
"""Benchmark merge with ~250 invariants."""
|
||||
merge_invariants(self.plan, [], self.project, self.global_invs)
|
||||
merge_invariants(self.plan, self.project, self.global_invs)
|
||||
|
||||
|
||||
class MergeDeduplicationSuite:
|
||||
@@ -137,7 +137,7 @@ class MergeDeduplicationSuite:
|
||||
|
||||
def time_merge_dedup(self) -> None:
|
||||
"""Benchmark merge with 60 invariants, all duplicates."""
|
||||
merge_invariants(self.plan, [], self.project, self.global_invs)
|
||||
merge_invariants(self.plan, self.project, self.global_invs)
|
||||
|
||||
|
||||
class InvariantSetMergeSuite:
|
||||
@@ -151,7 +151,7 @@ class InvariantSetMergeSuite:
|
||||
|
||||
def time_invariant_set_merge(self) -> None:
|
||||
"""Benchmark InvariantSet.merge()."""
|
||||
InvariantSet.merge(self.plan, [], self.project, self.global_invs)
|
||||
InvariantSet.merge(self.plan, self.project, self.global_invs)
|
||||
|
||||
|
||||
class ServiceEffectiveSuite:
|
||||
|
||||
@@ -418,7 +418,7 @@ Feature: Consolidated Domain Models
|
||||
Scenario: InvariantScope has four values
|
||||
Then InvariantScope should have values "global, project, action, plan"
|
||||
|
||||
# === Merge Precedence (plan > action > project > global) ===
|
||||
# === Merge Precedence (plan > project > global) ===
|
||||
|
||||
|
||||
Scenario: Merge with no duplicates preserves all invariants
|
||||
@@ -480,75 +480,6 @@ Feature: Consolidated Domain Models
|
||||
And the merged invariant at index 0 should have text "LOG ALL CHANGES"
|
||||
|
||||
|
||||
Scenario: Merge with all four scopes preserves action tier — Issue #9126
|
||||
Given I have plan invariants
|
||||
| text | source |
|
||||
| Plan rule | plan1 |
|
||||
And I have action invariants
|
||||
| text | source |
|
||||
| Action rule | action1 |
|
||||
And I have project invariants
|
||||
| text | source |
|
||||
| Project rule | proj1 |
|
||||
And I have global invariants
|
||||
| text | source |
|
||||
| Global rule | system |
|
||||
When I merge the invariants
|
||||
Then the merged set should have 4 invariants
|
||||
And plan invariants appear before action invariants in merge
|
||||
And action invariants appear before project invariants in merge
|
||||
And the merged invariant at index 0 should have text "Plan rule"
|
||||
And the merged invariant at index 1 should have text "Action rule"
|
||||
And the merged invariant at index 2 should have text "Project rule"
|
||||
And the merged invariant at index 3 should have text "Global rule"
|
||||
|
||||
|
||||
Scenario: Action invariant overrides project with same text
|
||||
Given I have plan invariants
|
||||
| text | source |
|
||||
And I have action invariants
|
||||
| text | source |
|
||||
| Log all changes | action1 |
|
||||
And I have project invariants
|
||||
| text | source |
|
||||
| Log all changes | proj1 |
|
||||
And I have global invariants
|
||||
| text | source |
|
||||
When I merge the invariants
|
||||
Then the merged set should have 1 invariants
|
||||
And the merged invariant at index 0 should have scope "action"
|
||||
|
||||
|
||||
Scenario: Action invariant is included in de-duplication with plan override
|
||||
Given I have plan invariants
|
||||
| text | source |
|
||||
| Log all changes | plan1 |
|
||||
And I have action invariants
|
||||
| text | source |
|
||||
| Log all changes | action1 |
|
||||
And I have project invariants
|
||||
| text | source |
|
||||
And I have global invariants
|
||||
| text | source |
|
||||
When I merge the invariants
|
||||
Then the merged set should have 1 invariants
|
||||
And the merged invariant at index 0 should have scope "plan"
|
||||
|
||||
|
||||
Scenario: Inactive action invariants are excluded from merge
|
||||
Given I have plan invariants with an inactive entry
|
||||
And I have action invariants
|
||||
| text | source |
|
||||
| Active | action1 |
|
||||
And I have project invariants
|
||||
| text | source |
|
||||
And I have global invariants
|
||||
| text | source |
|
||||
When I merge the invariants
|
||||
Then the merged set should have 1 invariants
|
||||
And the merged invariant at index 0 should have scope "action"
|
||||
|
||||
|
||||
Scenario: Inactive invariants are excluded from merge
|
||||
Given I have plan invariants with an inactive entry
|
||||
And I have project invariants
|
||||
@@ -561,7 +492,7 @@ Feature: Consolidated Domain Models
|
||||
# === InvariantSet merge class method ===
|
||||
|
||||
|
||||
Scenario: InvariantSet.merge produces correct result
|
||||
Scenario: InvariantSet.merge produces correct result
|
||||
Given I have plan invariants
|
||||
| text | source |
|
||||
| Plan rule | plan1 |
|
||||
@@ -574,25 +505,7 @@ Scenario: InvariantSet.merge produces correct result
|
||||
When I merge using InvariantSet
|
||||
Then the invariant set should have 3 invariants
|
||||
|
||||
|
||||
Scenario: InvariantSet.merge with four-tier precedence — Issue #9126
|
||||
Given I have plan invariants
|
||||
| text | source |
|
||||
| Plan rule | plan1 |
|
||||
And I have action invariants
|
||||
| text | source |
|
||||
| Action rule | action1 |
|
||||
And I have project invariants
|
||||
| text | source |
|
||||
| Project rule | proj1 |
|
||||
And I have global invariants
|
||||
| text | source |
|
||||
| Global rule | system |
|
||||
When I merge using InvariantSet
|
||||
Then the invariant set should have 4 invariants
|
||||
|
||||
|
||||
# === Service: Add/List/Remove ===
|
||||
# === Service: Add/List/Remove ===
|
||||
|
||||
|
||||
Scenario: Service add invariant
|
||||
@@ -661,16 +574,6 @@ Scenario: InvariantSet.merge produces correct result
|
||||
And the effective set should contain global invariants last
|
||||
|
||||
|
||||
Scenario: Effective invariants include action scope — Issue #9126
|
||||
Given an invariant service with invariants at all four scopes
|
||||
When I get effective invariants for plan "plan1", action "action1", and project "proj1"
|
||||
Then the effective set should contain plan invariants first
|
||||
And the effective set should contain action invariants second
|
||||
And the effective set should contain project invariants after action
|
||||
And the effective set should contain global invariants last
|
||||
And the effective set should have 4 invariants
|
||||
|
||||
|
||||
Scenario: Effective invariants de-duplicate across scopes
|
||||
Given an invariant service with duplicate text across scopes
|
||||
When I get effective invariants for plan "plan1" and project "proj1"
|
||||
|
||||
@@ -9,7 +9,6 @@ from .fake_provider import FakeProviderInfo, FakeProviderRegistry
|
||||
from .lsp_transport_mock import MockLspTransport, parse_lsp_responses
|
||||
from .mock_ai_provider import MockAIProvider
|
||||
from .mock_mcp_transport import MockMCPTransport
|
||||
from .recording_event_bus import RecordingEventBus
|
||||
from .transient_fail_audit_service import TransientFailAuditService
|
||||
|
||||
# NOTE: tdd_test_helpers is NOT re-exported here because it imports
|
||||
@@ -23,7 +22,6 @@ __all__ = [
|
||||
"MockAIProvider",
|
||||
"MockLspTransport",
|
||||
"MockMCPTransport",
|
||||
"RecordingEventBus",
|
||||
"TransientFailAuditService",
|
||||
"parse_lsp_responses",
|
||||
]
|
||||
|
||||
@@ -107,6 +107,10 @@ def _make_assembler(
|
||||
repo.get_context_policy.return_value = policy
|
||||
else:
|
||||
repo.get_context_policy.return_value = ProjectContextPolicy()
|
||||
# Default: project context config with hot_max_tokens=None (falls back to constructor).
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.hot_max_tokens = None
|
||||
repo.get_project_context_config.return_value = mock_cfg
|
||||
|
||||
mock_pipeline = MagicMock()
|
||||
if pipeline_result is not None:
|
||||
|
||||
@@ -135,11 +135,6 @@ def step_plan_invariants(context):
|
||||
context.plan_invariants = _parse_invariant_table(context, InvariantScope.PLAN)
|
||||
|
||||
|
||||
@given("I have action invariants")
|
||||
def step_action_invariants(context):
|
||||
context.action_invariants = _parse_invariant_table(context, InvariantScope.ACTION)
|
||||
|
||||
|
||||
@given("I have project invariants")
|
||||
def step_project_invariants(context):
|
||||
context.project_invariants = _parse_invariant_table(context, InvariantScope.PROJECT)
|
||||
@@ -165,7 +160,6 @@ def step_plan_invariants_inactive(context):
|
||||
def step_merge(context):
|
||||
context.merged = merge_invariants(
|
||||
getattr(context, "plan_invariants", []),
|
||||
getattr(context, "action_invariants", []),
|
||||
getattr(context, "project_invariants", []),
|
||||
getattr(context, "global_invariants", []),
|
||||
)
|
||||
@@ -186,43 +180,6 @@ def step_merged_scope(context, idx, scope):
|
||||
assert context.merged[idx].scope.value == scope
|
||||
|
||||
|
||||
@then("action invariants appear before project invariants in merge")
|
||||
def step_action_before_project(context):
|
||||
"""Verify ACTION tier comes before PROJECT tier in the merged result."""
|
||||
action_invs = [i for i in context.merged if i.scope == InvariantScope.ACTION]
|
||||
project_invs = [i for i in context.merged if i.scope == InvariantScope.PROJECT]
|
||||
assert len(action_invs) > 0, "No action invariants found in merged result"
|
||||
assert len(project_invs) > 0, "No project invariants found in merged result"
|
||||
first_action_idx = context.merged.index(action_invs[0])
|
||||
first_project_idx = context.merged.index(project_invs[0])
|
||||
assert first_action_idx < first_project_idx, (
|
||||
f"Action invariant at index {first_action_idx} "
|
||||
f"should appear before project invariant at index {first_project_idx}"
|
||||
)
|
||||
|
||||
|
||||
@then("plan invariants appear before action invariants in merge")
|
||||
def step_plan_before_action(context):
|
||||
"""Verify PLAN tier comes before ACTION tier in the merged result."""
|
||||
plan_invs = [i for i in context.merged if i.scope == InvariantScope.PLAN]
|
||||
action_invs = [i for i in context.merged if i.scope == InvariantScope.ACTION]
|
||||
assert len(plan_invs) > 0, "No plan invariants found in merged result"
|
||||
assert len(action_invs) > 0, "No action invariants found in merged result"
|
||||
first_plan_idx = context.merged.index(plan_invs[0])
|
||||
first_action_idx = context.merged.index(action_invs[0])
|
||||
assert first_plan_idx < first_action_idx, (
|
||||
f"Plan invariant at index {first_plan_idx} "
|
||||
f"should appear before action invariant at index {first_action_idx}"
|
||||
)
|
||||
|
||||
|
||||
@then("action invariants are preserved in merge result")
|
||||
def step_action_invariants_preserved(context):
|
||||
"""Verify that action-scoped invariants appear in the merged output."""
|
||||
action_invs = [i for i in context.merged if i.scope == InvariantScope.ACTION]
|
||||
assert len(action_invs) > 0, "Expected action-scoped invariants in merge result"
|
||||
|
||||
|
||||
# ================================================================
|
||||
# InvariantSet
|
||||
# ================================================================
|
||||
@@ -232,7 +189,6 @@ def step_action_invariants_preserved(context):
|
||||
def step_merge_invariant_set(context):
|
||||
inv_set = InvariantSet.merge(
|
||||
getattr(context, "plan_invariants", []),
|
||||
getattr(context, "action_invariants", []),
|
||||
getattr(context, "project_invariants", []),
|
||||
getattr(context, "global_invariants", []),
|
||||
)
|
||||
@@ -433,15 +389,6 @@ def step_service_all_scopes(context):
|
||||
context.service.add_invariant("Plan rule", InvariantScope.PLAN, "plan1")
|
||||
|
||||
|
||||
@given("an invariant service with invariants at all four scopes")
|
||||
def step_service_all_four_scopes(context):
|
||||
context.service = InvariantService()
|
||||
context.service.add_invariant("Global rule", InvariantScope.GLOBAL, "system")
|
||||
context.service.add_invariant("Project rule", InvariantScope.PROJECT, "proj1")
|
||||
context.service.add_invariant("Action rule", InvariantScope.ACTION, "action1")
|
||||
context.service.add_invariant("Plan rule", InvariantScope.PLAN, "plan1")
|
||||
|
||||
|
||||
@when('I get effective invariants for plan "{plan_id}" and project "{project}"')
|
||||
def step_effective(context, plan_id, project):
|
||||
context.effective = context.service.get_effective_invariants(
|
||||
@@ -449,15 +396,6 @@ def step_effective(context, plan_id, project):
|
||||
)
|
||||
|
||||
|
||||
@when(
|
||||
'I get effective invariants for plan "{plan_id}", action "{action}", and project "{project}"'
|
||||
)
|
||||
def step_effective_with_action(context, plan_id, action, project):
|
||||
context.effective = context.service.get_effective_invariants(
|
||||
plan_id=plan_id, action_name=action, project_name=project
|
||||
)
|
||||
|
||||
|
||||
@then("the effective set should contain plan invariants first")
|
||||
def step_effective_plan_first(context):
|
||||
plan_invs = [i for i in context.effective if i.scope == InvariantScope.PLAN]
|
||||
@@ -466,35 +404,6 @@ def step_effective_plan_first(context):
|
||||
assert first_plan_idx == 0
|
||||
|
||||
|
||||
@then("the effective set should contain action invariants second")
|
||||
def step_effective_action_second(context):
|
||||
"""Verify ACTION tier appears after PLAN and before PROJECT."""
|
||||
plan_invs = [i for i in context.effective if i.scope == InvariantScope.PLAN]
|
||||
action_invs = [i for i in context.effective if i.scope == InvariantScope.ACTION]
|
||||
project_invs = [i for i in context.effective if i.scope == InvariantScope.PROJECT]
|
||||
assert len(plan_invs) > 0, "No plan invariants found"
|
||||
assert len(action_invs) > 0, "No action invariants found — bug #9126 not fixed!"
|
||||
assert len(project_invs) > 0, "No project invariants found"
|
||||
first_action_idx = context.effective.index(action_invs[0])
|
||||
if plan_invs:
|
||||
last_plan_idx = context.effective.index(plan_invs[-1])
|
||||
assert first_action_idx > last_plan_idx
|
||||
first_project_idx = context.effective.index(project_invs[0])
|
||||
assert first_project_idx > first_action_idx
|
||||
|
||||
|
||||
@then("the effective set should contain project invariants after action")
|
||||
def step_effective_project_after_action(context):
|
||||
"""Verify PROJECT tier appears after ACTION tier."""
|
||||
action_invs = [i for i in context.effective if i.scope == InvariantScope.ACTION]
|
||||
project_invs = [i for i in context.effective if i.scope == InvariantScope.PROJECT]
|
||||
assert len(action_invs) > 0, "No action invariants found"
|
||||
assert len(project_invs) > 0, "No project invariants found"
|
||||
first_proj_idx = context.effective.index(project_invs[0])
|
||||
last_action_idx = context.effective.index(action_invs[-1])
|
||||
assert first_proj_idx > last_action_idx
|
||||
|
||||
|
||||
@then("the effective set should contain project invariants second")
|
||||
def step_effective_project_second(context):
|
||||
proj_invs = [i for i in context.effective if i.scope == InvariantScope.PROJECT]
|
||||
@@ -526,13 +435,6 @@ def step_no_duplicates(context):
|
||||
assert len(texts) == len(set(texts)), f"Duplicates found: {texts}"
|
||||
|
||||
|
||||
@then("the effective set should have {count:d} invariants")
|
||||
def step_effective_count(context, count):
|
||||
assert len(context.effective) == count, (
|
||||
f"Expected {count}, got {len(context.effective)}"
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Enforcement
|
||||
# ================================================================
|
||||
|
||||
@@ -57,6 +57,7 @@ E2E Plan Lifecycle Through Audit Pipeline
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} E2E_OK
|
||||
|
||||
User Identity Field Propagates Through Audit Pipeline
|
||||
[Documentation] DomainEvent.user_identity propagates via AuditEventSubscriber to AuditService DB entry
|
||||
[Tags] observability audit identity
|
||||
@@ -86,13 +87,3 @@ User Identity Field Precedence Over Details
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} IDENTITY_PRECEDENCE_OK
|
||||
|
||||
Auth Middleware Emits Audit Events
|
||||
[Documentation] End-to-end: TokenAuthMiddleware emits AUTH_SUCCESS/AUTH_FAILURE through audit pipeline
|
||||
[Tags] observability audit auth
|
||||
${result}= Run Process ${PYTHON} ${HELPER} auth_middleware_pipeline
|
||||
... cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} AUTH_PIPELINE_OK
|
||||
|
||||
@@ -23,9 +23,6 @@ from cleveragents.application.services.audit_event_subscriber import ( # noqa:
|
||||
from cleveragents.application.services.audit_service import ( # noqa: E402
|
||||
AuditService,
|
||||
)
|
||||
from cleveragents.application.services.auth_middleware import ( # noqa: E402
|
||||
TokenAuthMiddleware,
|
||||
)
|
||||
from cleveragents.application.services.plan_lifecycle_service import ( # noqa: E402
|
||||
PlanLifecycleService,
|
||||
)
|
||||
@@ -326,102 +323,6 @@ def user_identity_field_precedence() -> None:
|
||||
print("IDENTITY_PRECEDENCE_OK")
|
||||
|
||||
|
||||
def auth_middleware_pipeline() -> None:
|
||||
"""E2E: TokenAuthMiddleware -> EventBus -> AuditEventSubscriber -> DB."""
|
||||
svc = _make_audit_service()
|
||||
bus = ReactiveEventBus()
|
||||
AuditEventSubscriber(audit_service=svc, event_bus=bus)
|
||||
middleware = TokenAuthMiddleware(expected_token="tok_secret_123", event_bus=bus)
|
||||
|
||||
success = middleware.authenticate(
|
||||
"tok_secret_123",
|
||||
identity="carol@example.com",
|
||||
ip_address="10.1.2.3",
|
||||
)
|
||||
failure = middleware.authenticate(
|
||||
"tok_bad_123",
|
||||
identity="dave@example.com",
|
||||
ip_address="10.1.2.4",
|
||||
)
|
||||
if not success or failure:
|
||||
print(
|
||||
"FAIL: unexpected auth boolean results "
|
||||
f"success={success} failure={failure}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
success_entries = svc.list_entries(event_type="auth_success")
|
||||
failure_entries = svc.list_entries(event_type="auth_failure")
|
||||
if len(success_entries) != 1:
|
||||
print(
|
||||
f"FAIL: expected 1 auth_success entry, got {len(success_entries)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if len(failure_entries) != 1:
|
||||
print(
|
||||
f"FAIL: expected 1 auth_failure entry, got {len(failure_entries)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
s_entry = success_entries[0]
|
||||
f_entry = failure_entries[0]
|
||||
s_details = s_entry.details
|
||||
f_details = f_entry.details
|
||||
if s_entry.user_identity != "carol@example.com":
|
||||
print(
|
||||
f"FAIL: unexpected auth_success identity {s_entry.user_identity!r}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if f_entry.user_identity != "unauthenticated":
|
||||
print(
|
||||
f"FAIL: unexpected auth_failure identity {f_entry.user_identity!r}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if s_details.get("ip_address") != "10.1.2.3":
|
||||
print(
|
||||
f"FAIL: unexpected auth_success ip {s_details.get('ip_address')!r}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if s_details.get("token_prefix") != "tok_se...":
|
||||
print(
|
||||
"FAIL: auth_success token_prefix mismatch",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if f_details.get("ip_address") != "10.1.2.4":
|
||||
print(
|
||||
f"FAIL: unexpected auth_failure ip {f_details.get('ip_address')!r}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if f_details.get("attempted_identity") != "dave@example.com":
|
||||
print(
|
||||
"FAIL: auth_failure attempted_identity mismatch",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if f_details.get("failure_reason") != "invalid_token":
|
||||
print(
|
||||
f"FAIL: unexpected auth_failure reason {f_details.get('failure_reason')!r}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if f_details.get("token_prefix") != "tok_ba...":
|
||||
print(
|
||||
"FAIL: auth_failure token_prefix mismatch",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print("AUTH_PIPELINE_OK")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -435,7 +336,6 @@ _COMMANDS = {
|
||||
"user_identity_field": user_identity_field,
|
||||
"user_identity_details_fallback": user_identity_details_fallback,
|
||||
"user_identity_field_precedence": user_identity_field_precedence,
|
||||
"auth_middleware_pipeline": auth_middleware_pipeline,
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -867,7 +867,6 @@ def invariants_enforced_during_strategize() -> None:
|
||||
|
||||
merged = merge_invariants(
|
||||
plan_invariants=[plan_inv],
|
||||
action_invariants=[],
|
||||
project_invariants=[project_inv],
|
||||
global_invariants=[global_inv],
|
||||
)
|
||||
@@ -891,7 +890,6 @@ def invariants_enforced_during_strategize() -> None:
|
||||
|
||||
invariant_set = InvariantSet.merge(
|
||||
plan_invariants=[plan_inv],
|
||||
action_invariants=[],
|
||||
project_invariants=[project_inv],
|
||||
global_invariants=[global_inv],
|
||||
)
|
||||
|
||||
@@ -89,22 +89,7 @@ class AutoDebugAgent:
|
||||
|
||||
return workflow
|
||||
|
||||
def _analyze_error(self, state: AutoDebugState) -> dict[str, Any]:
|
||||
"""Analyze the error message and code context.
|
||||
|
||||
Per LangGraph's node contract, this function returns a **partial-state**
|
||||
dict (keys only for fields updated by this node); LangGraph merges
|
||||
those keys into the existing workflow state. Mutating the input
|
||||
``state`` in-place is avoided — we build a new messages list via
|
||||
unpacking so that concurrent graph runs and checkpoint writes stay
|
||||
isolated, per HAL9000 observation #2 (LLM peer review).
|
||||
|
||||
Args:
|
||||
state: Current workflow state.
|
||||
|
||||
Returns:
|
||||
Partial-state dict with an appended ``messages`` entry.
|
||||
"""
|
||||
def _analyze_error(self, state: AutoDebugState) -> AutoDebugState:
|
||||
logger.info("Analyzing error message")
|
||||
|
||||
error_msg = state.get("error_message", "")
|
||||
@@ -144,31 +129,17 @@ Analyze this error and provide insights."""
|
||||
logger.warning("LLM analysis failed, using fallback: %s", exc)
|
||||
analysis = "Error analysis completed"
|
||||
|
||||
return {
|
||||
"messages": [
|
||||
*state.get("messages", []),
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": analysis,
|
||||
"type": "error_analysis",
|
||||
},
|
||||
],
|
||||
}
|
||||
state.setdefault("messages", []).append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": analysis,
|
||||
"type": "error_analysis",
|
||||
}
|
||||
)
|
||||
|
||||
def _generate_fix(self, state: AutoDebugState) -> dict[str, Any]:
|
||||
"""Generate a fix suggestion.
|
||||
return state
|
||||
|
||||
Per LangGraph's node contract, this function returns a **partial-state**
|
||||
dict containing only the ``current_fix`` key it updates; omitted keys
|
||||
(e.g., ``messages``, ``attempted_fixes``) are left unchanged in the
|
||||
workflow state (LangGraph merges behavior).
|
||||
|
||||
Args:
|
||||
state: Current workflow state.
|
||||
|
||||
Returns:
|
||||
Partial-state dict with the new ``current_fix`` value.
|
||||
"""
|
||||
def _generate_fix(self, state: AutoDebugState) -> AutoDebugState:
|
||||
logger.info("Generating fix suggestion")
|
||||
|
||||
error_analysis = next(
|
||||
@@ -249,28 +220,10 @@ Generate fix attempt #{attempt_num}."""
|
||||
"files_to_modify": [],
|
||||
}
|
||||
|
||||
return {"current_fix": fix_data}
|
||||
state["current_fix"] = fix_data
|
||||
return state
|
||||
|
||||
def _validate_fix(self, state: AutoDebugState) -> dict[str, Any]:
|
||||
"""Validate the proposed fix.
|
||||
|
||||
Per LangGraph's node contract this function returns a **partial-state**
|
||||
dict. When the fix is invalid we include both ``fix_validated`` and
|
||||
``attempted_fixes`` keys; when valid only ``fix_validated`` is returned
|
||||
— LangGraph treats omitted keys as "leave unchanged".
|
||||
|
||||
Security note: the exception handler defaults to **fail-close**
|
||||
(``is_valid = False``) rather than the previous fail-open (``True``),
|
||||
preventing a crashed LLM validator from accidentally marking an
|
||||
unvalidated fix as acceptable. Closes #10496.
|
||||
|
||||
Args:
|
||||
state: Current workflow state.
|
||||
|
||||
Returns:
|
||||
Partial-state dict with validation result and, on failure, the
|
||||
current fix appended to ``attempted_fixes``.
|
||||
"""
|
||||
def _validate_fix(self, state: AutoDebugState) -> AutoDebugState:
|
||||
logger.info("Validating fix")
|
||||
|
||||
current_fix = state.get("current_fix", {})
|
||||
@@ -323,51 +276,33 @@ Validate this fix."""
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning("LLM validation failed, using fallback: %s", exc)
|
||||
is_valid = False
|
||||
is_valid = True
|
||||
|
||||
state["fix_validated"] = is_valid
|
||||
|
||||
if not is_valid:
|
||||
attempted_fixes = state.get("attempted_fixes", [])
|
||||
return {
|
||||
"fix_validated": is_valid,
|
||||
"attempted_fixes": [*attempted_fixes, current_fix],
|
||||
}
|
||||
attempted_fixes.append(current_fix)
|
||||
state["attempted_fixes"] = attempted_fixes
|
||||
|
||||
return {"fix_validated": is_valid}
|
||||
return state
|
||||
|
||||
def _should_retry_fix(self, state: AutoDebugState) -> str:
|
||||
"""Decide whether to retry fix generation.
|
||||
|
||||
This conditional-edge function is read-only from LangGraph's
|
||||
perspective — any mutations it makes to ``state`` are silently
|
||||
discarded. Only the keys that are returned by node functions are
|
||||
merged into the workflow state.
|
||||
"""
|
||||
if not state.get("fix_validated", False):
|
||||
attempts = len(state.get("attempted_fixes", []))
|
||||
if attempts < self.max_fix_attempts:
|
||||
return "retry"
|
||||
return "done"
|
||||
|
||||
def _finalize(self, state: AutoDebugState) -> dict[str, Any]:
|
||||
"""Finalize auto-debug results.
|
||||
|
||||
Returns a partial-state dict containing only the ``result`` key.
|
||||
|
||||
Args:
|
||||
state: Current workflow state.
|
||||
|
||||
Returns:
|
||||
Partial-state dict with the final ``result`` summary.
|
||||
"""
|
||||
def _finalize(self, state: AutoDebugState) -> AutoDebugState:
|
||||
logger.info("Finalizing auto-debug results")
|
||||
|
||||
return {
|
||||
"result": {
|
||||
"success": state.get("fix_validated", False),
|
||||
"fix": state.get("current_fix"),
|
||||
"attempts": len(state.get("attempted_fixes", [])),
|
||||
},
|
||||
state["result"] = {
|
||||
"success": state.get("fix_validated", False),
|
||||
"fix": state.get("current_fix"),
|
||||
"attempts": len(state.get("attempted_fixes", [])),
|
||||
}
|
||||
return state
|
||||
|
||||
def invoke(
|
||||
self, input_state: AutoDebugState, config: dict[str, Any] | None = None
|
||||
|
||||
@@ -23,7 +23,6 @@ from cleveragents.application.services.audit_event_subscriber import (
|
||||
AuditEventSubscriber,
|
||||
)
|
||||
from cleveragents.application.services.audit_service import AuditService
|
||||
from cleveragents.application.services.auth_middleware import TokenAuthMiddleware
|
||||
from cleveragents.application.services.automation_profile_service import (
|
||||
AutomationProfileService,
|
||||
)
|
||||
@@ -411,25 +410,6 @@ def _resolve_auto_reindex() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _resolve_server_token() -> str | None:
|
||||
"""Resolve ``server.token`` from config, returning ``None`` when unset."""
|
||||
try:
|
||||
svc = ConfigService()
|
||||
resolved = svc.resolve("server.token")
|
||||
raw = resolved.value
|
||||
if raw is None:
|
||||
return None
|
||||
token = str(raw).strip()
|
||||
return token if token else None
|
||||
except Exception as exc:
|
||||
_logger.warning(
|
||||
"server_token_resolution_failed",
|
||||
error_type=type(exc).__name__,
|
||||
error_message=redact_value(str(exc)),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _build_analyzer_registry() -> AnalyzerRegistry:
|
||||
"""Build an AnalyzerRegistry with built-in analyzers registered."""
|
||||
from cleveragents.domain.models.acms.python_analyzer import PythonAnalyzer
|
||||
@@ -687,13 +667,6 @@ class Container(containers.DeclarativeContainer):
|
||||
# Event Bus - Singleton (one shared instance for the process)
|
||||
event_bus = providers.Singleton(ReactiveEventBus)
|
||||
|
||||
# Per-request auth middleware with fresh token resolution from config.
|
||||
auth_middleware = providers.Factory(
|
||||
TokenAuthMiddleware,
|
||||
expected_token=providers.Callable(_resolve_server_token),
|
||||
event_bus=event_bus,
|
||||
)
|
||||
|
||||
# Services - Factory (new instance per request with injected dependencies)
|
||||
project_service = providers.Factory(
|
||||
ProjectService,
|
||||
|
||||
@@ -37,13 +37,19 @@ _logger = structlog.get_logger(__name__)
|
||||
SECURITY_EVENT_MAP: dict[EventType, str] = {
|
||||
EventType.PLAN_APPLIED: "plan_applied",
|
||||
EventType.PLAN_CANCELLED: "plan_cancelled",
|
||||
# Emitted by ResourceFileWatcher when indexed resources change.
|
||||
# NOTE: RESOURCE_MODIFIED has no producing service yet — the tool
|
||||
# execution framework does not emit this event. The subscriber handler
|
||||
# is intentionally registered so that audit entries are automatically
|
||||
# created once tool-write event emission is implemented.
|
||||
EventType.RESOURCE_MODIFIED: "resource_modified",
|
||||
EventType.CORRECTION_APPLIED: "correction_applied",
|
||||
EventType.CONFIG_CHANGED: "config_changed",
|
||||
EventType.ENTITY_DELETED: "entity_deleted",
|
||||
EventType.SESSION_CREATED: "session_created",
|
||||
# Emitted by TokenAuthMiddleware during bearer-token checks.
|
||||
# NOTE: AUTH_SUCCESS and AUTH_FAILURE have no producing service yet —
|
||||
# server-mode authentication is not implemented. The subscriber handlers
|
||||
# are registered so that audit entries are automatically created once
|
||||
# server auth emits these events.
|
||||
EventType.AUTH_SUCCESS: "auth_success",
|
||||
EventType.AUTH_FAILURE: "auth_failure",
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ from cleveragents.domain.models.core.context_fragment import (
|
||||
FragmentProvenance as CoreFragmentProvenance,
|
||||
)
|
||||
from cleveragents.domain.models.core.context_policy import ProjectContextPolicy
|
||||
from cleveragents.domain.models.core.project import ContextConfig
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
NamespacedProjectRepository,
|
||||
)
|
||||
@@ -45,12 +46,13 @@ class ACMSExecutePhaseContextAssembler(ExecutePhaseContextAssembler):
|
||||
context_tier_service: Any,
|
||||
project_repository: NamespacedProjectRepository,
|
||||
acms_pipeline: ACMSPipeline | None = None,
|
||||
hot_max_tokens: int = 4096,
|
||||
hot_max_tokens: int | None = None,
|
||||
) -> None:
|
||||
self._tier = context_tier_service
|
||||
self._project_repository = project_repository
|
||||
self._pipeline = acms_pipeline or ACMSPipeline()
|
||||
self._hot_max_tokens = hot_max_tokens
|
||||
# Global/container-level fallback when no project-level value is set.
|
||||
self._fallback_hot_max_tokens = hot_max_tokens
|
||||
self._logger = logger.bind(component="execute_phase_context_assembler")
|
||||
|
||||
@property
|
||||
@@ -166,6 +168,47 @@ class ACMSExecutePhaseContextAssembler(ExecutePhaseContextAssembler):
|
||||
return False
|
||||
return not (exclude and _matches_any(exclude))
|
||||
|
||||
def _resolve_effective_hot_max_tokens(
|
||||
self, project_names: list[str],
|
||||
) -> int:
|
||||
"""Return the effective hot-tier max-tokens for *project_names*.
|
||||
|
||||
Resolves per-project ``ContextConfig.hot_max_tokens`` values. The
|
||||
maximum non-None value found across all linked projects is used
|
||||
so that a multi-project plan can benefit from the most generous
|
||||
per-project budget setting. When no project provides a value,
|
||||
falls back to the container/global default stored in
|
||||
``self._fallback_hot_max_tokens``; if that is also unset, returns
|
||||
the historical safety-net of **4096**.
|
||||
"""
|
||||
candidates: list[int] = []
|
||||
|
||||
for name in project_names:
|
||||
try:
|
||||
config: ContextConfig = (
|
||||
self._project_repository.get_project_context_config(name)
|
||||
)
|
||||
raw = config.hot_max_tokens
|
||||
if raw is not None and isinstance(raw, int):
|
||||
candidates.append(raw)
|
||||
except Exception:
|
||||
self._logger.warning(
|
||||
"context_config_lookup_failed",
|
||||
project_name=name,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Use the maximum project-level value (most generous wins).
|
||||
if candidates:
|
||||
return max(candidates)
|
||||
|
||||
# Fall back to container/global default.
|
||||
if self._fallback_hot_max_tokens is not None:
|
||||
return self._fallback_hot_max_tokens
|
||||
|
||||
# Absolute safety-net.
|
||||
return 4096
|
||||
|
||||
@staticmethod
|
||||
def _resource_matches(
|
||||
resource_id: str, include: list[str], exclude: list[str]
|
||||
|
||||
@@ -11,8 +11,8 @@ a dict keyed by invariant ID.
|
||||
|
||||
## Merge Precedence
|
||||
|
||||
Effective invariants are computed using plan > action > project > global
|
||||
order. See ``merge_invariants`` for de-duplication semantics.
|
||||
Effective invariants are computed using plan > project > global order.
|
||||
See ``merge_invariants`` for de-duplication semantics.
|
||||
|
||||
Based on ``docs/specification.md`` and implementation plan Stage M3.5.
|
||||
"""
|
||||
@@ -124,7 +124,6 @@ class InvariantService:
|
||||
if effective:
|
||||
return self.get_effective_invariants(
|
||||
plan_id=source_name if scope == InvariantScope.PLAN else None,
|
||||
action_name=source_name if scope == InvariantScope.ACTION else None,
|
||||
project_name=source_name if scope == InvariantScope.PROJECT else None,
|
||||
)
|
||||
|
||||
@@ -168,22 +167,16 @@ class InvariantService:
|
||||
def get_effective_invariants(
|
||||
self,
|
||||
plan_id: str | None = None,
|
||||
action_name: str | None = None,
|
||||
project_name: str | None = None,
|
||||
) -> list[Invariant]:
|
||||
"""Return the merged precedence chain for a plan/action/project context.
|
||||
"""Return the merged precedence chain for a plan/project context.
|
||||
|
||||
Collects active invariants from each scope tier and merges them
|
||||
using plan > action > project > global precedence.
|
||||
using plan > project > global precedence.
|
||||
|
||||
Args:
|
||||
plan_id: Optional plan identifier to collect plan-scoped
|
||||
invariants.
|
||||
action_name: Optional action name to collect action-scoped
|
||||
invariants (promoted to plan-level during reconciliation).
|
||||
When ``None``, the action tier is omitted for backward
|
||||
compatibility. Pass ``"*"`` to include all action-scoped
|
||||
invariants regardless of source name.
|
||||
project_name: Optional project name to collect project-scoped
|
||||
invariants.
|
||||
|
||||
@@ -198,13 +191,6 @@ class InvariantService:
|
||||
if inv.scope == InvariantScope.PLAN
|
||||
and (plan_id is None or inv.source_name == plan_id)
|
||||
]
|
||||
action_invs = [
|
||||
inv
|
||||
for inv in active
|
||||
if inv.scope == InvariantScope.ACTION
|
||||
and action_name is not None # Only include when explicitly requested
|
||||
and (inv.source_name == action_name or action_name == "*")
|
||||
]
|
||||
project_invs = [
|
||||
inv
|
||||
for inv in active
|
||||
@@ -213,7 +199,7 @@ class InvariantService:
|
||||
]
|
||||
global_invs = [inv for inv in active if inv.scope == InvariantScope.GLOBAL]
|
||||
|
||||
return merge_invariants(plan_invs, action_invs, project_invs, global_invs)
|
||||
return merge_invariants(plan_invs, project_invs, global_invs)
|
||||
|
||||
def enforce_invariants(
|
||||
self,
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
Invariants are natural-language constraints on plan execution, scoped at
|
||||
global, project, action, or plan level. When an action is used, its
|
||||
invariants are promoted to plan-level and participate in the merge.
|
||||
The runtime precedence chain is:
|
||||
invariants are promoted to plan-level. The runtime precedence chain is:
|
||||
|
||||
plan > action > project > global
|
||||
plan > project > global
|
||||
|
||||
They are reconciled by the Invariant Reconciliation Actor at the start
|
||||
of Strategize and recorded as ``invariant_enforced`` decisions.
|
||||
@@ -22,8 +21,8 @@ of Strategize and recorded as ``invariant_enforced`` decisions.
|
||||
## Merge Precedence
|
||||
|
||||
When computing the effective set of invariants for a plan, the merge
|
||||
order is **plan > action > project > global**. Duplicate texts
|
||||
(case-insensitive) are de-duplicated, keeping the highest-precedence copy.
|
||||
order is **plan > project > global**. Duplicate texts (case-insensitive)
|
||||
are de-duplicated, keeping the highest-precedence copy.
|
||||
|
||||
Based on ``docs/specification.md`` and implementation plan Stage M3.5.
|
||||
"""
|
||||
@@ -40,10 +39,8 @@ from ulid import ULID
|
||||
class InvariantScope(StrEnum):
|
||||
"""Scope at which an invariant applies.
|
||||
|
||||
Precedence (highest to lowest): PLAN > ACTION > PROJECT > GLOBAL.
|
||||
ACTION invariants are promoted to plan-level and participate in the
|
||||
merge during reconciliation, sitting between PLAN and PROJECT in the
|
||||
precedence chain.
|
||||
Precedence (highest to lowest): PLAN > PROJECT > GLOBAL.
|
||||
ACTION invariants are promoted to PLAN scope at ``plan use`` time.
|
||||
"""
|
||||
|
||||
GLOBAL = "global"
|
||||
@@ -140,11 +137,10 @@ class InvariantSet(BaseModel):
|
||||
def merge(
|
||||
cls,
|
||||
plan_invariants: list[Invariant],
|
||||
action_invariants: list[Invariant] | None = None,
|
||||
project_invariants: list[Invariant] | None = None,
|
||||
global_invariants: list[Invariant] | None = None,
|
||||
project_invariants: list[Invariant],
|
||||
global_invariants: list[Invariant],
|
||||
) -> InvariantSet:
|
||||
"""Merge invariants respecting plan > action > project > global precedence.
|
||||
"""Merge invariants respecting plan > project > global precedence.
|
||||
|
||||
De-duplicates by text (case-insensitive), keeping the copy from
|
||||
the highest-precedence tier. Within each tier, source ordering
|
||||
@@ -152,9 +148,6 @@ class InvariantSet(BaseModel):
|
||||
|
||||
Args:
|
||||
plan_invariants: Plan-level invariants (highest precedence).
|
||||
action_invariants: Action-scoped invariants (second-highest
|
||||
precedence; promoted to plan-level when an
|
||||
action is used).
|
||||
project_invariants: Project-level invariants.
|
||||
global_invariants: Global-level invariants (lowest precedence).
|
||||
|
||||
@@ -163,12 +156,7 @@ class InvariantSet(BaseModel):
|
||||
"""
|
||||
return cls(
|
||||
invariants=tuple(
|
||||
merge_invariants(
|
||||
plan_invariants,
|
||||
action_invariants or [],
|
||||
project_invariants or [],
|
||||
global_invariants or [],
|
||||
)
|
||||
merge_invariants(plan_invariants, project_invariants, global_invariants)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -177,11 +165,10 @@ class InvariantSet(BaseModel):
|
||||
|
||||
def merge_invariants(
|
||||
plan_invariants: list[Invariant],
|
||||
action_invariants: list[Invariant] | None = None,
|
||||
project_invariants: list[Invariant] | None = None,
|
||||
global_invariants: list[Invariant] | None = None,
|
||||
project_invariants: list[Invariant],
|
||||
global_invariants: list[Invariant],
|
||||
) -> list[Invariant]:
|
||||
"""Merge invariants implementing plan > action > project > global precedence.
|
||||
"""Merge invariants implementing plan > project > global precedence.
|
||||
|
||||
De-duplicates by text (case-insensitive). The first occurrence
|
||||
(from the highest-precedence tier) wins. Within each tier, the
|
||||
@@ -189,13 +176,8 @@ def merge_invariants(
|
||||
|
||||
Args:
|
||||
plan_invariants: Plan-level invariants (highest precedence).
|
||||
action_invariants: Action-scoped invariants (second-highest
|
||||
precedence; promoted to plan level when an action is used).
|
||||
Defaults to empty list when ``None``.
|
||||
project_invariants: Project-level invariants. Defaults to empty
|
||||
list when ``None``.
|
||||
project_invariants: Project-level invariants.
|
||||
global_invariants: Global-level invariants (lowest precedence).
|
||||
Defaults to empty list when ``None``.
|
||||
|
||||
Returns:
|
||||
A de-duplicated list of invariants in precedence order.
|
||||
@@ -203,12 +185,7 @@ def merge_invariants(
|
||||
seen: set[str] = set()
|
||||
result: list[Invariant] = []
|
||||
|
||||
for inv_list in (
|
||||
plan_invariants,
|
||||
action_invariants or [],
|
||||
project_invariants or [],
|
||||
global_invariants or [],
|
||||
):
|
||||
for inv_list in (plan_invariants, project_invariants, global_invariants):
|
||||
for inv in inv_list:
|
||||
if not inv.active:
|
||||
continue
|
||||
|
||||
@@ -3146,6 +3146,56 @@ class NamespacedProjectRepository(ProjectRepositoryProtocol):
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@database_retry
|
||||
def get_project_context_config(self, namespaced_name: str) -> Any:
|
||||
"""Return a project's stored ``ContextConfig`` or the default.
|
||||
|
||||
Unlike :meth:`get_context_policy` which returns a
|
||||
``ProjectContextPolicy``, this method returns the full
|
||||
``ContextConfig`` containing memory-tier settings such as
|
||||
hot_max_tokens, warm/cold tier limits, and more.
|
||||
|
||||
Args:
|
||||
namespaced_name: The project's ``namespace/name`` string.
|
||||
|
||||
Returns:
|
||||
A ``ContextConfig`` instance (default if the project or its
|
||||
config is not found).
|
||||
"""
|
||||
from cleveragents.domain.models.core.project import ContextConfig
|
||||
|
||||
session = self._session()
|
||||
try:
|
||||
row = (
|
||||
session.query(NamespacedProjectModel)
|
||||
.filter_by(namespaced_name=namespaced_name)
|
||||
.first()
|
||||
)
|
||||
if row is None or row.context_policy_json is None:
|
||||
return ContextConfig()
|
||||
|
||||
payload = json.loads(cast(str, row.context_policy_json))
|
||||
if not isinstance(payload, dict):
|
||||
_log.warning(
|
||||
"project_context_config_payload_invalid",
|
||||
project_name=namespaced_name,
|
||||
)
|
||||
return ContextConfig()
|
||||
return ContextConfig.model_validate(payload)
|
||||
except (ValueError, TypeError):
|
||||
_log.warning(
|
||||
"project_context_config_invalid",
|
||||
project_name=namespaced_name,
|
||||
exc_info=True,
|
||||
)
|
||||
return ContextConfig()
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
raise DatabaseError(
|
||||
f"Failed to load project context config for '{namespaced_name}': {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@database_retry
|
||||
def update(self, project: Any) -> Any:
|
||||
"""Update mutable fields of an existing project.
|
||||
|
||||
@@ -143,11 +143,33 @@ class StdioTransport:
|
||||
self._process = None
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
"lsp.transport.started",
|
||||
pid=self._process.pid,
|
||||
command=self._command,
|
||||
)
|
||||
try:
|
||||
logger.info(
|
||||
"lsp.transport.started",
|
||||
pid=self._process.pid,
|
||||
command=self._command,
|
||||
)
|
||||
except Exception:
|
||||
# Post-spawn initialization failed — clean up the orphaned
|
||||
# subprocess to prevent resource leaks. We re-raise the
|
||||
# original exception so callers still get proper error
|
||||
# semantics; this block only ensures resources are freed.
|
||||
logger.warning(
|
||||
"lsp.transport.start_post_init_failed",
|
||||
pid=self._process.pid,
|
||||
)
|
||||
self._process.terminate()
|
||||
try:
|
||||
self._process.wait(timeout=_GRACEFUL_SHUTDOWN_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
"lsp.transport.force_killing_cleanup",
|
||||
pid=self._process.pid,
|
||||
)
|
||||
self._process.kill()
|
||||
self._process.wait(timeout=2.0)
|
||||
self._process = None
|
||||
raise
|
||||
|
||||
def stop(self, timeout: float = _GRACEFUL_SHUTDOWN_TIMEOUT) -> int | None:
|
||||
"""Terminate the subprocess gracefully, then force-kill if needed.
|
||||
|
||||
@@ -42,7 +42,6 @@ _SENSITIVE_SUBSTRINGS: set[str] = {
|
||||
# though they contain a sensitive substring (e.g. "token_count").
|
||||
_FALSE_POSITIVE_KEYS: set[str] = {
|
||||
"token_count",
|
||||
"token_prefix",
|
||||
"token_limit",
|
||||
"token_usage",
|
||||
"input_tokens",
|
||||
|
||||
Reference in New Issue
Block a user