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.
- agentforge/__init__.py +114 -0
- agentforge/_testing/__init__.py +19 -0
- agentforge/_testing/fake_llm.py +126 -0
- agentforge/_testing/fake_tool.py +122 -0
- agentforge/_tools/__init__.py +14 -0
- agentforge/_tools/calculator.py +102 -0
- agentforge/_tools/decorator.py +300 -0
- agentforge/_tools/file_read.py +112 -0
- agentforge/_tools/shell.py +134 -0
- agentforge/_tools/web_search.py +207 -0
- agentforge/agent.py +817 -0
- agentforge/auth.py +42 -0
- agentforge/cli/__init__.py +18 -0
- agentforge/cli/_build.py +323 -0
- agentforge/cli/_scaffold_state.py +250 -0
- agentforge/cli/_shared_scaffold.py +174 -0
- agentforge/cli/config_cmd.py +174 -0
- agentforge/cli/db_cmd.py +262 -0
- agentforge/cli/debug_cmd.py +168 -0
- agentforge/cli/docs_cmd.py +217 -0
- agentforge/cli/eval_cmd.py +181 -0
- agentforge/cli/health_cmd.py +139 -0
- agentforge/cli/list_modules.py +85 -0
- agentforge/cli/main.py +81 -0
- agentforge/cli/manifest_apply.py +368 -0
- agentforge/cli/module_cmd.py +247 -0
- agentforge/cli/new_cmd.py +171 -0
- agentforge/cli/run_cmd.py +234 -0
- agentforge/cli/upgrade_cmd.py +230 -0
- agentforge/config/__init__.py +45 -0
- agentforge/eval/__init__.py +18 -0
- agentforge/eval/consistency.py +107 -0
- agentforge/eval/coverage.py +100 -0
- agentforge/eval/format_compliance.py +107 -0
- agentforge/eval/regression.py +143 -0
- agentforge/findings.py +166 -0
- agentforge/guardrails/__init__.py +32 -0
- agentforge/guardrails/allowlist.py +49 -0
- agentforge/guardrails/capability_check.py +58 -0
- agentforge/guardrails/engine.py +289 -0
- agentforge/guardrails/pii_redact_basic.py +61 -0
- agentforge/guardrails/prompt_injection_basic.py +90 -0
- agentforge/memory/__init__.py +16 -0
- agentforge/memory/in_memory.py +130 -0
- agentforge/memory/in_memory_graph.py +262 -0
- agentforge/memory/in_memory_vector.py +167 -0
- agentforge/pipeline/__init__.py +26 -0
- agentforge/pipeline/engine.py +189 -0
- agentforge/pipeline/errors.py +19 -0
- agentforge/pipeline/tool.py +93 -0
- agentforge/py.typed +0 -0
- agentforge/recording.py +189 -0
- agentforge/renderers/__init__.py +28 -0
- agentforge/renderers/_defaults.py +32 -0
- agentforge/renderers/markdown.py +44 -0
- agentforge/renderers/patch_applier.py +46 -0
- agentforge/renderers/registry.py +108 -0
- agentforge/renderers/scorecard.py +59 -0
- agentforge/renderers/span_table.py +71 -0
- agentforge/replay.py +260 -0
- agentforge/resolver_register.py +41 -0
- agentforge/retrieval.py +410 -0
- agentforge/runtime.py +63 -0
- agentforge/strategies/__init__.py +27 -0
- agentforge/strategies/_base.py +280 -0
- agentforge/strategies/_plan.py +93 -0
- agentforge/strategies/multi_agent.py +541 -0
- agentforge/strategies/plan_execute.py +506 -0
- agentforge/strategies/react.py +237 -0
- agentforge/strategies/tot.py +472 -0
- agentforge/templates/_shared/.cursorrules +12 -0
- agentforge/templates/_shared/.github/copilot-instructions.md +13 -0
- agentforge/templates/_shared/.gitkeep +0 -0
- agentforge/templates/_shared/AGENTS.md.tmpl +123 -0
- agentforge/templates/_shared/CLAUDE.md +13 -0
- agentforge/templates/_shared/docs/runbooks/01-set-up-new-agent.md.tmpl +67 -0
- agentforge/templates/_shared/docs/runbooks/02-add-a-tool.md +67 -0
- agentforge/templates/_shared/docs/runbooks/03-add-a-pipeline-task.md +69 -0
- agentforge/templates/_shared/docs/runbooks/04-pick-reasoning-strategy.md +67 -0
- agentforge/templates/_shared/docs/runbooks/05-write-prompts.md +75 -0
- agentforge/templates/_shared/docs/runbooks/06-test-your-agent.md +75 -0
- agentforge/templates/_shared/docs/runbooks/07-debug-a-run.md +70 -0
- agentforge/templates/_shared/docs/runbooks/08-add-memory.md +75 -0
- agentforge/templates/_shared/docs/runbooks/09-add-mcp.md +78 -0
- agentforge/templates/_shared/docs/runbooks/10-add-evaluators.md +76 -0
- agentforge/templates/_shared/docs/runbooks/11-add-safety-guardrails.md +83 -0
- agentforge/templates/_shared/docs/runbooks/12-add-observability.md +77 -0
- agentforge/templates/_shared/docs/runbooks/13-configure-multi-provider.md +91 -0
- agentforge/templates/_shared/docs/runbooks/14-deploy-your-agent.md +70 -0
- agentforge/templates/_shared/docs/runbooks/15-upgrade-your-agent.md +67 -0
- agentforge/templates/_shared/docs/runbooks/16-configuration-reference.md +81 -0
- agentforge/templates/_shared/docs/runbooks/17-add-reranker.md +78 -0
- agentforge/templates/_shared/docs/runbooks/18-add-hybrid-search.md +78 -0
- agentforge/templates/_shared/docs/runbooks/19-add-graphrag.md +83 -0
- agentforge/templates/_shared/docs/runbooks/20-apply-schema-migrations.md +92 -0
- agentforge/templates/_shared/docs/runbooks/21-use-streaming-guardrails.md +82 -0
- agentforge/templates/_shared/docs/runbooks/README.md.tmpl +68 -0
- agentforge/templates/code-reviewer/.env.example +8 -0
- agentforge/templates/code-reviewer/.gitignore +7 -0
- agentforge/templates/code-reviewer/README.md +12 -0
- agentforge/templates/code-reviewer/agentforge.yaml +23 -0
- agentforge/templates/code-reviewer/copier.yml +34 -0
- agentforge/templates/code-reviewer/pyproject.toml +18 -0
- agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/docs-qa/.env.example +8 -0
- agentforge/templates/docs-qa/.gitignore +7 -0
- agentforge/templates/docs-qa/README.md +14 -0
- agentforge/templates/docs-qa/agentforge.yaml +19 -0
- agentforge/templates/docs-qa/copier.yml +31 -0
- agentforge/templates/docs-qa/pyproject.toml +18 -0
- agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/minimal/.env.example +11 -0
- agentforge/templates/minimal/.gitignore +10 -0
- agentforge/templates/minimal/README.md +28 -0
- agentforge/templates/minimal/agentforge.yaml +10 -0
- agentforge/templates/minimal/copier.yml +52 -0
- agentforge/templates/minimal/pyproject.toml +18 -0
- agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/main.py +34 -0
- agentforge/templates/patch-bot/.env.example +8 -0
- agentforge/templates/patch-bot/.gitignore +7 -0
- agentforge/templates/patch-bot/README.md +13 -0
- agentforge/templates/patch-bot/agentforge.yaml +15 -0
- agentforge/templates/patch-bot/copier.yml +31 -0
- agentforge/templates/patch-bot/pyproject.toml +18 -0
- agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/research/.env.example +8 -0
- agentforge/templates/research/.gitignore +7 -0
- agentforge/templates/research/README.md +14 -0
- agentforge/templates/research/agentforge.yaml +17 -0
- agentforge/templates/research/copier.yml +31 -0
- agentforge/templates/research/pyproject.toml +18 -0
- agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/main.py +31 -0
- agentforge/templates/triage/.env.example +8 -0
- agentforge/templates/triage/.gitignore +7 -0
- agentforge/templates/triage/README.md +14 -0
- agentforge/templates/triage/agentforge.yaml +25 -0
- agentforge/templates/triage/copier.yml +31 -0
- agentforge/templates/triage/pyproject.toml +18 -0
- agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/main.py +30 -0
- agentforge/testing/__init__.py +69 -0
- agentforge/testing/conformance.py +40 -0
- agentforge/testing/factory.py +89 -0
- agentforge/testing/fixtures.py +42 -0
- agentforge/testing/llm.py +235 -0
- agentforge/testing/recording.py +177 -0
- agentforge/tools/__init__.py +41 -0
- agentforge_py-0.2.1.dist-info/METADATA +158 -0
- agentforge_py-0.2.1.dist-info/RECORD +157 -0
- agentforge_py-0.2.1.dist-info/WHEEL +4 -0
- agentforge_py-0.2.1.dist-info/entry_points.txt +2 -0
- 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
|
agentforge/recording.py
ADDED
|
@@ -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}` |"
|