agent-eval-planner 0.1.0__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.
@@ -0,0 +1,50 @@
1
+ """agent_eval_planner package.
2
+
3
+ Turns an agent contract into a guardrail evaluation plan, JSONL suite, and remediations.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .errors import Error, InputError, ValidationError
9
+ from .models import PlannerResult, Vector
10
+ from .planner import Planner
11
+ from .suite_validator import SuiteValidator
12
+ from .version import __version__
13
+
14
+
15
+ def generate(input_path: str | None = None, raw: str | None = None, **options) -> PlannerResult:
16
+ """Generate plan, suite, and remediations from an agent contract."""
17
+ return Planner(input_path=input_path, raw=raw, **options).call()
18
+
19
+
20
+ def generate_plan(input_path: str | None = None, raw: str | None = None, **options) -> str:
21
+ """Generate only the Markdown evaluation plan."""
22
+ return generate(input_path=input_path, raw=raw, **options).plan
23
+
24
+
25
+ def validate_suite(
26
+ path: str,
27
+ known_tools: list[str] | None = None,
28
+ agent_has_no_tools: bool = False,
29
+ ) -> list[str]:
30
+ """Validate a suite.jsonl file. Returns a list of errors (empty if OK)."""
31
+ return SuiteValidator(
32
+ path=path,
33
+ known_tools=known_tools or [],
34
+ agent_has_no_tools=agent_has_no_tools,
35
+ ).validate()
36
+
37
+
38
+ __all__ = [
39
+ "__version__",
40
+ "generate",
41
+ "generate_plan",
42
+ "validate_suite",
43
+ "Planner",
44
+ "PlannerResult",
45
+ "Vector",
46
+ "SuiteValidator",
47
+ "Error",
48
+ "InputError",
49
+ "ValidationError",
50
+ ]
@@ -0,0 +1,8 @@
1
+ """Main entry point when invoked as python -m agent_eval_planner."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,211 @@
1
+ """Command-line interface for agent-eval-planner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Sequence
9
+
10
+ from . import generate, validate_suite
11
+ from .errors import Error
12
+ from .models import PlannerResult
13
+ from .version import __version__
14
+
15
+
16
+ def build_parser() -> argparse.ArgumentParser:
17
+ """Builds the argument parser for generate mode."""
18
+ parser = argparse.ArgumentParser(
19
+ prog="agent-eval-planner",
20
+ description="Gera plano de eval, suite JSONL e remediações a partir do contrato de um agente.",
21
+ formatter_class=argparse.RawDescriptionHelpFormatter,
22
+ epilog=(
23
+ "Usage:\n"
24
+ " agent-eval-planner [options] <contract.md|prompt.txt|agent.json>\n"
25
+ " agent-eval-planner validate [options] <suite.jsonl>"
26
+ ),
27
+ )
28
+ parser.add_argument("input", nargs="?", help="Caminho do contrato do agente")
29
+ parser.add_argument("-t", "--team", help="Nome do time no título")
30
+ parser.add_argument("-a", "--agent", dest="agent_name", help="Nome do target_agent")
31
+ parser.add_argument("--tools", help="Tools conhecidas (vírgula)")
32
+ parser.add_argument("--scope", dest="declared_scope", help="Escopo declarado (resumo)")
33
+ parser.add_argument("--out-of-scope", dest="out_of_scope", help="Fora de escopo declarado")
34
+ parser.add_argument(
35
+ "--harness",
36
+ default="generic",
37
+ help="Harness alvo (generic|marketing-copilot)",
38
+ )
39
+ parser.add_argument("-o", "--output", help="Arquivo .md/.jsonl ou diretório de saída")
40
+ parser.add_argument("--plan-only", action="store_true", help="Gerar apenas o plano")
41
+ parser.add_argument("--suite-only", action="store_true", help="Gerar apenas a suite JSONL")
42
+ parser.add_argument(
43
+ "--remediations-only",
44
+ action="store_true",
45
+ help="Gerar apenas remediações",
46
+ )
47
+ parser.add_argument(
48
+ "-v",
49
+ "--version",
50
+ action="version",
51
+ version=f"agent_eval_planner {__version__}",
52
+ )
53
+ return parser
54
+
55
+
56
+ def build_validate_parser() -> argparse.ArgumentParser:
57
+ """Builds the argument parser for validate mode."""
58
+ parser = argparse.ArgumentParser(
59
+ prog="agent-eval-planner validate",
60
+ description="Valida uma suite JSONL (hard-fail em forbidden_tools vazios / placeholders).",
61
+ )
62
+ parser.add_argument("suite", nargs="?", help="Caminho da suite.jsonl")
63
+ parser.add_argument(
64
+ "--known-tools",
65
+ help="Tools reais (vírgula)",
66
+ )
67
+ parser.add_argument(
68
+ "--agent-has-no-tools",
69
+ action="store_true",
70
+ help="Agente sem tools",
71
+ )
72
+ return parser
73
+
74
+
75
+ def main(argv: Sequence[str] | None = None) -> int:
76
+ """Main CLI execution method."""
77
+ args_list = list(argv) if argv is not None else sys.argv[1:]
78
+ if args_list and args_list[0] == "validate":
79
+ return _run_validate(args_list[1:])
80
+ return _run_generate(args_list)
81
+
82
+
83
+ def _run_generate(argv: list[str]) -> int:
84
+ parser = build_parser()
85
+ args = parser.parse_args(argv)
86
+ if not args.input:
87
+ parser.print_help(sys.stderr)
88
+ return 1
89
+
90
+ tools = None
91
+ if args.tools:
92
+ tools = [part.strip() for part in args.tools.split(",") if part.strip()]
93
+
94
+ try:
95
+ result = generate(
96
+ input_path=args.input,
97
+ team=args.team,
98
+ agent_name=args.agent_name,
99
+ tools=tools,
100
+ declared_scope=args.declared_scope,
101
+ out_of_scope=args.out_of_scope,
102
+ harness=args.harness,
103
+ )
104
+ _write_outputs(result, args)
105
+ return 0
106
+ except Error as exc:
107
+ print(f"Erro: {exc}", file=sys.stderr)
108
+ return 1
109
+
110
+
111
+ def _looks_like_directory(path: str) -> bool:
112
+ candidate = Path(path)
113
+ return candidate.is_dir() or path.endswith("/") or candidate.suffix == ""
114
+
115
+
116
+ def _select_single(result: PlannerResult, args: argparse.Namespace) -> str | None:
117
+ if args.suite_only:
118
+ return result.suite
119
+ if args.remediations_only:
120
+ return result.remediations
121
+ if args.plan_only:
122
+ return result.plan
123
+ return None
124
+
125
+
126
+ def _write_bundle(directory: Path, result: PlannerResult, args: argparse.Namespace) -> None:
127
+ directory.mkdir(parents=True, exist_ok=True)
128
+ if not args.suite_only and not args.remediations_only:
129
+ plan_path = directory / "plano-de-acao-agent-eval.md"
130
+ plan_path.write_text(result.plan, encoding="utf-8")
131
+ print(f"Plano gerado em {plan_path}", file=sys.stderr)
132
+ if not args.plan_only and not args.remediations_only:
133
+ suite_path = directory / "suite.jsonl"
134
+ suite_path.write_text(result.suite, encoding="utf-8")
135
+ print(f"Suite gerada em {suite_path}", file=sys.stderr)
136
+ if args.plan_only or args.suite_only:
137
+ return
138
+ rem_path = directory / "remediacoes.md"
139
+ rem_path.write_text(result.remediations, encoding="utf-8")
140
+ print(f"Remediações geradas em {rem_path}", file=sys.stderr)
141
+
142
+
143
+ def _emit_stdout(result: PlannerResult, args: argparse.Namespace) -> None:
144
+ if args.suite_only:
145
+ sys.stdout.write(result.suite)
146
+ elif args.remediations_only:
147
+ sys.stdout.write(result.remediations)
148
+ elif args.plan_only:
149
+ sys.stdout.write(result.plan)
150
+ else:
151
+ sys.stdout.write(result.plan)
152
+ print("\n# --- suite.jsonl ---\n", file=sys.stderr)
153
+ sys.stdout.write(result.suite)
154
+ print("\n# --- remediacoes.md ---\n", file=sys.stderr)
155
+ sys.stdout.write(result.remediations)
156
+
157
+
158
+ def _write_outputs(result: PlannerResult, args: argparse.Namespace) -> None:
159
+ if not args.output:
160
+ _emit_stdout(result, args)
161
+ return
162
+
163
+ path = args.output
164
+ if _looks_like_directory(path):
165
+ _write_bundle(Path(path), result, args)
166
+ return
167
+
168
+ output = Path(path)
169
+ if args.suite_only or output.suffix == ".jsonl":
170
+ output.write_text(result.suite, encoding="utf-8")
171
+ print(f"Suite gerada em {path}", file=sys.stderr)
172
+ return
173
+
174
+ content = _select_single(result, args) or result.plan
175
+ output.write_text(content, encoding="utf-8")
176
+ print(f"Artefato gerado em {path}", file=sys.stderr)
177
+
178
+
179
+ def _run_validate(argv: list[str]) -> int:
180
+ parser = build_validate_parser()
181
+ args = parser.parse_args(argv)
182
+ if not args.suite:
183
+ parser.print_help(sys.stderr)
184
+ return 1
185
+
186
+ known_tools: list[str] = []
187
+ if args.known_tools:
188
+ known_tools = [part.strip() for part in args.known_tools.split(",") if part.strip()]
189
+
190
+ try:
191
+ errors = validate_suite(
192
+ args.suite,
193
+ known_tools=known_tools,
194
+ agent_has_no_tools=args.agent_has_no_tools,
195
+ )
196
+ except Error as exc:
197
+ print(f"Erro: {exc}", file=sys.stderr)
198
+ return 1
199
+
200
+ if errors:
201
+ print(f"INVALID: {args.suite} ({len(errors)} error(s))", file=sys.stderr)
202
+ for error in errors:
203
+ print(f" - {error}", file=sys.stderr)
204
+ return 1
205
+
206
+ print(f"OK: {args.suite}")
207
+ return 0
208
+
209
+
210
+ if __name__ == "__main__":
211
+ sys.exit(main())
@@ -0,0 +1,195 @@
1
+ """Parses an agent contract (markdown/text/JSON/YAML) into a structured profile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from .errors import InputError
13
+
14
+ TOOL_LINE_RE = re.compile(
15
+ r"^\s*[-*]\s*`?([a-z][a-z0-9_]*)`?\s*$|"
16
+ r"^\s*[-*]\s*tool[s]?\s*:\s*`?([a-z][a-z0-9_]*)`?|"
17
+ r"^\s*`([a-z][a-z0-9_]*)`\s*[—(]\s*tool",
18
+ re.IGNORECASE | re.MULTILINE,
19
+ )
20
+ TOOLS_INLINE_RE = re.compile(r"tools?\s*[:=]\s*\[([^\]]+)\]", re.IGNORECASE)
21
+ NAME_RE = re.compile(
22
+ r"(?:agent(?:[_\s]name)?|specialist|target[_ ]?agent)\s*[:=]\s*[\"']?([A-Za-z0-9_\-./]+)[\"']?",
23
+ re.IGNORECASE,
24
+ )
25
+ AGENT_NAME_KEY_RE = re.compile(
26
+ r"(?:agent_name|target_agent)\s*[:=]\s*[\"']?([A-Za-z0-9_\-./]+)[\"']?",
27
+ re.IGNORECASE,
28
+ )
29
+ SCOPE_RE = re.compile(r"escopo(?:\s+declarado)?\s*[:=]\s*(.+)$", re.IGNORECASE | re.MULTILINE)
30
+ HEADING_RE = re.compile(r"^#+\s*(.+)$", re.MULTILINE)
31
+
32
+
33
+ class ContractAnalyzer:
34
+ """Parses an agent contract file into a structured profile."""
35
+
36
+ def __init__(
37
+ self,
38
+ source_path: str | None = None,
39
+ raw: str | None = None,
40
+ agent_name: str | None = None,
41
+ tools: list[str] | str | None = None,
42
+ declared_scope: str | None = None,
43
+ out_of_scope: str | None = None,
44
+ harness: str = "generic",
45
+ ) -> None:
46
+ self.source_path = source_path
47
+ self.raw = raw if raw is not None else self._read_source(source_path)
48
+ self.agent_name = agent_name or self._extract_agent_name() or "default"
49
+ self.tools = self._normalize_tools(tools if tools is not None else self._extract_tools())
50
+ self.declared_scope = (
51
+ declared_scope or self._extract_scope_hint() or "Escopo declarado no contrato do agente"
52
+ )
53
+ self.out_of_scope = out_of_scope or (
54
+ "Pedidos off-topic, assistente geral, conteúdo fora do domínio"
55
+ )
56
+ self.harness = harness or "generic"
57
+ self.signals = self._detect_signals()
58
+
59
+ def has_tools(self) -> bool:
60
+ """Returns True when the contract declares at least one tool."""
61
+ return bool(self.tools)
62
+
63
+ def multi_specialist(self) -> bool:
64
+ """Returns True when the contract looks like a multi-agent setup."""
65
+ return bool(self.signals.get("multi_specialist"))
66
+
67
+ def analytics_domain(self) -> bool:
68
+ """Returns True when the contract mentions analytics/metrics."""
69
+ return bool(self.signals.get("analytics"))
70
+
71
+ def introduction(self) -> str:
72
+ """Short introduction used in the generated plan."""
73
+ parts = [f"Agente **{self.agent_name}**"]
74
+ if self.has_tools():
75
+ parts.append(f"com {len(self.tools)} tool(s) no contrato")
76
+ else:
77
+ parts.append("sem tools declaradas")
78
+ parts.append(f"(harness: {self.harness})")
79
+ return (
80
+ f"{' '.join(parts)}. Pipeline de eval de guardrails — paralelo do plano de "
81
+ "pentest de API, aplicado a comportamento de LLM."
82
+ )
83
+
84
+ def _read_source(self, path: str | None) -> str:
85
+ if not path:
86
+ raise InputError("input_path é obrigatório quando raw não é informado.")
87
+ source = Path(path)
88
+ if not source.is_file():
89
+ raise InputError(f"Arquivo não encontrado: {path}")
90
+ return source.read_text(encoding="utf-8")
91
+
92
+ def _normalize_tools(self, lst: list[str] | str | None) -> list[str]:
93
+ if lst is None:
94
+ return []
95
+ items = lst if isinstance(lst, list) else [lst]
96
+ found: list[str] = []
97
+ for item in items:
98
+ found.extend(part.strip() for part in str(item).split(","))
99
+ unique: list[str] = []
100
+ seen: set[str] = set()
101
+ for name in found:
102
+ if name and name not in seen:
103
+ seen.add(name)
104
+ unique.append(name)
105
+ return unique
106
+
107
+ def _extract_tools(self) -> list[str]:
108
+ from_structured = self._tools_from_structured()
109
+ if from_structured:
110
+ return from_structured
111
+
112
+ found: list[str] = []
113
+ for match in TOOLS_INLINE_RE.finditer(self.raw):
114
+ found.extend(
115
+ part.strip().strip("\"'`") for part in re.split(r"[,\s]+", match.group(1))
116
+ )
117
+ for match in TOOL_LINE_RE.finditer(self.raw):
118
+ found.append(next(g for g in match.groups() if g))
119
+ return found
120
+
121
+ def _tools_from_structured(self) -> list[str]:
122
+ data = self._parse_structured()
123
+ if not isinstance(data, dict):
124
+ return []
125
+ agent = data.get("agent")
126
+ candidates = (
127
+ data.get("tools")
128
+ or data.get("agent_tools")
129
+ or (agent.get("tools") if isinstance(agent, dict) else None)
130
+ or []
131
+ )
132
+ if not isinstance(candidates, list):
133
+ candidates = [candidates]
134
+ return [str(item) for item in candidates]
135
+
136
+ def _parse_structured(self) -> dict[str, Any] | None:
137
+ if not self.raw or not self.raw.strip():
138
+ return None
139
+ path = self.source_path or ""
140
+ stripped = self.raw.strip()
141
+ try:
142
+ if path.endswith(".json") or stripped.startswith("{"):
143
+ parsed = json.loads(self.raw)
144
+ return parsed if isinstance(parsed, dict) else None
145
+ if re.search(r"\.(ya?ml)$", path, re.IGNORECASE):
146
+ parsed = yaml.safe_load(self.raw)
147
+ return parsed if isinstance(parsed, dict) else None
148
+ except Exception:
149
+ return None
150
+ return None
151
+
152
+ def _extract_agent_name(self) -> str | None:
153
+ data = self._parse_structured()
154
+ if isinstance(data, dict):
155
+ agent = data.get("agent")
156
+ name = (
157
+ data.get("agent_name")
158
+ or data.get("target_agent")
159
+ or data.get("name")
160
+ or (agent.get("name") if isinstance(agent, dict) else None)
161
+ )
162
+ if name:
163
+ return str(name)
164
+ key_match = AGENT_NAME_KEY_RE.search(self.raw)
165
+ if key_match:
166
+ return key_match.group(1)
167
+ match = NAME_RE.search(self.raw)
168
+ return match.group(1) if match else None
169
+
170
+ def _extract_scope_hint(self) -> str | None:
171
+ match = SCOPE_RE.search(self.raw)
172
+ if match:
173
+ return match.group(1).strip()
174
+ heading = HEADING_RE.search(self.raw)
175
+ if heading:
176
+ return heading.group(1).strip()
177
+ return None
178
+
179
+ def _detect_signals(self) -> dict[str, bool]:
180
+ down = self.raw.lower()
181
+ return {
182
+ "has_tools": self.has_tools(),
183
+ "multi_specialist": bool(
184
+ re.search(r"specialist|orchestrator|handoff|multi[- ]?agent", down)
185
+ ),
186
+ "analytics": bool(
187
+ re.search(r"analytics|métrica|metrica|funnel|abertura|convers[aã]o|bigquery", down)
188
+ ),
189
+ "docs": bool(re.search(r"docs?|help.?center|conhecimento|rag|retrieval", down)),
190
+ "mutable_tools": any(
191
+ re.search(r"ticket|delete|create|update|write|send|open_", tool, re.IGNORECASE)
192
+ for tool in self.tools
193
+ ),
194
+ "pii_surface": bool(re.search(r"pii|cpf|email|tenant|cliente|customer", down)),
195
+ }
@@ -0,0 +1,13 @@
1
+ """Exceptions for agent_eval_planner."""
2
+
3
+
4
+ class Error(Exception):
5
+ """Base exception for agent_eval_planner."""
6
+
7
+
8
+ class InputError(Error):
9
+ """Raised when input path or arguments are invalid."""
10
+
11
+
12
+ class ValidationError(Error):
13
+ """Raised when a suite JSONL fails validation."""
@@ -0,0 +1,39 @@
1
+ """Data models for agent_eval_planner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class Vector:
11
+ """A guardrail evaluation vector (attack prompt + expected behavior)."""
12
+
13
+ id: str
14
+ name: str
15
+ category: str
16
+ category_title: str
17
+ severity: str
18
+ objective: str
19
+ attack_prompt: str
20
+ expected_validation: str
21
+ failure_criteria: str
22
+ intent: str
23
+ tags: list[str] = field(default_factory=list)
24
+ refusal: bool = True
25
+ split: str = "smoke"
26
+ notes: str = ""
27
+ always: bool = False
28
+ triggers: list[str] = field(default_factory=list)
29
+
30
+
31
+ @dataclass
32
+ class PlannerResult:
33
+ """Artifacts produced by a planner run."""
34
+
35
+ plan: str
36
+ suite: str
37
+ remediations: str
38
+ analyzer: Any
39
+ vectors: list[Vector]
@@ -0,0 +1,125 @@
1
+ """Renders the Markdown evaluation action plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .contract_analyzer import ContractAnalyzer
6
+ from .models import Vector
7
+ from .vector_catalog import CATEGORY_TITLES
8
+
9
+ CATEGORY_ORDER = ["SCOPE", "INJECT", "ROLE", "PII", "TOOL", "HALLUC", "EXFIL"]
10
+
11
+
12
+ class PlanRenderer:
13
+ """Renders a Markdown eval action plan from analyzer + selected vectors."""
14
+
15
+ def __init__(
16
+ self,
17
+ analyzer: ContractAnalyzer,
18
+ vectors: list[Vector],
19
+ team: str | None = None,
20
+ ) -> None:
21
+ self.analyzer = analyzer
22
+ self.vectors = vectors
23
+ self.team = team or "TIME"
24
+
25
+ def render(self) -> str:
26
+ """Returns the full Markdown plan."""
27
+ sections = [
28
+ self._header(),
29
+ self._introduction(),
30
+ self._eval_sections(),
31
+ self._summary_table(),
32
+ self._recommendations(),
33
+ self._out_of_scope(),
34
+ ]
35
+ return "\n\n".join(section for section in sections if section) + "\n"
36
+
37
+ def _header(self) -> str:
38
+ return f"# {self.team} — Plano de Ação de Eval de Agente"
39
+
40
+ def _introduction(self) -> str:
41
+ tools = (
42
+ "_(nenhuma)_"
43
+ if not self.analyzer.tools
44
+ else ", ".join(f"`{tool}`" for tool in self.analyzer.tools)
45
+ )
46
+ source = self.analyzer.source_path or "inline"
47
+ return (
48
+ "## Introdução\n\n"
49
+ f"{self.analyzer.introduction()}\n\n"
50
+ f"**Agente(s) em escopo:** {self.analyzer.agent_name}\n\n"
51
+ f"**Contrato analisado:** `{source}`\n\n"
52
+ f"**Harness alvo:** {self.analyzer.harness}\n\n"
53
+ f"**Escopo declarado (resumo):** {self.analyzer.declared_scope}\n\n"
54
+ f"**Fora de escopo declarado:** {self.analyzer.out_of_scope}\n\n"
55
+ f"**Tools / capabilities:** {tools}"
56
+ )
57
+
58
+ def _eval_sections(self) -> str:
59
+ grouped: dict[str, list[Vector]] = {}
60
+ for vector in self.vectors:
61
+ grouped.setdefault(vector.category, []).append(vector)
62
+
63
+ blocks: list[str] = []
64
+ for category in CATEGORY_ORDER:
65
+ items = grouped.get(category)
66
+ if not items:
67
+ continue
68
+ title = CATEGORY_TITLES.get(category, category)
69
+ body = "\n\n".join(self._render_vector(vector) for vector in items)
70
+ blocks.append(f"## EVAL — {title}\n\n{body}")
71
+ return "\n\n---\n\n".join(blocks)
72
+
73
+ def _render_vector(self, vector: Vector) -> str:
74
+ notes = vector.notes if vector.notes else "—"
75
+ return (
76
+ f"#### {vector.id} — {vector.name}\n\n"
77
+ f"- **Severidade:** {vector.severity}\n"
78
+ f"- **Objetivo:** {vector.objective}\n"
79
+ "- **Prompt de ataque:**\n"
80
+ " ```text\n"
81
+ f" {vector.attack_prompt}\n"
82
+ " ```\n"
83
+ f"- **Validação esperada:** {vector.expected_validation}\n"
84
+ f"- **Critério de falha:** {vector.failure_criteria}\n"
85
+ f"- **Observação:** {notes}"
86
+ )
87
+
88
+ def _summary_table(self) -> str:
89
+ rows = [
90
+ f"| {v.id} | {v.category} | {v.severity} | {v.name} | {v.failure_criteria} |"
91
+ for v in self.vectors
92
+ ]
93
+ return (
94
+ "## Tabela de resumo\n\n"
95
+ "| ID | Categoria | Severidade | Vetor | Falha se |\n"
96
+ "|----|-----------|------------|-------|----------|\n"
97
+ + "\n".join(rows)
98
+ + "\n\n"
99
+ "**Prioridade de execução:** SCOPE → INJECT → ROLE → PII → TOOL → HALLUC → EXFIL"
100
+ )
101
+
102
+ def _recommendations(self) -> str:
103
+ tips = [
104
+ "Rodar smoke pack (SCOPE-01, INJECT-01, ROLE-01) no harness antes de expandir a suite.",
105
+ "Preencher `forbidden_tools` com as tools reais do agente — suite com lista vazia em refusal é falso verde.",
106
+ "Preferir policy gate determinístico para off-topic previsível; o modelo sozinho não basta.",
107
+ ]
108
+ if self.analyzer.multi_specialist():
109
+ tips.append(
110
+ "Validar handoff entre specialists (SCOPE-04) — orchestrator detectado no contrato."
111
+ )
112
+ if self.analyzer.analytics_domain():
113
+ tips.append(
114
+ "Cobrir grounding de métricas (HALLUC-01) com tool obrigatória ou recusa explícita."
115
+ )
116
+ body = "\n".join(f"{index}. {tip}" for index, tip in enumerate(tips, start=1))
117
+ return f"## Recomendações imediatas\n\n{body}"
118
+
119
+ def _out_of_scope(self) -> str:
120
+ return (
121
+ "## Fora do escopo deste plano\n\n"
122
+ "- Pentest de API/infra (usar `security_pentest_planner`)\n"
123
+ "- Journeys de UI / synthetics de browser\n"
124
+ "- Modelo de ameaça corporativo completo"
125
+ )