agentforge-core 0.2.3__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/.gitignore +10 -0
  2. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/PKG-INFO +1 -1
  3. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/pyproject.toml +1 -1
  4. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/__init__.py +4 -0
  5. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/config/__init__.py +6 -0
  6. agentforge_core-0.3.0/src/agentforge_core/config/app_sections.py +124 -0
  7. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/config/loader.py +70 -8
  8. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/config/schema.py +91 -10
  9. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/contracts/__init__.py +2 -1
  10. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/contracts/chat.py +24 -1
  11. agentforge_core-0.3.0/src/agentforge_core/contracts/protocol_bridge.py +50 -0
  12. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/contracts/tool.py +35 -0
  13. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/production/__init__.py +2 -0
  14. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/production/exceptions.py +14 -0
  15. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/resolver/discover.py +8 -0
  16. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/testing/conformance.py +26 -0
  17. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/values/messages.py +11 -10
  18. agentforge_core-0.3.0/tests/integration/test_app_sections_real_discovery.py +170 -0
  19. agentforge_core-0.3.0/tests/integration/test_config_imports_cli.py +86 -0
  20. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_chat_conformance.py +3 -1
  21. agentforge_core-0.3.0/tests/unit/test_config_app_passthrough.py +154 -0
  22. agentforge_core-0.3.0/tests/unit/test_config_app_sections.py +169 -0
  23. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_config_feat012.py +7 -10
  24. agentforge_core-0.3.0/tests/unit/test_config_imports.py +216 -0
  25. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_config_module_schemas.py +55 -0
  26. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_contracts_tool.py +49 -1
  27. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_messages.py +11 -0
  28. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_resolver_discovery.py +37 -0
  29. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/LICENSE +0 -0
  30. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/README.md +0 -0
  31. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/_bm25.py +0 -0
  32. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/config/module_schemas.py +0 -0
  33. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/contracts/auth.py +0 -0
  34. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/contracts/embedding.py +0 -0
  35. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/contracts/evaluator.py +0 -0
  36. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/contracts/finding.py +0 -0
  37. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/contracts/graph_store.py +0 -0
  38. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/contracts/guardrails.py +0 -0
  39. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/contracts/llm.py +0 -0
  40. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/contracts/memory.py +0 -0
  41. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/contracts/migrator.py +0 -0
  42. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/contracts/renderer.py +0 -0
  43. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/contracts/reranker.py +0 -0
  44. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/contracts/strategy.py +0 -0
  45. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/contracts/task.py +0 -0
  46. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/contracts/vector_store.py +0 -0
  47. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/migrations/__init__.py +0 -0
  48. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/migrations/discover.py +0 -0
  49. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/migrations/template.py +0 -0
  50. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/observability/__init__.py +0 -0
  51. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/observability/tracing.py +0 -0
  52. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/production/budget.py +0 -0
  53. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/production/fallback.py +0 -0
  54. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/production/log_filter.py +0 -0
  55. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/production/log_format.py +0 -0
  56. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/production/run_context.py +0 -0
  57. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/py.typed +0 -0
  58. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/resolver/__init__.py +0 -0
  59. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/resolver/resolve.py +0 -0
  60. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/testing/__init__.py +0 -0
  61. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/values/__init__.py +0 -0
  62. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/values/auth.py +0 -0
  63. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/values/chat.py +0 -0
  64. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/values/claim.py +0 -0
  65. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/values/graph.py +0 -0
  66. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/values/guardrails.py +0 -0
  67. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/values/manifest.py +0 -0
  68. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/values/module.py +0 -0
  69. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/values/pipeline.py +0 -0
  70. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/values/retrieval.py +0 -0
  71. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/values/state.py +0 -0
  72. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/src/agentforge_core/values/vector.py +0 -0
  73. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/conftest.py +0 -0
  74. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/.gitkeep +0 -0
  75. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_auth_values.py +0 -0
  76. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_bm25.py +0 -0
  77. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_budget.py +0 -0
  78. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_capability_extensions.py +0 -0
  79. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_chat_values.py +0 -0
  80. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_claim.py +0 -0
  81. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_config_chat.py +0 -0
  82. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_config_pipeline.py +0 -0
  83. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_config_retrieval.py +0 -0
  84. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_contracts_auth.py +0 -0
  85. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_contracts_chat.py +0 -0
  86. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_contracts_evaluator.py +0 -0
  87. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_contracts_finding.py +0 -0
  88. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_contracts_llm.py +0 -0
  89. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_contracts_memory.py +0 -0
  90. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_contracts_strategy.py +0 -0
  91. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_contracts_task.py +0 -0
  92. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_embedding_client.py +0 -0
  93. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_exceptions.py +0 -0
  94. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_fallback_chain.py +0 -0
  95. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_graph_store_contract.py +0 -0
  96. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_guardrails_config.py +0 -0
  97. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_guardrails_contracts.py +0 -0
  98. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_log_filter.py +0 -0
  99. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_log_format.py +0 -0
  100. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_migrations.py +0 -0
  101. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_pipeline_values.py +0 -0
  102. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_provider_errors.py +0 -0
  103. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_reranker_contract.py +0 -0
  104. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_resolver.py +0 -0
  105. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_run_context.py +0 -0
  106. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_state.py +0 -0
  107. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_strategy_conformance.py +0 -0
  108. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_strategy_stream_default.py +0 -0
  109. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_task_conformance.py +0 -0
  110. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_values_graph.py +0 -0
  111. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_values_vector.py +0 -0
  112. {agentforge_core-0.2.3 → agentforge_core-0.3.0}/tests/unit/test_vector_store_contract.py +0 -0
@@ -47,3 +47,13 @@ Thumbs.db
47
47
  # Project-local
48
48
  *.local
49
49
  .agentforge-state/.session-cache
50
+
51
+ # AI-assistant working state — local session tracking, not part of
52
+ # the published project. The process docs under .claude/ (standards,
53
+ # checklists, CLAUDE.md) are intentionally kept tracked; only the
54
+ # churny per-session state files are ignored.
55
+ .claude/state/
56
+
57
+ # Launch / go-to-market drafts — local-only marketing material,
58
+ # never published to the repo.
59
+ launch/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentforge-core
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: AgentForge core — stable contracts (ABCs, value types) for the agentic framework
5
5
  Project-URL: Homepage, https://github.com/Scaffoldic/agentforge-py
6
6
  Project-URL: Repository, https://github.com/Scaffoldic/agentforge-py
@@ -9,7 +9,7 @@
9
9
 
10
10
  [project]
11
11
  name = "agentforge-core"
12
- version = "0.2.3"
12
+ version = "0.3.0"
13
13
  description = "AgentForge core — stable contracts (ABCs, value types) for the agentic framework"
14
14
  readme = "README.md"
15
15
  requires-python = ">=3.13"
@@ -47,6 +47,7 @@ from agentforge_core.contracts import (
47
47
  Reranker,
48
48
  Tool,
49
49
  VectorStore,
50
+ validate_tool_name,
50
51
  )
51
52
  from agentforge_core.migrations import discover_migrations
52
53
  from agentforge_core.observability import SCOPE_NAME as OBSERVABILITY_SCOPE_NAME
@@ -67,6 +68,7 @@ from agentforge_core.production import (
67
68
  RunIdFilter,
68
69
  ServiceError,
69
70
  TimeoutError,
71
+ ToolNameInvalidError,
70
72
  bind_run,
71
73
  current_run,
72
74
  install_json_formatter,
@@ -202,6 +204,7 @@ __all__ = [
202
204
  "TokenUsage",
203
205
  "Tool",
204
206
  "ToolCall",
207
+ "ToolNameInvalidError",
205
208
  "ToolSpec",
206
209
  "VectorItem",
207
210
  "VectorMatch",
@@ -225,4 +228,5 @@ __all__ = [
225
228
  "reset_run",
226
229
  "uninstall_json_formatter",
227
230
  "uninstall_run_id_filter",
231
+ "validate_tool_name",
228
232
  ]
@@ -20,6 +20,10 @@ bump; removing or renaming requires a major bump.
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
+ from agentforge_core.config.app_sections import (
24
+ discover_app_sections,
25
+ validate_app_config,
26
+ )
23
27
  from agentforge_core.config.loader import load_config, parse_overrides
24
28
  from agentforge_core.config.module_schemas import validate_module_configs
25
29
  from agentforge_core.config.schema import (
@@ -56,7 +60,9 @@ __all__ = [
56
60
  "RerankerEntry",
57
61
  "RetrievalConfig",
58
62
  "RetrieverModuleConfig",
63
+ "discover_app_sections",
59
64
  "load_config",
60
65
  "parse_overrides",
66
+ "validate_app_config",
61
67
  "validate_module_configs",
62
68
  ]
@@ -0,0 +1,124 @@
1
+ """Registered app-config section validation (feat-026 Phase 2).
2
+
3
+ Phase 1 (enh-002) added the reserved ``app:`` namespace and the
4
+ ``app_as`` accessor: the framework stored the subtree but validated
5
+ nothing inside it — a derived agent owned validation entirely. Phase 2
6
+ closes the parity gap with *module* config. A derived agent or plugin
7
+ registers a Pydantic schema per ``app.<section>`` through a new
8
+ entry-point group, exactly mirroring how modules register their classes
9
+ (ADR-0004)::
10
+
11
+ [project.entry-points."agentforge.config_sections"]
12
+ graph = "agentforge_graph.config:GraphConfig"
13
+
14
+ ``agentforge config validate`` then validates each registered section
15
+ the same way :func:`validate_module_configs` validates ``modules.*``:
16
+
17
+ - A section present in ``app:`` **and** registered → validated strictly
18
+ against its schema; a typo or bad value fails the command.
19
+ - A section present in ``app:`` but **not** registered → left untouched
20
+ (free-form, like an undocumented ``[tool.x]`` in ``pyproject.toml``).
21
+ - A registered section **absent** from ``app:`` → nothing to validate.
22
+ - A section whose package isn't installed → simply never discovered, so
23
+ validation degrades gracefully with no special-casing (the lenient
24
+ behaviour ``validate_module_configs`` gets from ``strict=False``).
25
+
26
+ This keeps the single ``app:`` boundary (feat-026 §8): registration maps
27
+ names *under* ``app:`` to schemas; it never opens new top-level keys.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ from importlib.metadata import entry_points
34
+ from typing import TYPE_CHECKING
35
+
36
+ from pydantic import BaseModel, ValidationError
37
+
38
+ from agentforge_core.production.exceptions import ModuleError
39
+
40
+ if TYPE_CHECKING:
41
+ from collections.abc import Mapping
42
+
43
+ from agentforge_core.config.schema import AgentForgeConfig
44
+
45
+ _log = logging.getLogger("agentforge.config")
46
+
47
+ #: Entry-point group mapping an ``app.<section>`` name to a Pydantic
48
+ #: schema. Parallel to the ``agentforge.<category>`` groups the resolver
49
+ #: scans, but consumed here rather than by the runtime resolver (the
50
+ #: resolver skips this group — see ``resolver.discover``).
51
+ SECTIONS_GROUP = "agentforge.config_sections"
52
+
53
+
54
+ def discover_app_sections() -> dict[str, type[BaseModel]]:
55
+ """Scan the ``agentforge.config_sections`` entry-point group.
56
+
57
+ Returns a mapping of section name → Pydantic model class. Entries
58
+ that fail to import, or whose target is not a ``BaseModel`` subclass,
59
+ are skipped with a warning. On a duplicate name across distributions
60
+ the first registration wins (matches the resolver's §8 conflict
61
+ rule).
62
+ """
63
+ sections: dict[str, type[BaseModel]] = {}
64
+ for ep in entry_points(group=SECTIONS_GROUP):
65
+ if ep.name in sections:
66
+ _log.warning(
67
+ "duplicate app-config section %r ignored (first registration wins)",
68
+ ep.name,
69
+ )
70
+ continue
71
+ try:
72
+ model = ep.load()
73
+ except Exception as exc:
74
+ _log.warning(
75
+ "skipping app-config section %r: load failed (%s: %s)",
76
+ ep.name,
77
+ type(exc).__name__,
78
+ exc,
79
+ )
80
+ continue
81
+ if not (isinstance(model, type) and issubclass(model, BaseModel)):
82
+ _log.warning(
83
+ "skipping app-config section %r: %r is not a pydantic BaseModel subclass",
84
+ ep.name,
85
+ model,
86
+ )
87
+ continue
88
+ sections[ep.name] = model
89
+ return sections
90
+
91
+
92
+ def validate_app_config(
93
+ cfg: AgentForgeConfig,
94
+ *,
95
+ sections: Mapping[str, type[BaseModel]] | None = None,
96
+ ) -> None:
97
+ """Validate each registered ``app.<section>`` against its schema.
98
+
99
+ Mirrors :func:`validate_module_configs`. Only sections that are both
100
+ present in ``cfg.app`` and registered via an entry point are checked;
101
+ unregistered sections are left untouched (free-form). A registered
102
+ section that fails its schema always raises :class:`ModuleError` —
103
+ validation failures are fatal, the same as for module config.
104
+
105
+ Args:
106
+ cfg: a loaded :class:`AgentForgeConfig` (post-``load_config``).
107
+ sections: pre-discovered section map. Defaults to
108
+ :func:`discover_app_sections`; injectable so tests (and
109
+ future callers with a cached registry) can supply their own.
110
+
111
+ Raises:
112
+ ModuleError: a registered ``app.<section>`` subtree fails its
113
+ schema.
114
+ """
115
+ registry = sections if sections is not None else discover_app_sections()
116
+ for name, model in registry.items():
117
+ if name not in cfg.app:
118
+ continue
119
+ try:
120
+ model.model_validate(cfg.app[name])
121
+ except ValidationError as exc:
122
+ raise ModuleError(
123
+ f"app.{name} failed validation: {exc.errors(include_url=False)}"
124
+ ) from exc
@@ -3,11 +3,13 @@
3
3
  Resolution order (last wins) per spec §4.3:
4
4
 
5
5
  1. Defaults from each Pydantic model.
6
- 2. agentforge.yaml on disk (if present).
7
- 3. agentforge.<env>.yaml (if AGENTFORGE_ENV set).
8
- 4. Env-var interpolation inside YAML values.
9
- 5. CLI / loader-API `--override agent.budget.usd=10` arguments.
10
- 6. Constructor kwargs to Agent (handled in `agentforge.agent`).
6
+ 2. Files pulled in via an `imports:` directive (feat-026 Phase 3),
7
+ lower precedence than the file that imports them.
8
+ 3. agentforge.yaml on disk (if present).
9
+ 4. agentforge.<env>.yaml (if AGENTFORGE_ENV set).
10
+ 5. Env-var interpolation inside YAML values.
11
+ 6. CLI / loader-API `--override agent.budget.usd=10` arguments.
12
+ 7. Constructor kwargs to Agent (handled in `agentforge.agent`).
11
13
 
12
14
  Env-var interpolation syntax (feat-001):
13
15
  - `${VAR}` — required; raises at load if missing.
@@ -155,6 +157,65 @@ def _read_yaml(path: Path) -> dict[str, Any]:
155
157
  return raw
156
158
 
157
159
 
160
+ #: Reserved top-level loader directive (feat-026 Phase 3): a list of
161
+ #: paths to other config files to pull in. Consumed by the loader
162
+ #: (popped before validation), so it never reaches `AgentForgeConfig`.
163
+ _IMPORTS_KEY = "imports"
164
+
165
+
166
+ def _load_file_with_imports(path: Path, *, _visiting: tuple[Path, ...] = ()) -> dict[str, Any]:
167
+ """Read a config file and resolve its ``imports:`` directive (feat-026
168
+ Phase 3 — pluggable config *sources*).
169
+
170
+ ``imports:`` is a top-level list of paths to other config files,
171
+ resolved **relative to this file's directory** (absolute paths are
172
+ honoured as-is). Import path strings get ``${ENV}`` interpolation so
173
+ ``imports: ["${EXTRA_CONFIG}"]`` works.
174
+
175
+ Precedence follows Spring Boot's ``spring.config.import``: an imported
176
+ file is **lower precedence than the file that imports it** — you
177
+ import shared defaults and override them locally. Within one
178
+ ``imports:`` list, later entries beat earlier ones. Imports compose
179
+ transitively (an imported file may itself import); cycles raise
180
+ ``ModuleError``.
181
+
182
+ The directive is consumed here — the returned mapping never contains
183
+ an ``imports`` key, so it doesn't collide with the root model's
184
+ ``extra="forbid"`` and ``config show --resolved`` shows the merged
185
+ result, not the directive.
186
+ """
187
+ resolved = path.resolve()
188
+ if resolved in _visiting:
189
+ chain = " -> ".join(str(p) for p in (*_visiting, resolved))
190
+ raise ModuleError(f"Circular config import detected: {chain}")
191
+
192
+ raw = _read_yaml(path)
193
+ imports = raw.pop(_IMPORTS_KEY, None)
194
+ if imports is None:
195
+ return raw
196
+ if not isinstance(imports, list):
197
+ raise ModuleError(
198
+ f"`imports:` in {path} must be a list of file paths; got {type(imports).__name__}."
199
+ )
200
+
201
+ merged: dict[str, Any] = {}
202
+ for entry in imports:
203
+ if not isinstance(entry, str):
204
+ raise ModuleError(f"`imports:` entries in {path} must be strings; got {entry!r}.")
205
+ import_path = Path(_interp(entry))
206
+ if not import_path.is_absolute():
207
+ import_path = path.parent / import_path
208
+ if not import_path.exists():
209
+ raise ModuleError(
210
+ f"Imported config file not found: {import_path} (imported by {path})."
211
+ )
212
+ imported = _load_file_with_imports(import_path, _visiting=(*_visiting, resolved))
213
+ merged = _deep_merge(merged, imported)
214
+
215
+ # The importing file's own keys win over everything it imports.
216
+ return _deep_merge(merged, raw)
217
+
218
+
158
219
  def _env_overlay_path(base: Path, env: str) -> Path:
159
220
  """Compute the overlay path next to `base`: foo.yaml → foo.<env>.yaml."""
160
221
  return base.with_suffix(f".{env}{base.suffix}")
@@ -191,14 +252,15 @@ def load_config(
191
252
  if resolved_path is None or not resolved_path.exists():
192
253
  merged: dict[str, Any] = {}
193
254
  else:
194
- merged = _read_yaml(resolved_path)
255
+ merged = _load_file_with_imports(resolved_path)
195
256
  # Layered env file overlays the base. Missing overlay is fine
196
- # (env-without-file is just "use base").
257
+ # (env-without-file is just "use base"). The overlay is itself
258
+ # import-aware, so an `agentforge.<env>.yaml` may pull in files too.
197
259
  resolved_env = env if env is not None else os.environ.get("AGENTFORGE_ENV")
198
260
  if resolved_env:
199
261
  overlay_path = _env_overlay_path(resolved_path, resolved_env)
200
262
  if overlay_path.exists():
201
- merged = _deep_merge(merged, _read_yaml(overlay_path))
263
+ merged = _deep_merge(merged, _load_file_with_imports(overlay_path))
202
264
 
203
265
  interpolated = _walk(merged)
204
266
  if overrides:
@@ -15,9 +15,39 @@ is the data-side representation.
15
15
  from __future__ import annotations
16
16
 
17
17
  from pathlib import Path
18
- from typing import Any, Literal
18
+ from typing import Any, Literal, TypeVar
19
19
 
20
- from pydantic import BaseModel, ConfigDict, Field, field_validator
20
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
21
+
22
+ _AppModelT = TypeVar("_AppModelT", bound=BaseModel)
23
+
24
+
25
+ def _normalise_named_entry(value: Any) -> Any:
26
+ """Normalise the terse YAML sugar for `name + config` entries into the
27
+ canonical mapping before strict validation (bug-019).
28
+
29
+ Three shapes are accepted; the first two are sugar:
30
+
31
+ - String form ``faithfulness`` → ``{"name": "faithfulness"}``
32
+ - Single-key mapping ``{geval: {rubric: "..."}}`` →
33
+ ``{"name": "geval", "config": {"rubric": "..."}}``
34
+ - Canonical mapping ``{name: x, config: {...}}`` → returned unchanged.
35
+
36
+ Anything else is returned untouched so the model's own validation
37
+ raises a clear error. Used by `EvaluatorEntry` and `GuardrailEntry`,
38
+ so every list that holds them (`modules.evaluators` and guardrails'
39
+ `input` / `output` / `tool_gates`) accepts all three forms.
40
+ """
41
+ if isinstance(value, str):
42
+ return {"name": value}
43
+ if isinstance(value, dict) and "name" not in value and len(value) == 1:
44
+ ((key, cfg),) = value.items()
45
+ if isinstance(key, str):
46
+ entry: dict[str, Any] = {"name": key}
47
+ if cfg is not None:
48
+ entry["config"] = cfg
49
+ return entry
50
+ return value
21
51
 
22
52
 
23
53
  class BudgetConfig(BaseModel):
@@ -178,13 +208,14 @@ class RetrievalConfig(BaseModel):
178
208
 
179
209
 
180
210
  class EvaluatorEntry(BaseModel):
181
- """An entry in `modules.evaluators:`. Two YAML shapes are valid:
211
+ """An entry in `modules.evaluators:`. Three YAML shapes are valid:
182
212
 
183
213
  - String form: `- faithfulness` (just the name).
184
- - Mapping form: `- faithfulness: {cost_cap_usd: 0.05, ...}`.
214
+ - Single-key mapping: `- faithfulness: {cost_cap_usd: 0.05, ...}`.
215
+ - Canonical mapping: `- {name: faithfulness, config: {...}}`.
185
216
 
186
- We model the mapping form here; the loader normalises strings to
187
- `EvaluatorEntry(name=..., config={})` before validation.
217
+ The first two are sugar; a `mode="before"` validator normalises them
218
+ to `EvaluatorEntry(name=..., config={...})` before strict validation.
188
219
  """
189
220
 
190
221
  model_config = ConfigDict(strict=True, extra="forbid")
@@ -192,6 +223,11 @@ class EvaluatorEntry(BaseModel):
192
223
  name: str = Field(min_length=1)
193
224
  config: dict[str, Any] = Field(default_factory=dict)
194
225
 
226
+ @model_validator(mode="before")
227
+ @classmethod
228
+ def _coerce_sugar(cls, value: Any) -> Any:
229
+ return _normalise_named_entry(value)
230
+
195
231
 
196
232
  class ObservabilityEntry(BaseModel):
197
233
  """An entry in `modules.observability:` — same shape as evaluator
@@ -226,13 +262,14 @@ class GuardrailPolicy(BaseModel):
226
262
  class GuardrailEntry(BaseModel):
227
263
  """One entry inside `modules.guardrails.{input,output,tool_gates}`.
228
264
 
229
- Two YAML shapes are valid (mirrors `EvaluatorEntry`):
265
+ Three YAML shapes are valid (mirrors `EvaluatorEntry`):
230
266
 
231
267
  - String form: `- prompt_injection_basic` (just the name).
232
- - Mapping form: `- presidio: {entities: ["EMAIL_ADDRESS"]}`.
268
+ - Single-key mapping: `- presidio: {entities: ["EMAIL_ADDRESS"]}`.
269
+ - Canonical mapping: `- {name: presidio, config: {...}}`.
233
270
 
234
- Both normalise to `GuardrailEntry(name=..., config={})` before
235
- validation.
271
+ The first two are sugar; a `mode="before"` validator normalises them
272
+ to `GuardrailEntry(name=..., config={...})` before strict validation.
236
273
  """
237
274
 
238
275
  model_config = ConfigDict(strict=True, extra="forbid")
@@ -240,6 +277,11 @@ class GuardrailEntry(BaseModel):
240
277
  name: str = Field(min_length=1)
241
278
  config: dict[str, Any] = Field(default_factory=dict)
242
279
 
280
+ @model_validator(mode="before")
281
+ @classmethod
282
+ def _coerce_sugar(cls, value: Any) -> Any:
283
+ return _normalise_named_entry(value)
284
+
243
285
 
244
286
  class ChatHistoryDriverConfig(BaseModel):
245
287
  """`modules.chat.history:` — driver + config for a chat history
@@ -269,6 +311,14 @@ class ChatSessionConfig(BaseModel):
269
311
  per_session_budget_usd: float | None = Field(default=None, ge=0.0)
270
312
  idempotency_window_s: float = Field(default=60.0, ge=0.0)
271
313
  concurrency: Literal["queue", "reject", "replace"] = "queue"
314
+ persist_steps: bool = True
315
+ """When True (default), intermediate `act` / `observe` agent steps
316
+ are persisted to `ChatHistoryStore` as `role="assistant"` (with
317
+ `tool_calls`) and `role="tool"` (with `tool_call_id`) turns
318
+ respectively, in addition to the final assistant turn. Tool-using
319
+ chat agents need this on for the next turn's prompt to reflect
320
+ what tools ran. Opt out by setting to False when an external
321
+ consumer reconstructs history from another source (bug-010)."""
272
322
  safety_mode: Literal["buffer-then-stream", "sentence-window", "stream-then-redact"] = (
273
323
  "buffer-then-stream"
274
324
  )
@@ -422,3 +472,34 @@ class AgentForgeConfig(BaseModel):
422
472
  logging: LoggingConfig = Field(default_factory=LoggingConfig)
423
473
  output: OutputConfig = Field(default_factory=OutputConfig)
424
474
  guardrail_policy: GuardrailPolicy = Field(default_factory=GuardrailPolicy)
475
+ app: dict[str, Any] = Field(default_factory=dict)
476
+ """Reserved namespace for **application** config (enh-002, feat-026
477
+ Phase 1). The framework accepts this subtree but does not interpret
478
+ it: a consuming agent puts its own config here and validates it with
479
+ its own Pydantic model via :meth:`app_as`. Every other top-level key
480
+ stays strict (`extra="forbid"`), so framework-key typos are still
481
+ caught. Values inside `app:` get `${ENV}` interpolation, env-file
482
+ layering, dotted-path overrides, and `config show --resolved` for
483
+ free — they ride the same loader passes as framework keys. The
484
+ framework performs no *registered-schema* validation inside `app:`
485
+ in Phase 1; that arrives in feat-026 Phase 2."""
486
+
487
+ def app_as(self, model: type[_AppModelT], key: str | None = None) -> _AppModelT:
488
+ """Validate and return an application-config subtree.
489
+
490
+ `key=None` validates the whole `app:` mapping; otherwise the
491
+ `app[key]` subtree (missing key → empty mapping, so the caller's
492
+ model supplies its own defaults). The caller's model owns its own
493
+ strictness, so app-key typos surface here — strictness is
494
+ delegated into `app:`, not lost.
495
+
496
+ Example::
497
+
498
+ class GraphConfig(BaseModel):
499
+ store: StoreConfig
500
+
501
+
502
+ graph_cfg = cfg.app_as(GraphConfig, "graph")
503
+ """
504
+ raw = self.app if key is None else self.app.get(key, {})
505
+ return model.model_validate(raw)
@@ -25,7 +25,7 @@ from agentforge_core.contracts.renderer import FindingRenderer
25
25
  from agentforge_core.contracts.reranker import Reranker
26
26
  from agentforge_core.contracts.strategy import ReasoningStrategy
27
27
  from agentforge_core.contracts.task import Task
28
- from agentforge_core.contracts.tool import Tool
28
+ from agentforge_core.contracts.tool import Tool, validate_tool_name
29
29
  from agentforge_core.contracts.vector_store import VectorStore
30
30
 
31
31
  __all__ = [
@@ -49,4 +49,5 @@ __all__ = [
49
49
  "Task",
50
50
  "Tool",
51
51
  "VectorStore",
52
+ "validate_tool_name",
52
53
  ]
@@ -71,9 +71,32 @@ class ChatHistoryStore(ABC):
71
71
  """Merge ``metadata`` into the session's metadata dict.
72
72
 
73
73
  Implementations may overwrite top-level keys; nested merging
74
- is the caller's responsibility.
74
+ is the caller's responsibility. The session need **not** already
75
+ exist: if it doesn't, the driver creates it (upsert), so callers
76
+ can record metadata before the first turn is appended (bug-018).
75
77
  """
76
78
 
79
+ async def create_session(
80
+ self,
81
+ session_id: str,
82
+ *,
83
+ owner: str | None = None,
84
+ metadata: Mapping[str, Any] | None = None,
85
+ ) -> None:
86
+ """Ensure a session row exists before any turn is appended.
87
+
88
+ Concrete (non-abstract) so it is additive to this locked ABC
89
+ (ADR-0007): existing and third-party drivers inherit it without
90
+ change. The default records the initial ``owner`` / ``metadata``
91
+ via :meth:`update_session_metadata`, which every shipped driver
92
+ upserts. Idempotent — calling it for an existing session merges
93
+ the given metadata. Drivers may override with a direct insert.
94
+ """
95
+ merged: dict[str, Any] = dict(metadata or {})
96
+ if owner is not None:
97
+ merged["owner"] = owner
98
+ await self.update_session_metadata(session_id, merged)
99
+
77
100
  @abstractmethod
78
101
  async def expire_before(self, cutoff: datetime) -> int:
79
102
  """TTL sweep: delete every session whose ``last_active_at <
@@ -0,0 +1,50 @@
1
+ """`ProtocolBridge` — the runtime contract for `modules.protocols`
2
+ handlers (feat-013).
3
+
4
+ A protocol handler turns a `modules.protocols[*]` config block into a
5
+ set of `Tool`s the agent can call, and owns the lifecycle of whatever
6
+ connections back that (subprocesses, sockets, sessions). The runtime
7
+ (`build_agent_from_config`) resolves each entry's name under the
8
+ ``protocols`` resolver category, builds the handler from its config,
9
+ `start()`s it, merges `tools` into the `Agent`, and `close()`s it on
10
+ `Agent.close()`.
11
+
12
+ The contract is a `@runtime_checkable` `Protocol` rather than an ABC so
13
+ handlers in optional packages (`agentforge-mcp`'s `MCPBridge`, a future
14
+ `agentforge-a2a` bridge) satisfy it structurally — `agentforge` /
15
+ `agentforge-core` never import the handler packages.
16
+
17
+ Expected construction shape (duck-typed, not part of the Protocol since
18
+ classmethods don't express cleanly here): a classmethod
19
+ ``from_config(config: dict) -> ProtocolBridge`` that is **pure data** —
20
+ it must not open transports or drive an event loop (do that in
21
+ ``start()``), so it is safe to call from within a running loop.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Protocol, runtime_checkable
27
+
28
+ from agentforge_core.contracts.tool import Tool
29
+
30
+
31
+ @runtime_checkable
32
+ class ProtocolBridge(Protocol):
33
+ """Lifecycle + tool-source contract for a protocols handler."""
34
+
35
+ @property
36
+ def tools(self) -> list[Tool]:
37
+ """The tools this bridge contributes, populated by `start()`."""
38
+ ...
39
+
40
+ async def start(self) -> None:
41
+ """Open connections and populate `tools`. Safe to call inside a
42
+ running event loop."""
43
+ ...
44
+
45
+ async def close(self) -> None:
46
+ """Tear down every connection this bridge opened."""
47
+ ...
48
+
49
+
50
+ __all__ = ["ProtocolBridge"]
@@ -9,13 +9,48 @@ hood.
9
9
  from __future__ import annotations
10
10
 
11
11
  import inspect
12
+ import re
12
13
  from abc import ABC, abstractmethod
13
14
  from typing import Any, ClassVar
14
15
 
15
16
  from pydantic import BaseModel
16
17
 
18
+ from agentforge_core.production.exceptions import ToolNameInvalidError
17
19
  from agentforge_core.values.messages import ToolSpec
18
20
 
21
+ # The tool-name charset every major provider enforces: Bedrock Converse,
22
+ # OpenAI function calling, and Anthropic tool use all validate names
23
+ # against `^[a-zA-Z0-9_-]{1,64}$`. A name legal here is portable across
24
+ # all of them.
25
+ _TOOL_NAME_RE = re.compile(r"[a-zA-Z0-9_-]{1,64}")
26
+
27
+
28
+ def validate_tool_name(name: str) -> None:
29
+ """Raise `ToolNameInvalidError` if `name` isn't portable across providers.
30
+
31
+ Providers call this at request-build time so an illegal name (e.g. the
32
+ dotted `kb.search`) surfaces as a local, actionable error *before* the
33
+ request leaves the process — instead of a cryptic remote validation
34
+ failure on the first LLM call.
35
+
36
+ Core itself does **not** auto-invoke this: `ToolSpec` stays a neutral
37
+ representation, and each provider opts into the policy it enforces. The
38
+ charset happens to be identical across today's providers, but it is a
39
+ per-provider wire constraint, not a property of the tool definition.
40
+ """
41
+ if not _TOOL_NAME_RE.fullmatch(name):
42
+ raise ToolNameInvalidError(
43
+ f"tool name {name!r} is not portable: it must match [a-zA-Z0-9_-] "
44
+ f"and be 1-64 characters (the charset Bedrock, OpenAI, and "
45
+ f"Anthropic all enforce). Try {_suggest_tool_name(name)!r}."
46
+ )
47
+
48
+
49
+ def _suggest_tool_name(name: str) -> str:
50
+ """Best-effort legal rewrite for the error message (`kb.search` → `kb_search`)."""
51
+ cleaned = re.sub(r"[^a-zA-Z0-9_-]", "_", name)[:64]
52
+ return cleaned or "tool"
53
+
19
54
 
20
55
  class Tool(ABC):
21
56
  """A typed callable the agent can invoke.
@@ -31,6 +31,7 @@ from agentforge_core.production.exceptions import (
31
31
  RateLimitError,
32
32
  ServiceError,
33
33
  TimeoutError,
34
+ ToolNameInvalidError,
34
35
  )
35
36
  from agentforge_core.production.log_filter import (
36
37
  RunIdFilter,
@@ -66,6 +67,7 @@ __all__ = [
66
67
  "RunIdFilter",
67
68
  "ServiceError",
68
69
  "TimeoutError",
70
+ "ToolNameInvalidError",
69
71
  "bind_run",
70
72
  "current_run",
71
73
  "install_json_formatter",
@@ -101,6 +101,20 @@ class TimeoutError(ProviderError):
101
101
  """
102
102
 
103
103
 
104
+ class ToolNameInvalidError(ProviderError):
105
+ """A tool name violates the portable tool-name charset.
106
+
107
+ Every major provider (Bedrock Converse, OpenAI, Anthropic) validates
108
+ tool names against ``^[a-zA-Z0-9_-]{1,64}$``. Provider drivers call
109
+ `agentforge_core.contracts.tool.validate_tool_name` at request-build
110
+ time and raise this *before* the request leaves the process, turning a
111
+ cryptic remote validation failure on the first LLM call into a local,
112
+ actionable error. Subclasses `ProviderError` so the same handler that
113
+ catches other provider failures catches this too. Not retryable — the
114
+ fix is to rename the tool.
115
+ """
116
+
117
+
104
118
  class CapabilityNotSupported(AgentForgeError):
105
119
  """Raised when an optional capability is invoked on a driver that
106
120
  does not declare it.