Files
cleveractors-core/features/steps/credential_composite_steps.py
hurui200320 f281fa3b24
CI / lint (pull_request) Successful in 35s
CI / typecheck (pull_request) Successful in 50s
CI / security (pull_request) Successful in 50s
CI / quality (pull_request) Successful in 32s
CI / unit_tests (pull_request) Successful in 3m32s
CI / integration_tests (pull_request) Successful in 59s
CI / build (pull_request) Successful in 34s
CI / lint (push) Successful in 33s
CI / build (push) Successful in 37s
CI / quality (push) Successful in 48s
CI / typecheck (push) Successful in 48s
CI / security (push) Successful in 1m4s
CI / integration_tests (push) Successful in 1m18s
CI / coverage (pull_request) Successful in 3m34s
CI / status-check (pull_request) Successful in 3s
CI / unit_tests (push) Successful in 3m45s
CI / coverage (push) Successful in 3m34s
CI / status-check (push) Successful in 3s
feat(credentials): refactor LLMAgent/AgentFactory for per-request credential injection and extended provider routing
Implements Wave 3 of the cleveractors-core integration (ADR-2026, ADR-2028).

## AgentFactory
- Added optional credentials: dict[str, dict[str, str]] | None parameter to
  __init__. Stored as self.credentials.
- When creating LLMAgent instances, the full credentials dict is forwarded via
  a new credentials constructor kwarg, threading per-request API keys without
  touching the stored actor config_dict.
- Added explicit type annotation ReactiveAgentFactory: type[AgentFactory].
- _create_agent_instance raises ConfigurationError when credentials is not None
  but does not contain an entry for the requested provider (AC4 compliance).

## LLMAgent
- Added optional credentials: dict[str, str] | None parameter to __init__.
  Stored as self._credentials; config_dict is never modified.
- LangChain client construction deferred to first access (lazy init) via a
  chat_model property backed by _ensure_chat_model(). The property setter
  allows test-injection of mock models without going through the lazy-init path.
- _create_chat_model() dispatches to credential-injection / standalone paths.
- Extracted to llm_client.py and llm_providers.py for modularity.
- Defined shared module-level constants DEFAULT_MODEL, DEFAULT_TEMPERATURE,
  DEFAULT_MAX_TOKENS to eliminate duplicated magic numbers across llm.py,
  runtime.py, and factory.py.
- Added public credentials property exposing self._credentials.
- Added type narrowing guard in chat_model property to satisfy static analysis.
- Added temperature_override type validation in process_message.
- Guarded cleanup() null-assignment with _chat_model_lock (TOCTOU fix).

## SSRF Prevention (ADR-2028)
- _validate_base_url(): https-only, userinfo rejection, localhost rejection,
  raw IP literal rejection, numeric-hostname (dotted-hex/octal/compact) rejection,
  percent-decode loop with iteration cap (10), trailing-dot FQDN strip.
- DNS resolution intentionally omitted (blocking call in async path).
- Canonical URL reconstruction uses lowercased scheme.

## Credential Leak Prevention
- All logger.exception(...) replaced with logger.debug(..., exc_info=True).
- Error messages use type(e).__name__ instead of str(e).
- process_message exception chains broken with from None to prevent LangChain
  authentication errors from leaking via __cause__.

## Runtime fixes
- Executor._execute_llm: shallow copy for setdefault mutations.
- Executor._execute_graph: restructured try/finally for resource cleanup.
- Uses DEFAULT_MODEL, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS from llm.py.
- Defensive copy of credentials dict in Executor.__init__ to prevent caller
  mutation after construction.

## Test improvements
- Added BDD scenarios for llm_imports.py ImportError fallback branches using
  sys.modules manipulation with a custom import hook, exercised via subprocess.
- Added BDD scenario for AgentFactory.create_agents_from_config() credential
  threading to all created agents.
- Removed tautological dataclass test scenarios (NodeUsage, ActorResult) from
  runtime_coverage.feature.
- Fixed misleading scenario title in runtime_coverage.feature (exception chain
  suppressed, not chained) and updated step to assert __cause__ is None.
- Added CHANGELOG entry for ReactiveAgentFactory backward-compatibility alias.
- Removed duplicate log emission in _check_known_provider_domain.

ISSUES CLOSED: #12
2026-06-08 11:10:46 +00:00

201 lines
7.3 KiB
Python

"""Step definitions for composite agent credential threading.
Extracted from ``credential_injection_steps.py`` to keep that file
under 500 lines (CONTRIBUTING.md §General Principles).
Covers the ``components``-based composite path and the legacy
``"agents": [...]``-style composite path.
"""
from __future__ import annotations
from typing import Any
from behave import given, then, when
from features.mocks.credential_helpers import make_template_renderer
from cleveractors.agents.factory import AgentFactory
from cleveractors.agents.llm import LLMAgent
# ---------------------------------------------------------------------------
# Composite agent credential threading (m8)
# ---------------------------------------------------------------------------
@given("a composite agent config with a nested LLM component for credential test")
def step_composite_agent_config_for_creds(context: Any) -> None:
"""Set up a composite agent config with a nested LLM component."""
context.actor_config = {
"agents": {
"my_composite": {
"type": "composite",
"config": {
"components": {
"agents": {
"nested_llm": {
"type": "llm",
"config": {
"provider": "openai",
"model": "gpt-3.5-turbo",
},
}
}
}
},
}
}
}
context.template_renderer = make_template_renderer()
context.credentials = {"openai": {"api_key": "composite-injected-key"}}
context.factory = AgentFactory(
config=context.actor_config,
template_renderer=context.template_renderer,
credentials=context.credentials,
)
@when("I call create_agent for the composite agent in credential test")
def step_create_composite_agent_for_creds(context: Any) -> None:
"""Create the composite agent via the factory."""
context.created_agent = context.factory.create_agent("my_composite")
@then("the nested LLM agent within the composite should have the credentials dict set")
def step_nested_llm_has_credentials(context: Any) -> None:
"""Verify the nested LLM agent received the provider-specific credentials slice."""
composite = context.created_agent
assert hasattr(composite, "agents"), (
"Composite agent should have 'agents' attribute"
)
agents = composite.agents
assert "nested_llm" in agents, (
f"Expected 'nested_llm' in composite agents, got {list(agents.keys())}"
)
nested = agents["nested_llm"]
assert isinstance(nested, LLMAgent), (
f"Expected LLMAgent, got {type(nested).__name__}"
)
# Factory extracts provider-specific slice: credentials.get("openai")
assert nested._credentials == {"api_key": "composite-injected-key"}, (
f"Expected _credentials {{'api_key': 'composite-injected-key'!r}}, "
f"got {nested._credentials!r}"
)
# ---------------------------------------------------------------------------
# Legacy composite agent credential threading (m2)
# ---------------------------------------------------------------------------
@given("a legacy composite agent config with nested agents list for credential test")
def step_legacy_composite_agent_config_for_creds(context: Any) -> None:
"""Set up a legacy composite agent config using the ``"agents": [...]`` format.
This tests the fix for the legacy composite path where the cache is bypassed
when credentials are set (factory.py:227-233).
"""
context.actor_config = {
"agents": {
"my_legacy_composite": {
"type": "composite",
"config": {
"agents": ["nested_llm"],
},
},
"nested_llm": {
"type": "llm",
"config": {
"provider": "openai",
"model": "gpt-3.5-turbo",
},
},
}
}
context.template_renderer = make_template_renderer()
context.credentials = {"openai": {"api_key": "legacy-composite-key"}}
context.factory = AgentFactory(
config=context.actor_config,
template_renderer=context.template_renderer,
credentials=context.credentials,
)
@when("I call create_agent for the legacy composite agent in credential test")
def step_create_legacy_composite_agent_for_creds(context: Any) -> None:
"""Create the legacy composite agent via the factory."""
context.created_agent = context.factory.create_agent("my_legacy_composite")
@then(
"the nested LLM agent within the legacy composite should have the credentials dict set"
)
def step_nested_legacy_llm_has_credentials(context: Any) -> None:
"""Verify the nested LLM agent (legacy path) received the factory credentials."""
composite = context.created_agent
assert hasattr(composite, "agents"), (
"Composite agent should have 'agents' attribute"
)
agents = composite.agents
assert "nested_llm" in agents, (
f"Expected 'nested_llm' in legacy composite agents, got {list(agents.keys())}"
)
nested = agents["nested_llm"]
assert isinstance(nested, LLMAgent), (
f"Expected LLMAgent, got {type(nested).__name__}"
)
# Factory extracts provider-specific slice: credentials.get("openai")
assert nested._credentials == {"api_key": "legacy-composite-key"}, (
f"Expected _credentials {{'api_key': 'legacy-composite-key'!r}}, "
f"got {nested._credentials!r}"
)
# ---------------------------------------------------------------------------
# m6: legacy composite path without credentials
# ---------------------------------------------------------------------------
@given("a legacy composite agent config with nested agents list without credentials")
def step_legacy_composite_no_credentials(context: Any) -> None:
"""Set up a legacy composite agent config without a credentials dict.
Tests that the legacy composite path works when credentials is None
(no credential injection needed).
"""
context.actor_config = {
"agents": {
"my_legacy_composite": {
"type": "composite",
"config": {
"agents": ["nested_llm"],
},
},
"nested_llm": {
"type": "llm",
"config": {
"provider": "openai",
"model": "gpt-3.5-turbo",
},
},
}
}
context.template_renderer = make_template_renderer()
context.factory = AgentFactory(
config=context.actor_config,
template_renderer=context.template_renderer,
)
@then(
"the nested LLM agent within the legacy composite should have credentials "
"equal to None"
)
def step_nested_legacy_llm_credentials_none(context: Any) -> None:
"""Verify the nested LLM agent (legacy path) has credentials=None."""
composite = context.created_agent
agents = composite.agents
nested = agents["nested_llm"]
assert nested._credentials is None, (
f"Expected _credentials to be None, got {type(nested._credentials).__name__}"
)