chore: resolve merge conflicts and apply cosmetic fixes
CI / push-validation (push) Successful in 29s
CI / helm (push) Successful in 39s
CI / build (push) Successful in 1m9s
CI / lint (push) Successful in 1m23s
CI / typecheck (push) Successful in 1m34s
CI / quality (push) Successful in 1m39s
CI / security (push) Successful in 1m46s
CI / e2e_tests (push) Successful in 52s
CI / benchmark-regression (push) Failing after 40s
CI / integration_tests (push) Successful in 5m6s
CI / unit_tests (push) Successful in 6m7s
CI / docker (push) Successful in 1m28s
CI / coverage (push) Successful in 10m46s
CI / status-check (push) Successful in 7s
CI / benchmark-publish (push) Failing after 45m49s
CI / helm (pull_request) Successful in 45s
CI / build (pull_request) Successful in 1m8s
CI / lint (pull_request) Successful in 1m45s
CI / quality (pull_request) Successful in 1m39s
CI / security (pull_request) Successful in 2m11s
CI / typecheck (pull_request) Successful in 2m21s
CI / push-validation (pull_request) Successful in 22s
CI / integration_tests (pull_request) Successful in 4m19s
CI / unit_tests (pull_request) Successful in 5m5s
CI / docker (pull_request) Successful in 1m47s
CI / coverage (pull_request) Successful in 11m53s
CI / status-check (pull_request) Successful in 3s
CI / push-validation (push) Successful in 29s
CI / helm (push) Successful in 39s
CI / build (push) Successful in 1m9s
CI / lint (push) Successful in 1m23s
CI / typecheck (push) Successful in 1m34s
CI / quality (push) Successful in 1m39s
CI / security (push) Successful in 1m46s
CI / e2e_tests (push) Successful in 52s
CI / benchmark-regression (push) Failing after 40s
CI / integration_tests (push) Successful in 5m6s
CI / unit_tests (push) Successful in 6m7s
CI / docker (push) Successful in 1m28s
CI / coverage (push) Successful in 10m46s
CI / status-check (push) Successful in 7s
CI / benchmark-publish (push) Failing after 45m49s
CI / helm (pull_request) Successful in 45s
CI / build (pull_request) Successful in 1m8s
CI / lint (pull_request) Successful in 1m45s
CI / quality (pull_request) Successful in 1m39s
CI / security (pull_request) Successful in 2m11s
CI / typecheck (pull_request) Successful in 2m21s
CI / push-validation (pull_request) Successful in 22s
CI / integration_tests (pull_request) Successful in 4m19s
CI / unit_tests (pull_request) Successful in 5m5s
CI / docker (pull_request) Successful in 1m47s
CI / coverage (pull_request) Successful in 11m53s
CI / status-check (pull_request) Successful in 3s
- Resolve CHANGELOG.md conflict to include both entries, referencing issue #8164 instead of PR #11161 - Resolve CONTRIBUTORS.md conflict, fix leading space in new entry - Remove duplicate create_sequence_node function (dead code) Merges master into feat/structural-output-validation branch
This commit was merged in pull request #11161.
This commit is contained in:
+8
-1
@@ -54,7 +54,14 @@ Changed `wf10_batch.robot` to be less likely to create files, and
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Structural Component Output Validation** (#11161): 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)
|
||||
- **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)
|
||||
- **Fixed `agents actor add --config` crash with nested `actors:` map and `config.actor` combined shorthand** (#11189): The
|
||||
CLI command `agents actor add --config` now correctly parses spec-canonical YAML using the
|
||||
nested `actors:` map format with `config.actor: "provider/model"` combined shorthand.
|
||||
Fixed two defects in `ActorConfiguration._extract_v3_actor()`: `type` is now detected at
|
||||
the actor-entry level (sibling of `config`), and `config.actor` is parsed as fallback
|
||||
when separate `provider`/`model` keys are absent. Added validation to reject malformed
|
||||
combined values with empty provider or model halves.
|
||||
|
||||
- **`task-implementor` posts work-started notification comments** (#11031): Both
|
||||
the `issue_impl` and `pr_fix` procedures now post an informational "work
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@ Below are some of the specific details of various contributions.
|
||||
* 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.
|
||||
* HAL 9000 has contributed the benchmark workflow separation (#9040): moved the benchmark-regression job out of the default PR workflow into a dedicated scheduled workflow, reducing median PR CI turnaround time from 99-132 minutes to under 30 minutes.
|
||||
* HAL 9000 has contributed the plan tree JSON/YAML command envelope fix (#9163): wrapped `agents plan tree --format json/yaml` output in the spec-required command envelope structure, added summary statistics, decision_ids mapping, child_plans list, and accurate timing measurement.
|
||||
* HAL 9000 contributed Structural Component Output Validation (PR #11161): implemented `validate_plan_tree`, `validate_decision_dict`, `validate_structured_output`, and `validate_structured_component_output` validators that replace exact-character matching with structural schema checking for plan tree nodes, decision CLI dictionaries, and structured session output envelopes.
|
||||
* HAL 9000 contributed Structural Component Output Validation (PR #11161 / issue #8164): implemented `validate_plan_tree`, `validate_decision_dict`, `validate_structured_output`, and `validate_structured_component_output` validators that replace exact-character matching with structural schema checking for plan tree nodes, decision CLI dictionaries, and structured session output envelopes.
|
||||
* HAMZA KHYARI has contributed the ACMS execute-phase context assembler project-level hot_max_tokens fix (PR #11036 / issue #11035): added `_resolve_effective_budget()` method that reads each linked project's `settings.hot_max_tokens` and uses the maximum override value as the pipeline budget instead of the hardcoded global 16K default.
|
||||
* This project was made possible thanks to considerable donation of time, money, and resources by CleverThis, Inc.
|
||||
* HAL 9000 has contributed automated bug fixes, CLI output formatting improvements, and ongoing maintenance as part of the CleverAgents automation system.
|
||||
* HAL 9000 has contributed the file edit encoding parameter fix (PR #8258 / issue #7559).
|
||||
|
||||
@@ -271,3 +271,62 @@ Feature: Actor add CLI validates v3 YAML via ActorConfigSchema
|
||||
When I run the actor add command for "local/bad-edge-ref" with the prepared config file
|
||||
Then v3actor the command should fail
|
||||
And v3actor the error message should contain "not found"
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Nested actors map: combined config.actor field (bug #11189)
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
@tdd_issue @tdd_issue_11189
|
||||
Scenario: Register LLM actor using nested actors map with combined config.actor field
|
||||
Given a nested LLM actor YAML using combined config.actor field with name "local/nested-combined-llm"
|
||||
When I run the actor add command for "local/nested-combined-llm" with the prepared config file
|
||||
Then v3actor the command should succeed
|
||||
And v3actor the actor should be registered with provider "anthropic" and model "claude-sonnet-4-5"
|
||||
|
||||
Scenario: Explicit config.provider takes precedence over provider derived from config.actor
|
||||
Given a nested LLM actor YAML with explicit config.provider overriding config.actor with name "local/explicit-provider-llm"
|
||||
When I run the actor add command for "local/explicit-provider-llm" with the prepared config file
|
||||
Then v3actor the command should succeed
|
||||
And v3actor the actor should be registered with provider "explicit-provider"
|
||||
And v3actor the actor should be registered with model "gpt-4"
|
||||
|
||||
Scenario: Nested actors map with no provider or model still raises validation error
|
||||
Given a nested LLM actor YAML with no provider or model fields with name "local/no-provider-nested-llm"
|
||||
When I run the actor add command for "local/no-provider-nested-llm" with the prepared config file
|
||||
Then v3actor the command should fail
|
||||
And v3actor the error message should contain "provider"
|
||||
|
||||
Scenario: Explicit config.model takes precedence over model derived from config.actor
|
||||
Given a nested LLM actor YAML with explicit config.model overriding config.actor with name "local/explicit-model-llm"
|
||||
When I run the actor add command for "local/explicit-model-llm" with the prepared config file
|
||||
Then v3actor the command should succeed
|
||||
And v3actor the actor should be registered with provider "anthropic"
|
||||
And v3actor the actor should be registered with model "explicit-model"
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Malformed config.actor edge cases
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
Scenario: Combined config.actor with empty model half is treated as absent
|
||||
Given a nested LLM actor YAML with combined config.actor "provider/" and name "local/empty-model-actor"
|
||||
When I run the actor add command for "local/empty-model-actor" with the prepared config file
|
||||
Then v3actor the command should fail
|
||||
And v3actor the error message should contain "provider"
|
||||
|
||||
Scenario: Combined config.actor with empty provider half is treated as missing provider
|
||||
Given a nested LLM actor YAML with combined config.actor "/model" and name "local/empty-provider-actor"
|
||||
When I run the actor add command for "local/empty-provider-actor" with the prepared config file
|
||||
Then v3actor the command should fail
|
||||
And v3actor the error message should contain "provider"
|
||||
|
||||
Scenario: Combined config.actor without slash delimiter is treated as absent
|
||||
Given a nested LLM actor YAML with combined config.actor "noslash" and name "local/noslash-actor"
|
||||
When I run the actor add command for "local/noslash-actor" with the prepared config file
|
||||
Then v3actor the command should fail
|
||||
And v3actor the error message should contain "provider"
|
||||
|
||||
Scenario: Combined config.actor with both halves empty is treated as absent
|
||||
Given a nested LLM actor YAML with combined config.actor "/" and name "local/slash-only-actor"
|
||||
When I run the actor add command for "local/slash-only-actor" with the prepared config file
|
||||
Then v3actor the command should fail
|
||||
And v3actor the error message should contain "provider"
|
||||
|
||||
@@ -440,15 +440,22 @@ def step_run_actor_add(context: Context, name: str) -> None:
|
||||
assert result.exception is None, f"Unexpected exception: {result.exception}"
|
||||
context.command_error = None
|
||||
context.actor_registered = True
|
||||
# Verify registry was called and extract the actor_type from the config
|
||||
# Verify registry was called and extract actor metadata from the config blob
|
||||
if mock_registry.upsert_actor.called:
|
||||
call_kwargs = mock_registry.upsert_actor.call_args
|
||||
blob = call_kwargs.kwargs.get("config_blob") if call_kwargs.kwargs else None
|
||||
context.registered_actor_type = (
|
||||
blob.get("type") if isinstance(blob, dict) else None
|
||||
)
|
||||
if isinstance(blob, dict):
|
||||
context.registered_actor_type = blob.get("type")
|
||||
context.registered_actor_provider = blob.get("provider")
|
||||
context.registered_actor_model = blob.get("model")
|
||||
else:
|
||||
context.registered_actor_type = None
|
||||
context.registered_actor_provider = None
|
||||
context.registered_actor_model = None
|
||||
else:
|
||||
context.registered_actor_type = None
|
||||
context.registered_actor_provider = None
|
||||
context.registered_actor_model = None
|
||||
else:
|
||||
assert isinstance(result.exception, SystemExit), (
|
||||
f"Expected SystemExit, got: {result.exception}"
|
||||
@@ -456,6 +463,8 @@ def step_run_actor_add(context: Context, name: str) -> None:
|
||||
context.command_error = result.output
|
||||
context.actor_registered = False
|
||||
context.registered_actor_type = None
|
||||
context.registered_actor_provider = None
|
||||
context.registered_actor_model = None
|
||||
|
||||
|
||||
@when('I run the actor update command for "{name}" with the prepared config file')
|
||||
@@ -532,15 +541,22 @@ def step_run_actor_update(context: Context, name: str) -> None:
|
||||
assert result.exception is None, f"Unexpected exception: {result.exception}"
|
||||
context.command_error = None
|
||||
context.actor_registered = True
|
||||
# Verify registry was called and extract the actor_type from the config
|
||||
# Verify registry was called and extract actor metadata from the config blob
|
||||
if mock_registry.upsert_actor.called:
|
||||
call_kwargs = mock_registry.upsert_actor.call_args
|
||||
blob = call_kwargs.kwargs.get("config_blob") if call_kwargs.kwargs else None
|
||||
context.registered_actor_type = (
|
||||
blob.get("type") if isinstance(blob, dict) else None
|
||||
)
|
||||
if isinstance(blob, dict):
|
||||
context.registered_actor_type = blob.get("type")
|
||||
context.registered_actor_provider = blob.get("provider")
|
||||
context.registered_actor_model = blob.get("model")
|
||||
else:
|
||||
context.registered_actor_type = None
|
||||
context.registered_actor_provider = None
|
||||
context.registered_actor_model = None
|
||||
else:
|
||||
context.registered_actor_type = None
|
||||
context.registered_actor_provider = None
|
||||
context.registered_actor_model = None
|
||||
else:
|
||||
assert isinstance(result.exception, SystemExit), (
|
||||
f"Expected SystemExit, got: {result.exception}"
|
||||
@@ -548,6 +564,8 @@ def step_run_actor_update(context: Context, name: str) -> None:
|
||||
context.command_error = result.output
|
||||
context.actor_registered = False
|
||||
context.registered_actor_type = None
|
||||
context.registered_actor_provider = None
|
||||
context.registered_actor_model = None
|
||||
|
||||
|
||||
# ── Then Steps (Assertions) — prefixed with "v3actor" to avoid conflicts ─────
|
||||
@@ -1127,6 +1145,206 @@ route:
|
||||
context.config_file = _write_yaml_file(context, "bad-edge-ref", yaml_content)
|
||||
|
||||
|
||||
# ── Nested actors map: combined config.actor field (bug #11189) ───────────────
|
||||
|
||||
|
||||
@given('a nested LLM actor YAML using combined config.actor field with name "{name}"')
|
||||
def step_nested_llm_combined_actor(context: Context, name: str) -> None:
|
||||
"""Create a spec-compliant nested-actors-map YAML using the combined config.actor field.
|
||||
|
||||
The ``actors:`` map format places ``type: llm`` at the actor-entry level
|
||||
(``actors.<name>.type``), not inside the ``config:`` block. The provider
|
||||
and model are supplied via the combined ``config.actor: provider/model``
|
||||
shorthand rather than separate ``config.provider``/``config.model`` keys.
|
||||
|
||||
This is the exact format described in the spec and reproduced in bug #11189.
|
||||
"""
|
||||
yaml_content = f"""\
|
||||
name: {name}
|
||||
cleveragents:
|
||||
version: "3.0"
|
||||
default_actor: main
|
||||
actors:
|
||||
main:
|
||||
type: llm
|
||||
config:
|
||||
actor: anthropic/claude-sonnet-4-5
|
||||
system_prompt: "You are a test actor."
|
||||
temperature: 0.3
|
||||
"""
|
||||
context.config_file = _write_yaml_file(context, name, yaml_content)
|
||||
|
||||
|
||||
@given(
|
||||
"a nested LLM actor YAML with explicit config.provider overriding config.actor"
|
||||
' with name "{name}"'
|
||||
)
|
||||
def step_nested_llm_explicit_provider_overrides_combined(
|
||||
context: Context, name: str
|
||||
) -> None:
|
||||
"""Create a nested-actors-map YAML with both config.provider and config.actor set.
|
||||
|
||||
When ``config.provider`` is explicitly present it must take precedence over
|
||||
the provider parsed from the combined ``config.actor: provider/model``
|
||||
shorthand. The model half of the combined field is still used as the
|
||||
model fallback (since no separate ``config.model`` is provided here).
|
||||
"""
|
||||
yaml_content = f"""\
|
||||
name: {name}
|
||||
cleveragents:
|
||||
version: "3.0"
|
||||
default_actor: main
|
||||
actors:
|
||||
main:
|
||||
type: llm
|
||||
config:
|
||||
provider: explicit-provider
|
||||
actor: override/gpt-4
|
||||
system_prompt: "Precedence test actor."
|
||||
"""
|
||||
context.config_file = _write_yaml_file(context, name, yaml_content)
|
||||
|
||||
|
||||
@given('a nested LLM actor YAML with no provider or model fields with name "{name}"')
|
||||
def step_nested_llm_no_provider_or_model(context: Context, name: str) -> None:
|
||||
"""Create a nested-actors-map YAML with type: llm but no provider or model.
|
||||
|
||||
When neither ``config.provider``, ``config.model``, nor ``config.actor``
|
||||
are present the extraction must fail gracefully and propagate a validation
|
||||
error message so that users receive a clear diagnostic.
|
||||
"""
|
||||
yaml_content = f"""\
|
||||
name: {name}
|
||||
cleveragents:
|
||||
version: "3.0"
|
||||
default_actor: main
|
||||
actors:
|
||||
main:
|
||||
type: llm
|
||||
config:
|
||||
system_prompt: "Actor without provider or model."
|
||||
"""
|
||||
context.config_file = _write_yaml_file(context, name, yaml_content)
|
||||
|
||||
|
||||
@given(
|
||||
"a nested LLM actor YAML with explicit config.model overriding config.actor"
|
||||
' with name "{name}"'
|
||||
)
|
||||
def step_nested_llm_explicit_model_overrides_combined(
|
||||
context: Context, name: str
|
||||
) -> None:
|
||||
"""Create a nested-actors-map YAML with both config.model and config.actor set.
|
||||
|
||||
When ``config.model`` is explicitly present it must take precedence over
|
||||
the model parsed from the combined ``config.actor: provider/model``
|
||||
shorthand. The provider half of the combined field is still used as the
|
||||
provider fallback (since no separate ``config.provider`` is provided here).
|
||||
"""
|
||||
yaml_content = f"""\
|
||||
name: {name}
|
||||
cleveragents:
|
||||
version: "3.0"
|
||||
default_actor: main
|
||||
actors:
|
||||
main:
|
||||
type: llm
|
||||
config:
|
||||
model: explicit-model
|
||||
actor: anthropic/claude-sonnet-4-5
|
||||
system_prompt: "Model precedence test actor."
|
||||
"""
|
||||
context.config_file = _write_yaml_file(context, name, yaml_content)
|
||||
|
||||
|
||||
@given(
|
||||
'a nested LLM actor YAML with combined config.actor "{combined_value}"'
|
||||
' and name "{name}"'
|
||||
)
|
||||
def step_nested_llm_malformed_combined_actor(
|
||||
context: Context, combined_value: str, name: str
|
||||
) -> None:
|
||||
"""Create a nested-actors-map YAML with a specific config.actor value.
|
||||
|
||||
Used to test edge cases of the combined ``config.actor: provider/model``
|
||||
shorthand: empty model half (``provider/``), empty provider half
|
||||
(``/model``), no delimiter (``noslash``), empty string, etc.
|
||||
"""
|
||||
yaml_content = f"""\
|
||||
name: {name}
|
||||
cleveragents:
|
||||
version: "3.0"
|
||||
default_actor: main
|
||||
actors:
|
||||
main:
|
||||
type: llm
|
||||
config:
|
||||
actor: {combined_value}
|
||||
system_prompt: "Malformed actor test."
|
||||
"""
|
||||
context.config_file = _write_yaml_file(context, name, yaml_content)
|
||||
|
||||
|
||||
@then(
|
||||
'v3actor the actor should be registered with provider "{provider}" and model "{model}"'
|
||||
)
|
||||
def step_actor_registered_with_provider_and_model(
|
||||
context: Context, provider: str, model: str
|
||||
) -> None:
|
||||
"""Assert the actor was registered with the expected provider and model.
|
||||
|
||||
Inspects ``registered_actor_provider`` and ``registered_actor_model``
|
||||
captured by the ``@when`` step from the ``config_blob`` passed to
|
||||
``registry.upsert_actor()`` after CLI canonicalization. Canonicalization
|
||||
calls ``setdefault`` so the canonical blob always carries the resolved
|
||||
provider and model even when they were not present in the original YAML.
|
||||
"""
|
||||
assert context.actor_registered, (
|
||||
"Actor was not registered. Output: "
|
||||
f"{context.command_result.get('output', '') if context.command_result else ''}"
|
||||
)
|
||||
reg_provider = getattr(context, "registered_actor_provider", None)
|
||||
reg_model = getattr(context, "registered_actor_model", None)
|
||||
assert reg_provider == provider, (
|
||||
f"Expected provider '{provider}' but got '{reg_provider}'"
|
||||
)
|
||||
assert reg_model == model, f"Expected model '{model}' but got '{reg_model}'"
|
||||
|
||||
|
||||
@then('v3actor the actor should be registered with provider "{provider}"')
|
||||
def step_actor_registered_with_provider(context: Context, provider: str) -> None:
|
||||
"""Assert the actor was registered with the expected provider.
|
||||
|
||||
Checks ``registered_actor_provider`` captured by the ``@when`` step from
|
||||
the ``config_blob`` passed to ``registry.upsert_actor()`` after
|
||||
CLI canonicalization.
|
||||
"""
|
||||
assert context.actor_registered, (
|
||||
"Actor was not registered. Output: "
|
||||
f"{context.command_result.get('output', '') if context.command_result else ''}"
|
||||
)
|
||||
reg_provider = getattr(context, "registered_actor_provider", None)
|
||||
assert reg_provider == provider, (
|
||||
f"Expected provider '{provider}' but got '{reg_provider}'"
|
||||
)
|
||||
|
||||
|
||||
@then('v3actor the actor should be registered with model "{model}"')
|
||||
def step_actor_registered_with_model(context: Context, model: str) -> None:
|
||||
"""Assert the actor was registered with the expected model.
|
||||
|
||||
Checks ``registered_actor_model`` captured by the ``@when`` step from
|
||||
the ``config_blob`` passed to ``registry.upsert_actor()`` after
|
||||
CLI canonicalization.
|
||||
"""
|
||||
assert context.actor_registered, (
|
||||
"Actor was not registered. Output: "
|
||||
f"{context.command_result.get('output', '') if context.command_result else ''}"
|
||||
)
|
||||
reg_model = getattr(context, "registered_actor_model", None)
|
||||
assert reg_model == model, f"Expected model '{model}' but got '{reg_model}'"
|
||||
|
||||
|
||||
# ── Helper Functions ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -174,19 +174,6 @@ def step_node_sequence_negative(ctx: Context) -> None:
|
||||
create_sequence_node(ctx, -1)
|
||||
|
||||
|
||||
def create_sequence_node(ctx: Context, seq_val: int) -> None:
|
||||
"""Create a single plan tree node with the given sequence value."""
|
||||
ctx.tree_nodes = [
|
||||
{
|
||||
"decision_id": VALID_ULID_A,
|
||||
"type": "strategy_choice",
|
||||
"sequence": seq_val,
|
||||
"question": "Q",
|
||||
"children": [],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Decision dict scenarios
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -404,3 +404,37 @@ Reject v3 GRAPH Actor With Unreachable Node
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 1
|
||||
Should Contain ${result.stderr} unreachable
|
||||
|
||||
Register LLM Actor With Nested Actors Map And Combined config.actor Field
|
||||
[Documentation]
|
||||
... Verify that agents actor add accepts spec-compliant YAML using the
|
||||
... nested actors map format with type at actor-entry level and provider/model
|
||||
... supplied via the combined config.actor: "provider/model" shorthand.
|
||||
... This is the regression test for bug #11189.
|
||||
[Tags] slow
|
||||
${yaml_file}= Set Variable ${TEMPDIR}${/}nested_combined_actor.yaml
|
||||
${yaml_content}= Catenate SEPARATOR=\n
|
||||
... name: local/nested-combined-llm
|
||||
... cleveragents:
|
||||
... ${SPACE}${SPACE}version: "3.0"
|
||||
... ${SPACE}${SPACE}default_actor: main
|
||||
... actors:
|
||||
... ${SPACE}${SPACE}main:
|
||||
... ${SPACE}${SPACE}${SPACE}${SPACE}type: llm
|
||||
... ${SPACE}${SPACE}${SPACE}${SPACE}config:
|
||||
... ${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}actor: anthropic/claude-sonnet-4-5
|
||||
... ${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}system_prompt: "You are a test actor."
|
||||
... ${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}temperature: 0.3
|
||||
Create File ${yaml_file} ${yaml_content}
|
||||
${result}= Run Process ${PYTHON} ${HELPER} add local/nested-combined-llm ${yaml_file} cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} actor-add-success
|
||||
${show_result}= Run Process ${PYTHON} ${HELPER} show local/nested-combined-llm cwd=${WORKSPACE}
|
||||
Log ${show_result.stdout}
|
||||
Log ${show_result.stderr}
|
||||
Should Be Equal As Integers ${show_result.rc} 0
|
||||
Should Contain ${show_result.stdout} actor-show-success
|
||||
Should Contain ${show_result.stdout} anthropic
|
||||
Should Contain ${show_result.stdout} claude-sonnet-4-5
|
||||
|
||||
@@ -12,6 +12,41 @@ from cleveragents.actor.yaml_loader import load_yaml_text
|
||||
_V3_ACTOR_TYPES: frozenset[str] = frozenset({"llm", "graph", "tool"})
|
||||
|
||||
|
||||
def _parse_combined_actor_field(data: dict[str, Any]) -> tuple[str | None, str | None]:
|
||||
"""Extract (provider, model) from the combined ``config.actor`` shorthand.
|
||||
|
||||
Looks for ``config.actor: "provider/model"`` inside the first entry of
|
||||
the nested ``actors:`` map. Returns ``(None, None)`` when the shorthand
|
||||
is absent or malformed (empty provider or model half, missing delimiter).
|
||||
|
||||
Args:
|
||||
data: Top-level actor YAML blob (may contain an ``actors:`` map).
|
||||
|
||||
Returns:
|
||||
``(provider_value, model_value)`` — both ``None`` when the combined
|
||||
field is not present or cannot be parsed.
|
||||
"""
|
||||
actors_val = data.get("actors")
|
||||
if not isinstance(actors_val, dict) or not actors_val:
|
||||
return None, None
|
||||
|
||||
for _, first_entry in actors_val.items():
|
||||
if not isinstance(first_entry, dict):
|
||||
continue
|
||||
config_block = first_entry.get("config")
|
||||
if isinstance(config_block, dict):
|
||||
combined = config_block.get("actor")
|
||||
if isinstance(combined, str) and "/" in combined and combined.strip():
|
||||
parts = combined.strip().split("/", 1)
|
||||
provider = parts[0].strip()
|
||||
model = parts[1].strip()
|
||||
if provider and model:
|
||||
return provider, model
|
||||
break # Only examine the first dict entry in the actors map.
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
class ActorConfiguration(BaseModel):
|
||||
"""Canonical actor configuration parsed from user-provided blobs."""
|
||||
|
||||
@@ -159,12 +194,14 @@ class ActorConfiguration(BaseModel):
|
||||
|
||||
The v3 schema is identified by a top-level ``type`` key whose value
|
||||
is one of ``llm``, ``graph``, or ``tool`` — OR by a top-level
|
||||
``actors:`` map containing at least one entry whose ``config:`` block
|
||||
``actors:`` map containing at least one entry whose actor-entry level
|
||||
carries a ``type`` field with one of those values.
|
||||
|
||||
Provider and model may appear at the top level OR nested inside
|
||||
``actors.<actor-name>.config``. When found in the nested config,
|
||||
they take precedence over top-level values.
|
||||
``actors.<actor-name>.config`` (either as separate ``provider``/``model``
|
||||
keys, or as a combined ``actor: "provider/model"`` shorthand). Explicit
|
||||
``config.provider`` and ``config.model`` take precedence over the combined
|
||||
shorthand when both are present.
|
||||
|
||||
For ``type: graph`` actors the ``route`` block is wrapped into a
|
||||
graph descriptor.
|
||||
@@ -179,17 +216,16 @@ class ActorConfiguration(BaseModel):
|
||||
if isinstance(actors_val, dict) and actors_val:
|
||||
for _, first_entry in actors_val.items():
|
||||
if isinstance(first_entry, dict):
|
||||
config_block = first_entry.get("config")
|
||||
if isinstance(config_block, dict):
|
||||
nested_type = config_block.get("type")
|
||||
is_v3_type = (
|
||||
isinstance(nested_type, str)
|
||||
and nested_type.lower() in _V3_ACTOR_TYPES
|
||||
)
|
||||
if is_v3_type:
|
||||
actor_type = nested_type
|
||||
break
|
||||
break
|
||||
# The v3 spec places 'type' at the actor-entry level
|
||||
# (sibling of 'config'), not inside the config block.
|
||||
nested_type = first_entry.get("type")
|
||||
is_v3_type = (
|
||||
isinstance(nested_type, str)
|
||||
and nested_type.lower() in _V3_ACTOR_TYPES
|
||||
)
|
||||
if is_v3_type:
|
||||
actor_type = nested_type
|
||||
break
|
||||
if not isinstance(actor_type, str):
|
||||
return None, None, None, False
|
||||
|
||||
@@ -197,8 +233,16 @@ class ActorConfiguration(BaseModel):
|
||||
provider_value = data.get("provider")
|
||||
unsafe_flag = bool(data.get("unsafe", False))
|
||||
|
||||
if not model_value or not isinstance(model_value, str):
|
||||
if actor_type.lower() != "tool":
|
||||
# Derive provider/model from nested actors map when top-level fields
|
||||
# are absent. Separate config.provider / config.model keys take
|
||||
# precedence over the combined config.actor shorthand.
|
||||
if (not model_value or not isinstance(model_value, str)) or (
|
||||
not provider_value or not isinstance(provider_value, str)
|
||||
):
|
||||
combined_provider, combined_model = _parse_combined_actor_field(data)
|
||||
if not isinstance(model_value, str) or not model_value:
|
||||
# First, check for a separate config.model key in the nested map.
|
||||
model_from_separate: str | None = None
|
||||
actors_val = data.get("actors")
|
||||
if isinstance(actors_val, dict) and actors_val:
|
||||
for _, first_entry in actors_val.items():
|
||||
@@ -207,27 +251,37 @@ class ActorConfiguration(BaseModel):
|
||||
if isinstance(config_block, dict):
|
||||
mv = config_block.get("model")
|
||||
if isinstance(mv, str) and mv:
|
||||
model_value = mv
|
||||
model_from_separate = mv
|
||||
break
|
||||
break
|
||||
break
|
||||
if model_from_separate:
|
||||
model_value = model_from_separate
|
||||
elif combined_model:
|
||||
model_value = combined_model
|
||||
if not isinstance(provider_value, str) or not provider_value:
|
||||
# First, check for a separate config.provider key in the nested map.
|
||||
provider_from_separate: str | None = None
|
||||
actors_val = data.get("actors")
|
||||
if isinstance(actors_val, dict) and actors_val:
|
||||
for _, first_entry in actors_val.items():
|
||||
if isinstance(first_entry, dict):
|
||||
config_block = first_entry.get("config")
|
||||
if isinstance(config_block, dict):
|
||||
pv = config_block.get("provider")
|
||||
if isinstance(pv, str) and pv:
|
||||
provider_from_separate = pv
|
||||
break
|
||||
break
|
||||
if provider_from_separate:
|
||||
provider_value = provider_from_separate
|
||||
elif combined_provider:
|
||||
provider_value = combined_provider
|
||||
|
||||
if not model_value:
|
||||
if actor_type.lower() != "tool":
|
||||
return None, None, None, False
|
||||
model_value = ""
|
||||
|
||||
if not provider_value or not isinstance(provider_value, str):
|
||||
actors_val = data.get("actors")
|
||||
if isinstance(actors_val, dict) and actors_val:
|
||||
for _, first_entry in actors_val.items():
|
||||
if isinstance(first_entry, dict):
|
||||
config_block = first_entry.get("config")
|
||||
if isinstance(config_block, dict):
|
||||
pv = config_block.get("provider")
|
||||
if isinstance(pv, str) and pv:
|
||||
provider_value = pv
|
||||
break
|
||||
break
|
||||
|
||||
if not provider_value:
|
||||
provider_value = (
|
||||
infer_provider_from_model(model_value) if model_value else "custom"
|
||||
|
||||
@@ -112,6 +112,8 @@ class StdioTransport:
|
||||
cwd=self._cwd,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
self._process = None
|
||||
|
||||
from cleveragents.lsp.errors import LspError
|
||||
|
||||
raise LspError(
|
||||
@@ -119,12 +121,27 @@ class StdioTransport:
|
||||
details={"command": self._command, "args": self._args},
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
# Popen may have partially started the subprocess before
|
||||
# raising (e.g. execve failure post-fork on some platforms).
|
||||
# Ensure cleanup so the process does not leak into the caller's
|
||||
# address space.
|
||||
if self._process is not None:
|
||||
self.stop()
|
||||
self._process = None
|
||||
|
||||
from cleveragents.lsp.errors import LspError
|
||||
|
||||
raise LspError(
|
||||
f"Failed to start LSP server: {exc}",
|
||||
details={"command": self._command, "error": str(exc)},
|
||||
) from exc
|
||||
except Exception:
|
||||
# Catch-all for any other low-level OSError / resource-error
|
||||
# variants that might leave a zombie process behind.
|
||||
if self._process is not None:
|
||||
self.stop()
|
||||
self._process = None
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
"lsp.transport.started",
|
||||
|
||||
Reference in New Issue
Block a user