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.
- agentforge_core/__init__.py +228 -0
- agentforge_core/_bm25.py +132 -0
- agentforge_core/config/__init__.py +62 -0
- agentforge_core/config/loader.py +239 -0
- agentforge_core/config/module_schemas.py +208 -0
- agentforge_core/config/schema.py +424 -0
- agentforge_core/contracts/__init__.py +52 -0
- agentforge_core/contracts/auth.py +33 -0
- agentforge_core/contracts/chat.py +118 -0
- agentforge_core/contracts/embedding.py +71 -0
- agentforge_core/contracts/evaluator.py +56 -0
- agentforge_core/contracts/finding.py +39 -0
- agentforge_core/contracts/graph_store.py +180 -0
- agentforge_core/contracts/guardrails.py +129 -0
- agentforge_core/contracts/llm.py +152 -0
- agentforge_core/contracts/memory.py +113 -0
- agentforge_core/contracts/migrator.py +120 -0
- agentforge_core/contracts/renderer.py +57 -0
- agentforge_core/contracts/reranker.py +91 -0
- agentforge_core/contracts/strategy.py +70 -0
- agentforge_core/contracts/task.py +73 -0
- agentforge_core/contracts/tool.py +71 -0
- agentforge_core/contracts/vector_store.py +151 -0
- agentforge_core/migrations/__init__.py +14 -0
- agentforge_core/migrations/discover.py +77 -0
- agentforge_core/migrations/template.py +34 -0
- agentforge_core/observability/__init__.py +18 -0
- agentforge_core/observability/tracing.py +37 -0
- agentforge_core/production/__init__.py +77 -0
- agentforge_core/production/budget.py +134 -0
- agentforge_core/production/exceptions.py +136 -0
- agentforge_core/production/fallback.py +321 -0
- agentforge_core/production/log_filter.py +49 -0
- agentforge_core/production/log_format.py +117 -0
- agentforge_core/production/run_context.py +108 -0
- agentforge_core/py.typed +0 -0
- agentforge_core/resolver/__init__.py +38 -0
- agentforge_core/resolver/discover.py +145 -0
- agentforge_core/resolver/resolve.py +168 -0
- agentforge_core/testing/__init__.py +45 -0
- agentforge_core/testing/conformance.py +1138 -0
- agentforge_core/values/__init__.py +103 -0
- agentforge_core/values/auth.py +20 -0
- agentforge_core/values/chat.py +131 -0
- agentforge_core/values/claim.py +30 -0
- agentforge_core/values/graph.py +136 -0
- agentforge_core/values/guardrails.py +49 -0
- agentforge_core/values/manifest.py +129 -0
- agentforge_core/values/messages.py +153 -0
- agentforge_core/values/module.py +40 -0
- agentforge_core/values/pipeline.py +43 -0
- agentforge_core/values/retrieval.py +53 -0
- agentforge_core/values/state.py +118 -0
- agentforge_core/values/vector.py +59 -0
- agentforge_core-0.2.1.dist-info/METADATA +66 -0
- agentforge_core-0.2.1.dist-info/RECORD +58 -0
- agentforge_core-0.2.1.dist-info/WHEEL +4 -0
- 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
|