agentforge-core 0.2.1__py3-none-any.whl

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 (58) hide show
  1. agentforge_core/__init__.py +228 -0
  2. agentforge_core/_bm25.py +132 -0
  3. agentforge_core/config/__init__.py +62 -0
  4. agentforge_core/config/loader.py +239 -0
  5. agentforge_core/config/module_schemas.py +208 -0
  6. agentforge_core/config/schema.py +424 -0
  7. agentforge_core/contracts/__init__.py +52 -0
  8. agentforge_core/contracts/auth.py +33 -0
  9. agentforge_core/contracts/chat.py +118 -0
  10. agentforge_core/contracts/embedding.py +71 -0
  11. agentforge_core/contracts/evaluator.py +56 -0
  12. agentforge_core/contracts/finding.py +39 -0
  13. agentforge_core/contracts/graph_store.py +180 -0
  14. agentforge_core/contracts/guardrails.py +129 -0
  15. agentforge_core/contracts/llm.py +152 -0
  16. agentforge_core/contracts/memory.py +113 -0
  17. agentforge_core/contracts/migrator.py +120 -0
  18. agentforge_core/contracts/renderer.py +57 -0
  19. agentforge_core/contracts/reranker.py +91 -0
  20. agentforge_core/contracts/strategy.py +70 -0
  21. agentforge_core/contracts/task.py +73 -0
  22. agentforge_core/contracts/tool.py +71 -0
  23. agentforge_core/contracts/vector_store.py +151 -0
  24. agentforge_core/migrations/__init__.py +14 -0
  25. agentforge_core/migrations/discover.py +77 -0
  26. agentforge_core/migrations/template.py +34 -0
  27. agentforge_core/observability/__init__.py +18 -0
  28. agentforge_core/observability/tracing.py +37 -0
  29. agentforge_core/production/__init__.py +77 -0
  30. agentforge_core/production/budget.py +134 -0
  31. agentforge_core/production/exceptions.py +136 -0
  32. agentforge_core/production/fallback.py +321 -0
  33. agentforge_core/production/log_filter.py +49 -0
  34. agentforge_core/production/log_format.py +117 -0
  35. agentforge_core/production/run_context.py +108 -0
  36. agentforge_core/py.typed +0 -0
  37. agentforge_core/resolver/__init__.py +38 -0
  38. agentforge_core/resolver/discover.py +145 -0
  39. agentforge_core/resolver/resolve.py +168 -0
  40. agentforge_core/testing/__init__.py +45 -0
  41. agentforge_core/testing/conformance.py +1138 -0
  42. agentforge_core/values/__init__.py +103 -0
  43. agentforge_core/values/auth.py +20 -0
  44. agentforge_core/values/chat.py +131 -0
  45. agentforge_core/values/claim.py +30 -0
  46. agentforge_core/values/graph.py +136 -0
  47. agentforge_core/values/guardrails.py +49 -0
  48. agentforge_core/values/manifest.py +129 -0
  49. agentforge_core/values/messages.py +153 -0
  50. agentforge_core/values/module.py +40 -0
  51. agentforge_core/values/pipeline.py +43 -0
  52. agentforge_core/values/retrieval.py +53 -0
  53. agentforge_core/values/state.py +118 -0
  54. agentforge_core/values/vector.py +59 -0
  55. agentforge_core-0.2.1.dist-info/METADATA +66 -0
  56. agentforge_core-0.2.1.dist-info/RECORD +58 -0
  57. agentforge_core-0.2.1.dist-info/WHEEL +4 -0
  58. agentforge_core-0.2.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,108 @@
1
+ """`RunContext` and the `current_run()` ContextVar.
2
+
3
+ Per ADR-0010, every agent run carries a `run_id` propagated through
4
+ async tasks via `contextvars.ContextVar`. Every log line, every span,
5
+ every tool call, every claim record carries this id — there is no path
6
+ to a log line without one.
7
+
8
+ Generation: ULID (sortable, monotonic, 26 chars). Imported from
9
+ `python-ulid`.
10
+
11
+ Idempotency keys are derived from the run's `idempotency_seed` plus
12
+ caller-supplied parts. Same parts within the same run produce the same
13
+ key; different parts produce different keys; different runs produce
14
+ different keys for the same parts.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ from contextvars import ContextVar, Token
21
+ from datetime import UTC, datetime
22
+ from typing import Any
23
+
24
+ from pydantic import BaseModel, ConfigDict, Field
25
+ from ulid import ULID
26
+
27
+
28
+ class RunContext(BaseModel):
29
+ """Per-run correlation primitive bound to the current async task."""
30
+
31
+ model_config = ConfigDict(strict=True, validate_assignment=True)
32
+
33
+ run_id: str
34
+ parent_run_id: str | None = None
35
+ started_at: datetime
36
+ idempotency_seed: str
37
+ metadata: dict[str, Any] = Field(default_factory=dict)
38
+
39
+ def idempotency_key_for(self, *parts: object) -> str:
40
+ """Stable key derived from `(idempotency_seed, *parts)`.
41
+
42
+ Tools that mutate external state read this to safely retry within
43
+ a single run. Same `parts` → same key; different `parts` →
44
+ different key; different run → different key.
45
+
46
+ Returns:
47
+ 64-hex-character SHA-256 digest.
48
+ """
49
+ joined = self.idempotency_seed
50
+ for part in parts:
51
+ joined += "|" + str(part)
52
+ return hashlib.sha256(joined.encode("utf-8")).hexdigest()
53
+
54
+
55
+ _current_run: ContextVar[RunContext | None] = ContextVar("agentforge_current_run", default=None)
56
+
57
+
58
+ def current_run() -> RunContext:
59
+ """Return the live `RunContext` bound to this async task.
60
+
61
+ Raises:
62
+ RuntimeError: no run is active. `current_run()` is only callable
63
+ from within an `Agent.run()` invocation.
64
+ """
65
+ ctx = _current_run.get()
66
+ if ctx is None:
67
+ raise RuntimeError(
68
+ "No active RunContext. current_run() can only be called inside "
69
+ "Agent.run() (or a tool / hook invoked from one)."
70
+ )
71
+ return ctx
72
+
73
+
74
+ def new_run(
75
+ *,
76
+ parent_run_id: str | None = None,
77
+ task: str | None = None,
78
+ ) -> RunContext:
79
+ """Create a new `RunContext` with a fresh ULID `run_id`.
80
+
81
+ Does NOT bind it to the ContextVar — call `bind_run` for that.
82
+ """
83
+ run_id = str(ULID())
84
+ seed_input = f"{run_id}|{task or ''}"
85
+ seed = hashlib.sha256(seed_input.encode("utf-8")).hexdigest()[:32]
86
+ return RunContext(
87
+ run_id=run_id,
88
+ parent_run_id=parent_run_id,
89
+ started_at=datetime.now(UTC),
90
+ idempotency_seed=seed,
91
+ )
92
+
93
+
94
+ def bind_run(ctx: RunContext) -> Token[RunContext | None]:
95
+ """Bind a `RunContext` to the current async scope.
96
+
97
+ Returns the `Token` that `reset_run` consumes. Always pair with
98
+ `reset_run` in a try/finally to avoid leaking context across runs.
99
+ """
100
+ return _current_run.set(ctx)
101
+
102
+
103
+ def reset_run(token: Token[RunContext | None]) -> None:
104
+ """Reset the ContextVar to its prior value.
105
+
106
+ Pair with `bind_run`; safe to call exactly once per token.
107
+ """
108
+ _current_run.reset(token)
File without changes
@@ -0,0 +1,38 @@
1
+ """Module resolver — maps name → registered class.
2
+
3
+ Per ADR-0004, modules normally register via Python entry points
4
+ (loaded by feat-010). For feat-001 we ship the in-process registry
5
+ that the entry-point loader will populate. Anyone can register a
6
+ class manually with `@register("strategies", "my-loop")` for an
7
+ in-repo or test-only module.
8
+
9
+ A model identifier string `"<provider>:<model_id>"` is parsed by
10
+ `parse_model_string`; the leading provider is looked up as an entry
11
+ in `agentforge.providers`, the trailing piece is treated as the
12
+ model id and passed through to the provider constructor (per
13
+ feat-003).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from agentforge_core.resolver.discover import (
19
+ discover_entry_points,
20
+ reset_discovery,
21
+ )
22
+ from agentforge_core.resolver.resolve import (
23
+ Resolver,
24
+ parse_model_string,
25
+ register,
26
+ register_embedding_provider,
27
+ register_provider,
28
+ )
29
+
30
+ __all__ = [
31
+ "Resolver",
32
+ "discover_entry_points",
33
+ "parse_model_string",
34
+ "register",
35
+ "register_embedding_provider",
36
+ "register_provider",
37
+ "reset_discovery",
38
+ ]
@@ -0,0 +1,145 @@
1
+ """Entry-point discovery for feat-010.
2
+
3
+ Scans `importlib.metadata.entry_points()` for groups starting with
4
+ `agentforge.` and registers each entry against the global resolver.
5
+ A group named `agentforge.<category>` (e.g. `agentforge.providers`,
6
+ `agentforge.memory`) maps to the resolver's `<category>` slot.
7
+
8
+ The scan runs lazily — `Resolver.resolve` calls `ensure_discovered()`
9
+ on first use. Tests that want to start with a clean slate can call
10
+ `Resolver.global_().clear()` (also resets the discovery cache).
11
+
12
+ Conflict handling: if two distributions register under the same
13
+ `(category, name)` pair, the first one to register wins (entry-point
14
+ iteration order is the source). The resolver's own `register`
15
+ method raises `ModuleError` when the second registration arrives at
16
+ a different class — we catch that, log, and let the first win. This
17
+ matches the spec's §8 "Conflict resolution" entry while keeping the
18
+ runtime predictable.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from importlib.metadata import entry_points
25
+ from typing import TYPE_CHECKING
26
+
27
+ from agentforge_core.production.exceptions import ModuleError
28
+ from agentforge_core.values.module import ModuleInfo
29
+
30
+ if TYPE_CHECKING:
31
+ from agentforge_core.resolver.resolve import Resolver
32
+
33
+ _log = logging.getLogger("agentforge.resolver")
34
+
35
+ _GROUP_PREFIX = "agentforge."
36
+
37
+ # Module-level state held on a mutable list to avoid `global` (PLW0603).
38
+ _discovered: list[bool] = [False]
39
+ # Cache of resolved ModuleInfo per registered entry — keyed by
40
+ # `(category, name)`. Populated alongside the registry by
41
+ # `discover_entry_points`.
42
+ _module_info_cache: dict[tuple[str, str], ModuleInfo] = {}
43
+
44
+
45
+ def ensure_discovered(resolver: Resolver) -> None:
46
+ """Lazy hook — runs `discover_entry_points` once per resolver lifetime.
47
+
48
+ Resolver methods that need entry-point-registered modules call
49
+ this on entry. Safe to call repeatedly; only the first call does
50
+ work.
51
+ """
52
+ if _discovered[0]:
53
+ return
54
+ discover_entry_points(resolver)
55
+
56
+
57
+ def discover_entry_points(resolver: Resolver, *, force: bool = False) -> int:
58
+ """Scan `agentforge.*` entry points and register them on `resolver`.
59
+
60
+ Args:
61
+ resolver: Target resolver — typically `Resolver.global_()`.
62
+ force: When `True`, re-scan even if discovery has already run
63
+ (used by tests and by `Resolver.clear()`).
64
+
65
+ Returns:
66
+ Number of entries registered (excluding conflicts skipped).
67
+ """
68
+ if _discovered[0] and not force:
69
+ return 0
70
+ registered = 0
71
+ eps = entry_points()
72
+ # `entry_points()` returns an `EntryPoints` selectable view on
73
+ # 3.10+; iterate by group prefix.
74
+ for ep in eps:
75
+ if not ep.group.startswith(_GROUP_PREFIX):
76
+ continue
77
+ category = ep.group[len(_GROUP_PREFIX) :]
78
+ try:
79
+ cls = ep.load()
80
+ except Exception as exc:
81
+ _log.warning(
82
+ "skipping entry point %s.%s: load failed (%s: %s)",
83
+ ep.group,
84
+ ep.name,
85
+ type(exc).__name__,
86
+ exc,
87
+ )
88
+ continue
89
+ if not isinstance(cls, type):
90
+ _log.warning(
91
+ "skipping entry point %s.%s: target %r is not a class",
92
+ ep.group,
93
+ ep.name,
94
+ cls,
95
+ )
96
+ continue
97
+ try:
98
+ resolver.register(category, ep.name, cls)
99
+ except ModuleError as exc:
100
+ # Another distribution already registered the same key —
101
+ # first wins per spec §8. Log and move on.
102
+ _log.warning(
103
+ "entry-point conflict %s.%s ignored: %s",
104
+ ep.group,
105
+ ep.name,
106
+ exc,
107
+ )
108
+ continue
109
+ # Carry the source distribution metadata for `list_installed`.
110
+ dist = ep.dist
111
+ info = ModuleInfo(
112
+ category=category,
113
+ name=ep.name,
114
+ package=dist.name if dist is not None else None,
115
+ version=dist.version if dist is not None else None,
116
+ cls_qualname=f"{cls.__module__}.{cls.__qualname__}",
117
+ )
118
+ _module_info_cache[(category, ep.name)] = info
119
+ registered += 1
120
+ _discovered[0] = True
121
+ return registered
122
+
123
+
124
+ def reset_discovery() -> None:
125
+ """Clear the discovery cache. Called from `Resolver.clear()` and
126
+ by tests that want a clean slate."""
127
+ _discovered[0] = False
128
+ _module_info_cache.clear()
129
+
130
+
131
+ def module_info_for(category: str, name: str, cls: type) -> ModuleInfo:
132
+ """Return cached `ModuleInfo` if the entry was discovered via
133
+ entry points; otherwise synthesise one from `cls.__module__` /
134
+ `__qualname__` (for `@register`-registered classes).
135
+ """
136
+ cached = _module_info_cache.get((category, name))
137
+ if cached is not None:
138
+ return cached
139
+ return ModuleInfo(
140
+ category=category,
141
+ name=name,
142
+ package=None,
143
+ version=None,
144
+ cls_qualname=f"{cls.__module__}.{cls.__qualname__}",
145
+ )
@@ -0,0 +1,168 @@
1
+ """In-process module registry + model-string parsing.
2
+
3
+ feat-010 will extend `Resolver` to scan `importlib.metadata` entry
4
+ points; until then, anything in this registry was put there by an
5
+ explicit `register(...)` call. The two-phase design lets us write
6
+ integration tests in feat-001 without entry-point machinery.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+ from typing import TYPE_CHECKING, TypeVar
13
+
14
+ from agentforge_core.production.exceptions import ModuleError
15
+ from agentforge_core.resolver.discover import (
16
+ ensure_discovered,
17
+ module_info_for,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ from agentforge_core.values.module import ModuleInfo
22
+
23
+ T = TypeVar("T", bound=type)
24
+
25
+
26
+ class Resolver:
27
+ """Maps `(category, name) -> registered class`.
28
+
29
+ A single global instance is shared by `register()` and `Agent`.
30
+ Tests reset it via `Resolver.global_().clear()`.
31
+ """
32
+
33
+ def __init__(self) -> None:
34
+ self._registry: dict[tuple[str, str], type] = {}
35
+
36
+ @classmethod
37
+ def global_(cls) -> Resolver:
38
+ global _GLOBAL_RESOLVER # noqa: PLW0603 — singleton intentional
39
+ if _GLOBAL_RESOLVER is None:
40
+ _GLOBAL_RESOLVER = cls()
41
+ return _GLOBAL_RESOLVER
42
+
43
+ def register(self, category: str, name: str, cls: type) -> None:
44
+ key = (category, name)
45
+ if key in self._registry and self._registry[key] is not cls:
46
+ raise ModuleError(
47
+ f"Cannot register {cls.__name__} as {category}:{name}; "
48
+ f"already registered to {self._registry[key].__name__}."
49
+ )
50
+ self._registry[key] = cls
51
+
52
+ def resolve(self, category: str, name: str) -> type:
53
+ # Lazy entry-point discovery (feat-010): the first call to
54
+ # `resolve()` triggers a one-shot scan of installed
55
+ # `agentforge.*` entry points so packages-by-pip-install are
56
+ # available without explicit imports.
57
+ ensure_discovered(self)
58
+ key = (category, name)
59
+ if key not in self._registry:
60
+ available = sorted(n for c, n in self._registry if c == category)
61
+ raise ModuleError(
62
+ f"No module registered for {category}:{name!r}. "
63
+ f"Registered {category}: {available or '(none)'}. "
64
+ f"Install the relevant agentforge-* package or register "
65
+ f"a custom class with @register('{category}', '{name}')."
66
+ )
67
+ return self._registry[key]
68
+
69
+ def list_(self, category: str | None = None) -> list[tuple[str, str, type]]:
70
+ ensure_discovered(self)
71
+ items = [(c, n, cls) for (c, n), cls in self._registry.items()]
72
+ if category is not None:
73
+ items = [item for item in items if item[0] == category]
74
+ return sorted(items, key=lambda item: (item[0], item[1]))
75
+
76
+ def list_installed(self, category: str | None = None) -> list[ModuleInfo]:
77
+ """Return `ModuleInfo` for every registered module (feat-010).
78
+
79
+ Triggers entry-point discovery first so the returned list
80
+ reflects every `agentforge-*` package pip-installed in the
81
+ active environment plus anything registered manually via
82
+ `@register`.
83
+ """
84
+ ensure_discovered(self)
85
+ items: list[ModuleInfo] = []
86
+ for (cat, name), cls in self._registry.items():
87
+ if category is not None and cat != category:
88
+ continue
89
+ items.append(module_info_for(cat, name, cls))
90
+ return sorted(items, key=lambda m: (m.category, m.name))
91
+
92
+ def clear(self) -> None:
93
+ """Empty the in-process registry.
94
+
95
+ Does NOT reset the entry-point discovery cache — call
96
+ `reset_discovery()` separately if you need a fresh scan on
97
+ the next `resolve()`. Typical tests want a clean slate
98
+ without re-triggering discovery; tests that install fake
99
+ entry points opt in to the rediscovery explicitly.
100
+ """
101
+ self._registry.clear()
102
+
103
+
104
+ _GLOBAL_RESOLVER: Resolver | None = None
105
+
106
+
107
+ def register(category: str, name: str) -> Callable[[T], T]:
108
+ """Decorator: register a class under `(category, name)` in the global resolver.
109
+
110
+ Example:
111
+ @register("strategies", "my-loop")
112
+ class MyLoop(ReasoningStrategy):
113
+ ...
114
+ """
115
+
116
+ def decorator(cls: T) -> T:
117
+ Resolver.global_().register(category, name, cls)
118
+ return cls
119
+
120
+ return decorator
121
+
122
+
123
+ def register_provider(name: str) -> Callable[[T], T]:
124
+ """Decorator: register an `LLMClient` subclass as a model provider.
125
+
126
+ Convenience wrapper around `register("providers", name)` — used by
127
+ every concrete provider package (`agentforge-bedrock`,
128
+ `agentforge-anthropic`, ...). The provider name corresponds to the
129
+ leading token in a model string: `register_provider("bedrock")`
130
+ enables `Agent(model="bedrock:...")`.
131
+
132
+ Example:
133
+ @register_provider("bedrock")
134
+ class BedrockClient(LLMClient):
135
+ def __init__(self, *, model_id: str, region: str = "us-east-1") -> None:
136
+ ...
137
+ """
138
+ return register("providers", name)
139
+
140
+
141
+ def register_embedding_provider(name: str) -> Callable[[T], T]:
142
+ """Decorator: register an `EmbeddingClient` subclass as an embedding provider.
143
+
144
+ Mirrors `register_provider` but under the `"embeddings"` category
145
+ so chat models and embedding models can share a provider name
146
+ without colliding (e.g. `bedrock:anthropic.claude-...` for chat,
147
+ `embeddings:bedrock:amazon.titan-embed-text-v2:0` for embeddings).
148
+ """
149
+ return register("embeddings", name)
150
+
151
+
152
+ def parse_model_string(model_str: str) -> tuple[str, str]:
153
+ """Parse `"<provider>:<model_id>"` into `(provider, model_id)`.
154
+
155
+ Raises:
156
+ ModuleError: if the string does not contain a `:`.
157
+ """
158
+ if ":" not in model_str:
159
+ raise ModuleError(
160
+ f"Invalid model string {model_str!r}: expected '<provider>:<model_id>' "
161
+ f"(for example, 'anthropic:claude-sonnet-4.7')."
162
+ )
163
+ provider, _, model_id = model_str.partition(":")
164
+ if not provider or not model_id:
165
+ raise ModuleError(
166
+ f"Invalid model string {model_str!r}: provider and model_id must both be non-empty."
167
+ )
168
+ return provider, model_id
@@ -0,0 +1,45 @@
1
+ """Testing utilities — conformance suites that every driver must pass.
2
+
3
+ Per ADR-0007 and feat-016, every ABC in `agentforge-core` ships a
4
+ shared conformance suite. External module packages (e.g.
5
+ `agentforge-memory-postgres`) import these helpers to verify their
6
+ drivers against the locked contract.
7
+
8
+ The suites live in core (rather than the runtime package) so module
9
+ authors only need `agentforge-core` to test conformance — they don't
10
+ have to depend on the full runtime.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from agentforge_core.testing.conformance import (
16
+ run_chat_history_conformance,
17
+ run_embedding_conformance,
18
+ run_graph_conformance,
19
+ run_hybrid_search_conformance,
20
+ run_input_validator_conformance,
21
+ run_memory_conformance,
22
+ run_output_validator_conformance,
23
+ run_reranker_conformance,
24
+ run_strategy_conformance,
25
+ run_task_conformance,
26
+ run_tool_gate_conformance,
27
+ run_truncation_conformance,
28
+ run_vector_conformance,
29
+ )
30
+
31
+ __all__ = [
32
+ "run_chat_history_conformance",
33
+ "run_embedding_conformance",
34
+ "run_graph_conformance",
35
+ "run_hybrid_search_conformance",
36
+ "run_input_validator_conformance",
37
+ "run_memory_conformance",
38
+ "run_output_validator_conformance",
39
+ "run_reranker_conformance",
40
+ "run_strategy_conformance",
41
+ "run_task_conformance",
42
+ "run_tool_gate_conformance",
43
+ "run_truncation_conformance",
44
+ "run_vector_conformance",
45
+ ]