Skip to content

Commit a73534b

Browse files
Merge pull request #918 from valory-xyz/feat/open-autonomy-2485-upstream-hooks
feat(api): upstream hooks for open-autonomy fetchai#2485
2 parents a7e7aca + ac7d996 commit a73534b

17 files changed

Lines changed: 1102 additions & 40 deletions

File tree

HISTORY.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
Framework:
66

7+
- `aea.configurations.loader`: exposes `parse_service_yaml(file_pointer) -> (head, overrides)` as a public module-level helper that parses a service YAML stream into the head document and the trailing override documents without schema validation. `ConfigLoader._load_service_config` now delegates to it. Lets downstream packagers (e.g. open-autonomy, which needs to substitute environment variables in the head document before validating it through its own `Service` config class) drop a hand-maintained copy of the parse-and-split logic. Raises `ValueError("Service configuration file was empty.")` whenever the head document is not a mapping — covers empty streams, bare `---`, and YAML scalars (`false`, `0`, `""`, `[]`) — so the failure surfaces at parse time rather than as an opaque `AttributeError` from the downstream validator.
8+
- `aea.cli.scaffold.scaffold_item`: saves `ctx.cwd` on entry and restores it via `try ... finally`. Previously the `--to-local-registry` branch swapped `ctx.cwd` to `<registry_path>/<author>` to make the inner `fingerprint_item` call resolve correctly, but never restored it, leaking the registry path back to callers. The post-fingerprint steps in the same function (notably the `--with-symlinks` block, which builds vendor symlinks from `ctx.cwd`) also see the original value again. Downstream wrappers that had to bracket `scaffold_item` with their own `preserve_cwd = ctx.cwd; ...; ctx.cwd = preserve_cwd` can drop the workaround.
9+
- `aea.contracts.base.Contract.from_config` and `aea.connections.base.Connection.from_config`: pass the canonical packages-prefixed dotted path (`packages.<author>.contracts.<name>.contract` / `packages.<author>.connections.<name>.connection`) to `load_module` instead of the bare `"contracts"` / `"connection_module"` constants. With the new `sys.modules` registration in `load_module` (below), the bare keys would have collided across contracts and across connections loaded in the same process. Caller-visible behaviour is unchanged because `load_module` always returns a fresh module object.
10+
- `aea.skills.base._SkillComponentLoader`: makes the dotted module path returned by `_compute_module_dotted_path` the canonical one (`packages.<author>.skills.<name>.<file>`), drops the now-redundant "exclude classes whose `__module__` starts with this skill's dotted path" condition from `_filter_classes`, and updates the unused-class warning filter at `_print_warning_message_for_unused_classes` to keep classes that belong to this skill (both submodule classes and those defined at the skill's own `__init__.py` level) instead of dropping every `packages.`-prefixed class. Cross-skill re-exports (the canonical FSM composition idiom — `class_name: AbciDialogues` in `skill.yaml` bound to an `AbciDialogues` imported from a parent skill) still pass the filter because their `__module__` starts with another skill's prefix, not `aea.`. The legacy `_parse_module` filter (used by `Behaviour.parse_module` / `Handler.parse_module` / `Model.parse_module`) drops the same local-prefix exclusion clause and switches its `load_module` call to the canonical packages-prefixed dotted path so its `sys.modules` cache entries do not collide on bare keys (`"behaviours"` / `"handlers"` / `"models"`) across skills loaded in the same process. The remaining difference vs. `_filter_classes` is the legacy filter's name-allowlist gate (`x[0] in component_names`), which is unchanged. `aea.helpers.base.load_module` now registers the loaded module in `sys.modules` under its dotted path before `exec_module` and rolls the entry back if `exec_module` raises, so (a) a subsequent ordinary `from packages.<author>.skills.<name>.<file> import X` returns the same module object instead of re-executing the file and producing a second copy of every class defined in it, and (b) a transient module-level error does not permanently poison `sys.modules` with a half-initialised stub. Together these eliminate the dual short-name / long-name class objects that downstream code (open-autonomy's `_MetaPayload.registry`) previously had to paper over with a key-rewrite loop. `_compute_module_dotted_path` is no longer a `@classmethod` (it reads `self.skill_dotted_path`) — internal API, no out-of-tree caller.
11+
712
- `aea.cli` / `aea.helpers.protocols`: removes the two module-top `logging.basicConfig(...)` calls that fired as a side effect of `import aea.cli` and attached a stderr `StreamHandler` to the root logger. That orphan root handler survived the agent's `logging.config.dictConfig` (the config block has no `root:` key) and caught every `aea.agent.*` record a second time via propagation, producing the `] [INFO]` vs `][INFO]` doubling seen in operator captures. Fix moves the `basicConfig` in `aea/cli/generate_all_protocols.py` into the click command body and switches `aea/helpers/protocols.py` from `setup_logger(__name__)` to `logging.getLogger(__name__)`. Behaviour change for operators: third-party library `INFO`/`DEBUG` logs (e.g. from `web3`, `requests` in the ledger plugins) that the orphan handler previously surfaced now go silent under an agent config without a `root:` block in `logging_config`. `WARNING` and above still appear on stderr via Python's `logging.lastResort` handler, though with that handler's bare-message format (just `%(message)s`, no level or logger-name prefix and no timestamp) rather than the formatted one the orphan installed. Operators who want the previous behaviour (timestamped third-party `INFO`/`DEBUG`) can add a `root:` block to `logging_config` in the agent's `aea-config.yaml`. #914 #917
813

914
## 2.2.6 (2026-05-14)

aea/cli/scaffold.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,9 @@ def scaffold_item(ctx: Context, item_type: str, item_name: str) -> None:
244244
)
245245

246246
ctx.clean_paths.append(str(dest))
247+
# Save ctx.cwd so the local-registry path swap below does not leak out
248+
# of this call.
249+
preserve_cwd = ctx.cwd
247250
try:
248251
# copy the item package into the agent project.
249252
src = Path(os.path.join(AEA_DIR, item_type_plural, "scaffold"))
@@ -283,6 +286,12 @@ def scaffold_item(ctx: Context, item_type: str, item_name: str) -> None:
283286
if to_local_registry:
284287
ctx.cwd = str(registry_path / author_name)
285288
fingerprint_item(ctx, item_type, new_public_id)
289+
# Restore now (not just in the `finally` below) so the
290+
# `--with-symlinks` calls to `create_symlink_vendor_to_local` /
291+
# `create_symlink_packages_to_vendor` further down — both of
292+
# which read `ctx.cwd` — see the original agent directory
293+
# instead of the registry author path.
294+
ctx.cwd = preserve_cwd
286295
else:
287296
fingerprint_item(ctx, item_type, new_public_id)
288297

@@ -314,6 +323,8 @@ def scaffold_item(ctx: Context, item_type: str, item_name: str) -> None:
314323
)
315324
except Exception as e:
316325
raise click.ClickException(str(e))
326+
finally:
327+
ctx.cwd = preserve_cwd
317328

318329

319330
def _scaffold_dm_handler(ctx: Context) -> None:

aea/configurations/loader.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
List,
3030
Optional,
3131
TextIO,
32+
Tuple,
3233
Type,
3334
TypeVar,
3435
Union,
@@ -320,11 +321,7 @@ def _load_agent_config(self, file_pointer: TextIO) -> AgentConfig:
320321

321322
def _load_service_config(self, file_pointer: TextIO) -> PackageConfiguration:
322323
"""Load a service configuration."""
323-
configuration_data = yaml_load_all(file_pointer)
324-
if not configuration_data:
325-
raise ValueError("Service configuration file was empty.")
326-
327-
service_config, *overrides = configuration_data
324+
service_config, overrides = parse_service_yaml(file_pointer)
328325
self.validate(service_config)
329326
key_order = list(service_config.keys())
330327
service = self.configuration_class.from_json(service_config)
@@ -384,6 +381,44 @@ def _process_component_section(
384381
return component_id
385382

386383

384+
def parse_service_yaml(
385+
file_pointer: TextIO,
386+
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
387+
"""
388+
Parse a service configuration YAML stream into its raw documents.
389+
390+
Returns the head service configuration document and any override
391+
documents without applying schema validation or constructing a
392+
typed configuration object. Callers that need to apply additional
393+
transformations (e.g. environment variable substitution) before
394+
validation can use this helper as the parse step.
395+
396+
:param file_pointer: an open text stream pointing at a service.yaml.
397+
:return: a ``(service_config, overrides)`` tuple where ``service_config``
398+
is the head document and ``overrides`` is the list of trailing
399+
documents.
400+
:raises ValueError: if the stream is empty, or if the head document is
401+
not a YAML mapping (e.g. a bare ``---``, a scalar, or a sequence).
402+
:raises yaml.YAMLError: if the stream contains malformed YAML and the underlying parser fails (propagated as-is so callers can handle parse errors and shape errors distinctly). # noqa: DAR402
403+
"""
404+
configuration_data = yaml_load_all(file_pointer)
405+
if not configuration_data:
406+
raise ValueError("Service configuration file was empty.")
407+
service_config, *overrides = configuration_data
408+
# The head document must be a mapping. `yaml_load_all` happily returns
409+
# `[None]` / `[False]` / `[0]` / `[""]` / `[[]]` for malformed YAML
410+
# (bare `---`, a scalar, a sequence, etc.) — surface those at parse
411+
# time rather than as opaque AttributeErrors from the downstream
412+
# validator. Distinct from the empty-stream message so an operator
413+
# reading the traceback can tell the two cases apart.
414+
if not isinstance(service_config, dict):
415+
raise ValueError(
416+
"Service configuration head document must be a YAML mapping, "
417+
f"got {type(service_config).__name__}."
418+
)
419+
return service_config, overrides
420+
421+
387422
class ConfigLoaders:
388423
"""Configuration Loader class to load any package type."""
389424

aea/configurations/utils.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,34 @@
3434
PublicId,
3535
SkillConfig,
3636
)
37+
from aea.configurations.constants import PACKAGES
3738
from aea.configurations.data_types import PackageIdPrefix
3839

3940

41+
def package_dotted_path(
42+
author: str,
43+
package_type_plural: str,
44+
package_name: str,
45+
file_stem: str,
46+
) -> str:
47+
"""
48+
Compose the canonical dotted path string for a file inside an AEA package.
49+
50+
Returns a string of the form ``packages.<author>.<plural>.<name>.<stem>``.
51+
Has no side effects; the loaded module is registered in
52+
``sys.modules`` by ``aea.helpers.base.load_module``.
53+
54+
:param author: package author (e.g. ``"valory"``).
55+
:param package_type_plural: plural package-type token (``"skills"`` /
56+
``"contracts"`` / ``"connections"`` / ``"protocols"``).
57+
:param package_name: the package's local name.
58+
:param file_stem: source file stem without the ``.py`` suffix
59+
(``"contract"`` / ``"connection"`` / ``"behaviours"`` / ...).
60+
:return: the composed packages-prefixed dotted path string.
61+
"""
62+
return f"{PACKAGES}.{author}.{package_type_plural}.{package_name}.{file_stem}"
63+
64+
4065
@singledispatch
4166
def replace_component_ids(
4267
_arg: PackageConfiguration,

aea/connections/base.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@
3030

3131
from aea.components.base import Component, load_aea_package
3232
from aea.configurations.base import ComponentType, ConnectionConfig
33+
from aea.configurations.constants import CONNECTIONS
3334
from aea.configurations.data_types import PublicId
3435
from aea.configurations.loader import load_component_configuration
36+
from aea.configurations.utils import package_dotted_path
3537
from aea.crypto.wallet import CryptoStore
3638
from aea.exceptions import (
3739
AEAComponentLoadException,
@@ -284,9 +286,13 @@ def from_config(
284286
"Connection module '{}' not found.".format(connection_module_path)
285287
)
286288
load_aea_package(configuration)
287-
connection_module = load_module(
288-
"connection_module", directory / "connection.py"
289+
dotted_path = package_dotted_path(
290+
configuration.public_id.author,
291+
CONNECTIONS,
292+
configuration.public_id.name,
293+
"connection",
289294
)
295+
connection_module = load_module(dotted_path, directory / "connection.py")
290296
classes = inspect.getmembers(connection_module, inspect.isclass)
291297
connection_class_name = cast(str, configuration.class_name)
292298
connection_classes = list(

aea/contracts/base.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from aea.configurations.base import ComponentType, ContractConfig, PublicId
3131
from aea.configurations.constants import CONTRACTS
3232
from aea.configurations.loader import load_component_configuration
33+
from aea.configurations.utils import package_dotted_path
3334
from aea.crypto.base import LedgerApi
3435
from aea.crypto.registries import Registry, ledger_apis_registry, make_ledger_api_cls
3536
from aea.exceptions import AEAComponentLoadException, AEAException
@@ -123,7 +124,13 @@ def from_config(cls, configuration: ContractConfig, **kwargs: Any) -> "Contract"
123124
raise ValueError("Configuration must be associated with a directory.")
124125
directory = configuration.directory
125126
load_aea_package(configuration)
126-
contract_module = load_module(CONTRACTS, directory / "contract.py")
127+
dotted_path = package_dotted_path(
128+
configuration.public_id.author,
129+
CONTRACTS,
130+
configuration.public_id.name,
131+
"contract",
132+
)
133+
contract_module = load_module(dotted_path, directory / "contract.py")
127134
classes = inspect.getmembers(contract_module, inspect.isclass)
128135
contract_class_name = cast(str, configuration.class_name)
129136
contract_classes = list(filter(lambda x: x[0] == contract_class_name, classes))

aea/helpers/base.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,37 @@ def load_module(dotted_path: str, filepath: Path) -> types.ModuleType:
127127
:raises ValueError: if the filepath provided is not a module. # noqa: DAR402
128128
:raises Exception: if the execution of the module raises exception. # noqa: DAR402
129129
"""
130+
# If the dotted path is already in ``sys.modules``, reuse it instead
131+
# of re-executing the file. Re-execution creates a duplicate copy of
132+
# every class defined in the source, breaking class identity for
133+
# callers that already hold a reference (e.g. via an earlier
134+
# ``from ... import X``). ``__file__`` is intentionally NOT compared
135+
# — in test fixtures the same logical package may be reached
136+
# through different physical files (vendor copy in a tmpdir vs the
137+
# source tree) but both register the same dotted path and must
138+
# resolve to the same module object across the whole process. An
139+
# explicit ``None`` entry is CPython's block-import sentinel: raise
140+
# ``ImportError`` to match the standard import semantics rather
141+
# than silently overwriting it with a fresh exec.
142+
if dotted_path in sys.modules:
143+
existing = sys.modules[dotted_path]
144+
if existing is None:
145+
raise ImportError(f"import of {dotted_path!r} halted; None in sys.modules")
146+
return existing
130147
spec = importlib.util.spec_from_file_location(dotted_path, str(filepath))
131148
module = importlib.util.module_from_spec(cast(ModuleSpec, spec))
132-
spec.loader.exec_module(module) # type: ignore
149+
# Register before exec so a downstream `import` of the same dotted
150+
# path during exec resolves to this module. We only reach this point
151+
# when the key was absent (the cache-hit and block-import branches
152+
# above covered the other two cases), so on exec failure we pop the
153+
# half-built stub rather than restoring a prior entry that, by
154+
# construction, did not exist.
155+
sys.modules[dotted_path] = module
156+
try:
157+
spec.loader.exec_module(module) # type: ignore
158+
except BaseException: # pylint: disable=broad-except
159+
sys.modules.pop(dotted_path, None)
160+
raise
133161
return module
134162

135163

0 commit comments

Comments
 (0)