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,152 @@
1
+ """`LLMClient` — the locked LLM provider abstraction.
2
+
3
+ The mandatory surface is `call`, `close`, and capability introspection
4
+ (`capabilities` / `supports`). The optional surface — `call_with_cache`,
5
+ `call_with_thinking`, `stream` — is layered as default-raise methods so
6
+ the contract stays additive (per ADR-0009): drivers that don't support
7
+ a capability simply leave the default in place; consumers gate on
8
+ `client.supports("capability")` before invoking.
9
+
10
+ Capability vocabulary (closed enum, additions are minor bumps):
11
+ - "tools" — `tools=` argument honoured by `call`
12
+ - "json_mode" — provider returns guaranteed-valid JSON
13
+ - "vision" — multimodal input
14
+ - "caching" — `call_with_cache` works (prompt caching)
15
+ - "thinking" — `call_with_thinking` works (extended thinking)
16
+ - "streaming" — `stream` works
17
+ - "parallel_tools" — provider may emit multiple tool calls per turn
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from abc import ABC, abstractmethod
23
+ from collections.abc import AsyncIterator
24
+
25
+ from agentforge_core.production.exceptions import CapabilityNotSupported
26
+ from agentforge_core.values.messages import (
27
+ LLMResponse,
28
+ Message,
29
+ StreamChunk,
30
+ ToolSpec,
31
+ )
32
+
33
+
34
+ class LLMClient(ABC):
35
+ """Provider-agnostic chat-completion client.
36
+
37
+ Every provider module implements this ABC. Reasoning strategies
38
+ consume `LLMClient` (not the concrete provider type) so a string-id
39
+ swap (`"anthropic:..."` → `"bedrock:..."`) requires no code change.
40
+ """
41
+
42
+ @abstractmethod
43
+ async def call(
44
+ self,
45
+ system: str,
46
+ messages: list[Message],
47
+ tools: list[ToolSpec] | None = None,
48
+ ) -> LLMResponse:
49
+ """Issue a single chat-completion request.
50
+
51
+ Args:
52
+ system: System prompt.
53
+ messages: Conversation turns to date.
54
+ tools: Optional tool catalogue exposed to the LLM.
55
+
56
+ Returns:
57
+ The provider's response, normalised to `LLMResponse`.
58
+ """
59
+
60
+ @abstractmethod
61
+ async def close(self) -> None:
62
+ """Release any resources (HTTP clients, connection pools)."""
63
+
64
+ def capabilities(self) -> set[str]:
65
+ """Optional capabilities this provider supports.
66
+
67
+ Default empty set. Subclasses override to declare capabilities
68
+ from the closed vocabulary (per ADR-0009).
69
+
70
+ Returns:
71
+ Set of capability names.
72
+ """
73
+ return set()
74
+
75
+ def supports(self, capability: str) -> bool:
76
+ """True if this client declares the given capability."""
77
+ return capability in self.capabilities()
78
+
79
+ # ------------------------------------------------------------------
80
+ # Optional capabilities — drivers override; default raise.
81
+ # ------------------------------------------------------------------
82
+
83
+ async def call_with_cache(
84
+ self,
85
+ system: str,
86
+ messages: list[Message],
87
+ tools: list[ToolSpec] | None = None,
88
+ *,
89
+ cache_breakpoints: list[int],
90
+ ) -> LLMResponse:
91
+ """Call the model with explicit prompt-cache breakpoints.
92
+
93
+ `cache_breakpoints` is a list of `messages` indices after which
94
+ the provider should mark a cache point. Drivers that support
95
+ prompt caching (Anthropic, Bedrock with Claude) honour this;
96
+ every other driver leaves the default in place.
97
+
98
+ Raises:
99
+ CapabilityNotSupported: this driver did not declare
100
+ `"caching"` in `capabilities()`.
101
+ """
102
+ raise CapabilityNotSupported(
103
+ f"{type(self).__name__} does not support 'caching'. "
104
+ f"Check client.supports('caching') before calling."
105
+ )
106
+
107
+ async def call_with_thinking(
108
+ self,
109
+ system: str,
110
+ messages: list[Message],
111
+ tools: list[ToolSpec] | None = None,
112
+ *,
113
+ thinking_budget_tokens: int,
114
+ ) -> LLMResponse:
115
+ """Call the model with extended-thinking enabled.
116
+
117
+ `thinking_budget_tokens` caps the model's internal reasoning
118
+ budget. The returned `LLMResponse.usage.thinking_tokens`
119
+ reports actual usage; the public `content` excludes the
120
+ thinking trace.
121
+
122
+ Raises:
123
+ CapabilityNotSupported: this driver did not declare
124
+ `"thinking"` in `capabilities()`.
125
+ """
126
+ raise CapabilityNotSupported(
127
+ f"{type(self).__name__} does not support 'thinking'. "
128
+ f"Check client.supports('thinking') before calling."
129
+ )
130
+
131
+ def stream(
132
+ self,
133
+ system: str,
134
+ messages: list[Message],
135
+ tools: list[ToolSpec] | None = None,
136
+ ) -> AsyncIterator[StreamChunk]:
137
+ """Stream the model's response chunk-by-chunk.
138
+
139
+ Returns an async iterator that yields `StreamChunk`s and
140
+ terminates with exactly one `kind="stop"` chunk carrying final
141
+ usage and cost. Synchronous in shape (returns an iterator) so
142
+ the caller can pass it through pipes/transforms without an
143
+ extra `await`.
144
+
145
+ Raises:
146
+ CapabilityNotSupported: this driver did not declare
147
+ `"streaming"` in `capabilities()`.
148
+ """
149
+ raise CapabilityNotSupported(
150
+ f"{type(self).__name__} does not support 'streaming'. "
151
+ f"Check client.supports('streaming') before calling."
152
+ )
@@ -0,0 +1,113 @@
1
+ """`MemoryStore` — the locked persistence ABC.
2
+
3
+ feat-001 ships the contract plus an `InMemoryStore` reference impl in
4
+ the runtime package. feat-005 adds drivers for SQLite, PostgreSQL,
5
+ SurrealDB, and Neo4j (all passing the same conformance suite per
6
+ ADR-0007 and feat-016's `run_memory_conformance`).
7
+
8
+ Per feat-005's design (`docs/design/persistence-and-orm.md`), every
9
+ driver implements `MemoryStore`; graph-capable drivers additionally
10
+ implement `GraphStore` (deferred to feat-005).
11
+
12
+ Cross-agent isolation: every query is scoped by `(project, agent)` by
13
+ default. Cross-scope access requires explicit `None` filters — a
14
+ deliberate verb, not an accident.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from abc import ABC, abstractmethod
20
+ from collections.abc import AsyncIterator
21
+ from datetime import datetime
22
+
23
+ from agentforge_core.values.claim import Claim
24
+
25
+
26
+ class MemoryStore(ABC):
27
+ """Persistent store of `Claim`s with `(project, agent)` namespacing."""
28
+
29
+ @abstractmethod
30
+ async def put(self, claim: Claim) -> str:
31
+ """Persist `claim`. Returns its id (the claim's own ULID by default)."""
32
+
33
+ @abstractmethod
34
+ async def get(self, claim_id: str) -> Claim | None:
35
+ """Fetch a claim by id, or None if not present."""
36
+
37
+ @abstractmethod
38
+ async def query(
39
+ self,
40
+ *,
41
+ project: str | None = None,
42
+ agent: str | None = None,
43
+ category: str | None = None,
44
+ run_id: str | None = None,
45
+ limit: int = 100,
46
+ ) -> list[Claim]:
47
+ """Query claims with the given filters.
48
+
49
+ Filters are conjunctive. Passing `None` for `project` or `agent`
50
+ explicitly broadens the scope (cross-scope access is a verb).
51
+ """
52
+
53
+ @abstractmethod
54
+ async def supersede(self, old_id: str, new_claim: Claim) -> str:
55
+ """Replace `old_id` with `new_claim`; preserves history.
56
+
57
+ Sets `new_claim.supersedes = old_id` if not already set; returns
58
+ the new claim's id.
59
+ """
60
+
61
+ @abstractmethod
62
+ def stream(
63
+ self,
64
+ *,
65
+ project: str | None = None,
66
+ agent: str | None = None,
67
+ category: str | None = None,
68
+ run_id: str | None = None,
69
+ ) -> AsyncIterator[Claim]:
70
+ """Stream all matching claims as an async iterator.
71
+
72
+ Required even on backends with paged queries — drivers paginate
73
+ internally and yield `Claim`s as they arrive.
74
+ """
75
+
76
+ @abstractmethod
77
+ async def delete(
78
+ self,
79
+ *,
80
+ run_id: str | None = None,
81
+ older_than: datetime | None = None,
82
+ category: str | None = None,
83
+ ) -> int:
84
+ """Delete claims matching the given conjunctive filters.
85
+
86
+ At least one filter must be set; calling `delete()` with every
87
+ filter `None` raises `ModuleError` — a deliberate guard against
88
+ the silent total-wipe footgun. Returns the number of claims
89
+ deleted.
90
+
91
+ Added in feat-017 to back `agentforge db purge`. The opt-in
92
+ filter set is small on purpose; broader DSL queries route
93
+ through `agentforge db query` followed by per-id deletions.
94
+ """
95
+
96
+ @abstractmethod
97
+ async def close(self) -> None:
98
+ """Release backing resources (connections, file handles)."""
99
+
100
+ def capabilities(self) -> set[str]:
101
+ """Optional capabilities this driver supports.
102
+
103
+ Default empty set. Subclasses declare capabilities from the
104
+ closed vocabulary: "graph", "vector", "fts", "transactions",
105
+ "ttl", "encryption_at_rest". Per ADR-0009, declarations must be
106
+ honest — a nightly conformance test exercises every declared
107
+ capability against the real backend.
108
+ """
109
+ return set()
110
+
111
+ def supports(self, capability: str) -> bool:
112
+ """True if this driver declares the given capability."""
113
+ return capability in self.capabilities()
@@ -0,0 +1,120 @@
1
+ """`Migrator` Protocol + `Migration` value type — feat-024.
2
+
3
+ A migration is a single versioned schema delta: a numbered
4
+ filename, a name, an SQL/Cypher/SurrealQL body, and a checksum
5
+ of the body. Drivers ship migrations in-package; the migrator
6
+ applies pending ones in order and records each in a per-driver
7
+ tracking table or graph node so re-runs are idempotent.
8
+
9
+ Per ADR-0007 the surface is locked at v0.1: adding a method to
10
+ :class:`Migrator` is a major version bump. The bodies of migrations
11
+ are driver-dialect-specific (SQL / Cypher / SurrealQL); the
12
+ framework only enforces filename convention, checksum stability,
13
+ ordering, and apply-once semantics.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from datetime import datetime
20
+ from typing import Protocol, runtime_checkable
21
+
22
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
23
+
24
+ from agentforge_core.production.exceptions import ModuleError
25
+
26
+ _MIGRATION_ID_RE = re.compile(r"^\d{4}$")
27
+
28
+
29
+ class Migration(BaseModel):
30
+ """One versioned schema migration.
31
+
32
+ The ``id`` is the 4-digit prefix of the migration filename
33
+ (e.g. ``"0001"``); ``name`` is the snake-case description
34
+ (e.g. ``"initial"``); ``up`` is the migration body the
35
+ driver executes; ``checksum`` is the SHA-256 hex digest over
36
+ the LF-normalised UTF-8 body — recorded at apply time and
37
+ re-verified on every subsequent migrate / status invocation.
38
+ """
39
+
40
+ model_config = ConfigDict(frozen=True, strict=True)
41
+
42
+ id: str = Field(min_length=1)
43
+ name: str = Field(min_length=1)
44
+ up: str
45
+ checksum: str = Field(min_length=64, max_length=64)
46
+
47
+ @field_validator("id")
48
+ @classmethod
49
+ def _validate_id_format(cls, value: str) -> str:
50
+ if not _MIGRATION_ID_RE.match(value):
51
+ msg = f"Migration id must be exactly 4 digits, got {value!r}"
52
+ raise ValueError(msg)
53
+ return value
54
+
55
+
56
+ class MigrationStatus(BaseModel):
57
+ """Per-migration applied-state record for :meth:`Migrator.status`.
58
+
59
+ ``applied`` and ``applied_at`` track whether the migration has
60
+ been recorded against this driver. ``checksum_match`` is True
61
+ when the migration is both applied and its recorded checksum
62
+ equals the file's current checksum — drift triggers
63
+ :class:`MigrationChecksumError` on the next ``apply_pending``.
64
+ """
65
+
66
+ model_config = ConfigDict(frozen=True, strict=True)
67
+
68
+ migration: Migration
69
+ applied: bool
70
+ applied_at: datetime | None = None
71
+ checksum_match: bool
72
+
73
+
74
+ class MigrationChecksumError(ModuleError):
75
+ """An applied migration's recorded checksum no longer matches
76
+ the file's checksum.
77
+
78
+ Indicates the migration body was edited after deployment. The
79
+ framework refuses to silently re-apply — operators must either
80
+ restore the original file or add a forward-migration that
81
+ expresses the intended delta.
82
+ """
83
+
84
+
85
+ @runtime_checkable
86
+ class Migrator(Protocol):
87
+ """Driver-specific migration runner.
88
+
89
+ Implementations live alongside each persistent-store driver
90
+ (`PostgresMigrator`, `SqliteMigrator`, `Neo4jMigrator`,
91
+ `SurrealMigrator`). They share a single Protocol so the
92
+ `agentforge db migrate` CLI can drive any of them through the
93
+ same surface.
94
+ """
95
+
96
+ async def apply_pending(self) -> list[Migration]:
97
+ """Apply every discovered migration whose id is strictly
98
+ greater than ``await current_version()``. Returns the
99
+ applied migrations in order.
100
+
101
+ Raises:
102
+ MigrationChecksumError: An already-applied migration's
103
+ recorded checksum doesn't match the file's
104
+ checksum. Aborts before applying any pending
105
+ migration.
106
+ """
107
+ ...
108
+
109
+ async def status(self) -> list[MigrationStatus]:
110
+ """Return per-migration applied + checksum-match status
111
+ for every discovered migration, sorted by id ascending.
112
+ """
113
+ ...
114
+
115
+ async def current_version(self) -> str | None:
116
+ """Return the id of the highest applied migration, or
117
+ ``None`` if nothing has been applied yet (e.g. the
118
+ tracking table doesn't exist).
119
+ """
120
+ ...
@@ -0,0 +1,57 @@
1
+ """`FindingRenderer` — locked contract for rendering Findings to text.
2
+
3
+ Per feat-008 / ADR-0007, this ABC is part of the framework's stable
4
+ surface. Concrete renderers ship in `agentforge.renderers` and resolve
5
+ through `RendererRegistry`; agent / module authors implement this ABC
6
+ to ship custom renderers for their own `Finding` variants.
7
+
8
+ Rendering is intentionally text-out. Format strings are advisory —
9
+ implementations should accept at least `"text"` (plain) and
10
+ `"markdown"` (markdown-formatted), and may support additional formats
11
+ they declare.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from abc import ABC, abstractmethod
17
+
18
+ from agentforge_core.contracts.finding import Finding
19
+
20
+
21
+ class FindingRenderer(ABC):
22
+ """Render a `Finding` to a string in one of several formats.
23
+
24
+ Implementations are typically pinned to a single variant via the
25
+ registry's type-based dispatch (most-specific-wins). The base
26
+ `supports()` returns `True` only for the concrete variant the
27
+ renderer was registered against, so the registry handles dispatch.
28
+
29
+ Subclass and override `render`. If a subclass supports multiple
30
+ `Finding` variants, override `supports()` to declare which.
31
+ """
32
+
33
+ @abstractmethod
34
+ def render(self, finding: Finding, format: str = "text") -> str:
35
+ """Render `finding` to a string in the given format.
36
+
37
+ Args:
38
+ finding: Any object satisfying the `Finding` Protocol.
39
+ format: Output format. At minimum, implementations support
40
+ `"text"` (plain) and `"markdown"`. Unknown formats
41
+ should raise `ValueError`.
42
+
43
+ Returns:
44
+ A string suitable for direct emission to a terminal,
45
+ markdown file, log line, etc. — depending on `format`.
46
+ """
47
+
48
+ def supports(self, finding_type: type) -> bool:
49
+ """Whether this renderer accepts findings of `finding_type`.
50
+
51
+ Default: returns `False`; subclasses or the registry pin
52
+ renderers to a specific variant. The registry uses this only
53
+ as a fallback — primary dispatch is by isinstance-against-the-
54
+ registered-type, not by calling `supports()`.
55
+ """
56
+ del finding_type
57
+ return False
@@ -0,0 +1,91 @@
1
+ """`Reranker` — locked cross-encoder reranking ABC (feat-021).
2
+
3
+ A reranker scores `(query, candidate)` pairs directly, rather
4
+ than indexing then matching like a `VectorStore`. The standard
5
+ production RAG pattern is: pull the top-`K * factor` candidates
6
+ from a `VectorStore` for recall, then rerank to `top_k` for
7
+ precision. The framework owns the contract so swapping
8
+ SentenceTransformers ↔ Cohere ↔ Voyage is a config change, not
9
+ a rewrite.
10
+
11
+ Per ADR-0007 the surface is locked at v0.2: adding a method is
12
+ a major version bump. Optional capabilities layer the same way
13
+ as `VectorStore` / `LLMClient` capabilities — declared via
14
+ `capabilities()` and gated by callers.
15
+
16
+ Conformance: every shipped or third-party reranker must pass
17
+ `run_reranker_conformance(reranker)` (ships alongside this
18
+ contract).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from abc import ABC, abstractmethod
24
+
25
+ from agentforge_core.values.vector import VectorMatch
26
+
27
+
28
+ class Reranker(ABC):
29
+ """Re-orders a candidate list by relevance to a query.
30
+
31
+ Implementations:
32
+ - return a *new* list (callers may inspect the input list
33
+ after the call; mutation is forbidden)
34
+ - sort descending by the reranker's own relevance score
35
+ - replace each returned `VectorMatch.score` with the
36
+ reranker's normalised score (still in `[0, 1]`); other
37
+ fields (`id`, `text`, `metadata`) pass through unchanged
38
+ - when ``top_k`` is None, return all candidates re-sorted
39
+ - when set, truncate to the top ``top_k`` after sorting
40
+
41
+ Cross-driver invariants enforced by the conformance suite:
42
+ - len(rerank(...)) == min(len(candidates), top_k or ∞)
43
+ - 0 ≤ result[i].score ≤ 1 for every returned match
44
+ - result is sorted descending by score
45
+ - empty `candidates` returns `[]`
46
+ """
47
+
48
+ @abstractmethod
49
+ async def rerank(
50
+ self,
51
+ query: str,
52
+ candidates: list[VectorMatch],
53
+ *,
54
+ top_k: int | None = None,
55
+ ) -> list[VectorMatch]:
56
+ """Re-sort `candidates` by relevance to `query`.
57
+
58
+ Args:
59
+ query: Free-text query to score candidates against.
60
+ candidates: Output of an earlier `VectorStore.search`
61
+ (or any list of `VectorMatch`). Read-only — the
62
+ reranker must not mutate the input.
63
+ top_k: When set, truncate the result to this many
64
+ items. None returns all candidates re-sorted.
65
+
66
+ Returns:
67
+ A new list of `VectorMatch`, sorted descending by the
68
+ reranker's relevance score (replacing the original
69
+ `score`). Other fields pass through unchanged.
70
+
71
+ Raises:
72
+ ValueError: ``top_k < 1`` when not None.
73
+ """
74
+
75
+ @abstractmethod
76
+ async def close(self) -> None:
77
+ """Release backing resources (model handles, HTTP clients)."""
78
+
79
+ def capabilities(self) -> set[str]:
80
+ """Optional capabilities this reranker declares.
81
+
82
+ Default empty set. Closed vocabulary (additions are minor
83
+ bumps): ``"local"`` (runs offline, no network calls),
84
+ ``"managed"`` (calls an external API), ``"batched"``
85
+ (`rerank` internally batches the candidate pairs).
86
+ """
87
+ return set()
88
+
89
+ def supports(self, capability: str) -> bool:
90
+ """True if this reranker declares the given capability."""
91
+ return capability in self.capabilities()
@@ -0,0 +1,70 @@
1
+ """`ReasoningStrategy` — the locked reasoning-loop ABC.
2
+
3
+ feat-001 ships only the contract. feat-002 ships `ReActLoop` (the stable
4
+ default) and three experimental loops (`PlanExecuteLoop`,
5
+ `TreeOfThoughts`, `MultiAgentSupervisor`).
6
+
7
+ Every concrete strategy honours these invariants (enforced by the
8
+ conformance suite in feat-002):
9
+
10
+ - Guardrails (`BudgetPolicy.check`) are called before every LLM call.
11
+ - All state flows through one shared `AgentState` — no module globals.
12
+ - Every reasoning step is appended to `state.steps`.
13
+ - Termination is one of: finish signal, max_iterations, guardrail trip.
14
+
15
+ feat-020 v0.2 adds a non-abstract `stream()` default that wraps
16
+ `run()` and emits a single terminal `done` `StreamingEvent` so
17
+ existing concrete strategies keep working unchanged. Strategies
18
+ that want real per-token (or per-step) streaming override it to
19
+ yield events as the LLM emits tokens. `ChatSession.stream()`
20
+ detects the override and forwards events to the wire.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from abc import ABC, abstractmethod
26
+ from collections.abc import AsyncIterator
27
+
28
+ from agentforge_core.values.chat import StreamingEvent
29
+ from agentforge_core.values.state import AgentState
30
+
31
+
32
+ class ReasoningStrategy(ABC):
33
+ """Drives the agent from initial task to terminal state."""
34
+
35
+ @abstractmethod
36
+ async def run(self, state: AgentState) -> AgentState:
37
+ """Execute the reasoning loop until termination.
38
+
39
+ Args:
40
+ state: The mutable per-run state.
41
+
42
+ Returns:
43
+ The same `AgentState` instance with `steps` populated.
44
+ """
45
+
46
+ async def stream(self, state: AgentState) -> AsyncIterator[StreamingEvent]:
47
+ """Drive the agent and yield `StreamingEvent` frames as they arrive.
48
+
49
+ Default implementation: call `run(state)` to completion, then
50
+ yield exactly one `done` event carrying the run-level summary.
51
+ Backward-compatible — every existing strategy gets a working
52
+ `stream()` for free, and `ChatSession.stream()` falls back to
53
+ the v0.1 buffer-then-stream path when this default is in
54
+ effect.
55
+
56
+ Concrete strategies that want per-token streaming override
57
+ this and yield `text` / `tool_call` / `tool_result` /
58
+ `thinking` events as the LLM produces them, then a terminal
59
+ `done` event. `ChatSession.stream()` detects the override
60
+ via `type(strategy).stream is not ReasoningStrategy.stream`
61
+ and switches to forwarding events directly to the wire.
62
+ """
63
+ result = await self.run(state)
64
+ yield StreamingEvent(
65
+ kind="done",
66
+ content={
67
+ "run_id": getattr(result, "run_id", ""),
68
+ "cost_usd": float(getattr(result, "cost_usd", 0.0) or 0.0),
69
+ },
70
+ )
@@ -0,0 +1,73 @@
1
+ """`Task` — the locked pipeline-task ABC.
2
+
3
+ feat-015 introduces deterministic, pre-LLM analysis steps. A `Task`
4
+ emits a list of `Finding`s; `Pipeline` (in `agentforge.pipeline`)
5
+ runs a DAG of tasks in parallel and hands the consolidated findings
6
+ to the agent before the reasoning loop starts.
7
+
8
+ Subclasses declare four class attributes:
9
+
10
+ name: ClassVar[str]
11
+ Unique identifier within a pipeline. Must be non-empty.
12
+ cost_estimate_usd: ClassVar[float]
13
+ Declared cost. ``0.0`` for deterministic tasks; positive for
14
+ tasks that call the LLM (charged against the agent's budget).
15
+ timeout_s: ClassVar[float]
16
+ Per-task timeout in seconds. The engine wraps each
17
+ ``run()`` call in ``asyncio.wait_for(timeout_s)``.
18
+ depends_on: ClassVar[tuple[str, ...]]
19
+ Names of tasks that must finish before this one starts.
20
+ The engine validates the DAG at construction (no cycles, no
21
+ dangling references).
22
+
23
+ Subclasses implement ``run(context)`` and return ``list[Finding]``.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import inspect
29
+ from abc import ABC, abstractmethod
30
+ from collections.abc import Mapping
31
+ from typing import Any, ClassVar
32
+
33
+ from agentforge_core.contracts.finding import Finding
34
+
35
+
36
+ class Task(ABC):
37
+ """A deterministic (or LLM-using) step in a `Pipeline`."""
38
+
39
+ name: ClassVar[str]
40
+ cost_estimate_usd: ClassVar[float] = 0.0
41
+ timeout_s: ClassVar[float] = 60.0
42
+ depends_on: ClassVar[tuple[str, ...]] = ()
43
+
44
+ def __init_subclass__(cls, **kwargs: Any) -> None:
45
+ super().__init_subclass__(**kwargs)
46
+ if inspect.isabstract(cls):
47
+ return
48
+ if "name" not in cls.__dict__ and not _inherited_attr(cls, "name"):
49
+ raise TypeError(
50
+ f"{cls.__name__} must declare class attribute 'name' (see Task docstring)."
51
+ )
52
+
53
+ @abstractmethod
54
+ async def run(self, context: Mapping[str, Any]) -> list[Finding]:
55
+ """Execute the task and emit findings.
56
+
57
+ Args:
58
+ context: Caller-provided dict, merged with prior tasks'
59
+ findings under the key ``"pipeline_findings_so_far"``.
60
+ Treat as read-only; do not mutate.
61
+
62
+ Returns:
63
+ A list of `Finding`s. An empty list is valid.
64
+ """
65
+
66
+
67
+ def _inherited_attr(cls: type, attr: str) -> bool:
68
+ for base in cls.__mro__[1:]:
69
+ if base is Task or base is object:
70
+ continue
71
+ if attr in base.__dict__:
72
+ return True
73
+ return False