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,103 @@
1
+ """Locked value types — Pydantic v2 models the framework's contracts use.
2
+
3
+ Per ADR-0007, these shapes are part of the framework's stable surface;
4
+ adding a field requires a minor bump, removing or renaming a field
5
+ requires a major bump.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from agentforge_core.values.auth import Principal
11
+ from agentforge_core.values.chat import (
12
+ ChatChunk,
13
+ ChatChunkKind,
14
+ ChatResponse,
15
+ ChatRole,
16
+ ChatTurn,
17
+ SessionInfo,
18
+ StreamingChunkKind,
19
+ StreamingEvent,
20
+ )
21
+ from agentforge_core.values.claim import Claim
22
+ from agentforge_core.values.graph import (
23
+ GraphEdge,
24
+ GraphNode,
25
+ GraphPattern,
26
+ GraphSegment,
27
+ Path,
28
+ )
29
+ from agentforge_core.values.manifest import (
30
+ AppliedEnvVar,
31
+ AppliedManifest,
32
+ AppliedTemplate,
33
+ EnvVarEntry,
34
+ Manifest,
35
+ TemplateFile,
36
+ )
37
+ from agentforge_core.values.messages import (
38
+ EmbeddingResponse,
39
+ LLMResponse,
40
+ Message,
41
+ MessageRole,
42
+ StopReason,
43
+ StreamChunk,
44
+ StreamChunkKind,
45
+ TokenUsage,
46
+ ToolCall,
47
+ ToolSpec,
48
+ )
49
+ from agentforge_core.values.module import ModuleInfo
50
+ from agentforge_core.values.pipeline import PipelineResult
51
+ from agentforge_core.values.retrieval import GraphExpansion
52
+ from agentforge_core.values.state import (
53
+ AgentState,
54
+ FinishReason,
55
+ RunResult,
56
+ Step,
57
+ StepKind,
58
+ )
59
+ from agentforge_core.values.vector import VectorItem, VectorMatch
60
+
61
+ __all__ = [
62
+ "AgentState",
63
+ "AppliedEnvVar",
64
+ "AppliedManifest",
65
+ "AppliedTemplate",
66
+ "ChatChunk",
67
+ "ChatChunkKind",
68
+ "ChatResponse",
69
+ "ChatRole",
70
+ "ChatTurn",
71
+ "Claim",
72
+ "EmbeddingResponse",
73
+ "EnvVarEntry",
74
+ "FinishReason",
75
+ "GraphEdge",
76
+ "GraphExpansion",
77
+ "GraphNode",
78
+ "GraphPattern",
79
+ "GraphSegment",
80
+ "LLMResponse",
81
+ "Manifest",
82
+ "Message",
83
+ "MessageRole",
84
+ "ModuleInfo",
85
+ "Path",
86
+ "PipelineResult",
87
+ "Principal",
88
+ "RunResult",
89
+ "SessionInfo",
90
+ "Step",
91
+ "StepKind",
92
+ "StopReason",
93
+ "StreamChunk",
94
+ "StreamChunkKind",
95
+ "StreamingChunkKind",
96
+ "StreamingEvent",
97
+ "TemplateFile",
98
+ "TokenUsage",
99
+ "ToolCall",
100
+ "ToolSpec",
101
+ "VectorItem",
102
+ "VectorMatch",
103
+ ]
@@ -0,0 +1,20 @@
1
+ """Auth value types (feat-014).
2
+
3
+ `Principal` is the identity returned by an `AuthPolicy` after a
4
+ successful authentication. Carries an opaque ``id`` (the token
5
+ itself by default; an opaque user id once a real identity
6
+ provider is wired) plus optional metadata for downstream use
7
+ (role tags, tenant id, etc.).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Principal:
17
+ """Identity returned by `AuthPolicy.authenticate(...)`."""
18
+
19
+ id: str
20
+ metadata: dict[str, str] = field(default_factory=dict)
@@ -0,0 +1,131 @@
1
+ """Chat-agent value types (feat-020).
2
+
3
+ Frozen Pydantic models that ride the wire between `ChatSession`,
4
+ `ChatHistoryStore` drivers, and the chat-http server.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import UTC, datetime
10
+ from typing import Any, Literal
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+ from agentforge_core.values.messages import ToolCall
15
+
16
+ ChatRole = Literal["user", "assistant", "system", "tool"]
17
+ """Closed enum of chat turn roles."""
18
+
19
+ StreamingChunkKind = Literal[
20
+ "text",
21
+ "thinking",
22
+ "step",
23
+ "tool_call",
24
+ "tool_result",
25
+ "done",
26
+ "error",
27
+ ]
28
+ """Closed enum of streaming chunk kinds shared across chat (token-level)
29
+ and A2A (step + token-level) wire formats. Adding a new kind requires a
30
+ minor version bump per ADR-0007. Receivers must ignore unknown kinds on
31
+ the wire — forward-compat for future additions.
32
+
33
+ The ``step`` kind is reserved for strategies that emit step-level
34
+ events alongside (or instead of) per-token text. Chat receivers
35
+ typically ignore it; A2A clients render it as a generic step boundary.
36
+ """
37
+
38
+ ChatChunkKind = StreamingChunkKind
39
+ """Backward-compatible alias retained for callers that imported the
40
+ chat-shaped name in feat-020 v0.1 / v0.2. Prefer ``StreamingChunkKind``
41
+ in new code."""
42
+
43
+
44
+ class ChatTurn(BaseModel):
45
+ """One persisted message in a chat session.
46
+
47
+ Stored by `ChatHistoryStore` drivers, surfaced through
48
+ `ChatSession.history()`, and emitted on the chat-http wire.
49
+ """
50
+
51
+ model_config = ConfigDict(frozen=True, strict=True)
52
+
53
+ id: str
54
+ session_id: str
55
+ role: ChatRole
56
+ content: str
57
+ timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
58
+ run_id: str | None = None
59
+ """Links assistant turns (and the tool turns produced inside them)
60
+ back to the AgentForge run that emitted them. None for user /
61
+ system turns."""
62
+ tool_calls: tuple[ToolCall, ...] = ()
63
+ tool_call_id: str | None = None
64
+ tokens_in: int = Field(default=0, ge=0)
65
+ tokens_out: int = Field(default=0, ge=0)
66
+ cost_usd: float = Field(default=0.0, ge=0.0)
67
+ metadata: dict[str, Any] = Field(default_factory=dict)
68
+
69
+
70
+ class SessionInfo(BaseModel):
71
+ """Session-level metadata returned by `list_sessions` /
72
+ `delete_session` / `update_session_metadata`."""
73
+
74
+ model_config = ConfigDict(frozen=True, strict=True)
75
+
76
+ id: str
77
+ owner: str | None = None
78
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
79
+ last_active_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
80
+ turn_count: int = Field(default=0, ge=0)
81
+ total_cost_usd: float = Field(default=0.0, ge=0.0)
82
+ metadata: dict[str, Any] = Field(default_factory=dict)
83
+
84
+
85
+ class ChatChunk(BaseModel):
86
+ """One streaming chunk emitted by `ChatSession.stream()` /
87
+ `ChatServer` SSE+WS."""
88
+
89
+ model_config = ConfigDict(frozen=True, strict=True)
90
+
91
+ kind: ChatChunkKind
92
+ content: str | dict[str, Any] | None = None
93
+ cumulative_text: str | None = None
94
+ turn_id: str
95
+ metadata: dict[str, Any] = Field(default_factory=dict)
96
+
97
+
98
+ class ChatResponse(BaseModel):
99
+ """Aggregated response returned by `ChatSession.send()`."""
100
+
101
+ model_config = ConfigDict(frozen=True, strict=True)
102
+
103
+ content: str
104
+ turn_id: str
105
+ run_id: str
106
+ tool_calls: tuple[ToolCall, ...] = ()
107
+ tokens_in: int = Field(default=0, ge=0)
108
+ tokens_out: int = Field(default=0, ge=0)
109
+ cost_usd: float = Field(default=0.0, ge=0.0)
110
+ duration_ms: int = Field(default=0, ge=0)
111
+ finish_reason: str = "completed"
112
+
113
+
114
+ class StreamingEvent(BaseModel):
115
+ """One event emitted by `ReasoningStrategy.stream()` (feat-020 v0.2).
116
+
117
+ Strategies that want per-token streaming override the default
118
+ `stream()` to yield these events as the LLM emits tokens / step
119
+ transitions. `ChatSession.stream()` forwards each event to a
120
+ `ChatChunk` on the wire (kinds map 1:1 with `ChatChunkKind`).
121
+ The default base-class `stream()` calls `run()` and yields a
122
+ single `done` event so existing concrete strategies keep
123
+ working unchanged.
124
+ """
125
+
126
+ model_config = ConfigDict(frozen=True, strict=True)
127
+
128
+ kind: ChatChunkKind
129
+ content: str | dict[str, Any] | None = None
130
+ cumulative_text: str | None = None
131
+ metadata: dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,30 @@
1
+ """`Claim` — a persisted unit of agent-produced knowledge.
2
+
3
+ Written through `MemoryStore`. The `(project, agent)` pair namespaces
4
+ claims; cross-agent and cross-project queries are explicit verbs (per
5
+ feat-005 design). `id` is a ULID for sortable monotonic ordering.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import UTC, datetime
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+ from ulid import ULID
15
+
16
+
17
+ class Claim(BaseModel):
18
+ """A persisted unit of agent-produced knowledge."""
19
+
20
+ model_config = ConfigDict(frozen=True, strict=True)
21
+
22
+ id: str = Field(default_factory=lambda: str(ULID()))
23
+ run_id: str
24
+ project: str
25
+ agent: str
26
+ category: str
27
+ payload: dict[str, Any]
28
+ supersedes: str | None = None
29
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
30
+ metadata: dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,136 @@
1
+ """Frozen value types for the `GraphStore` contract.
2
+
3
+ `GraphNode` and `GraphEdge` are what callers upsert; `GraphPattern`
4
+ (composed of `GraphSegment`) is the query DSL `match()` accepts; `Path`
5
+ is what `match()` and `traverse()` return. All immutable Pydantic
6
+ models — safe to pass across async boundaries without aliasing bugs.
7
+
8
+ Per ADR-0007 these shapes are part of the framework's locked surface.
9
+ Adding a field is a minor bump; removing or renaming requires a major
10
+ bump.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Literal
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
18
+
19
+
20
+ class GraphNode(BaseModel):
21
+ """One node in a `GraphStore`.
22
+
23
+ Attributes:
24
+ id: Caller-controlled identifier. Re-upserting an existing id
25
+ replaces the prior record (write-through, not append).
26
+ labels: Type tags. Empty tuple is allowed — nodes without
27
+ labels are still queryable by id and properties. Order is
28
+ insignificant; drivers may sort internally.
29
+ properties: Free-form key-value attributes. Used by
30
+ `GraphPattern.node_filters` for AND-style equality
31
+ filtering during `match()`.
32
+ """
33
+
34
+ model_config = ConfigDict(frozen=True, strict=True)
35
+
36
+ id: str = Field(min_length=1)
37
+ labels: tuple[str, ...] = Field(default_factory=tuple)
38
+ properties: dict[str, Any] = Field(default_factory=dict)
39
+
40
+
41
+ class GraphEdge(BaseModel):
42
+ """One directed edge in a `GraphStore`.
43
+
44
+ Attributes:
45
+ src: Source node id. Drivers must reject edges referring to
46
+ absent nodes (raise `ValueError`); `add_node` first.
47
+ dst: Destination node id. Same constraint as `src`.
48
+ edge_type: Relationship type, e.g. `"CITES"`, `"AUTHORED_BY"`.
49
+ Required and non-empty (the field name `type` would shadow
50
+ the builtin, hence `edge_type`).
51
+ properties: Free-form key-value attributes on the edge itself
52
+ (weight, timestamp, etc.).
53
+ """
54
+
55
+ model_config = ConfigDict(frozen=True, strict=True)
56
+
57
+ src: str = Field(min_length=1)
58
+ dst: str = Field(min_length=1)
59
+ edge_type: str = Field(min_length=1)
60
+ properties: dict[str, Any] = Field(default_factory=dict)
61
+
62
+
63
+ class GraphSegment(BaseModel):
64
+ """One hop in a `GraphPattern`.
65
+
66
+ `None` for `src_label`, `edge_type`, or `dst_label` is a wildcard
67
+ at that position. `direction` controls how the edge is matched
68
+ against the underlying store — `"out"` is the default and matches
69
+ `src -> dst`; `"in"` matches `dst <- src` (i.e. reversed); `"any"`
70
+ matches either direction.
71
+ """
72
+
73
+ model_config = ConfigDict(frozen=True, strict=True)
74
+
75
+ src_label: str | None = None
76
+ edge_type: str | None = None
77
+ dst_label: str | None = None
78
+ direction: Literal["out", "in", "any"] = "out"
79
+
80
+
81
+ class GraphPattern(BaseModel):
82
+ """A chained pattern for `GraphStore.match`.
83
+
84
+ `segments` is the ordered chain of hops (length 1 = one edge).
85
+ `node_filters` is an optional sequence of equality filters indexed
86
+ by node-position: position 0 is the start node, position 1 is the
87
+ destination of segment 0, position 2 is the destination of segment
88
+ 1, and so on. Length must be either 0 (no filters), or `len(segments) + 1`.
89
+
90
+ Example: matching `(:Doc {topic="ml"})-[:CITES]->(:Doc)` becomes
91
+ ``GraphPattern(segments=(GraphSegment(src_label="Doc",
92
+ edge_type="CITES", dst_label="Doc"),),
93
+ node_filters=({"topic": "ml"}, {}))``.
94
+ """
95
+
96
+ model_config = ConfigDict(frozen=True, strict=True)
97
+
98
+ segments: tuple[GraphSegment, ...] = Field(min_length=1)
99
+ node_filters: tuple[dict[str, Any], ...] = Field(default_factory=tuple)
100
+
101
+ @model_validator(mode="after")
102
+ def _filters_match_segments(self) -> GraphPattern:
103
+ if self.node_filters and len(self.node_filters) != len(self.segments) + 1:
104
+ msg = (
105
+ f"node_filters length {len(self.node_filters)} must equal "
106
+ f"len(segments) + 1 = {len(self.segments) + 1} (one filter "
107
+ f"per node position) or be empty"
108
+ )
109
+ raise ValueError(msg)
110
+ return self
111
+
112
+
113
+ class Path(BaseModel):
114
+ """An ordered chain of nodes connected by edges.
115
+
116
+ Returned by both `match()` and `traverse()`. Invariants enforced:
117
+ - at least one node
118
+ - `len(edges) == len(nodes) - 1`
119
+ - each edge `i` connects `nodes[i]` to `nodes[i+1]` (drivers
120
+ respect this; the value type doesn't re-check the topology
121
+ beyond length, since per-edge id checking would require
122
+ property-level equality which is wasteful)
123
+ """
124
+
125
+ model_config = ConfigDict(frozen=True, strict=True)
126
+
127
+ nodes: tuple[GraphNode, ...] = Field(min_length=1)
128
+ edges: tuple[GraphEdge, ...] = Field(default_factory=tuple)
129
+
130
+ @model_validator(mode="after")
131
+ def _edges_count_matches_nodes(self) -> Path:
132
+ expected = len(self.nodes) - 1
133
+ if len(self.edges) != expected:
134
+ msg = f"edges length {len(self.edges)} must equal len(nodes) - 1 = {expected}"
135
+ raise ValueError(msg)
136
+ return self
@@ -0,0 +1,49 @@
1
+ """Guardrail runtime value types (feat-018).
2
+
3
+ `ValidationResult` is the locked return shape every `InputValidator`,
4
+ `OutputValidator`, and `ToolCallGate` produces. The framework-wide
5
+ `GuardrailPolicy` lives in `agentforge_core.config.schema` instead
6
+ of here — co-locating it with the other config models avoids the
7
+ import cycle through `values.state`.
8
+
9
+ Per ADR-0009 this shape is frozen so it crosses process / module
10
+ boundaries safely.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field
18
+
19
+
20
+ class ValidationResult(BaseModel):
21
+ """Outcome of one validator's `validate(...)` / `authorize(...)` call."""
22
+
23
+ model_config = ConfigDict(frozen=True, strict=True)
24
+
25
+ passed: bool
26
+ score: float = Field(default=1.0, ge=0.0, le=1.0)
27
+ """Confidence; 1.0 = definitely clean, 0.0 = definitely bad. Used
28
+ by redaction-vs-block policy thresholds and audit aggregation."""
29
+
30
+ violations: tuple[str, ...] = ()
31
+ """Rule identifiers that fired (e.g. `("prompt_injection", "jailbreak")`).
32
+ Empty tuple when `passed=True`."""
33
+
34
+ redacted_content: str | None = None
35
+ """If the validator can both flag AND redact, the post-redaction
36
+ string. Output validators with `policy.on_output_violation =
37
+ "redact"` use this; input validators usually only flag."""
38
+
39
+ metadata: dict[str, Any] = Field(default_factory=dict)
40
+ """Validator-specific extra detail — scores per entity, spans,
41
+ upstream raw payload. Never required, never relied on."""
42
+
43
+ @classmethod
44
+ def ok(cls) -> ValidationResult:
45
+ """Clean-pass result with no violations and full confidence."""
46
+ return cls(passed=True)
47
+
48
+
49
+ __all__ = ["ValidationResult"]
@@ -0,0 +1,129 @@
1
+ """Manifest value types for `agentforge add/swap/remove module` (feat-010b).
2
+
3
+ Each Tier-3 module ships a `manifest.yaml` at the root of its
4
+ package describing the side-effects `agentforge add module X` should
5
+ apply to a consuming agent's repo:
6
+
7
+ # agentforge_memory_postgres/manifest.yaml
8
+ category: memory
9
+ name: postgres
10
+ env_vars:
11
+ - name: POSTGRES_DSN
12
+ description: "Connection string"
13
+ required: true
14
+ templates:
15
+ - source: db/migrations/0001_init.sql
16
+ destination: db/migrations/agentforge/0001_init.sql
17
+ config_block:
18
+ modules:
19
+ memory:
20
+ driver: postgres
21
+ config:
22
+ dsn: "${POSTGRES_DSN}"
23
+ next_steps:
24
+ - "Set POSTGRES_DSN in your .env file."
25
+ - "Run `agentforge db migrate` to apply the schema."
26
+
27
+ The applier serialises what it actually did into an `AppliedManifest`
28
+ state file at `.agentforge-state/manifests/<distribution>.yaml`, so
29
+ `agentforge remove module X` can reverse the application.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from typing import Any
35
+
36
+ from pydantic import BaseModel, ConfigDict, Field
37
+
38
+
39
+ class EnvVarEntry(BaseModel):
40
+ """One env-var the module needs. Appended to `.env.example` on apply."""
41
+
42
+ model_config = ConfigDict(strict=True, extra="forbid")
43
+
44
+ name: str = Field(min_length=1)
45
+ description: str = ""
46
+ required: bool = True
47
+ default: str | None = None
48
+
49
+
50
+ class TemplateFile(BaseModel):
51
+ """A file the module ships that gets copied into the agent repo."""
52
+
53
+ model_config = ConfigDict(strict=True, extra="forbid")
54
+
55
+ source: str = Field(min_length=1)
56
+ """Path inside the module package (relative to the package root)."""
57
+
58
+ destination: str = Field(min_length=1)
59
+ """Path in the consuming repo (relative to cwd at `add` time)."""
60
+
61
+ overwrite: bool = False
62
+ """Whether to overwrite an existing destination. Default False —
63
+ a pre-existing file aborts the apply with a clear error."""
64
+
65
+
66
+ class Manifest(BaseModel):
67
+ """Parsed `manifest.yaml`. Source of truth for what `add` does."""
68
+
69
+ model_config = ConfigDict(strict=True, extra="forbid")
70
+
71
+ category: str = Field(min_length=1)
72
+ """Module category (`memory`, `tools`, `providers`, etc.) —
73
+ must match the entry-point group suffix."""
74
+
75
+ name: str = Field(min_length=1)
76
+ """Module name within the category (e.g. `postgres`)."""
77
+
78
+ env_vars: list[EnvVarEntry] = Field(default_factory=list)
79
+ templates: list[TemplateFile] = Field(default_factory=list)
80
+ config_block: dict[str, Any] = Field(default_factory=dict)
81
+ """A nested-dict snippet to deep-merge into `agentforge.yaml`."""
82
+
83
+ next_steps: list[str] = Field(default_factory=list)
84
+ """Free-form lines printed after a successful `add`."""
85
+
86
+
87
+ class AppliedEnvVar(BaseModel):
88
+ """Record of an env var the applier appended to `.env.example`."""
89
+
90
+ model_config = ConfigDict(strict=True, extra="forbid")
91
+
92
+ name: str = Field(min_length=1)
93
+ line: str = Field(min_length=1)
94
+ """The exact line written, so `remove` can match-and-strip it."""
95
+
96
+
97
+ class AppliedTemplate(BaseModel):
98
+ """Record of a file the applier created."""
99
+
100
+ model_config = ConfigDict(strict=True, extra="forbid")
101
+
102
+ destination: str = Field(min_length=1)
103
+ """Path relative to cwd; safe to `unlink` on `remove`."""
104
+
105
+
106
+ class AppliedManifest(BaseModel):
107
+ """State file at `.agentforge-state/manifests/<distribution>.yaml`.
108
+
109
+ Records exactly what `agentforge add module X` wrote, so
110
+ `agentforge remove module X` can reverse it. Atomic at the
111
+ individual-step level (each list reflects what *did* land);
112
+ apply failures partway through still write the state for what
113
+ succeeded so `remove` can clean up.
114
+ """
115
+
116
+ model_config = ConfigDict(strict=True, extra="forbid")
117
+
118
+ distribution: str = Field(min_length=1)
119
+ """The `agentforge-X` distribution name."""
120
+
121
+ category: str = Field(min_length=1)
122
+ name: str = Field(min_length=1)
123
+ """Mirrors `Manifest.category` / `Manifest.name` for diagnostics."""
124
+
125
+ env_vars: list[AppliedEnvVar] = Field(default_factory=list)
126
+ templates: list[AppliedTemplate] = Field(default_factory=list)
127
+ config_block_applied: bool = False
128
+ """Whether the deep-merge into `agentforge.yaml` landed. Used by
129
+ `remove` to know whether to attempt the reverse merge."""