agentforge-core 0.2.3__tar.gz → 0.2.4__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 (106) hide show
  1. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/PKG-INFO +1 -1
  2. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/pyproject.toml +1 -1
  3. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/__init__.py +4 -0
  4. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/config/schema.py +57 -9
  5. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/contracts/__init__.py +2 -1
  6. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/contracts/chat.py +24 -1
  7. agentforge_core-0.2.4/src/agentforge_core/contracts/protocol_bridge.py +50 -0
  8. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/contracts/tool.py +35 -0
  9. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/production/__init__.py +2 -0
  10. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/production/exceptions.py +14 -0
  11. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/testing/conformance.py +26 -0
  12. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/values/messages.py +11 -10
  13. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_chat_conformance.py +3 -1
  14. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_config_feat012.py +7 -10
  15. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_config_module_schemas.py +55 -0
  16. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_contracts_tool.py +49 -1
  17. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_messages.py +11 -0
  18. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/.gitignore +0 -0
  19. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/LICENSE +0 -0
  20. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/README.md +0 -0
  21. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/_bm25.py +0 -0
  22. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/config/__init__.py +0 -0
  23. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/config/loader.py +0 -0
  24. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/config/module_schemas.py +0 -0
  25. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/contracts/auth.py +0 -0
  26. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/contracts/embedding.py +0 -0
  27. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/contracts/evaluator.py +0 -0
  28. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/contracts/finding.py +0 -0
  29. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/contracts/graph_store.py +0 -0
  30. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/contracts/guardrails.py +0 -0
  31. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/contracts/llm.py +0 -0
  32. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/contracts/memory.py +0 -0
  33. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/contracts/migrator.py +0 -0
  34. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/contracts/renderer.py +0 -0
  35. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/contracts/reranker.py +0 -0
  36. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/contracts/strategy.py +0 -0
  37. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/contracts/task.py +0 -0
  38. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/contracts/vector_store.py +0 -0
  39. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/migrations/__init__.py +0 -0
  40. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/migrations/discover.py +0 -0
  41. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/migrations/template.py +0 -0
  42. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/observability/__init__.py +0 -0
  43. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/observability/tracing.py +0 -0
  44. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/production/budget.py +0 -0
  45. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/production/fallback.py +0 -0
  46. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/production/log_filter.py +0 -0
  47. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/production/log_format.py +0 -0
  48. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/production/run_context.py +0 -0
  49. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/py.typed +0 -0
  50. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/resolver/__init__.py +0 -0
  51. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/resolver/discover.py +0 -0
  52. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/resolver/resolve.py +0 -0
  53. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/testing/__init__.py +0 -0
  54. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/values/__init__.py +0 -0
  55. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/values/auth.py +0 -0
  56. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/values/chat.py +0 -0
  57. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/values/claim.py +0 -0
  58. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/values/graph.py +0 -0
  59. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/values/guardrails.py +0 -0
  60. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/values/manifest.py +0 -0
  61. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/values/module.py +0 -0
  62. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/values/pipeline.py +0 -0
  63. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/values/retrieval.py +0 -0
  64. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/values/state.py +0 -0
  65. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/src/agentforge_core/values/vector.py +0 -0
  66. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/conftest.py +0 -0
  67. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/.gitkeep +0 -0
  68. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_auth_values.py +0 -0
  69. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_bm25.py +0 -0
  70. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_budget.py +0 -0
  71. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_capability_extensions.py +0 -0
  72. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_chat_values.py +0 -0
  73. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_claim.py +0 -0
  74. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_config_chat.py +0 -0
  75. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_config_pipeline.py +0 -0
  76. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_config_retrieval.py +0 -0
  77. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_contracts_auth.py +0 -0
  78. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_contracts_chat.py +0 -0
  79. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_contracts_evaluator.py +0 -0
  80. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_contracts_finding.py +0 -0
  81. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_contracts_llm.py +0 -0
  82. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_contracts_memory.py +0 -0
  83. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_contracts_strategy.py +0 -0
  84. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_contracts_task.py +0 -0
  85. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_embedding_client.py +0 -0
  86. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_exceptions.py +0 -0
  87. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_fallback_chain.py +0 -0
  88. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_graph_store_contract.py +0 -0
  89. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_guardrails_config.py +0 -0
  90. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_guardrails_contracts.py +0 -0
  91. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_log_filter.py +0 -0
  92. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_log_format.py +0 -0
  93. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_migrations.py +0 -0
  94. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_pipeline_values.py +0 -0
  95. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_provider_errors.py +0 -0
  96. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_reranker_contract.py +0 -0
  97. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_resolver.py +0 -0
  98. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_resolver_discovery.py +0 -0
  99. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_run_context.py +0 -0
  100. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_state.py +0 -0
  101. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_strategy_conformance.py +0 -0
  102. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_strategy_stream_default.py +0 -0
  103. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_task_conformance.py +0 -0
  104. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_values_graph.py +0 -0
  105. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_values_vector.py +0 -0
  106. {agentforge_core-0.2.3 → agentforge_core-0.2.4}/tests/unit/test_vector_store_contract.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentforge-core
3
- Version: 0.2.3
3
+ Version: 0.2.4
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.2.4"
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
  ]
@@ -17,7 +17,35 @@ from __future__ import annotations
17
17
  from pathlib import Path
18
18
  from typing import Any, Literal
19
19
 
20
- from pydantic import BaseModel, ConfigDict, Field, field_validator
20
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
21
+
22
+
23
+ def _normalise_named_entry(value: Any) -> Any:
24
+ """Normalise the terse YAML sugar for `name + config` entries into the
25
+ canonical mapping before strict validation (bug-019).
26
+
27
+ Three shapes are accepted; the first two are sugar:
28
+
29
+ - String form ``faithfulness`` → ``{"name": "faithfulness"}``
30
+ - Single-key mapping ``{geval: {rubric: "..."}}`` →
31
+ ``{"name": "geval", "config": {"rubric": "..."}}``
32
+ - Canonical mapping ``{name: x, config: {...}}`` → returned unchanged.
33
+
34
+ Anything else is returned untouched so the model's own validation
35
+ raises a clear error. Used by `EvaluatorEntry` and `GuardrailEntry`,
36
+ so every list that holds them (`modules.evaluators` and guardrails'
37
+ `input` / `output` / `tool_gates`) accepts all three forms.
38
+ """
39
+ if isinstance(value, str):
40
+ return {"name": value}
41
+ if isinstance(value, dict) and "name" not in value and len(value) == 1:
42
+ ((key, cfg),) = value.items()
43
+ if isinstance(key, str):
44
+ entry: dict[str, Any] = {"name": key}
45
+ if cfg is not None:
46
+ entry["config"] = cfg
47
+ return entry
48
+ return value
21
49
 
22
50
 
23
51
  class BudgetConfig(BaseModel):
@@ -178,13 +206,14 @@ class RetrievalConfig(BaseModel):
178
206
 
179
207
 
180
208
  class EvaluatorEntry(BaseModel):
181
- """An entry in `modules.evaluators:`. Two YAML shapes are valid:
209
+ """An entry in `modules.evaluators:`. Three YAML shapes are valid:
182
210
 
183
211
  - String form: `- faithfulness` (just the name).
184
- - Mapping form: `- faithfulness: {cost_cap_usd: 0.05, ...}`.
212
+ - Single-key mapping: `- faithfulness: {cost_cap_usd: 0.05, ...}`.
213
+ - Canonical mapping: `- {name: faithfulness, config: {...}}`.
185
214
 
186
- We model the mapping form here; the loader normalises strings to
187
- `EvaluatorEntry(name=..., config={})` before validation.
215
+ The first two are sugar; a `mode="before"` validator normalises them
216
+ to `EvaluatorEntry(name=..., config={...})` before strict validation.
188
217
  """
189
218
 
190
219
  model_config = ConfigDict(strict=True, extra="forbid")
@@ -192,6 +221,11 @@ class EvaluatorEntry(BaseModel):
192
221
  name: str = Field(min_length=1)
193
222
  config: dict[str, Any] = Field(default_factory=dict)
194
223
 
224
+ @model_validator(mode="before")
225
+ @classmethod
226
+ def _coerce_sugar(cls, value: Any) -> Any:
227
+ return _normalise_named_entry(value)
228
+
195
229
 
196
230
  class ObservabilityEntry(BaseModel):
197
231
  """An entry in `modules.observability:` — same shape as evaluator
@@ -226,13 +260,14 @@ class GuardrailPolicy(BaseModel):
226
260
  class GuardrailEntry(BaseModel):
227
261
  """One entry inside `modules.guardrails.{input,output,tool_gates}`.
228
262
 
229
- Two YAML shapes are valid (mirrors `EvaluatorEntry`):
263
+ Three YAML shapes are valid (mirrors `EvaluatorEntry`):
230
264
 
231
265
  - String form: `- prompt_injection_basic` (just the name).
232
- - Mapping form: `- presidio: {entities: ["EMAIL_ADDRESS"]}`.
266
+ - Single-key mapping: `- presidio: {entities: ["EMAIL_ADDRESS"]}`.
267
+ - Canonical mapping: `- {name: presidio, config: {...}}`.
233
268
 
234
- Both normalise to `GuardrailEntry(name=..., config={})` before
235
- validation.
269
+ The first two are sugar; a `mode="before"` validator normalises them
270
+ to `GuardrailEntry(name=..., config={...})` before strict validation.
236
271
  """
237
272
 
238
273
  model_config = ConfigDict(strict=True, extra="forbid")
@@ -240,6 +275,11 @@ class GuardrailEntry(BaseModel):
240
275
  name: str = Field(min_length=1)
241
276
  config: dict[str, Any] = Field(default_factory=dict)
242
277
 
278
+ @model_validator(mode="before")
279
+ @classmethod
280
+ def _coerce_sugar(cls, value: Any) -> Any:
281
+ return _normalise_named_entry(value)
282
+
243
283
 
244
284
  class ChatHistoryDriverConfig(BaseModel):
245
285
  """`modules.chat.history:` — driver + config for a chat history
@@ -269,6 +309,14 @@ class ChatSessionConfig(BaseModel):
269
309
  per_session_budget_usd: float | None = Field(default=None, ge=0.0)
270
310
  idempotency_window_s: float = Field(default=60.0, ge=0.0)
271
311
  concurrency: Literal["queue", "reject", "replace"] = "queue"
312
+ persist_steps: bool = True
313
+ """When True (default), intermediate `act` / `observe` agent steps
314
+ are persisted to `ChatHistoryStore` as `role="assistant"` (with
315
+ `tool_calls`) and `role="tool"` (with `tool_call_id`) turns
316
+ respectively, in addition to the final assistant turn. Tool-using
317
+ chat agents need this on for the next turn's prompt to reflect
318
+ what tools ran. Opt out by setting to False when an external
319
+ consumer reconstructs history from another source (bug-010)."""
272
320
  safety_mode: Literal["buffer-then-stream", "sentence-window", "stream-then-redact"] = (
273
321
  "buffer-then-stream"
274
322
  )
@@ -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.
@@ -815,6 +815,29 @@ async def run_task_conformance(
815
815
  # ----------------------------------------------------------------------
816
816
 
817
817
 
818
+ async def _assert_create_before_first_turn(store: ChatHistoryStore) -> None:
819
+ """A session must be creatable / metadata-settable before its first
820
+ turn is appended (bug-018), and listable as soon as it is."""
821
+ from uuid import uuid4 # noqa: PLC0415
822
+
823
+ fresh = f"conf-{uuid4().hex[:8]}"
824
+ await store.create_session(fresh, owner="carol")
825
+ listed = await store.list_sessions()
826
+ assert fresh in {s.id for s in listed}, (
827
+ "create_session() must make a session listable before any turn"
828
+ )
829
+ assert any(s.id == fresh and s.owner == "carol" for s in listed), (
830
+ "create_session(owner=...) must persist the owner"
831
+ )
832
+ fresh2 = f"conf-{uuid4().hex[:8]}"
833
+ await store.update_session_metadata(fresh2, {"owner": "dave"})
834
+ assert fresh2 in {s.id for s in await store.list_sessions()}, (
835
+ "update_session_metadata() on an unknown session must upsert it, not raise"
836
+ )
837
+ await store.delete_session(fresh)
838
+ await store.delete_session(fresh2)
839
+
840
+
818
841
  async def run_chat_history_conformance(store: ChatHistoryStore) -> None:
819
842
  """Validate that a `ChatHistoryStore` honours the locked contract.
820
843
 
@@ -906,6 +929,9 @@ async def run_chat_history_conformance(store: ChatHistoryStore) -> None:
906
929
  removed_b = await store.expire_before(far_future)
907
930
  assert isinstance(removed_b, int), "expire_before must return an int"
908
931
 
932
+ # 11. create_session / metadata before any turn (bug-018).
933
+ await _assert_create_before_first_turn(store)
934
+
909
935
  # 11. capabilities is a set.
910
936
  caps = store.capabilities()
911
937
  assert isinstance(caps, set)
@@ -24,6 +24,16 @@ StopReason = Literal["end_turn", "tool_use", "max_tokens", "stop_sequence", "oth
24
24
  """Provider-normalised reason the LLM stopped emitting tokens."""
25
25
 
26
26
 
27
+ class ToolCall(BaseModel):
28
+ """A tool invocation emitted by the LLM."""
29
+
30
+ model_config = ConfigDict(frozen=True, strict=True)
31
+
32
+ id: str
33
+ name: str
34
+ arguments: dict[str, Any] = Field(default_factory=dict)
35
+
36
+
27
37
  class Message(BaseModel):
28
38
  """One turn in the chat-completion exchange."""
29
39
 
@@ -33,16 +43,7 @@ class Message(BaseModel):
33
43
  content: str
34
44
  name: str | None = None
35
45
  tool_call_id: str | None = None
36
-
37
-
38
- class ToolCall(BaseModel):
39
- """A tool invocation emitted by the LLM."""
40
-
41
- model_config = ConfigDict(frozen=True, strict=True)
42
-
43
- id: str
44
- name: str
45
- arguments: dict[str, Any] = Field(default_factory=dict)
46
+ tool_calls: tuple[ToolCall, ...] = ()
46
47
 
47
48
 
48
49
  class ToolSpec(BaseModel):
@@ -70,7 +70,9 @@ class _DictHistory(ChatHistoryStore):
70
70
  limit: int = 100,
71
71
  before: datetime | None = None,
72
72
  ) -> list[SessionInfo]:
73
- ids = {t.session_id for t in self._turns}
73
+ # Include metadata-only sessions (created before their first
74
+ # turn — bug-018), not just sessions that have turns.
75
+ ids = {t.session_id for t in self._turns} | set(self._meta)
74
76
  out: list[SessionInfo] = []
75
77
  for sid in ids:
76
78
  o = self._owners.get(sid)
@@ -14,7 +14,6 @@ from agentforge_core.config import (
14
14
  parse_overrides,
15
15
  )
16
16
  from agentforge_core.production.exceptions import ModuleError
17
- from pydantic import ValidationError
18
17
 
19
18
  # --- widened schema ----------------------------------------------
20
19
 
@@ -269,16 +268,14 @@ def test_explicit_env_arg_beats_env_var(tmp_path: Path, monkeypatch: pytest.Monk
269
268
  assert cfg.agent.budget.usd == pytest.approx(50.0)
270
269
 
271
270
 
272
- # --- evaluator string-shorthand (NYI in this chunk) ------------
271
+ # --- evaluator string-shorthand (normalised since bug-019) -----
273
272
 
274
273
 
275
- def test_evaluator_string_shorthand_not_yet_supported(tmp_path: Path) -> None:
276
- """The spec §4.1 mentions `evaluators: - faithfulness` (bare
277
- string). This loader doesn't yet normalise that the YAML must
278
- spell out `- name: faithfulness`. Tracked under feat-012's
279
- Implementation status.
280
- """
274
+ def test_evaluator_string_shorthand_loads(tmp_path: Path) -> None:
275
+ """Spec §4.1's `evaluators: - faithfulness` (bare string) now loads
276
+ end-to-end: the entry's `mode="before"` validator normalises the
277
+ string to `{name: faithfulness, config: {}}` (bug-019)."""
281
278
  yaml_path = tmp_path / "agentforge.yaml"
282
279
  yaml_path.write_text("modules:\n evaluators:\n - faithfulness\n")
283
- with pytest.raises(ValidationError):
284
- load_config(yaml_path)
280
+ cfg = load_config(yaml_path)
281
+ assert [(e.name, e.config) for e in cfg.modules.evaluators] == [("faithfulness", {})]
@@ -172,6 +172,61 @@ def test_evaluator_entry_invalid_config_raises():
172
172
  validate_module_configs(cfg)
173
173
 
174
174
 
175
+ # --- bug-019: terse string / single-key-mapping sugar normalises ----
176
+
177
+
178
+ def test_evaluators_accept_all_three_sugar_forms() -> None:
179
+ """String, single-key-mapping, and canonical forms all parse from raw
180
+ YAML-shaped dicts (bug-019)."""
181
+ modules = ModulesConfig.model_validate(
182
+ {
183
+ "evaluators": [
184
+ "faithfulness", # string sugar
185
+ {"geval": {"rubric": "be nice"}}, # single-key mapping sugar
186
+ {"name": "correctness", "config": {"ground_truth_field": "ref"}}, # canonical
187
+ ]
188
+ }
189
+ )
190
+ assert [(e.name, e.config) for e in modules.evaluators] == [
191
+ ("faithfulness", {}),
192
+ ("geval", {"rubric": "be nice"}),
193
+ ("correctness", {"ground_truth_field": "ref"}),
194
+ ]
195
+
196
+
197
+ @pytest.mark.parametrize("gate", ["input", "output", "tool_gates"])
198
+ def test_guardrail_gates_accept_all_three_sugar_forms(gate: str) -> None:
199
+ modules = ModulesConfig.model_validate(
200
+ {
201
+ "guardrails": {
202
+ gate: [
203
+ "prompt_injection_basic", # string sugar
204
+ {"presidio": {"entities": ["EMAIL_ADDRESS"]}}, # single-key mapping sugar
205
+ {"name": "capability_check", "config": {}}, # canonical
206
+ ]
207
+ }
208
+ }
209
+ )
210
+ entries = getattr(modules.guardrails, gate)
211
+ assert [(e.name, e.config) for e in entries] == [
212
+ ("prompt_injection_basic", {}),
213
+ ("presidio", {"entities": ["EMAIL_ADDRESS"]}),
214
+ ("capability_check", {}),
215
+ ]
216
+
217
+
218
+ def test_string_sugar_rejects_empty_name() -> None:
219
+ """An empty string still fails (name has min_length=1)."""
220
+ with pytest.raises(ValueError, match="evaluators"):
221
+ ModulesConfig.model_validate({"evaluators": [""]})
222
+
223
+
224
+ def test_canonical_form_still_forbids_extra_keys() -> None:
225
+ """Normalisation must not loosen strict/extra=forbid for canonical dicts."""
226
+ with pytest.raises(ValueError, match="evaluators"):
227
+ ModulesConfig.model_validate({"evaluators": [{"name": "x", "config": {}, "bogus": 1}]})
228
+
229
+
175
230
  # --- non-class schema attribute (defensive) --------------------
176
231
 
177
232
 
@@ -6,7 +6,8 @@ from abc import abstractmethod
6
6
  from typing import Any
7
7
 
8
8
  import pytest
9
- from agentforge_core.contracts.tool import Tool
9
+ from agentforge_core.contracts.tool import Tool, validate_tool_name
10
+ from agentforge_core.production.exceptions import ProviderError, ToolNameInvalidError
10
11
  from pydantic import BaseModel
11
12
 
12
13
 
@@ -92,3 +93,50 @@ def test_inherited_attributes_satisfy_the_check() -> None:
92
93
  return "child"
93
94
 
94
95
  assert _Child.name == "base"
96
+
97
+
98
+ # ---- validate_tool_name (bug-017) ----
99
+
100
+
101
+ @pytest.mark.parametrize(
102
+ "name",
103
+ [
104
+ "search",
105
+ "kb_search",
106
+ "fs__read_file",
107
+ "tool-1",
108
+ "A",
109
+ "a" * 64,
110
+ "Mixed_Case-123",
111
+ ],
112
+ )
113
+ def test_validate_tool_name_accepts_portable_names(name: str) -> None:
114
+ validate_tool_name(name) # does not raise
115
+
116
+
117
+ @pytest.mark.parametrize(
118
+ "name",
119
+ [
120
+ "kb.search", # dot — the bug-012 / bug-017 case
121
+ "ns:tool", # colon
122
+ "a b", # space
123
+ "tool/name", # slash
124
+ "café", # non-ascii
125
+ "", # empty
126
+ "a" * 65, # too long
127
+ ],
128
+ )
129
+ def test_validate_tool_name_rejects_illegal_names(name: str) -> None:
130
+ with pytest.raises(ToolNameInvalidError):
131
+ validate_tool_name(name)
132
+
133
+
134
+ def test_validate_tool_name_error_is_a_provider_error() -> None:
135
+ """Subclasses ProviderError so existing provider-failure handlers catch it."""
136
+ with pytest.raises(ProviderError):
137
+ validate_tool_name("kb.search")
138
+
139
+
140
+ def test_validate_tool_name_message_includes_actionable_suggestion() -> None:
141
+ with pytest.raises(ToolNameInvalidError, match="kb_search"):
142
+ validate_tool_name("kb.search")
@@ -39,6 +39,17 @@ def test_message_accepts_each_valid_role(role: str) -> None:
39
39
  Message(role=role, content="ok") # type: ignore[arg-type]
40
40
 
41
41
 
42
+ def test_message_default_tool_calls_is_empty_tuple() -> None:
43
+ m = Message(role="assistant", content="hi")
44
+ assert m.tool_calls == ()
45
+
46
+
47
+ def test_message_carries_tool_calls() -> None:
48
+ tc = ToolCall(id="t-1", name="search", arguments={"q": "hi"})
49
+ m = Message(role="assistant", content="", tool_calls=(tc,))
50
+ assert m.tool_calls == (tc,)
51
+
52
+
42
53
  # ---- ToolCall ----
43
54
 
44
55
 
File without changes