From c768e8c4f53fa4093058ea90f88e2820a61e7823 Mon Sep 17 00:00:00 2001 From: CleverThis Date: Thu, 14 May 2026 01:27:27 +0000 Subject: [PATCH 1/3] fix(lsp): cleanup subprocess on failed initialization in StdioTransport.start() --- src/cleveragents/lsp/transport.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/cleveragents/lsp/transport.py b/src/cleveragents/lsp/transport.py index c85262cff..841475b1c 100644 --- a/src/cleveragents/lsp/transport.py +++ b/src/cleveragents/lsp/transport.py @@ -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", From 8c5dbf3f3308a6ae45f6239a9d6da67b6735c1d9 Mon Sep 17 00:00:00 2001 From: CleverThis Date: Tue, 12 May 2026 20:07:36 +0000 Subject: [PATCH 2/3] chore(contributors): add ACMS hot_max_tokens fix detail entry Adds contributor detail line for Hamza Khyari's ACMS execute-phase context assembler project-level budget override fix. ISSUES CLOSED: #11035 --- CONTRIBUTORS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 59a6ba5bf..7b3906384 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -24,6 +24,7 @@ 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. +* 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). From cc8e013f9bcad976e6eb891cfdd2415c9cca27e6 Mon Sep 17 00:00:00 2001 From: Rui Hu Date: Wed, 13 May 2026 10:40:53 +0000 Subject: [PATCH 3/3] fix(actor): handle nested actor type and combined config.actor field in v3 YAML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in ActorConfiguration._extract_v3_actor() prevented YAML files using the spec-canonical nested actors map format from being registered: Defect A: The method looked for the 'type' field inside the 'config:' block (config_block.get('type')) instead of at the actor-entry level (first_entry.get('type')). Because the spec places 'type' at actors..type — a sibling of 'config:', not a child of it — the v3 detection branch never fired for nested-actors-map YAMLs, causing _extract_v3_actor() to return (None, None, None, False) immediately. Defect B: Even if the type was found, the method only consulted separate config.provider and config.model keys; it never parsed the combined config.actor: 'provider/model' shorthand. Without the combined-field fallback, provider and model remained None for all spec-canonical YAMLs that use the shorthand, causing from_blob() to raise 'BadParameter: provider is required'. Both defects had to be fixed together because Defect A blocked the code from ever reaching the path where Defect B would otherwise have been encountered. The combined-format parser (config.actor split on first '/') is inserted after the explicit config.provider / config.model lookups so that explicit separate fields always take precedence over the shorthand when both are present. Parsing logic extracted into _parse_combined_actor_field() helper to eliminate DRY violation. Both provider and model halves validated consistently (empty provider half was previously silently inferred). Adds Behave scenarios covering: nested actors map + combined config.actor (success), explicit config.provider precedence over config.actor, explicit config.model precedence over config.actor, genuinely missing provider/model raising validation error, and malformed combined values (empty model half, empty provider half, missing delimiter). Adds a Robot integration test for the combined-actor YAML path with provider/model assertions via show. Restores accidentally deleted TOOL and GRAPH Robot integration tests. Mirrors provider/model extraction in step_run_actor_update for assertion step compatibility. ISSUES CLOSED: #11189 --- CHANGELOG.md | 8 + .../actor_add_v3_schema_validation.feature | 59 +++++ .../actor_add_v3_schema_validation_steps.py | 234 +++++++++++++++++- robot/actor_add_v3_schema_validation.robot | 34 +++ src/cleveragents/actor/config.py | 116 ++++++--- 5 files changed, 412 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff686c00b..091344c93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,14 @@ Changed `wf10_batch.robot` to be less likely to create files, and ## [Unreleased] +- **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 started" comment to the Forgejo issue/PR before beginning implementation. diff --git a/features/actor_add_v3_schema_validation.feature b/features/actor_add_v3_schema_validation.feature index 3c75bd4b9..27f16cfbf 100644 --- a/features/actor_add_v3_schema_validation.feature +++ b/features/actor_add_v3_schema_validation.feature @@ -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" diff --git a/features/steps/actor_add_v3_schema_validation_steps.py b/features/steps/actor_add_v3_schema_validation_steps.py index 4a25fc102..9a5b3c4e6 100644 --- a/features/steps/actor_add_v3_schema_validation_steps.py +++ b/features/steps/actor_add_v3_schema_validation_steps.py @@ -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..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 ────────────────────────────────────────────────────────── diff --git a/robot/actor_add_v3_schema_validation.robot b/robot/actor_add_v3_schema_validation.robot index 2530964dd..ded51bbad 100644 --- a/robot/actor_add_v3_schema_validation.robot +++ b/robot/actor_add_v3_schema_validation.robot @@ -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 diff --git a/src/cleveragents/actor/config.py b/src/cleveragents/actor/config.py index ad6a7f7a4..014af336b 100644 --- a/src/cleveragents/actor/config.py +++ b/src/cleveragents/actor/config.py @@ -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..config``. When found in the nested config, - they take precedence over top-level values. + ``actors..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"