Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39b06e7324 | |||
| 8a48f2e62b | |||
| c58a5d772b | |||
| 2fff625a02 |
+6
-16
@@ -7,12 +7,6 @@ Changed `wf10_batch.robot` to be less likely to create files, and
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Data integrity fix: ValidationAttachmentRepository argument swap (#7492): Fixed
|
||||
a critical data integrity issue in `ValidationAttachmentRepository.attach` where
|
||||
`validation_name` and `resource_id` arguments were being silently swapped based on a
|
||||
fragile heuristic (`"/" in resource_id`). Arguments are now passed in the correct order,
|
||||
ensuring data is stored with proper parameter values.
|
||||
|
||||
- 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
|
||||
@@ -74,6 +68,12 @@ ensuring data is stored with proper parameter values.
|
||||
counter, spec-required `validation_summary` and
|
||||
`final_validation_results` fields on the result model, DI container
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- **ContextStrategy protocol and StrategyRegistry with entry-point discovery** (#8616, Epic #8505): Implements the `ContextStrategy` Protocol as a standardized interface for context selection algorithms with configurable budget and scope parameters. Adds centralized `StrategyRegistry` supporting registration, lookup by name, listing enabled/all strategies, configuration-driven enable/disable, per-strategy timeout/max-fragment limits, thread-safe concurrent access via RLock, entry-point-based automatic discovery from the ``cleveragents.context_strategies`` group for plugin-style extensibility, and six built-in strategies (`simple-keyword` quality 0.3, `semantic-embedding` quality 0.6, `breadth-depth-navigator` quality 0.85, `arce` quality 0.95, `temporal-archaeology` quality 0.5, `plan-decision-context` quality 0.7). Includes comprehensive BDD test coverage (>= 97%) validating protocol compliance, registry operations, and strategy discovery mechanisms.
|
||||
|
||||
### Fixed
|
||||
- **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)
|
||||
@@ -128,16 +128,6 @@ ensuring data is stored with proper parameter values.
|
||||
- **Plan Rollback Command** (#8557): Implemented `agents plan rollback <plan-id> [<checkpoint-id>]` for checkpoint-based plan state restoration in Epic #8493. The command restores a plan's sandbox to the state captured at a given checkpoint, discarding all decisions made after that checkpoint. The checkpoint can be specified as an optional positional second argument or via the `--to-checkpoint` named option. Supports `--yes/-y` flag to skip confirmation prompts and `--format/-f` for output format selection (rich/plain/json/yaml). Included with comprehensive BDD test coverage (>= 97%) and spec-aligned output formatting showing rollback summary, changes reverted, impact analysis, and post-rollback state panels.
|
||||
|
||||
### Fixed
|
||||
- **ACMS execute-phase assembler respects project-level hot_max_tokens** (#11035): Fixed
|
||||
``_resolve_hot_max_tokens()`` to read ``hot_max_tokens`` from
|
||||
``context_policy_json["acms_config"]["hot_max_tokens"]`` — the correct sub-key written
|
||||
by ``agents project context set --hot-max-tokens``. The previous implementation read
|
||||
from the top-level key (``config_dict.get("hot_max_tokens")``), which was always
|
||||
``None``, causing the assembler to silently fall back to the global 16K default even
|
||||
when a project-level override was configured. Also adds two Behave regression scenarios
|
||||
with ``@tdd_issue @tdd_issue_11035`` tags that exercise the DB query code path and
|
||||
verify the project-level budget is applied to ``CoreContextBudget`` and
|
||||
``ContextRequest``.
|
||||
- **Guard cleanup_stale against execute/processing and execute/complete plans** (#11121):
|
||||
``_create_sandbox_for_plan()`` in ``src/cleveragents/cli/commands/plan.py`` now
|
||||
skips ``GitWorktreeSandbox.cleanup_stale()`` when the plan is in
|
||||
|
||||
@@ -20,6 +20,7 @@ Below are some of the specific details of various contributions.
|
||||
* 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.
|
||||
* HAL 9000 has contributed the plan concurrency race-condition fix (#7989): wired `LockService` into the plan lifecycle, guarding `execute_plan()` and `apply_plan()` with plan-level advisory locks and unique per-invocation owner identities to prevent silent concurrent state corruption.
|
||||
|
||||
* HAL 9000 has contributed the bug-hunt-pool-supervisor non-blocking tracking fix (#7875 / PR #7957): updated step 5 to be best-effort and added rule 9 to prevent the automation-tracking-manager call from blocking the main supervisor loop.
|
||||
* Jeffrey Phillips Freeman has contributed the complete AUTO-BUG-POOL to AUTO-BUG-SUP tracking prefix fix across agent-system-specification.md, automation-tracking.md documentation and agent-system-specification.md spec document, replaced with correct `AUTO-BUG-SUP` prefix used by the bug-hunt-pool-supervisor agent (#7875).
|
||||
* HAL 9000 has contributed the plugin entry point security hardening fix (#7476): enforced entry point allowlist validation before importing plugin modules to prevent malicious plugin loading.
|
||||
@@ -44,6 +45,7 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed the ACMS context path matching fix (PR #10975 / issue #10972): corrects `_path_matches()` and `_matches_pattern()` to properly match absolute fragment paths against relative glob patterns by auto-prefixing with `**/` before calling `PurePath.full_match()`, preventing silent inefficacy of include/exclude filters for absolute paths in fragment metadata.
|
||||
* HAL 9000 has contributed database resource types (PostgreSQL, SQLite) with transaction-based sandbox strategy: implemented ``DatabaseResourceHandler`` providing full CRUD operations (`read`, `write`, `delete`, `list_children`) and connection validation with automatic credential masking for PostgreSQL and SQLite backends. Includes ``TransactionSandbox`` infrastructure wired into ``SandboxFactory``, BDD test coverage in ``features/database_resources.feature``, and Robot Framework integration tests in ``robot/database_resources.robot`` (PR #10591 / issue #8608, Epic #8568).
|
||||
* HAL 9000 has contributed the agents plan rollback command (PR #8674 / issue #8557): implemented checkpoint-based plan state restoration with the `agents plan rollback <plan-id> [<checkpoint-id>]` CLI command as part of Epic #8493, enabling plans to be restored to previous checkpoints, discarding post-checkpoint decisions, and resuming execution from the rolled-back state. Supported by `--yes/-y`, `--to-checkpoint`, and `--format/-f` flags. Includes comprehensive BDD test coverage (>= 97%) for rollback, decision discarding, and plan resume functionality.
|
||||
* HAL 9000 has contributed the ContextStrategy protocol and StrategyRegistry system (PR, Epic #8505): implements the ``ContextStrategy`` Protocol with type-safe strategy implementations, a centralized ``StrategyRegistry`` supporting registration/lookup/discovery, entry-point-based automatic discovery from the ``cleveragents.context_strategies`` group for plugin-style extensibility, and six built-in strategies (``simple-keyword``, ``semantic-embedding``, ``breadth-depth-navigator``, ``arce``, ``temporal-archaeology``, ``plan-decision-context``) with BDD test coverage of 97%+.
|
||||
* HAL 9000 has contributed the PyYAML security upgrade (PR #11012 / issue #9055): added `pyyaml>=6.0.3` dependency constraint to address known YAML parsing vulnerabilities.
|
||||
* HAL 9000 has contributed the DecisionService wiring for PlanExecutor strategize persistence fix (#10813): added decision_service to the PlanExecutor constructor and wired it from the CLI dependency-injection container in `_get_plan_executor()`, plus implemented `_persist_strategy_decisions()` to persist strategy decisions as domain `Decision` objects.
|
||||
* HAL 9000 has contributed the A2A module rename standardization BDD tests (PR #10583 / issue #8615): comprehensive Behave test suite validating that all 22 A2A symbols are properly exported from `cleveragents.a2a`, no legacy ACP references remain in the module source, and documentation uses correct A2A naming conventions — fixing inline imports, unused behave symbols, cross-scenario context dependencies, and missing type annotations.
|
||||
|
||||
@@ -211,24 +211,3 @@ Feature: v3 Actor YAML schema support in CLI and registry
|
||||
Given a v3 graph actor config dict with skills and lsp as dict
|
||||
When I parse the v3 config through ReactiveConfigParser
|
||||
Then each graph node agent should have lsp dict propagated
|
||||
|
||||
# M5: options block forwarded to agent_config in _build_from_v3 (#11223).
|
||||
@tdd_issue @tdd_issue_11223
|
||||
Scenario: ReactiveConfigParser propagates options block to agent config
|
||||
Given a v3 LLM actor config dict with options block
|
||||
When I parse the v3 config through ReactiveConfigParser
|
||||
Then the agent config should contain the options block
|
||||
|
||||
# M5: actor without options block is unaffected (#11223).
|
||||
@tdd_issue @tdd_issue_11223
|
||||
Scenario: ReactiveConfigParser handles v3 actor without options block
|
||||
Given a v3 LLM actor config dict without options block
|
||||
When I parse the v3 config through ReactiveConfigParser
|
||||
Then the agent config should not contain an options key
|
||||
|
||||
# M5: empty options dict is preserved (#11223).
|
||||
@tdd_issue @tdd_issue_11223
|
||||
Scenario: ReactiveConfigParser preserves empty options block
|
||||
Given a v3 LLM actor config dict with empty options block
|
||||
When I parse the v3 config through ReactiveConfigParser
|
||||
Then the agent config should contain an empty options block
|
||||
|
||||
@@ -1379,24 +1379,3 @@ Feature: Consolidated Routing
|
||||
When the stream router tool agent processes "keep_me" through operation
|
||||
Then the stream router tool agent operation result should be "keep_me"
|
||||
|
||||
# M5: SimpleLLMAgent._resolve_llm forwards options to registry.create_llm (#11223).
|
||||
@tdd_issue @tdd_issue_11223
|
||||
Scenario: SimpleLLMAgent forwards options block to LLM constructor
|
||||
Given a stream router llm agent with options block containing custom base url
|
||||
When the stream router llm agent resolves the llm
|
||||
Then the llm constructor should have received the custom base url
|
||||
|
||||
# M5: actor without options block is unaffected (#11223).
|
||||
@tdd_issue @tdd_issue_11223
|
||||
Scenario: SimpleLLMAgent without options block calls LLM constructor without extra kwargs
|
||||
Given a stream router llm agent without options block
|
||||
When the stream router llm agent resolves the llm
|
||||
Then the llm constructor should not have received extra kwargs
|
||||
|
||||
# M5: top-level keys take precedence over duplicates in options (#11223).
|
||||
@tdd_issue @tdd_issue_11223
|
||||
Scenario: SimpleLLMAgent top-level key takes precedence over options duplicate
|
||||
Given a stream router llm agent with top-level temperature and options block
|
||||
When the stream router llm agent resolves the llm
|
||||
Then the llm constructor should have received the top-level temperature
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
@phase2 @acms @context_strategies_batch2
|
||||
Feature: Built-in Context Strategies Batch 2 — Advanced Strategies
|
||||
As a CleverAgents developer
|
||||
I want advanced built-in context strategies (ARCE, Temporal Archaeology, Plan Decision)
|
||||
So that the ACMS pipeline can use high-quality strategies for diverse retrieval scenarios
|
||||
|
||||
# ===========================================================================
|
||||
# ARCE Strategy (quality 0.95)
|
||||
# ===========================================================================
|
||||
|
||||
@arce
|
||||
Scenario: ARCE returns correct quality score with all backends
|
||||
Given an empty context strategy registry
|
||||
When I register all 6 built-in strategies in the registry
|
||||
Then the registry should contain "arce"
|
||||
And the arce explain should contain "adaptive"
|
||||
And the 'arce' name should be "arce"
|
||||
|
||||
|
||||
@arce
|
||||
Scenario: ARCE assembles with composite scoring and iterative refinement
|
||||
Given an empty context strategy registry
|
||||
When I register all 6 built-in strategies in the registry
|
||||
Then the entry for "arce" should be marked as builtin
|
||||
And the arce name should be "arce"
|
||||
|
||||
# ===========================================================================
|
||||
# TemporalArchaeologyStrategy (quality 0.5)
|
||||
# ===========================================================================
|
||||
|
||||
@temporal_archaeology
|
||||
Scenario: TemporalArchaeology returns correct quality score
|
||||
Given an empty context strategy registry
|
||||
When I register all 6 built-in strategies in the registry
|
||||
Then the entry for "temporal-archaeology" should be marked as builtin
|
||||
|
||||
|
||||
@temporal_archaeology
|
||||
Scenario: TemporalArchaeology explain mentions historical patterns
|
||||
Given a BackendSet with temporal backend only
|
||||
And a default ContextRequest
|
||||
When I instantiate the "temporal-archaeology" strategy
|
||||
Then the temporal archaeology explain should contain "historical"
|
||||
|
||||
# ===========================================================================
|
||||
# PlanDecisionContextStrategy (quality 0.7)
|
||||
# ===========================================================================
|
||||
|
||||
@plan_decision_context
|
||||
Scenario: PlanDecisionContext returns correct quality score
|
||||
Given an empty context strategy registry
|
||||
When I register all 6 built-in strategies in the registry
|
||||
Then the entry for "plan-decision-context" should be marked as builtin
|
||||
|
||||
|
||||
@plan_decision_context
|
||||
Scenario: PlanDecisionContext explain mentions decision history
|
||||
Given a BackendSet with temporal backend only
|
||||
And a default ContextRequest
|
||||
When I instantiate the "plan-decision-context" strategy
|
||||
Then the plan decision context explain should contain "prior"
|
||||
@@ -1,53 +0,0 @@
|
||||
Feature: Database URL sanitisation — credentials must never be exposed
|
||||
|
||||
As a CleverAgents operator
|
||||
I want database URLs in CLI output to have their credentials masked
|
||||
So that running ``agents info`` never leaks passwords or API tokens
|
||||
|
||||
@tdd_bug_8395
|
||||
Scenario Outline: PostgreSQL URL with user and password — all are masked
|
||||
Given the database url is "<url>"
|
||||
When I run sanitise_db_url
|
||||
Then the sanitised database url should be "<expected>"
|
||||
|
||||
Examples:
|
||||
| url | expected |
|
||||
| postgresql://user:secret@localhost/mydb | postgresql://***:***@localhost/mydb |
|
||||
| postgresql://admin:p%40ssw0rd@db.example.com:5432/agents | postgresql://***:***@db.example.com:5432/agents |
|
||||
| postgresql://readonly:r0ad_only@pg.cluster.internal:5433/production | postgresql://***:***@pg.cluster.internal:5433/production |
|
||||
|
||||
Scenario Outline: MySQL URL with credentials — all are masked
|
||||
Given the database url is "<url>"
|
||||
When I run sanitise_db_url
|
||||
Then the sanitised database url should be "<expected>"
|
||||
|
||||
Examples:
|
||||
| url | expected |
|
||||
| mysql://app:s3cret@mysql.internal:3306/agents | mysql://***:***@mysql.internal:3306/agents |
|
||||
| mysql+pymysql://root:toor@localhost/testdb | mysql+pymysql://***:***@localhost/testdb |
|
||||
|
||||
Scenario Outline: SQLite URLs — remain unchanged (no credentials)
|
||||
Given the database url is "<url>"
|
||||
When I run sanitise_db_url
|
||||
Then the sanitised database url should be "<expected>"
|
||||
|
||||
Examples:
|
||||
| url | expected |
|
||||
| sqlite:///data/cleveragents.db | sqlite:///data/cleveragents.db |
|
||||
| sqlite:////absolute/path/to/db.sqlite | sqlite:////absolute/path/to/db.sqlite |
|
||||
| memory | memory |
|
||||
|
||||
Scenario: Username-only URL — password is still masked
|
||||
Given the database url is "postgres://deploy@db.example.com/prod"
|
||||
When I run sanitise_db_url
|
||||
Then the sanitised database url should be "postgres://***:***@db.example.com/prod"
|
||||
|
||||
Scenario: SQLite URL without credentials remains unchanged
|
||||
Given the database url is "sqlite:///test.db"
|
||||
When I run sanitise_db_url
|
||||
Then the sanitised database url should be "sqlite:///test.db"
|
||||
|
||||
Scenario: build_info_data returns sanitised database URL
|
||||
Given a mock settings object with database url "postgresql://admin:supersecret@host.example.com:5432/mydb"
|
||||
When I call build_info_data
|
||||
Then the database field in info data should be "postgresql://***:***@host.example.com:5432/mydb"
|
||||
@@ -0,0 +1,52 @@
|
||||
@phase2 @acms @entry_points
|
||||
Feature: Context Strategy Entry-Point Discovery
|
||||
As a CleverAgents developer
|
||||
I want context strategies to be auto-discovered via Python entry points
|
||||
So that third-party strategies can be loaded without modifying core code
|
||||
|
||||
# ===========================================================================
|
||||
# Entry-point registration verification
|
||||
# ===========================================================================
|
||||
|
||||
@entry_points_registration
|
||||
Scenario: All 6 built-in strategies are discoverable via entry points
|
||||
Given an empty strategy registry
|
||||
When I discover all strategies from the entry point group in the registry
|
||||
Then the registry should contain "simple-keyword"
|
||||
And the registry should contain "semantic-embedding"
|
||||
And the registry should contain "breadth-depth-navigator"
|
||||
And the registry should contain "arce"
|
||||
And the registry should contain "temporal-archaeology"
|
||||
And the registry should contain "plan-decision-context"
|
||||
And the registry should list 6 strategies
|
||||
|
||||
|
||||
@entry_points_registration
|
||||
Scenario: Entry-point strategy is callable and has correct name
|
||||
Given an empty strategy registry
|
||||
When I discover all strategies from the entry point group in the registry
|
||||
Then the 'arce' should be in the registry via entry points
|
||||
And the 'arce' name should be "arce"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Non-existent group handling
|
||||
# ===========================================================================
|
||||
|
||||
@entry_points_missing_group
|
||||
Scenario: Discovery returns empty for non-existent entry point group
|
||||
Given an empty strategy registry
|
||||
When I attempt to discover strategies from a non-existent entry point group
|
||||
Then no new strategies should be registered
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Third-party strategy discovery (mocked / future)
|
||||
# ===========================================================================
|
||||
|
||||
@third_party_entry_point
|
||||
Scenario: Built-in strategies are properly marked as builtins
|
||||
Given an empty strategy registry
|
||||
When I discover all strategies from the entry point group in the registry
|
||||
Then the builtins should include "simple-keyword"
|
||||
And the builtins should include "arce"
|
||||
@@ -235,21 +235,3 @@ Feature: Execute-phase context assembler coverage
|
||||
When epcov I call assemble on the plan
|
||||
Then epcov the assembled result should be an AssembledContext
|
||||
And epcov the assembled result should have fragments and metadata
|
||||
|
||||
# ---- _resolve_hot_max_tokens: reads context_policy_json from DB ----
|
||||
|
||||
@tdd_issue @tdd_issue_11035
|
||||
Scenario: epcov assemble uses project-level hot_max_tokens from context_policy_json
|
||||
Given epcov an assembler with context_policy_json hot_max_tokens 32000
|
||||
And epcov a plan with project links
|
||||
And epcov scoped fragments that pass all filters
|
||||
When epcov I call assemble on the plan
|
||||
Then epcov the pipeline received budget max_tokens of 32000
|
||||
|
||||
@tdd_issue @tdd_issue_11035
|
||||
Scenario: epcov assemble falls back to global hot_max_tokens when context_policy_json has no override
|
||||
Given epcov an assembler with no hot_max_tokens in context_policy_json
|
||||
And epcov a plan with project links
|
||||
And epcov scoped fragments that pass all filters
|
||||
When epcov I call assemble on the plan
|
||||
Then epcov the pipeline received budget max_tokens of 4096
|
||||
|
||||
@@ -14,7 +14,6 @@ from unittest.mock import patch
|
||||
|
||||
import yaml
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.actor.compiler import ActorCompilationError
|
||||
from cleveragents.actor.config import ActorConfiguration
|
||||
@@ -782,77 +781,3 @@ def step_graph_nodes_have_lsp_dict(context: Any) -> None:
|
||||
assert lsp.get("auto") is True, (
|
||||
f"Node '{node_id}' expected lsp.auto=True, got {lsp}"
|
||||
)
|
||||
|
||||
|
||||
# ── M5: options block forwarding (#11223) ─────────────────────────────────
|
||||
|
||||
|
||||
@given("a v3 LLM actor config dict with options block")
|
||||
def step_v3_llm_config_with_options(context: Context) -> None:
|
||||
context.v3_config = {
|
||||
"name": "local/my-llama-actor",
|
||||
"type": "llm",
|
||||
"description": "Local llama.cpp actor",
|
||||
"provider": "openai",
|
||||
"model": "my-local-model",
|
||||
"options": {
|
||||
"openai_api_base": "http://localhost:8080/v1",
|
||||
"openai_api_key": "none",
|
||||
"temperature": 1.0,
|
||||
},
|
||||
"system_prompt": "You are a helpful assistant.",
|
||||
}
|
||||
|
||||
|
||||
@given("a v3 LLM actor config dict without options block")
|
||||
def step_v3_llm_config_without_options(context: Context) -> None:
|
||||
context.v3_config = {
|
||||
"name": "local/standard-actor",
|
||||
"type": "llm",
|
||||
"description": "Standard actor without options",
|
||||
"provider": "openai",
|
||||
"model": "gpt-4",
|
||||
"system_prompt": "You are a helpful assistant.",
|
||||
}
|
||||
|
||||
|
||||
@then("the agent config should contain the options block")
|
||||
def step_agent_config_has_options(context: Context) -> None:
|
||||
agents = context.reactive_config.agents
|
||||
agent = next(iter(agents.values()))
|
||||
options = agent.config.get("options")
|
||||
assert options == {
|
||||
"openai_api_base": "http://localhost:8080/v1",
|
||||
"openai_api_key": "none",
|
||||
"temperature": 1.0,
|
||||
}, f"Expected exact options dict, got {options}"
|
||||
|
||||
|
||||
@then("the agent config should not contain an options key")
|
||||
def step_agent_config_no_options(context: Context) -> None:
|
||||
agents = context.reactive_config.agents
|
||||
agent = next(iter(agents.values()))
|
||||
assert "options" not in agent.config, (
|
||||
f"Expected no 'options' key in agent config, got {agent.config}"
|
||||
)
|
||||
|
||||
|
||||
@given("a v3 LLM actor config dict with empty options block")
|
||||
def step_v3_llm_config_with_empty_options(context: Context) -> None:
|
||||
context.v3_config = {
|
||||
"name": "local/empty-options-actor",
|
||||
"type": "llm",
|
||||
"description": "Actor with empty options",
|
||||
"provider": "openai",
|
||||
"model": "gpt-4",
|
||||
"options": {},
|
||||
"system_prompt": "You are a helpful assistant.",
|
||||
}
|
||||
|
||||
|
||||
@then("the agent config should contain an empty options block")
|
||||
def step_agent_config_has_empty_options(context: Context) -> None:
|
||||
agents = context.reactive_config.agents
|
||||
agent = next(iter(agents.values()))
|
||||
options = agent.config.get("options")
|
||||
assert options == {}, f"Expected empty options dict, got {options}"
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Behave step implementations for context strategy registry batch 2 and entry points."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.application.services.strategy_registry import StrategyRegistry
|
||||
from cleveragents.domain.models.acms.strategy import StrategyConfig
|
||||
from cleveragents.domain.models.acms.strategy_stubs import (
|
||||
ARCEStrategy,
|
||||
BUILTIN_STRATEGY_CLASSES,
|
||||
PlanDecisionContextStrategy,
|
||||
TemporalArchaeologyStrategy,
|
||||
)
|
||||
|
||||
__all__: list[str] = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _builtin_dict() -> dict[str, object]:
|
||||
"""Return all built-in strategy instances keyed by name."""
|
||||
return {cls().name: cls() for cls in BUILTIN_STRATEGY_CLASSES}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps — batch 2 strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("an empty context strategy registry")
|
||||
def step_given_empty_registry(context: Context) -> None:
|
||||
"""Create a fresh ``StrategyRegistry`` with no strategies."""
|
||||
context.registry = StrategyRegistry()
|
||||
|
||||
|
||||
@given('I instantiate the "{name}" strategy')
|
||||
def step_given_instantiate_strategy(context: Context, name: str) -> None:
|
||||
"""Instantiate a single strategy for batch 2 scenarios."""
|
||||
strategy_map: dict[str, object] = {
|
||||
"arce": ARCEStrategy(),
|
||||
"temporal-archaeology": TemporalArchaeologyStrategy(),
|
||||
"plan-decision-context": PlanDecisionContextStrategy(),
|
||||
}
|
||||
if name not in strategy_map:
|
||||
raise ValueError(f"Unknown strategy name: {name}")
|
||||
context.strategy = strategy_map[name]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps — batch 2 strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I register all 6 built-in strategies in the registry")
|
||||
def step_when_register_all_6(context: Context) -> None:
|
||||
"""Register all six built-in strategies from strategy_stubs."""
|
||||
for cls in BUILTIN_STRATEGY_CLASSES:
|
||||
inst = cls()
|
||||
context.registry.register(inst, config=StrategyConfig(enabled=True), is_builtin=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps — entry points discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I discover all strategies from the entry point group in the registry")
|
||||
def step_when_discover_via_entry_points(context: Context) -> None:
|
||||
"""Trigger actual entry-point discovery via importlib.metadata."""
|
||||
discovered = context.registry.discover_from_entry_points()
|
||||
context.entry_points_discovered = discovered
|
||||
|
||||
|
||||
@when(
|
||||
"I attempt to discover strategies from a non-existent entry point group"
|
||||
)
|
||||
def step_when_discover_nonexistent_group(context: Context) -> None:
|
||||
"""Try to discover from a fake entry-point group — should find nothing."""
|
||||
discovered = context.registry.discover_from_entry_points(
|
||||
group="cleveragents.nonexistent.group"
|
||||
)
|
||||
context.entry_points_discovered = discovered
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps — batch 2 + entry points
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the "arce" should be in the registry via entry points')
|
||||
def step_then_arce_via_entry_points(context: Context) -> None:
|
||||
"""Verify ARCE was registered through entry-point discovery."""
|
||||
assert context.registry.is_registered("arce"), (
|
||||
"ARCE strategy was not registered via entry points"
|
||||
)
|
||||
|
||||
|
||||
@then("no new strategies should be registered")
|
||||
def step_then_no_new_strategies(context: Context) -> None:
|
||||
"""Verify no side effects occurred during discovery."""
|
||||
assert context.entry_points_discovered == 0, (
|
||||
f"Expected 0 discovered strategies, got {context.entry_points_discovered}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Additional Then steps — batch 2 scenarios
|
||||
# The following step implementations cover scenarios from:
|
||||
# features/context_strategies_batch2.feature
|
||||
# features/entry_point_discovery.feature
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the registry should contain "{name}"')
|
||||
def step_then_registry_contains(context: Context, name: str) -> None:
|
||||
"""Verify the registry contains a strategy by name."""
|
||||
assert context.registry.is_registered(name), (
|
||||
f"'{name}' not in registry. Registered: {context.registry.list_all()}"
|
||||
)
|
||||
|
||||
|
||||
@then('the entry for "{name}" should be marked as builtin')
|
||||
def step_then_entry_for_marked_builtin(context: Context, name: str) -> None:
|
||||
"""Verify a named strategy entry is marked as builtin."""
|
||||
entry = context.registry.get_entry(name)
|
||||
assert entry.is_builtin, (
|
||||
f"Entry '{name}' is not marked as builtin. "
|
||||
f"Is registered: {context.registry.is_registered(name)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the builtin list should include the following strategies")
|
||||
def step_then_builtin_list_include(context: Context, table: object) -> None:
|
||||
"""Verify a list of strategy names are marked as builtin."""
|
||||
builtins = context.registry.list_builtin()
|
||||
for row in table.dict: # type: ignore[union-attr]
|
||||
# Iterate over each column (all should map to the same name field)
|
||||
for _key, value in row.items():
|
||||
assert value in builtins, (
|
||||
f"Expected '{value}' in builtin list. Got: {builtins}"
|
||||
)
|
||||
|
||||
|
||||
##############################################################################
|
||||
# --- Named strategy explain steps (batch 2) ---
|
||||
###############################################################################
|
||||
|
||||
|
||||
@then("the arce explain should contain \"{text}\"")
|
||||
def step_then_arce_explain_contains(context: Context, text: str) -> None:
|
||||
"""Verify ARCE strategy explain mentions the given keyword."""
|
||||
strategy = context.registry.get("arce")
|
||||
explanation = strategy.explain()
|
||||
assert text in explanation, (
|
||||
f"Expected '{text}' in ARCE explain.\nGot:\n{explanation}"
|
||||
)
|
||||
|
||||
|
||||
@then("the arce name should be \"{name}\"")
|
||||
def step_then_arce_name(context: Context, name: str) -> None:
|
||||
"""Verify the ARCE strategy has the expected name."""
|
||||
strategy = context.registry.get("arce")
|
||||
assert strategy.name == name, (
|
||||
f"Expected ARCE name '{name}', got '{strategy.name}'"
|
||||
)
|
||||
|
||||
|
||||
##############################################################################
|
||||
# --- Named strategy explain steps — temporal archaeology ---
|
||||
###############################################################################
|
||||
|
||||
|
||||
@then("the temporal archaeology explain should contain \"{text}\"")
|
||||
def step_then_temporal_explain_contains(context: Context, text: str) -> None:
|
||||
"""Verify TemporalArchaeology strategy explain mentions the given keyword."""
|
||||
strategy = context.registry.get("temporal-archaeology")
|
||||
explanation = strategy.explain()
|
||||
assert text in explanation, (
|
||||
f"Expected '{text}' in temporal archaeology explain.\nGot:\n{explanation}"
|
||||
)
|
||||
|
||||
|
||||
##############################################################################
|
||||
# --- Named strategy explain steps — plan decision context ---
|
||||
###############################################################################
|
||||
|
||||
|
||||
@then("the plan decision context explain should contain \"{text}\"")
|
||||
def step_then_plan_dec_explain_contains(context: Context, text: str) -> None:
|
||||
"""Verify PlanDecisionContextStrategy explain mentions the given keyword."""
|
||||
strategy = context.registry.get("plan-decision-context")
|
||||
explanation = strategy.explain()
|
||||
assert text in explanation, (
|
||||
f"Expected '{text}' in plan decision context explain.\nGot:\n{explanation}"
|
||||
)
|
||||
|
||||
|
||||
##############################################################################
|
||||
# --- Named strategy name steps with quoted single quotes ---
|
||||
################################################################################
|
||||
|
||||
|
||||
@then("the 'arce' name should be \"{name}\"")
|
||||
def step_then_arce_quoted_name(context: Context, name: str) -> None:
|
||||
"""Verify ARCE (quoted key) has the expected name.
|
||||
|
||||
This step handles the Gherkin pattern: the 'arce' name should be "arce"
|
||||
where single quotes delimit the strategy identifier.
|
||||
"""
|
||||
strategy = context.registry.get("arce")
|
||||
assert strategy.name == name, (
|
||||
f"Expected 'arce' name '{name}', got '{strategy.name}'"
|
||||
)
|
||||
|
||||
|
||||
##############################################################################
|
||||
# --- Builtins should include individual names ---
|
||||
###############################################################################
|
||||
|
||||
|
||||
@then("the builtins should include \"{name}\"")
|
||||
def step_then_builtin_include(context: Context, name: str) -> None:
|
||||
"""Verify a named strategy exists in the builtin list."""
|
||||
builtins = context.registry.list_builtin()
|
||||
assert name in builtins, (
|
||||
f"Expected '{name}' in builtin list. Got: {builtins}"
|
||||
)
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Step definitions for db_url_sanitisation.feature.
|
||||
|
||||
Tests the ``_sanitise_db_url`` helper and its use in ``build_info_data``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from tempfile import mkdtemp
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('the database url is "{url}"')
|
||||
def step_db_url_given(context: Context, url: str) -> None:
|
||||
"""Store the raw database URL for processing."""
|
||||
context.raw_db_url = url
|
||||
|
||||
|
||||
@given('a mock settings object with database url "{db_url}"')
|
||||
def step_mock_settings_with_db_url(context: Context, db_url: str) -> None:
|
||||
"""Build a full mock Settings that ``build_info_data`` expects."""
|
||||
tmpdir = mkdtemp()
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.database_url = db_url
|
||||
mock_settings.data_dir = Path(tmpdir)
|
||||
mock_settings.storage_path = Path(tmpdir)
|
||||
mock_settings.config_path = Path(tmpdir) / "config.toml"
|
||||
mock_settings.log_dir = Path(tmpdir) / "logs"
|
||||
mock_settings.default_automation_profile = "auto"
|
||||
mock_settings.has_provider_configured = MagicMock(return_value=False)
|
||||
mock_settings.configured_provider_names = MagicMock(return_value=[])
|
||||
mock_settings.debug_enabled = False
|
||||
context.mock_settings = mock_settings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I run sanitise_db_url")
|
||||
def step_run_sanitise(context: Context) -> None:
|
||||
"""Call _sanitise_db_url with the stored raw URL."""
|
||||
from cleveragents.cli.commands.system import _sanitise_db_url
|
||||
|
||||
context.sanitised_url = _sanitise_db_url(context.raw_db_url)
|
||||
|
||||
|
||||
@when("I call build_info_data")
|
||||
def step_call_build_info(context: Context) -> None:
|
||||
"""Call ``build_info_data`` with the mock settings."""
|
||||
from cleveragents.cli.commands.system import build_info_data
|
||||
|
||||
with patch(
|
||||
"cleveragents.config.settings.get_settings",
|
||||
return_value=context.mock_settings,
|
||||
):
|
||||
context.info_data = build_info_data()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the sanitised database url should be "{expected}"')
|
||||
def step_assert_sanitised_url(context: Context, expected: str) -> None:
|
||||
"""Verify the sanitised URL matches expectation."""
|
||||
actual = context.sanitised_url
|
||||
assert actual == expected, f"Expected '{expected}', got '{actual}'"
|
||||
|
||||
|
||||
@then('the database field in info data should be "{expected}"')
|
||||
def step_assert_info_db_field(context: Context, expected: str) -> None:
|
||||
"""Verify the sanitised URL appears correctly in build_info_data output."""
|
||||
actual = context.info_data["database"]
|
||||
assert actual == expected, f"Expected database field '{expected}', got '{actual}'"
|
||||
@@ -12,7 +12,6 @@ Exercises all uncovered lines in
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -938,112 +937,3 @@ def step_epcov_assembled_has_data(context: Context) -> None:
|
||||
assert result.budget_used >= 0.0, "Expected non-negative budget_used"
|
||||
assert result.context_hash, "Expected non-empty context_hash"
|
||||
assert result.strategies_used, "Expected non-empty strategies_used"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_hot_max_tokens: reads hot_max_tokens from context_policy_json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_assembler_with_policy_json(
|
||||
policy_json: str | None,
|
||||
) -> ACMSExecutePhaseContextAssembler:
|
||||
"""Build an assembler whose DB session returns a row with *policy_json*."""
|
||||
pr = _make_pipeline_result()
|
||||
pr.fragments = ()
|
||||
pr.total_tokens = 5
|
||||
pr.budget_used = 0.1
|
||||
pr.strategies_used = ("relevance",)
|
||||
pr.context_hash = "hash-policy"
|
||||
pr.preamble = None
|
||||
pr.provenance_map = {}
|
||||
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.assemble.return_value = pr
|
||||
|
||||
tier_service = MagicMock()
|
||||
# Return a minimal passing fragment so assemble() does not short-circuit
|
||||
frag = _make_tiered_fragment(
|
||||
fragment_id="frag-policy",
|
||||
content="content",
|
||||
token_count=5,
|
||||
metadata={"path": "src/good.py"},
|
||||
)
|
||||
tier_service.get_scoped_view.return_value = [frag]
|
||||
|
||||
# Build a mock row with context_policy_json
|
||||
mock_row = MagicMock()
|
||||
mock_row.context_policy_json = policy_json
|
||||
|
||||
# Mock the session so _resolve_hot_max_tokens can query it
|
||||
mock_session = MagicMock()
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_row
|
||||
|
||||
repo = MagicMock()
|
||||
repo._session.return_value = mock_session
|
||||
repo.get_context_policy.return_value = ProjectContextPolicy()
|
||||
|
||||
return ACMSExecutePhaseContextAssembler(
|
||||
context_tier_service=tier_service,
|
||||
project_repository=repo,
|
||||
acms_pipeline=mock_pipeline,
|
||||
hot_max_tokens=4096,
|
||||
)
|
||||
|
||||
|
||||
@given("epcov an assembler with context_policy_json hot_max_tokens 32000")
|
||||
def step_epcov_assembler_policy_json_32k(context: Context) -> None:
|
||||
"""Assembler backed by a DB row with hot_max_tokens=32000 in policy JSON.
|
||||
|
||||
Mirrors the real storage format written by
|
||||
``agents project context set --hot-max-tokens 32000``:
|
||||
``{"acms_config": {"hot_max_tokens": 32000, ...}, ...}``.
|
||||
"""
|
||||
policy_json = json.dumps({"acms_config": {"hot_max_tokens": 32000}})
|
||||
context.epcov_assembler = _make_assembler_with_policy_json(policy_json)
|
||||
|
||||
|
||||
@given("epcov an assembler with no hot_max_tokens in context_policy_json")
|
||||
def step_epcov_assembler_policy_json_no_override(context: Context) -> None:
|
||||
"""Assembler backed by a DB row with no hot_max_tokens — global fallback."""
|
||||
policy_json = json.dumps({"acms_config": {"other_setting": "value"}})
|
||||
context.epcov_assembler = _make_assembler_with_policy_json(policy_json)
|
||||
|
||||
|
||||
@then("epcov the pipeline received budget max_tokens of 32000")
|
||||
def step_epcov_pipeline_budget_32k(context: Context) -> None:
|
||||
"""Verify the pipeline was called with max_tokens=32000 in the budget."""
|
||||
if context.epcov_error is not None:
|
||||
raise AssertionError(f"Unexpected error: {context.epcov_error}")
|
||||
pipeline = context.epcov_assembler._pipeline
|
||||
assert pipeline.assemble.called, "Pipeline.assemble() was not called"
|
||||
call_args = pipeline.assemble.call_args
|
||||
budget = call_args.kwargs.get("budget") or (
|
||||
call_args[1].get("budget") if call_args[1] else None
|
||||
)
|
||||
if budget is None and call_args[0]:
|
||||
budget = call_args[0][2] if len(call_args[0]) > 2 else None
|
||||
assert budget is not None, "Could not find budget in pipeline.assemble() call"
|
||||
assert budget.max_tokens == 32000, (
|
||||
f"Expected budget.max_tokens=32000 (from context_policy_json), "
|
||||
f"got {budget.max_tokens}"
|
||||
)
|
||||
|
||||
|
||||
@then("epcov the pipeline received budget max_tokens of 4096")
|
||||
def step_epcov_pipeline_budget_global(context: Context) -> None:
|
||||
"""Verify the pipeline fell back to the global hot_max_tokens=4096."""
|
||||
if context.epcov_error is not None:
|
||||
raise AssertionError(f"Unexpected error: {context.epcov_error}")
|
||||
pipeline = context.epcov_assembler._pipeline
|
||||
assert pipeline.assemble.called, "Pipeline.assemble() was not called"
|
||||
call_args = pipeline.assemble.call_args
|
||||
budget = call_args.kwargs.get("budget") or (
|
||||
call_args[1].get("budget") if call_args[1] else None
|
||||
)
|
||||
if budget is None and call_args[0]:
|
||||
budget = call_args[0][2] if len(call_args[0]) > 2 else None
|
||||
assert budget is not None, "Could not find budget in pipeline.assemble() call"
|
||||
assert budget.max_tokens == 4096, (
|
||||
f"Expected budget.max_tokens=4096 (global fallback), got {budget.max_tokens}"
|
||||
)
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
@@ -161,115 +160,3 @@ def step_then_tool_agent_operation_result(context: Context, expected: str) -> No
|
||||
assert context.op_result == expected, (
|
||||
f"Expected '{expected}', got '{context.op_result}'"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M5: SimpleLLMAgent._resolve_llm forwards options to create_llm (#11223).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a stream router llm agent with options block containing custom base url")
|
||||
def step_given_llm_agent_with_options(context: Context) -> None:
|
||||
context.llm_options_agent = SimpleLLMAgent(
|
||||
"test_options",
|
||||
{
|
||||
"provider": "openai",
|
||||
"model": "my-local-model",
|
||||
"options": {
|
||||
"openai_api_base": "http://localhost:8080/v1",
|
||||
"openai_api_key": "none",
|
||||
},
|
||||
},
|
||||
)
|
||||
context.create_llm_kwargs: dict[str, Any] = {}
|
||||
|
||||
|
||||
@given("a stream router llm agent without options block")
|
||||
def step_given_llm_agent_without_options(context: Context) -> None:
|
||||
context.llm_options_agent = SimpleLLMAgent(
|
||||
"test_no_options",
|
||||
{
|
||||
"provider": "openai",
|
||||
"model": "gpt-4",
|
||||
},
|
||||
)
|
||||
context.create_llm_kwargs = {}
|
||||
|
||||
|
||||
@when("the stream router llm agent resolves the llm")
|
||||
def step_when_llm_agent_resolves(context: Context) -> None:
|
||||
mock_llm = MagicMock()
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_create_llm(
|
||||
provider_type: Any = None, model_id: Any = None, **kwargs: Any
|
||||
) -> Any:
|
||||
captured["provider_type"] = provider_type
|
||||
captured["model_id"] = model_id
|
||||
captured["kwargs"] = kwargs
|
||||
return mock_llm
|
||||
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.create_llm.side_effect = fake_create_llm
|
||||
|
||||
with patch(
|
||||
"cleveragents.reactive.stream_router.get_provider_registry",
|
||||
return_value=mock_registry,
|
||||
):
|
||||
context.llm_options_agent._resolve_llm()
|
||||
|
||||
context.create_llm_kwargs = captured
|
||||
|
||||
|
||||
@then("the llm constructor should have received the custom base url")
|
||||
def step_then_llm_received_custom_base_url(context: Context) -> None:
|
||||
kwargs = context.create_llm_kwargs.get("kwargs", {})
|
||||
assert "openai_api_base" in kwargs, (
|
||||
f"Expected 'openai_api_base' in create_llm kwargs, got {kwargs}"
|
||||
)
|
||||
assert kwargs["openai_api_base"] == "http://localhost:8080/v1", (
|
||||
f"Expected openai_api_base='http://localhost:8080/v1', got {kwargs}"
|
||||
)
|
||||
# openai_api_key is routed through __api_key_sentinel so the
|
||||
# registry can distinguish explicit keys from environment defaults.
|
||||
assert kwargs.get("__api_key_sentinel") == "none", (
|
||||
f"Expected __api_key_sentinel='none' (from options.openai_api_key), "
|
||||
f"got {kwargs}"
|
||||
)
|
||||
|
||||
|
||||
@then("the llm constructor should not have received extra kwargs")
|
||||
def step_then_llm_no_extra_kwargs(context: Context) -> None:
|
||||
kwargs = context.create_llm_kwargs.get("kwargs", {})
|
||||
# When neither options nor top-level params are present, nothing
|
||||
# should be forwarded to the LLM constructor.
|
||||
assert kwargs == {}, f"Expected empty kwargs in create_llm call, got {kwargs}"
|
||||
|
||||
|
||||
@given("a stream router llm agent with top-level temperature and options block")
|
||||
def step_given_llm_agent_with_precedence(context: Context) -> None:
|
||||
context.llm_options_agent = SimpleLLMAgent(
|
||||
"test_precedence",
|
||||
{
|
||||
"provider": "openai",
|
||||
"model": "gpt-4",
|
||||
"temperature": 0.5,
|
||||
"options": {
|
||||
"temperature": 1.0,
|
||||
"max_tokens": 4096,
|
||||
},
|
||||
},
|
||||
)
|
||||
context.create_llm_kwargs: dict[str, Any] = {}
|
||||
|
||||
|
||||
@then("the llm constructor should have received the top-level temperature")
|
||||
def step_then_llm_received_top_level_temperature(context: Context) -> None:
|
||||
kwargs = context.create_llm_kwargs.get("kwargs", {})
|
||||
assert kwargs.get("temperature") == 0.5, (
|
||||
f"Expected temperature=0.5 (top-level precedence over options), got {kwargs}"
|
||||
)
|
||||
# max_tokens from options should still be forwarded.
|
||||
assert kwargs.get("max_tokens") == 4096, (
|
||||
f"Expected max_tokens=4096 from options, got {kwargs}"
|
||||
)
|
||||
|
||||
@@ -107,6 +107,14 @@ Issues = "https://git.cleverthis.com/cleveragents/core/issues"
|
||||
cleveragents = "cleveragents.cli:main"
|
||||
agents = "cleveragents.cli:main"
|
||||
|
||||
[project.entry-points."cleveragents.context_strategies"]
|
||||
simple-keyword = "cleveragents.domain.models.acms.strategy_stubs:SimpleKeywordStrategy"
|
||||
semantic-embedding = "cleveragents.domain.models.acms.strategy_stubs:SemanticEmbeddingStrategy"
|
||||
breadth-depth-navigator = "cleveragents.domain.models.acms.strategy_stubs:BreadthDepthNavigatorStrategy"
|
||||
arce = "cleveragents.domain.models.acms.strategy_stubs:ARCEStrategy"
|
||||
temporal-archaeology = "cleveragents.domain.models.acms.strategy_stubs:TemporalArchaeologyStrategy"
|
||||
plan-decision-context = "cleveragents.domain.models.acms.strategy_stubs:PlanDecisionContextStrategy"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/cleveragents"]
|
||||
include = [
|
||||
|
||||
@@ -116,10 +116,7 @@ class ACMSExecutePhaseContextAssembler(ExecutePhaseContextAssembler):
|
||||
if row is not None and row.context_policy_json is not None:
|
||||
try:
|
||||
config_dict = json.loads(cast(str, row.context_policy_json))
|
||||
# hot_max_tokens is stored under the "acms_config" sub-key
|
||||
# by agents project context set.
|
||||
acms = config_dict.get("acms_config") or {}
|
||||
tokens = acms.get("hot_max_tokens")
|
||||
tokens = config_dict.get("hot_max_tokens")
|
||||
if tokens is not None and isinstance(tokens, int) and tokens > 0:
|
||||
candidates.append(tokens)
|
||||
except (ValueError, TypeError):
|
||||
|
||||
@@ -497,6 +497,116 @@ class StrategyRegistry:
|
||||
|
||||
return warnings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Entry-point discovery
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def discover_from_entry_points(
|
||||
self, *, group: str = "cleveragents.context_strategies"
|
||||
) -> int:
|
||||
"""Discover and register strategies from Python entry points.
|
||||
|
||||
Scans the given entry-point group for strategy registrations
|
||||
(see ``pyproject.toml`` ``[project.entry-points]``). Each matching
|
||||
entry point is resolved to a class, instantiated, and registered
|
||||
with the registry as a built-in strategy.
|
||||
|
||||
Security:
|
||||
Only modules under :attr:`DEFAULT_ALLOWED_MODULE_PREFIXES` may be
|
||||
dynamically imported from external packages. Internal built-ins
|
||||
(under ``cleveragents.``) are always permitted.
|
||||
|
||||
Args:
|
||||
group: The entry-point group to scan. Defaults to
|
||||
``"cleveragents.context_strategies"``.
|
||||
|
||||
Returns:
|
||||
Number of strategies discovered and registered.
|
||||
|
||||
Example::
|
||||
|
||||
registry = StrategyRegistry()
|
||||
count = registry.discover_from_entry_points()
|
||||
# count == 6 for the six built-in strategies
|
||||
"""
|
||||
import importlib.metadata as _metadata
|
||||
|
||||
discovered = 0
|
||||
|
||||
try:
|
||||
eps = _metadata.entry_points(group=group)
|
||||
except (ValueError, TypeError): # group doesn't exist
|
||||
logger.debug(
|
||||
"strategy.discovering_no_group",
|
||||
group=group,
|
||||
)
|
||||
return 0
|
||||
|
||||
for ep in sorted(eps, key=lambda e: e.name):
|
||||
name = ep.name
|
||||
# Security (CWE-706): Enforce module allowlist BEFORE loading.
|
||||
# The entry point value is ``"module.path:ClassName"`` — extract
|
||||
# the module portion and validate against _allowed_module_prefixes.
|
||||
ep_value = str(ep.value) # e.g. "pkg.module:StrategyName"
|
||||
if ":" in ep_value:
|
||||
module_name = ep_value.split(":", 1)[0].strip()
|
||||
else:
|
||||
logger.warning(
|
||||
"strategy.discovering_bad_entry_point",
|
||||
name=name,
|
||||
value=ep_value,
|
||||
)
|
||||
continue
|
||||
|
||||
if self._allowed_module_prefixes and not any(
|
||||
module_name.startswith(prefix)
|
||||
for prefix in self._allowed_module_prefixes
|
||||
):
|
||||
logger.warning(
|
||||
"strategy.discovering_blocked",
|
||||
name=name,
|
||||
module=module_name,
|
||||
allowed=self._allowed_module_prefixes,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
loaded = ep.load()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"strategy.discovering_failed",
|
||||
name=name,
|
||||
error=str(exc),
|
||||
)
|
||||
continue
|
||||
|
||||
# Type safety: ``ep.load()`` returns ``Any`` — narrow to a Callable.
|
||||
if not callable(loaded):
|
||||
logger.warning(
|
||||
"strategy.discovering_not_callable",
|
||||
name=name,
|
||||
type=type(loaded).__name__,
|
||||
)
|
||||
continue
|
||||
|
||||
instance = loaded()
|
||||
|
||||
self.register(
|
||||
instance,
|
||||
name=name,
|
||||
config=StrategyConfig(enabled=True),
|
||||
is_builtin=True,
|
||||
)
|
||||
discovered += 1
|
||||
|
||||
if discovered > 0:
|
||||
logger.info(
|
||||
"strategy.discovered_entry_points",
|
||||
count=discovered,
|
||||
group=group,
|
||||
)
|
||||
return discovered
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Removal (for testing / reconfiguration)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -9,7 +9,6 @@ Deprecated alias: ``agents context <subcommand>`` (emits deprecation warning)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Annotated, Any
|
||||
|
||||
@@ -17,7 +16,6 @@ import typer
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from cleveragents.cli.formatting import OutputFormat
|
||||
from cleveragents.cli.renderers import _get_console
|
||||
from cleveragents.core.exceptions import (
|
||||
CleverAgentsError,
|
||||
@@ -27,20 +25,6 @@ from cleveragents.core.exceptions import (
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.domain.models.core import Context
|
||||
|
||||
|
||||
# Console instances for ACMS commands
|
||||
_err_console: Any = None
|
||||
|
||||
|
||||
def _get_err_console() -> Any:
|
||||
"""Lazily create an stderr console (for ACMS command output)."""
|
||||
global _err_console
|
||||
if _err_console is None:
|
||||
from rich.console import Console
|
||||
|
||||
_err_console = Console(stderr=True)
|
||||
return _err_console
|
||||
|
||||
# Create sub-app for context commands
|
||||
app = typer.Typer(
|
||||
help="Actor context management commands (canonical: agents actor context)"
|
||||
@@ -888,316 +872,6 @@ def context_analyze(
|
||||
typer.echo(ContextAnalysisEngine.format_text(result))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ACMS-aware commands: ACMS context index (tier-service backed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _truncate(value: str, max_width: int) -> str:
|
||||
"""Truncate a string to *max_width* characters, appending '...'."""
|
||||
if len(value) <= max_width:
|
||||
return value
|
||||
return value[: max_width - 3] + "..."
|
||||
|
||||
|
||||
_datetime_called = False # for testability
|
||||
|
||||
|
||||
def _datetime_now() -> Any:
|
||||
"""Return the current UTC datetime.
|
||||
|
||||
Overridable in tests by patching this function.
|
||||
"""
|
||||
global _datetime_called
|
||||
_datetime_called = True
|
||||
try:
|
||||
return datetime.now(tz=UTC)
|
||||
except Exception:
|
||||
from datetime import datetime as _dt
|
||||
|
||||
return _dt.utcnow()
|
||||
|
||||
|
||||
@app.command("index")
|
||||
def context_index(
|
||||
tier: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--tier",
|
||||
"-t",
|
||||
help="Filter by tier: hot, warm, cold (default: show all tiers)",
|
||||
),
|
||||
] = None,
|
||||
fmt: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
"--format",
|
||||
"-f",
|
||||
help="Output format: rich, color, table, plain, json, yaml (default: rich)",
|
||||
),
|
||||
] = "rich",
|
||||
) -> None:
|
||||
"""List ACMS context index entries (tier-service backed).
|
||||
|
||||
Queries the ContextTierService to display all indexed context
|
||||
fragments across hot/warm/cold tiers. Optionally filter by a
|
||||
specific tier with ``--tier``.
|
||||
|
||||
Examples::
|
||||
|
||||
agents actor context index
|
||||
agents actor context index --tier hot
|
||||
agents actor context index --format json
|
||||
"""
|
||||
try:
|
||||
from cleveragents.application.services.context_tiers import (
|
||||
ContextTierService,
|
||||
)
|
||||
from cleveragents.domain.models.acms.tiers import ContextTier
|
||||
|
||||
tier_service = ContextTierService()
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Error:[/red] Unable to init tier service: {exc}")
|
||||
raise typer.Abort() from exc
|
||||
|
||||
if tier is not None and tier.lower() not in ("hot", "warm", "cold"):
|
||||
_get_err_console().print(
|
||||
f"[red]Error:[/red] Invalid tier '{tier}'. Must be one of: hot, warm, cold."
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# Fetch fragments
|
||||
all_frags = tier_service.get_all_fragments()
|
||||
|
||||
if tier is not None:
|
||||
filter_tier = ContextTier(tier.lower())
|
||||
all_frags = [f for f in all_frags if f.tier == filter_tier]
|
||||
|
||||
if not all_frags:
|
||||
console.print("[yellow]No context index entries found.[/yellow]")
|
||||
console.print("Use 'agents actor context add-acms' to add new entries.")
|
||||
return
|
||||
|
||||
# Non-rich output delegates to format_output
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
entries = [
|
||||
{
|
||||
"fragment_id": f.fragment_id,
|
||||
"tier": f.tier.value,
|
||||
"resource_id": f.resource_id or "N/A",
|
||||
"project_name": f.project_name or "N/A",
|
||||
"token_count": f.token_count,
|
||||
"access_count": f.access_count,
|
||||
"last_accessed": f.last_accessed.isoformat(),
|
||||
}
|
||||
for f in all_frags
|
||||
]
|
||||
console.print(format_output(entries, fmt))
|
||||
return
|
||||
|
||||
# Rich output: table
|
||||
table = Table(
|
||||
title=f"ACMS Context Index ({len(all_frags)} entries)"
|
||||
+ (f" [tier={tier}]" if tier else ""),
|
||||
expand=False,
|
||||
)
|
||||
table.add_column("Fragment ID", style="cyan")
|
||||
table.add_column("Tier", style="green")
|
||||
table.add_column("Resource", style="magenta")
|
||||
table.add_column("Project", style="blue")
|
||||
table.add_column("Tokens", justify="right")
|
||||
table.add_column("Accesses", justify="right")
|
||||
table.add_column("Last Accessed", style="yellow")
|
||||
|
||||
for frag in all_frags:
|
||||
table.add_row(
|
||||
_truncate(frag.fragment_id, 40),
|
||||
frag.tier.value,
|
||||
_truncate(frag.resource_id or "N/A", 30),
|
||||
_truncate(frag.project_name or "N/A", 25),
|
||||
str(frag.token_count),
|
||||
str(frag.access_count),
|
||||
frag.last_accessed.strftime("%Y-%m-%d %H:%M"),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@app.command("add-acms")
|
||||
def context_add_acms(
|
||||
content: Annotated[
|
||||
str,
|
||||
typer.Argument(help="Text content to add as a context fragment"),
|
||||
],
|
||||
resource_id: Annotated[
|
||||
str | None,
|
||||
typer.Option("--resource", "-r", help="Resource ID / URI for the fragment"),
|
||||
] = None,
|
||||
project_name: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--project",
|
||||
"-p",
|
||||
help="Project name for scoping the fragment",
|
||||
),
|
||||
] = None,
|
||||
tier: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
"--tier",
|
||||
"-t",
|
||||
help="Target tier: hot, warm, or cold (default: hot)",
|
||||
),
|
||||
] = "hot",
|
||||
token_count: Annotated[
|
||||
int | None,
|
||||
typer.Option("--tokens", help="Token count for the fragment"),
|
||||
] = None,
|
||||
fmt: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
"--format",
|
||||
"-f",
|
||||
help="Output format: rich, color, table, plain, json, yaml (default: rich)",
|
||||
),
|
||||
] = "rich",
|
||||
) -> None:
|
||||
"""Add a fragment to the ACMS context index.
|
||||
|
||||
Stores content as a TieredFragment in the ContextTierService. The
|
||||
fragment is placed in the specified tier (default: hot).
|
||||
|
||||
Examples::
|
||||
|
||||
agents actor context add-acms 'Core implementation details' \\
|
||||
--resource file:///src/app/main.py --project my-app \\
|
||||
--tier warm --tokens 1200
|
||||
"""
|
||||
try:
|
||||
from cleveragents.application.services.context_tiers import (
|
||||
ContextTierService,
|
||||
)
|
||||
from cleveragents.domain.models.acms.tiers import ContextTier, TieredFragment
|
||||
|
||||
tier_service = ContextTierService()
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Error:[/red] Unable to init tier service: {exc}")
|
||||
raise typer.Abort() from exc
|
||||
|
||||
# Validate tier
|
||||
try:
|
||||
target_tier = ContextTier(tier.lower())
|
||||
except ValueError:
|
||||
_get_err_console().print(
|
||||
f"[red]Error:[/red] Invalid tier '{tier}'. Must be one of: hot, warm, cold."
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# Estimate token count if not provided (~4 chars per token)
|
||||
if token_count is None:
|
||||
token_count = max(1, len(content) // 4)
|
||||
|
||||
# Generate a fragment_id from resource or content hash
|
||||
if resource_id:
|
||||
fragment_id = (
|
||||
f"{resource_id}:frag-{hashlib.sha256(content.encode()).hexdigest()[:8]}"
|
||||
)
|
||||
else:
|
||||
fragment_id = f"frag-{hashlib.sha256(content.encode()).hexdigest()[:12]}"
|
||||
|
||||
# Build the TieredFragment
|
||||
now = _datetime_now()
|
||||
fragment = TieredFragment(
|
||||
fragment_id=fragment_id,
|
||||
content=content,
|
||||
tier=target_tier,
|
||||
resource_id=resource_id or "",
|
||||
project_name=project_name or "",
|
||||
token_count=token_count,
|
||||
last_accessed=now,
|
||||
access_count=0,
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
# Store the fragment (will replace if fragment_id already exists)
|
||||
tier_service.store(fragment)
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
data = {
|
||||
"fragment_added": {
|
||||
"fragment_id": fragment.fragment_id,
|
||||
"tier": fragment.tier.value,
|
||||
"resource_id": fragment.resource_id or "N/A",
|
||||
"project_name": fragment.project_name or "N/A",
|
||||
"token_count": fragment.token_count,
|
||||
}
|
||||
}
|
||||
console.print(format_output(data, fmt))
|
||||
else:
|
||||
console.print(
|
||||
Panel(
|
||||
(
|
||||
f"[bold]Fragment ID:[/bold] {fragment_id}\n"
|
||||
f"[bold]Tier:[/bold] {target_tier.value}\n"
|
||||
f"[bold]Resource:[/bold] {resource_id or 'N/A'}\n"
|
||||
f"[bold]Project:[/bold] {project_name or 'N/A'}\n"
|
||||
f"[bold]Tokens:[/bold] {token_count}"
|
||||
),
|
||||
title="ACMS Fragment Added",
|
||||
border_style="green",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
console.print("[green]\u2713 OK[/green] Fragment stored in ACMS context index")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@app.command("remove-acms")
|
||||
def context_remove_acms(
|
||||
fragment_id: Annotated[
|
||||
str,
|
||||
typer.Argument(help="Fragment ID to remove from the ACMS index"),
|
||||
],
|
||||
) -> None:
|
||||
"""Remove a fragment from the ACMS context index.
|
||||
|
||||
Removes the specified fragment from all tiers in the ContextTierService.
|
||||
|
||||
Examples::
|
||||
|
||||
agents actor context remove-acms file:///src/app/main.py:frag-a1b2c3d4
|
||||
"""
|
||||
try:
|
||||
from cleveragents.application.services.context_tiers import (
|
||||
ContextTierService,
|
||||
)
|
||||
|
||||
tier_service = ContextTierService()
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Error:[/red] Unable to init tier service: {exc}")
|
||||
raise typer.Abort() from exc
|
||||
|
||||
# Remove the fragment from all tiers using internal helper
|
||||
tier_service._remove_from_all(fragment_id) # noqa: SLF001
|
||||
|
||||
# Check that it was actually removed
|
||||
not_removed = (
|
||||
fragment_id in tier_service._hot # noqa: SLF001
|
||||
or fragment_id in tier_service._warm # noqa: SLF001
|
||||
or fragment_id in tier_service._cold # noqa: SLF001
|
||||
)
|
||||
|
||||
if not not_removed:
|
||||
console.print(
|
||||
f"[green]\u2713 OK[/green] Fragment '{fragment_id}' removed from ACMS index."
|
||||
)
|
||||
else:
|
||||
console.print(f"[yellow]Fragment ID '{fragment_id}' was already absent.[/yellow]")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Add a callback for when context command is called without subcommand
|
||||
@app.callback(invoke_without_command=True)
|
||||
def context_default(ctx: typer.Context) -> None:
|
||||
|
||||
@@ -17,7 +17,6 @@ from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
@@ -83,41 +82,6 @@ def _dep_version(package: str) -> str:
|
||||
return "not installed"
|
||||
|
||||
|
||||
def _sanitise_db_url(url: str) -> str:
|
||||
"""Sanitise a database URL by masking credentials.
|
||||
|
||||
Parses the URL and replaces username/password components with masked
|
||||
placeholders so that CLI output never exposes real credentials.
|
||||
|
||||
Examples::
|
||||
|
||||
>>> _sanitise_db_url("postgresql://user:secret@localhost/mydb")
|
||||
'postgresql://***:***@localhost/mydb'
|
||||
>>> _sanitise_db_url("sqlite:///path/to/db.sqlite")
|
||||
'sqlite:///path/to/db.sqlite'
|
||||
|
||||
Args:
|
||||
url: The raw database URL.
|
||||
|
||||
Returns:
|
||||
The sanitised URL with credentials masked (or unchanged if no
|
||||
username/password is present).
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
|
||||
# If there is no userinfo segment, return as-is (e.g. sqlite URLs)
|
||||
if not parsed.username and not parsed.password:
|
||||
return url
|
||||
|
||||
# Rebuild the netloc with masked credentials
|
||||
userinfo = "***:***"
|
||||
port_part = f":{parsed.port}" if parsed.port else ""
|
||||
new_netloc = f"{userinfo}@{parsed.hostname}{port_part}"
|
||||
|
||||
sanitized = parsed._replace(netloc=new_netloc)
|
||||
return urlunparse(sanitized)
|
||||
|
||||
|
||||
def build_version_data() -> dict[str, Any]:
|
||||
"""Assemble structured data for the ``version`` command."""
|
||||
return {
|
||||
@@ -181,7 +145,7 @@ def build_info_data() -> dict[str, Any]:
|
||||
"version": __version__,
|
||||
"data_dir": str(data_dir),
|
||||
"config_path": str(config_path),
|
||||
"database": _sanitise_db_url(db_url),
|
||||
"database": db_url,
|
||||
"server_mode": server_mode,
|
||||
"platform": f"{platform.system()} {platform.release()} ({platform.machine()})",
|
||||
"automation": settings.default_automation_profile,
|
||||
|
||||
@@ -3916,6 +3916,9 @@ class ValidationAttachmentRepository:
|
||||
|
||||
from ulid import ULID as _ULID
|
||||
|
||||
if "/" in resource_id and "/" not in validation_name:
|
||||
validation_name, resource_id = resource_id, validation_name
|
||||
|
||||
session = self._session()
|
||||
try:
|
||||
# Check for existing attachment with same validation+resource+scope
|
||||
|
||||
@@ -286,9 +286,9 @@ class ReactiveConfigParser:
|
||||
are propagated via the agent config so the runtime can attach them.
|
||||
|
||||
All v3 fields — ``context_view``, ``memory``, ``context``,
|
||||
``env_vars``, ``response_format``, ``lsp_capabilities``,
|
||||
``lsp_context_enrichment``, and ``options`` — are propagated
|
||||
into the agent config so the runtime can consume them.
|
||||
``env_vars``, ``response_format``, ``lsp_capabilities``, and
|
||||
``lsp_context_enrichment`` — are propagated into the agent config
|
||||
so the runtime can consume them.
|
||||
"""
|
||||
# m4: guard against None-to-string coercion for all three fields.
|
||||
actor_type = str(data.get("type") or "llm").lower()
|
||||
@@ -375,11 +375,6 @@ class ReactiveConfigParser:
|
||||
if isinstance(response_format, dict):
|
||||
agent_config["response_format"] = response_format
|
||||
|
||||
# M5: propagate actor options block for custom backend support (#11223).
|
||||
options_raw = data.get("options")
|
||||
if isinstance(options_raw, dict):
|
||||
agent_config["options"] = dict(options_raw)
|
||||
|
||||
if actor_type in ("llm", "tool"):
|
||||
# Single-agent synthesis.
|
||||
rc.agents[actor_name] = AgentConfig(
|
||||
@@ -429,9 +424,6 @@ class ReactiveConfigParser:
|
||||
else dict(lsp_bindings)
|
||||
)
|
||||
node_config.setdefault("lsp", lsp_copy)
|
||||
# M5: propagate actor-level options to graph nodes (#11223).
|
||||
if isinstance(options_raw, dict) and options_raw:
|
||||
node_config.setdefault("options", dict(options_raw))
|
||||
nodes_map[node_id] = node_config
|
||||
|
||||
# Create a reactive agent for each graph node.
|
||||
|
||||
@@ -226,55 +226,6 @@ class SimpleLLMAgent:
|
||||
llm_kwargs["max_tokens"] = max_tokens
|
||||
if max_retries is not None:
|
||||
llm_kwargs["max_retries"] = max_retries
|
||||
# M5: merge options block so custom LLM backend kwargs
|
||||
# (e.g. openai_api_base, openai_api_key) are forwarded to the
|
||||
# LLM constructor. Options are applied after the fixed keys so
|
||||
# that explicit top-level keys (temperature, max_tokens, etc.)
|
||||
# take precedence over duplicates in options.
|
||||
#
|
||||
# openai_api_key is handled specially: the registry uses a
|
||||
# __api_key_sentinel mechanism to distinguish explicitly
|
||||
# provided keys from environment-sourced ones. If the user
|
||||
# supplies openai_api_key in options, we extract it and inject
|
||||
# it via the sentinel so it overrides the registry's default.
|
||||
options = dict(self.config.get("options") or {})
|
||||
if "openai_api_key" in options:
|
||||
llm_kwargs["__api_key_sentinel"] = options.pop("openai_api_key")
|
||||
# Ensure sensitive/reserved ChatOpenAI constructor params cannot
|
||||
# be injected via a crafted actor YAML.
|
||||
_ALLOWED_OPTIONS: frozenset[str] = frozenset(
|
||||
{
|
||||
"openai_api_base",
|
||||
"temperature",
|
||||
"max_tokens",
|
||||
"timeout",
|
||||
"top_p",
|
||||
"frequency_penalty",
|
||||
"presence_penalty",
|
||||
}
|
||||
)
|
||||
_RESERVED: frozenset[str] = frozenset({"provider_type", "model_id"})
|
||||
for key, value in options.items():
|
||||
if key in _RESERVED:
|
||||
logger_sr.warning(
|
||||
"Actor '%s' options block contains reserved key '%s' "
|
||||
"that conflicts with positional arguments; ignoring.",
|
||||
self.name,
|
||||
key,
|
||||
)
|
||||
continue
|
||||
if key not in llm_kwargs:
|
||||
if key in _ALLOWED_OPTIONS:
|
||||
llm_kwargs[key] = value
|
||||
else:
|
||||
logger_sr.warning(
|
||||
"Actor '%s' options block contains unrecognized "
|
||||
"key '%s'; ignoring for security. Allowed keys: "
|
||||
"%s.",
|
||||
self.name,
|
||||
key,
|
||||
sorted(_ALLOWED_OPTIONS),
|
||||
)
|
||||
registry = get_provider_registry()
|
||||
self._llm = registry.create_llm(
|
||||
provider_type=provider, model_id=model, **llm_kwargs
|
||||
|
||||
Reference in New Issue
Block a user