agentforge-py 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 (157) hide show
  1. agentforge/__init__.py +114 -0
  2. agentforge/_testing/__init__.py +19 -0
  3. agentforge/_testing/fake_llm.py +126 -0
  4. agentforge/_testing/fake_tool.py +122 -0
  5. agentforge/_tools/__init__.py +14 -0
  6. agentforge/_tools/calculator.py +102 -0
  7. agentforge/_tools/decorator.py +300 -0
  8. agentforge/_tools/file_read.py +112 -0
  9. agentforge/_tools/shell.py +134 -0
  10. agentforge/_tools/web_search.py +207 -0
  11. agentforge/agent.py +817 -0
  12. agentforge/auth.py +42 -0
  13. agentforge/cli/__init__.py +18 -0
  14. agentforge/cli/_build.py +323 -0
  15. agentforge/cli/_scaffold_state.py +250 -0
  16. agentforge/cli/_shared_scaffold.py +174 -0
  17. agentforge/cli/config_cmd.py +174 -0
  18. agentforge/cli/db_cmd.py +262 -0
  19. agentforge/cli/debug_cmd.py +168 -0
  20. agentforge/cli/docs_cmd.py +217 -0
  21. agentforge/cli/eval_cmd.py +181 -0
  22. agentforge/cli/health_cmd.py +139 -0
  23. agentforge/cli/list_modules.py +85 -0
  24. agentforge/cli/main.py +81 -0
  25. agentforge/cli/manifest_apply.py +368 -0
  26. agentforge/cli/module_cmd.py +247 -0
  27. agentforge/cli/new_cmd.py +171 -0
  28. agentforge/cli/run_cmd.py +234 -0
  29. agentforge/cli/upgrade_cmd.py +230 -0
  30. agentforge/config/__init__.py +45 -0
  31. agentforge/eval/__init__.py +18 -0
  32. agentforge/eval/consistency.py +107 -0
  33. agentforge/eval/coverage.py +100 -0
  34. agentforge/eval/format_compliance.py +107 -0
  35. agentforge/eval/regression.py +143 -0
  36. agentforge/findings.py +166 -0
  37. agentforge/guardrails/__init__.py +32 -0
  38. agentforge/guardrails/allowlist.py +49 -0
  39. agentforge/guardrails/capability_check.py +58 -0
  40. agentforge/guardrails/engine.py +289 -0
  41. agentforge/guardrails/pii_redact_basic.py +61 -0
  42. agentforge/guardrails/prompt_injection_basic.py +90 -0
  43. agentforge/memory/__init__.py +16 -0
  44. agentforge/memory/in_memory.py +130 -0
  45. agentforge/memory/in_memory_graph.py +262 -0
  46. agentforge/memory/in_memory_vector.py +167 -0
  47. agentforge/pipeline/__init__.py +26 -0
  48. agentforge/pipeline/engine.py +189 -0
  49. agentforge/pipeline/errors.py +19 -0
  50. agentforge/pipeline/tool.py +93 -0
  51. agentforge/py.typed +0 -0
  52. agentforge/recording.py +189 -0
  53. agentforge/renderers/__init__.py +28 -0
  54. agentforge/renderers/_defaults.py +32 -0
  55. agentforge/renderers/markdown.py +44 -0
  56. agentforge/renderers/patch_applier.py +46 -0
  57. agentforge/renderers/registry.py +108 -0
  58. agentforge/renderers/scorecard.py +59 -0
  59. agentforge/renderers/span_table.py +71 -0
  60. agentforge/replay.py +260 -0
  61. agentforge/resolver_register.py +41 -0
  62. agentforge/retrieval.py +410 -0
  63. agentforge/runtime.py +63 -0
  64. agentforge/strategies/__init__.py +27 -0
  65. agentforge/strategies/_base.py +280 -0
  66. agentforge/strategies/_plan.py +93 -0
  67. agentforge/strategies/multi_agent.py +541 -0
  68. agentforge/strategies/plan_execute.py +506 -0
  69. agentforge/strategies/react.py +237 -0
  70. agentforge/strategies/tot.py +472 -0
  71. agentforge/templates/_shared/.cursorrules +12 -0
  72. agentforge/templates/_shared/.github/copilot-instructions.md +13 -0
  73. agentforge/templates/_shared/.gitkeep +0 -0
  74. agentforge/templates/_shared/AGENTS.md.tmpl +123 -0
  75. agentforge/templates/_shared/CLAUDE.md +13 -0
  76. agentforge/templates/_shared/docs/runbooks/01-set-up-new-agent.md.tmpl +67 -0
  77. agentforge/templates/_shared/docs/runbooks/02-add-a-tool.md +67 -0
  78. agentforge/templates/_shared/docs/runbooks/03-add-a-pipeline-task.md +69 -0
  79. agentforge/templates/_shared/docs/runbooks/04-pick-reasoning-strategy.md +67 -0
  80. agentforge/templates/_shared/docs/runbooks/05-write-prompts.md +75 -0
  81. agentforge/templates/_shared/docs/runbooks/06-test-your-agent.md +75 -0
  82. agentforge/templates/_shared/docs/runbooks/07-debug-a-run.md +70 -0
  83. agentforge/templates/_shared/docs/runbooks/08-add-memory.md +75 -0
  84. agentforge/templates/_shared/docs/runbooks/09-add-mcp.md +78 -0
  85. agentforge/templates/_shared/docs/runbooks/10-add-evaluators.md +76 -0
  86. agentforge/templates/_shared/docs/runbooks/11-add-safety-guardrails.md +83 -0
  87. agentforge/templates/_shared/docs/runbooks/12-add-observability.md +77 -0
  88. agentforge/templates/_shared/docs/runbooks/13-configure-multi-provider.md +91 -0
  89. agentforge/templates/_shared/docs/runbooks/14-deploy-your-agent.md +70 -0
  90. agentforge/templates/_shared/docs/runbooks/15-upgrade-your-agent.md +67 -0
  91. agentforge/templates/_shared/docs/runbooks/16-configuration-reference.md +81 -0
  92. agentforge/templates/_shared/docs/runbooks/17-add-reranker.md +78 -0
  93. agentforge/templates/_shared/docs/runbooks/18-add-hybrid-search.md +78 -0
  94. agentforge/templates/_shared/docs/runbooks/19-add-graphrag.md +83 -0
  95. agentforge/templates/_shared/docs/runbooks/20-apply-schema-migrations.md +92 -0
  96. agentforge/templates/_shared/docs/runbooks/21-use-streaming-guardrails.md +82 -0
  97. agentforge/templates/_shared/docs/runbooks/README.md.tmpl +68 -0
  98. agentforge/templates/code-reviewer/.env.example +8 -0
  99. agentforge/templates/code-reviewer/.gitignore +7 -0
  100. agentforge/templates/code-reviewer/README.md +12 -0
  101. agentforge/templates/code-reviewer/agentforge.yaml +23 -0
  102. agentforge/templates/code-reviewer/copier.yml +34 -0
  103. agentforge/templates/code-reviewer/pyproject.toml +18 -0
  104. agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  105. agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  106. agentforge/templates/docs-qa/.env.example +8 -0
  107. agentforge/templates/docs-qa/.gitignore +7 -0
  108. agentforge/templates/docs-qa/README.md +14 -0
  109. agentforge/templates/docs-qa/agentforge.yaml +19 -0
  110. agentforge/templates/docs-qa/copier.yml +31 -0
  111. agentforge/templates/docs-qa/pyproject.toml +18 -0
  112. agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  113. agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  114. agentforge/templates/minimal/.env.example +11 -0
  115. agentforge/templates/minimal/.gitignore +10 -0
  116. agentforge/templates/minimal/README.md +28 -0
  117. agentforge/templates/minimal/agentforge.yaml +10 -0
  118. agentforge/templates/minimal/copier.yml +52 -0
  119. agentforge/templates/minimal/pyproject.toml +18 -0
  120. agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  121. agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/main.py +34 -0
  122. agentforge/templates/patch-bot/.env.example +8 -0
  123. agentforge/templates/patch-bot/.gitignore +7 -0
  124. agentforge/templates/patch-bot/README.md +13 -0
  125. agentforge/templates/patch-bot/agentforge.yaml +15 -0
  126. agentforge/templates/patch-bot/copier.yml +31 -0
  127. agentforge/templates/patch-bot/pyproject.toml +18 -0
  128. agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  129. agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  130. agentforge/templates/research/.env.example +8 -0
  131. agentforge/templates/research/.gitignore +7 -0
  132. agentforge/templates/research/README.md +14 -0
  133. agentforge/templates/research/agentforge.yaml +17 -0
  134. agentforge/templates/research/copier.yml +31 -0
  135. agentforge/templates/research/pyproject.toml +18 -0
  136. agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  137. agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/main.py +31 -0
  138. agentforge/templates/triage/.env.example +8 -0
  139. agentforge/templates/triage/.gitignore +7 -0
  140. agentforge/templates/triage/README.md +14 -0
  141. agentforge/templates/triage/agentforge.yaml +25 -0
  142. agentforge/templates/triage/copier.yml +31 -0
  143. agentforge/templates/triage/pyproject.toml +18 -0
  144. agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  145. agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/main.py +30 -0
  146. agentforge/testing/__init__.py +69 -0
  147. agentforge/testing/conformance.py +40 -0
  148. agentforge/testing/factory.py +89 -0
  149. agentforge/testing/fixtures.py +42 -0
  150. agentforge/testing/llm.py +235 -0
  151. agentforge/testing/recording.py +177 -0
  152. agentforge/tools/__init__.py +41 -0
  153. agentforge_py-0.2.1.dist-info/METADATA +158 -0
  154. agentforge_py-0.2.1.dist-info/RECORD +157 -0
  155. agentforge_py-0.2.1.dist-info/WHEEL +4 -0
  156. agentforge_py-0.2.1.dist-info/entry_points.txt +2 -0
  157. agentforge_py-0.2.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,93 @@
1
+ """`pipeline_findings` — the built-in tool that surfaces a Pipeline's
2
+ output back to the LLM (feat-015).
3
+
4
+ The agent wires one `PipelineFindingsTool` instance into its tool set
5
+ whenever `Agent(pipeline=...)` is set. Before each `Agent.run()`, the
6
+ agent stashes the freshly produced findings on the tool via
7
+ ``_set_cache(...)``. The LLM can then call ``pipeline_findings()``
8
+ with optional ``category`` / ``severity`` filters to retrieve them as
9
+ JSON-friendly dicts.
10
+
11
+ Returning serialized dicts (via ``model_dump(mode="json")`` when
12
+ available, else ``to_dict()``) keeps the contract tolerant of
13
+ non-Pydantic Finding shapes — see ``agentforge_core.contracts.finding``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from agentforge_core.contracts.tool import Tool
21
+ from pydantic import BaseModel, ConfigDict, Field
22
+
23
+
24
+ class PipelineFindingsInput(BaseModel):
25
+ """Filter parameters for `pipeline_findings`."""
26
+
27
+ model_config = ConfigDict(extra="forbid")
28
+
29
+ category: str | None = Field(
30
+ default=None,
31
+ description="If set, return only findings whose `category` matches.",
32
+ )
33
+ severity: str | None = Field(
34
+ default=None,
35
+ description="If set, return only findings whose `severity` matches.",
36
+ )
37
+
38
+
39
+ class PipelineFindingsTool(Tool):
40
+ """Built-in tool that lets the LLM re-query pipeline output.
41
+
42
+ The agent caches the latest `PipelineResult.findings` on this
43
+ instance at the start of every `Agent.run()`. The tool's `run()`
44
+ filters the cache and returns serializable dicts.
45
+ """
46
+
47
+ name = "pipeline_findings"
48
+ description = (
49
+ "List the findings produced by this agent's deterministic pipeline. "
50
+ "Optional filters: `category` (e.g. 'lint', 'coverage') and `severity` "
51
+ "(e.g. 'error', 'warning', 'info'). Returns a list of dicts."
52
+ )
53
+ input_schema = PipelineFindingsInput
54
+
55
+ def __init__(self) -> None:
56
+ self._cached_findings: list[Any] = []
57
+
58
+ def _set_cache(self, findings: list[Any]) -> None:
59
+ """Replace the cached findings (called by `Agent.run()`)."""
60
+ self._cached_findings = list(findings)
61
+
62
+ async def run(self, **kwargs: Any) -> list[dict[str, Any]]:
63
+ params = PipelineFindingsInput.model_validate(kwargs)
64
+ out: list[dict[str, Any]] = []
65
+ for f in self._cached_findings:
66
+ if params.category is not None and getattr(f, "category", None) != params.category:
67
+ continue
68
+ if params.severity is not None and getattr(f, "severity", None) != params.severity:
69
+ continue
70
+ out.append(_serialise_finding(f))
71
+ return out
72
+
73
+
74
+ def _serialise_finding(f: Any) -> dict[str, Any]:
75
+ dump = getattr(f, "model_dump", None)
76
+ if callable(dump):
77
+ result = dump(mode="json")
78
+ if isinstance(result, dict):
79
+ return result
80
+ to_dict = getattr(f, "to_dict", None)
81
+ if callable(to_dict):
82
+ result = to_dict()
83
+ if isinstance(result, dict):
84
+ return result
85
+ # Best-effort fallback for arbitrary Finding-shaped objects.
86
+ return {
87
+ "severity": getattr(f, "severity", None),
88
+ "category": getattr(f, "category", None),
89
+ "message": getattr(f, "message", None),
90
+ }
91
+
92
+
93
+ __all__ = ["PipelineFindingsInput", "PipelineFindingsTool"]
agentforge/py.typed ADDED
File without changes
@@ -0,0 +1,189 @@
1
+ """Run-recording hooks (feat-017).
2
+
3
+ Records every emitted `Step` and the final `RunResult` to a configured
4
+ `MemoryStore` so `agentforge run --replay <run-id>` and `agentforge
5
+ debug --replay <run-id>` can reconstruct a run deterministically.
6
+
7
+ Layout in the store (reserved categories):
8
+
9
+ - `category="__step"` — one claim per `Step`, payload carries every
10
+ field on the frozen model so the step can be re-instantiated.
11
+ - `category="__eval"` — one claim per `EvalResult` from the run.
12
+ - `category="__run"` — one claim per run carrying the run-level
13
+ summary (output, cost, tokens, duration, finish_reason).
14
+
15
+ The hook is opt-in: pass `Agent(record_runs=memory)` (or use
16
+ `agentforge run --record` from the CLI). Errors are isolated by
17
+ `Agent`'s existing `_safe_call_hook` wrapper — recording will never
18
+ break a run.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ from agentforge_core.contracts.memory import MemoryStore
26
+ from agentforge_core.production.run_context import current_run
27
+ from agentforge_core.values.claim import Claim
28
+
29
+ if TYPE_CHECKING:
30
+ from agentforge_core.values.pipeline import PipelineResult
31
+ from agentforge_core.values.state import RunResult, Step
32
+
33
+ # Public category names. Tests, CLI, and external tooling can rely on
34
+ # these — they are part of the v0.1 on-disk contract.
35
+ STEP_CATEGORY = "__step"
36
+ EVAL_CATEGORY = "__eval"
37
+ RUN_CATEGORY = "__run"
38
+ PIPELINE_CATEGORY = "__pipeline"
39
+
40
+
41
+ class RecordRunHook:
42
+ """Builds hooks that persist run telemetry to a `MemoryStore`.
43
+
44
+ Single hook object exposes `.on_step` and `.on_finish` callables
45
+ that `Agent` installs alongside any user-supplied hooks.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ memory: MemoryStore,
52
+ project: str,
53
+ agent_name: str,
54
+ ) -> None:
55
+ self._memory = memory
56
+ self._project = project
57
+ self._agent_name = agent_name
58
+
59
+ async def on_step(self, step: Step) -> None:
60
+ try:
61
+ run_id = current_run().run_id
62
+ except RuntimeError:
63
+ # Outside Agent.run(); fall back to a sentinel so the
64
+ # claim still persists. Tests that call the hook directly
65
+ # exercise this branch.
66
+ run_id = "unknown"
67
+ await self._memory.put(
68
+ Claim(
69
+ run_id=run_id,
70
+ project=self._project,
71
+ agent=self._agent_name,
72
+ category=STEP_CATEGORY,
73
+ payload=_step_payload(step),
74
+ )
75
+ )
76
+
77
+ async def on_finish(self, result: RunResult) -> None:
78
+ await self._memory.put(
79
+ Claim(
80
+ run_id=result.run_id,
81
+ project=self._project,
82
+ agent=self._agent_name,
83
+ category=RUN_CATEGORY,
84
+ payload={
85
+ "output": _serializable(result.output),
86
+ "cost_usd": result.cost_usd,
87
+ "tokens_in": result.tokens_in,
88
+ "tokens_out": result.tokens_out,
89
+ "duration_ms": result.duration_ms,
90
+ "finish_reason": result.finish_reason,
91
+ "metadata": dict(result.metadata),
92
+ },
93
+ )
94
+ )
95
+ for eval_result in result.eval_scores:
96
+ await self._memory.put(
97
+ Claim(
98
+ run_id=result.run_id,
99
+ project=self._project,
100
+ agent=self._agent_name,
101
+ category=EVAL_CATEGORY,
102
+ payload=eval_result.model_dump(mode="json"),
103
+ )
104
+ )
105
+
106
+
107
+ def _step_payload(step: Step) -> dict[str, Any]:
108
+ """Serialize a `Step` into a JSON-safe dict for storage."""
109
+ return {
110
+ "iteration": step.iteration,
111
+ "kind": step.kind,
112
+ "content": _serializable(step.content),
113
+ "tool_call": step.tool_call.model_dump(mode="json") if step.tool_call else None,
114
+ "tokens_in": step.tokens_in,
115
+ "tokens_out": step.tokens_out,
116
+ "cost_usd": step.cost_usd,
117
+ "duration_ms": step.duration_ms,
118
+ "timestamp": step.timestamp.isoformat(),
119
+ "metadata": dict(step.metadata),
120
+ }
121
+
122
+
123
+ def _serializable(value: Any) -> Any:
124
+ """Pass-through for dicts/lists/scalars; coerce others to str."""
125
+ if isinstance(value, str | int | float | bool) or value is None:
126
+ return value
127
+ if isinstance(value, dict):
128
+ return {str(k): _serializable(v) for k, v in value.items()}
129
+ if isinstance(value, list | tuple):
130
+ return [_serializable(v) for v in value]
131
+ return str(value)
132
+
133
+
134
+ async def record_pipeline_result(
135
+ *,
136
+ memory: MemoryStore,
137
+ run_id: str,
138
+ project: str,
139
+ agent_name: str,
140
+ result: PipelineResult,
141
+ ) -> None:
142
+ """Persist a `PipelineResult` as one ``__pipeline`` claim.
143
+
144
+ Replay reads this back and threads it into `Agent.run`'s
145
+ ``replay_pipeline`` kwarg so the deterministic tasks don't
146
+ re-execute (side-effect-bearing tasks would double-run).
147
+ """
148
+ await memory.put(
149
+ Claim(
150
+ run_id=run_id,
151
+ project=project,
152
+ agent=agent_name,
153
+ category=PIPELINE_CATEGORY,
154
+ payload={
155
+ "findings": [_finding_payload(f) for f in result.findings],
156
+ "task_durations_ms": dict(result.task_durations_ms),
157
+ "task_failures": dict(result.task_failures),
158
+ "total_cost_usd": result.total_cost_usd,
159
+ },
160
+ )
161
+ )
162
+
163
+
164
+ def _finding_payload(f: Any) -> dict[str, Any]:
165
+ dump = getattr(f, "model_dump", None)
166
+ if callable(dump):
167
+ result = dump(mode="json")
168
+ if isinstance(result, dict):
169
+ return result
170
+ to_dict = getattr(f, "to_dict", None)
171
+ if callable(to_dict):
172
+ result = to_dict()
173
+ if isinstance(result, dict):
174
+ return result
175
+ return {
176
+ "severity": getattr(f, "severity", None),
177
+ "category": getattr(f, "category", None),
178
+ "message": getattr(f, "message", None),
179
+ }
180
+
181
+
182
+ __all__ = [
183
+ "EVAL_CATEGORY",
184
+ "PIPELINE_CATEGORY",
185
+ "RUN_CATEGORY",
186
+ "STEP_CATEGORY",
187
+ "RecordRunHook",
188
+ "record_pipeline_result",
189
+ ]
@@ -0,0 +1,28 @@
1
+ """Renderer registry + built-in renderers for `Finding` variants (feat-008).
2
+
3
+ `RendererRegistry` maps a `Finding` instance to a `FindingRenderer`
4
+ using isinstance-based dispatch with a most-specific-wins rule. The
5
+ four built-in renderers (shipped in chunk 3) handle the four shipped
6
+ variants.
7
+
8
+ A `default()` factory returns a pre-populated registry — the common
9
+ case for agent code. Custom agents register additional renderers for
10
+ their own variants.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from agentforge.renderers.markdown import MarkdownRenderer
16
+ from agentforge.renderers.patch_applier import PatchApplierRenderer
17
+ from agentforge.renderers.registry import MissingRendererError, RendererRegistry
18
+ from agentforge.renderers.scorecard import ScorecardRenderer
19
+ from agentforge.renderers.span_table import SpanTableRenderer
20
+
21
+ __all__ = [
22
+ "MarkdownRenderer",
23
+ "MissingRendererError",
24
+ "PatchApplierRenderer",
25
+ "RendererRegistry",
26
+ "ScorecardRenderer",
27
+ "SpanTableRenderer",
28
+ ]
@@ -0,0 +1,32 @@
1
+ """Pre-population helper for `RendererRegistry.default()` (feat-008).
2
+
3
+ Kept in a separate module so `registry.py` can lazy-import it inside
4
+ `default()` and avoid a top-level cycle between the registry and the
5
+ concrete renderer modules (which themselves import the variants from
6
+ `agentforge.findings`).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from agentforge.findings import MultiSpanFinding, NarrativeFinding, PatchFinding, SimpleFinding
14
+ from agentforge.renderers.markdown import MarkdownRenderer
15
+ from agentforge.renderers.patch_applier import PatchApplierRenderer
16
+ from agentforge.renderers.scorecard import ScorecardRenderer
17
+ from agentforge.renderers.span_table import SpanTableRenderer
18
+
19
+ if TYPE_CHECKING:
20
+ from agentforge.renderers.registry import RendererRegistry
21
+
22
+
23
+ def populate_defaults(registry: RendererRegistry) -> None:
24
+ """Register the four built-in renderers on `registry`.
25
+
26
+ Idempotent — re-registering the same exact type replaces the
27
+ prior entry (see `RendererRegistry.register`).
28
+ """
29
+ registry.register(SimpleFinding, ScorecardRenderer())
30
+ registry.register(PatchFinding, PatchApplierRenderer())
31
+ registry.register(NarrativeFinding, MarkdownRenderer())
32
+ registry.register(MultiSpanFinding, SpanTableRenderer())
@@ -0,0 +1,44 @@
1
+ """`MarkdownRenderer` — narrative rendering for `NarrativeFinding`.
2
+
3
+ text format: question (message) + body + "References:" footer.
4
+ markdown format: heading + body + "## References" section with a list.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from agentforge_core.contracts.finding import Finding
10
+ from agentforge_core.contracts.renderer import FindingRenderer
11
+
12
+ from agentforge.findings import NarrativeFinding
13
+
14
+ _SUPPORTED_FORMATS = frozenset({"text", "markdown"})
15
+
16
+
17
+ class MarkdownRenderer(FindingRenderer):
18
+ """Renders `NarrativeFinding` as prose with citations."""
19
+
20
+ def render(self, finding: Finding, format: str = "text") -> str:
21
+ if format not in _SUPPORTED_FORMATS:
22
+ raise ValueError(
23
+ f"MarkdownRenderer supports {sorted(_SUPPORTED_FORMATS)}; got {format!r}"
24
+ )
25
+ if not isinstance(finding, NarrativeFinding):
26
+ raise TypeError(
27
+ f"MarkdownRenderer renders NarrativeFinding; got {type(finding).__name__}"
28
+ )
29
+
30
+ if format == "markdown":
31
+ heading = f"## {finding.message}"
32
+ refs_section = ""
33
+ if finding.references:
34
+ refs_lines = "\n".join(f"- {r}" for r in finding.references)
35
+ refs_section = f"\n\n### References\n\n{refs_lines}"
36
+ return f"{heading}\n\n{finding.body}{refs_section}"
37
+
38
+ refs_section = ""
39
+ if finding.references:
40
+ refs_section = "\n\nReferences:\n" + "\n".join(f" - {r}" for r in finding.references)
41
+ return f"{finding.message}\n\n{finding.body}{refs_section}"
42
+
43
+ def supports(self, finding_type: type) -> bool:
44
+ return issubclass(finding_type, NarrativeFinding)
@@ -0,0 +1,46 @@
1
+ """`PatchApplierRenderer` — diff rendering for `PatchFinding`.
2
+
3
+ text format: header line + unified-diff body.
4
+ markdown format: same content wrapped in a fenced ` ```diff ` block.
5
+
6
+ The renderer does NOT apply the patch — that's the caller's
7
+ responsibility. The name reflects the typical downstream use, not what
8
+ this class does.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from agentforge_core.contracts.finding import Finding
14
+ from agentforge_core.contracts.renderer import FindingRenderer
15
+
16
+ from agentforge.findings import PatchFinding
17
+
18
+ _SUPPORTED_FORMATS = frozenset({"text", "markdown"})
19
+
20
+
21
+ class PatchApplierRenderer(FindingRenderer):
22
+ """Renders `PatchFinding` as a unified diff (plain or fenced)."""
23
+
24
+ def render(self, finding: Finding, format: str = "text") -> str:
25
+ if format not in _SUPPORTED_FORMATS:
26
+ raise ValueError(
27
+ f"PatchApplierRenderer supports {sorted(_SUPPORTED_FORMATS)}; got {format!r}"
28
+ )
29
+ if not isinstance(finding, PatchFinding):
30
+ raise TypeError(
31
+ f"PatchApplierRenderer renders PatchFinding; got {type(finding).__name__}"
32
+ )
33
+
34
+ header = (
35
+ f"[{finding.severity}] {finding.category}: {finding.message}\n"
36
+ f"file: {finding.patch.file} (confidence={finding.confidence:.2f})\n"
37
+ f"rationale: {finding.rationale}"
38
+ )
39
+
40
+ diff_body = finding.patch.diff.rstrip("\n")
41
+ if format == "markdown":
42
+ return f"{header}\n\n```diff\n{diff_body}\n```"
43
+ return f"{header}\n\n{diff_body}"
44
+
45
+ def supports(self, finding_type: type) -> bool:
46
+ return issubclass(finding_type, PatchFinding)
@@ -0,0 +1,108 @@
1
+ """`RendererRegistry` — isinstance-based dispatch for `FindingRenderer`s.
2
+
3
+ Registration maps a `Finding` (sub)type to a renderer. Lookup walks
4
+ the registrations and returns the renderer whose registered type is
5
+ the **most specific** (most-derived) ancestor of the finding's type.
6
+
7
+ Ties broken by registration order (first wins) — predictable and
8
+ documented. The most-specific rule matches the spec's intent for
9
+ custom variants subclassing a shipped variant: registering a renderer
10
+ for the subclass overrides the parent renderer.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from agentforge_core.contracts.finding import Finding
16
+ from agentforge_core.contracts.renderer import FindingRenderer
17
+
18
+ from agentforge.renderers._defaults import populate_defaults
19
+
20
+
21
+ class MissingRendererError(LookupError):
22
+ """Raised by `RendererRegistry.get` when no renderer matches."""
23
+
24
+
25
+ class RendererRegistry:
26
+ """Map `Finding` (sub)types to `FindingRenderer`s.
27
+
28
+ Use `register(finding_type, renderer)` to add an entry. Use
29
+ `get(finding)` to dispatch — returns the most-specific match.
30
+ `default()` returns a registry pre-populated with the four
31
+ built-in renderers shipped for the standard variants.
32
+ """
33
+
34
+ def __init__(self) -> None:
35
+ # List preserves registration order; `dict[type, renderer]`
36
+ # would lose duplicates and registration order is part of the
37
+ # tie-break rule.
38
+ self._registrations: list[tuple[type, FindingRenderer]] = []
39
+
40
+ def register(self, finding_type: type, renderer: FindingRenderer) -> None:
41
+ """Register `renderer` for findings of type `finding_type` or
42
+ any subclass.
43
+
44
+ Re-registering the same exact type replaces the prior
45
+ registration in place (preserves the original registration
46
+ order).
47
+ """
48
+ for idx, (existing_type, _) in enumerate(self._registrations):
49
+ if existing_type is finding_type:
50
+ self._registrations[idx] = (finding_type, renderer)
51
+ return
52
+ self._registrations.append((finding_type, renderer))
53
+
54
+ def get(self, finding: Finding) -> FindingRenderer:
55
+ """Look up the most-specific renderer for `finding`.
56
+
57
+ Iterates registrations and selects the one whose registered
58
+ type is the most-derived ancestor of `type(finding)`. Ties
59
+ are broken by registration order (first wins).
60
+
61
+ Raises:
62
+ MissingRendererError: if no registered type matches.
63
+ """
64
+ best: tuple[type, FindingRenderer] | None = None
65
+ finding_type = type(finding)
66
+ for registered_type, renderer in self._registrations:
67
+ if not isinstance(finding, registered_type):
68
+ continue
69
+ if best is None or _is_more_specific(registered_type, best[0], finding_type):
70
+ best = (registered_type, renderer)
71
+ if best is None:
72
+ raise MissingRendererError(
73
+ f"No renderer registered for {finding_type.__name__!r}. "
74
+ f"Use RendererRegistry.register({finding_type.__name__}, …) or call "
75
+ f"RendererRegistry.default() to get a pre-populated registry."
76
+ )
77
+ return best[1]
78
+
79
+ def registered_types(self) -> tuple[type, ...]:
80
+ """Diagnostic: types currently registered, in registration order."""
81
+ return tuple(t for t, _ in self._registrations)
82
+
83
+ @classmethod
84
+ def default(cls) -> RendererRegistry:
85
+ """Return a registry pre-populated with the four built-in renderers.
86
+
87
+ Maps `SimpleFinding` → `ScorecardRenderer`, `PatchFinding` →
88
+ `PatchApplierRenderer`, `NarrativeFinding` → `MarkdownRenderer`,
89
+ `MultiSpanFinding` → `SpanTableRenderer`. Agents can register
90
+ more variants on top, or replace any of these in place.
91
+ """
92
+ registry = cls()
93
+ populate_defaults(registry)
94
+ return registry
95
+
96
+
97
+ def _is_more_specific(candidate: type, current_best: type, target: type) -> bool:
98
+ """Is `candidate` a more-specific ancestor of `target` than
99
+ `current_best`? Both `candidate` and `current_best` are guaranteed
100
+ to be ancestors of `target` at this point.
101
+
102
+ "More specific" means: candidate is a proper subclass of
103
+ current_best. If candidate == current_best, that's a tie — the
104
+ first registration wins, so we report False (not strictly more
105
+ specific).
106
+ """
107
+ del target # only relied on by callers for the ancestor invariant
108
+ return candidate is not current_best and issubclass(candidate, current_best)
@@ -0,0 +1,59 @@
1
+ """`ScorecardRenderer` — text/markdown rendering for `SimpleFinding`.
2
+
3
+ text format: `[severity] category: message (file:line) — recommendation`.
4
+ markdown format: a single GFM table row (header is the caller's job).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from agentforge_core.contracts.finding import Finding
10
+ from agentforge_core.contracts.renderer import FindingRenderer
11
+
12
+ from agentforge.findings import SimpleFinding
13
+
14
+ _SUPPORTED_FORMATS = frozenset({"text", "markdown"})
15
+
16
+
17
+ class ScorecardRenderer(FindingRenderer):
18
+ """Renders `SimpleFinding` as a severity-tagged line or table row."""
19
+
20
+ def render(self, finding: Finding, format: str = "text") -> str:
21
+ if format not in _SUPPORTED_FORMATS:
22
+ raise ValueError(
23
+ f"ScorecardRenderer supports {sorted(_SUPPORTED_FORMATS)}; got {format!r}"
24
+ )
25
+ if not isinstance(finding, SimpleFinding):
26
+ raise TypeError(
27
+ f"ScorecardRenderer renders SimpleFinding; got {type(finding).__name__}"
28
+ )
29
+
30
+ if format == "markdown":
31
+ return self._render_markdown(finding)
32
+ return self._render_text(finding)
33
+
34
+ def supports(self, finding_type: type) -> bool:
35
+ return issubclass(finding_type, SimpleFinding)
36
+
37
+ @staticmethod
38
+ def _render_text(finding: SimpleFinding) -> str:
39
+ location = ""
40
+ if finding.file:
41
+ location = f" ({finding.file}"
42
+ if finding.line is not None:
43
+ location += f":{finding.line}"
44
+ location += ")"
45
+ trailer = f" — {finding.recommendation}" if finding.recommendation else ""
46
+ rule = f" [{finding.rule_id}]" if finding.rule_id else ""
47
+ return (
48
+ f"[{finding.severity}]{rule} {finding.category}: {finding.message}{location}{trailer}"
49
+ )
50
+
51
+ @staticmethod
52
+ def _render_markdown(finding: SimpleFinding) -> str:
53
+ location = finding.file
54
+ if finding.file and finding.line is not None:
55
+ location = f"{finding.file}:{finding.line}"
56
+ return (
57
+ f"| {finding.severity} | {finding.category} | "
58
+ f"{finding.message} | {location} | {finding.recommendation} |"
59
+ )
@@ -0,0 +1,71 @@
1
+ """`SpanTableRenderer` — multi-span rendering for `MultiSpanFinding`.
2
+
3
+ text format: header + one block per span (`file:start-end excerpt`).
4
+ markdown format: header + GFM table (file | lines | excerpt).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from agentforge_core.contracts.finding import Finding
10
+ from agentforge_core.contracts.renderer import FindingRenderer
11
+
12
+ from agentforge.findings import MultiSpanFinding, Span
13
+
14
+ _SUPPORTED_FORMATS = frozenset({"text", "markdown"})
15
+
16
+
17
+ class SpanTableRenderer(FindingRenderer):
18
+ """Renders `MultiSpanFinding` as a per-span block (text) or table (md)."""
19
+
20
+ def render(self, finding: Finding, format: str = "text") -> str:
21
+ if format not in _SUPPORTED_FORMATS:
22
+ raise ValueError(
23
+ f"SpanTableRenderer supports {sorted(_SUPPORTED_FORMATS)}; got {format!r}"
24
+ )
25
+ if not isinstance(finding, MultiSpanFinding):
26
+ raise TypeError(
27
+ f"SpanTableRenderer renders MultiSpanFinding; got {type(finding).__name__}"
28
+ )
29
+
30
+ header = f"[{finding.severity}] {finding.category}: {finding.message}"
31
+
32
+ if format == "markdown":
33
+ rows = "\n".join(self._md_row(s) for s in finding.spans)
34
+ table = "| file | lines | excerpt |\n|---|---|---|\n" + rows
35
+ footer = (
36
+ f"\n\n**Recommendation:** {finding.recommendation}"
37
+ if finding.recommendation
38
+ else ""
39
+ )
40
+ return (
41
+ f"## {finding.message}\n\n"
42
+ f"severity: `{finding.severity}` — "
43
+ f"category: `{finding.category}`\n\n"
44
+ f"{table}{footer}"
45
+ )
46
+
47
+ blocks = "\n".join(self._text_block(s) for s in finding.spans)
48
+ footer = f"\n\nrecommendation: {finding.recommendation}" if finding.recommendation else ""
49
+ return f"{header}\n\n{blocks}{footer}"
50
+
51
+ def supports(self, finding_type: type) -> bool:
52
+ return issubclass(finding_type, MultiSpanFinding)
53
+
54
+ @staticmethod
55
+ def _text_block(span: Span) -> str:
56
+ location = f"{span.file}:{span.start_line}"
57
+ if span.end_line != span.start_line:
58
+ location = f"{span.file}:{span.start_line}-{span.end_line}"
59
+ excerpt = f"\n {span.excerpt}" if span.excerpt else ""
60
+ return f" - {location}{excerpt}"
61
+
62
+ @staticmethod
63
+ def _md_row(span: Span) -> str:
64
+ lines = (
65
+ str(span.start_line)
66
+ if span.start_line == span.end_line
67
+ else f"{span.start_line}-{span.end_line}"
68
+ )
69
+ # Escape pipes in excerpts so we don't break the table.
70
+ excerpt = span.excerpt.replace("|", "\\|")
71
+ return f"| `{span.file}` | {lines} | `{excerpt}` |"