codedebrief 0.11.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.
- codedebrief/__init__.py +12 -0
- codedebrief/analysis/__init__.py +16 -0
- codedebrief/analysis/common.py +527 -0
- codedebrief/analysis/discovery.py +100 -0
- codedebrief/analysis/languages/__init__.py +6 -0
- codedebrief/analysis/languages/_common.py +68 -0
- codedebrief/analysis/languages/c.py +96 -0
- codedebrief/analysis/languages/cpp.py +146 -0
- codedebrief/analysis/languages/csharp.py +137 -0
- codedebrief/analysis/languages/go.py +157 -0
- codedebrief/analysis/languages/java.py +158 -0
- codedebrief/analysis/languages/php.py +83 -0
- codedebrief/analysis/languages/ruby.py +75 -0
- codedebrief/analysis/languages/rust.py +96 -0
- codedebrief/analysis/project.py +373 -0
- codedebrief/analysis/python.py +939 -0
- codedebrief/analysis/registry.py +320 -0
- codedebrief/analysis/treesitter.py +884 -0
- codedebrief/analysis/typescript.py +1019 -0
- codedebrief/artifacts.py +49 -0
- codedebrief/cli.py +585 -0
- codedebrief/config.py +226 -0
- codedebrief/doctor.py +175 -0
- codedebrief/install.py +441 -0
- codedebrief/mcp_server.py +2720 -0
- codedebrief/model.py +189 -0
- codedebrief/py.typed +1 -0
- codedebrief/quality.py +392 -0
- codedebrief/query.py +641 -0
- codedebrief/render/__init__.py +6 -0
- codedebrief/render/assets/generated/codedebrief-viewer-runtime.iife.js +10 -0
- codedebrief/render/assets/panels.js +462 -0
- codedebrief/render/assets/shell.js +1649 -0
- codedebrief/render/assets/styles.css +1715 -0
- codedebrief/render/assets/tree.js +616 -0
- codedebrief/render/html.py +191 -0
- codedebrief/render/markdown.py +153 -0
- codedebrief/render/payload.py +326 -0
- codedebrief/render/snapshot.py +769 -0
- codedebrief/schema/codedebrief.schema.json +449 -0
- codedebrief/util.py +65 -0
- codedebrief/validation.py +214 -0
- codedebrief-0.11.0.dist-info/METADATA +426 -0
- codedebrief-0.11.0.dist-info/RECORD +48 -0
- codedebrief-0.11.0.dist-info/WHEEL +4 -0
- codedebrief-0.11.0.dist-info/entry_points.txt +2 -0
- codedebrief-0.11.0.dist-info/licenses/LICENSE +176 -0
- codedebrief-0.11.0.dist-info/licenses/NOTICE +9 -0
|
@@ -0,0 +1,2720 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import html
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import sys
|
|
9
|
+
from collections import deque
|
|
10
|
+
from collections.abc import Iterable, Mapping
|
|
11
|
+
from itertools import pairwise
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
from codedebrief.analysis import ProjectAnalyzer
|
|
17
|
+
from codedebrief.artifacts import load_model, output_paths, write_artifacts
|
|
18
|
+
from codedebrief.config import CodeDebriefConfig
|
|
19
|
+
from codedebrief.model import Flow, FlowEdge, FlowNode, NodeKind, ProjectModel
|
|
20
|
+
from codedebrief.query import (
|
|
21
|
+
flow_navigation,
|
|
22
|
+
flow_summary,
|
|
23
|
+
git_changed_files,
|
|
24
|
+
impact_model,
|
|
25
|
+
query_model,
|
|
26
|
+
)
|
|
27
|
+
from codedebrief.render.snapshot import (
|
|
28
|
+
SNAPSHOT_FORMATS,
|
|
29
|
+
render_subgraph_snapshot,
|
|
30
|
+
unsupported_snapshot_format,
|
|
31
|
+
)
|
|
32
|
+
from codedebrief.util import metadata_scope_names
|
|
33
|
+
from codedebrief.validation import validate_codedebrief
|
|
34
|
+
|
|
35
|
+
# Rough tokens per returned list item, used to honor an agent's token_budget cap.
|
|
36
|
+
_TOKENS_PER_ITEM = 60
|
|
37
|
+
_DEFAULT_CONTEXT_VISUAL_BYTE_BUDGET = 120_000
|
|
38
|
+
|
|
39
|
+
# Errors raised while loading the on-disk model (missing file, corrupt/garbled JSON,
|
|
40
|
+
# unexpected schema). Surfaced to the agent as a clean {"error": ...} instead of a raw
|
|
41
|
+
# traceback, so a stale or never-built model is recoverable advice, not a crash.
|
|
42
|
+
_LOAD_ERRORS = (OSError, ValueError, KeyError, TypeError)
|
|
43
|
+
|
|
44
|
+
MCP_INSTRUCTIONS = """Use CodeDebrief as an agent-first code-logic understanding layer.
|
|
45
|
+
Prefer agent_context for ordinary user questions before broad file-by-file search.
|
|
46
|
+
Use the returned workflow_slice as the source of truth for visual explanations.
|
|
47
|
+
After substantial code edits, call update_codedebrief and validate_artifacts, then commit
|
|
48
|
+
the synchronized codedebrief.json and codedebrief.md artifacts when they changed.
|
|
49
|
+
Use update_codedebrief(full=true) when artifacts are missing, stale, or analyzer behavior
|
|
50
|
+
changed and cached file models should be ignored."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cap(items: list[dict[str, Any]], token_budget: int) -> list[dict[str, Any]]:
|
|
54
|
+
if token_budget <= 0:
|
|
55
|
+
return items
|
|
56
|
+
return items[: max(1, token_budget // _TOKENS_PER_ITEM)]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def model_hash(model: ProjectModel) -> str:
|
|
60
|
+
payload = model.to_dict()
|
|
61
|
+
payload.pop("generated_at", None)
|
|
62
|
+
raw = json.dumps(payload, sort_keys=True, default=str, separators=(",", ":"))
|
|
63
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _snapshot_node_budget(token_budget: int) -> int | None:
|
|
67
|
+
if token_budget <= 0:
|
|
68
|
+
return None
|
|
69
|
+
return max(4, token_budget // 80)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _snapshot_flow_budget(token_budget: int) -> int | None:
|
|
73
|
+
if token_budget <= 0:
|
|
74
|
+
return None
|
|
75
|
+
return max(1, token_budget // 120)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _impact_changed_files(
|
|
79
|
+
project_root: Path,
|
|
80
|
+
changed_files: list[str] | None,
|
|
81
|
+
flow_ids: list[str] | None,
|
|
82
|
+
symbols: list[str] | None,
|
|
83
|
+
dependency_paths: list[str] | None,
|
|
84
|
+
) -> list[str]:
|
|
85
|
+
has_targets = bool(flow_ids or symbols or dependency_paths)
|
|
86
|
+
if changed_files is not None:
|
|
87
|
+
return changed_files
|
|
88
|
+
return [] if has_targets else git_changed_files(project_root)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_mcp(root: Path, config: CodeDebriefConfig | None = None) -> None:
|
|
92
|
+
try:
|
|
93
|
+
from mcp.server.fastmcp import FastMCP
|
|
94
|
+
except ImportError as error:
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
"MCP support is not importable. Reinstall CodeDebrief with `uv tool install .` "
|
|
97
|
+
"or run `uv sync --extra dev` for development."
|
|
98
|
+
) from error
|
|
99
|
+
|
|
100
|
+
project_root = root.resolve()
|
|
101
|
+
active_config = config or CodeDebriefConfig.load(project_root)
|
|
102
|
+
server = FastMCP("CodeDebrief", instructions=MCP_INSTRUCTIONS, json_response=True)
|
|
103
|
+
|
|
104
|
+
@server.tool()
|
|
105
|
+
def agent_context(
|
|
106
|
+
question: str | None = None,
|
|
107
|
+
changed_files: list[str] | None = None,
|
|
108
|
+
selected_code: str | None = None,
|
|
109
|
+
current_file: str | None = None,
|
|
110
|
+
flow_id: str | None = None,
|
|
111
|
+
symbol: str | None = None,
|
|
112
|
+
dependency_path: str | None = None,
|
|
113
|
+
domain: str | None = None,
|
|
114
|
+
value: str | None = None,
|
|
115
|
+
scope: str | None = None,
|
|
116
|
+
include_visual: bool = False,
|
|
117
|
+
token_budget: int = 900,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""Primary agent entrypoint for code-logic questions and change impact.
|
|
120
|
+
|
|
121
|
+
Accepts the context a coding agent naturally has: user question, changed files,
|
|
122
|
+
selected code/current file, or focused flow/symbol/dependency targets.
|
|
123
|
+
Returns one bounded workflow_slice plus compatible query, impact, navigation,
|
|
124
|
+
source-range, guardrail, and optional visual snapshot context.
|
|
125
|
+
"""
|
|
126
|
+
effective_question = _agent_context_question(question, selected_code)
|
|
127
|
+
source_path = current_file.strip() if current_file and current_file.strip() else None
|
|
128
|
+
model, error = _try_load(project_root, active_config)
|
|
129
|
+
if error is not None:
|
|
130
|
+
return error
|
|
131
|
+
assert model is not None
|
|
132
|
+
domain_scope, _scope_query_hint = _agent_scope_filter(model, scope)
|
|
133
|
+
pack = _selection_context_payload(
|
|
134
|
+
project_root,
|
|
135
|
+
active_config,
|
|
136
|
+
model,
|
|
137
|
+
question=effective_question,
|
|
138
|
+
changed_files=changed_files,
|
|
139
|
+
scope=scope,
|
|
140
|
+
flow_ids=_single_item_list(flow_id),
|
|
141
|
+
symbols=_single_item_list(symbol),
|
|
142
|
+
dependency_paths=_single_item_list(dependency_path),
|
|
143
|
+
source_path=source_path,
|
|
144
|
+
domain=domain,
|
|
145
|
+
value=value,
|
|
146
|
+
include_visual=include_visual,
|
|
147
|
+
token_budget=token_budget,
|
|
148
|
+
)
|
|
149
|
+
domain_payload = _domain_logic_map(
|
|
150
|
+
model,
|
|
151
|
+
domain=domain,
|
|
152
|
+
value=value,
|
|
153
|
+
scope=domain_scope,
|
|
154
|
+
token_budget=token_budget,
|
|
155
|
+
)
|
|
156
|
+
workflow_slice = _workflow_slice_payload(
|
|
157
|
+
model,
|
|
158
|
+
pack,
|
|
159
|
+
question=effective_question,
|
|
160
|
+
inputs={
|
|
161
|
+
"question": question,
|
|
162
|
+
"changed_files": changed_files or [],
|
|
163
|
+
"current_file": source_path,
|
|
164
|
+
"flow_id": flow_id,
|
|
165
|
+
"symbol": symbol,
|
|
166
|
+
"dependency_path": dependency_path,
|
|
167
|
+
"domain": domain,
|
|
168
|
+
"value": value,
|
|
169
|
+
"scope": scope,
|
|
170
|
+
"include_visual": include_visual,
|
|
171
|
+
"token_budget": token_budget,
|
|
172
|
+
},
|
|
173
|
+
domain_logic_payload=domain_payload,
|
|
174
|
+
token_budget=token_budget,
|
|
175
|
+
)
|
|
176
|
+
recommended_next_tools = _agent_context_next_tools(pack, token_budget)
|
|
177
|
+
recommended_next_tools["workflow_slice"] = workflow_slice["next_tools"]
|
|
178
|
+
return {
|
|
179
|
+
"tool": "agent_context",
|
|
180
|
+
"guardrail": (
|
|
181
|
+
"Use this as source-grounded context for explanation or edits. Do not "
|
|
182
|
+
"invent workflow steps, branches, constants, limits, or error codes outside "
|
|
183
|
+
"the returned CodeDebrief payload."
|
|
184
|
+
),
|
|
185
|
+
"inputs": {
|
|
186
|
+
"question": question,
|
|
187
|
+
"changed_files": changed_files or [],
|
|
188
|
+
"current_file": source_path,
|
|
189
|
+
"selected_code_excerpt": _selected_code_excerpt(selected_code),
|
|
190
|
+
"flow_id": flow_id,
|
|
191
|
+
"symbol": symbol,
|
|
192
|
+
"dependency_path": dependency_path,
|
|
193
|
+
"domain": domain,
|
|
194
|
+
"value": value,
|
|
195
|
+
"scope": scope,
|
|
196
|
+
"include_visual": include_visual,
|
|
197
|
+
"token_budget": token_budget,
|
|
198
|
+
},
|
|
199
|
+
"workflow_slice": workflow_slice,
|
|
200
|
+
"context": pack,
|
|
201
|
+
"recommended_next_tools": recommended_next_tools,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
@server.tool()
|
|
205
|
+
def expand_slice(
|
|
206
|
+
slice_id: str | None = None,
|
|
207
|
+
flow_ids: list[str] | None = None,
|
|
208
|
+
direction: str = "neighbors",
|
|
209
|
+
depth: int = 1,
|
|
210
|
+
include_visual: bool = False,
|
|
211
|
+
token_budget: int = 900,
|
|
212
|
+
) -> dict[str, Any]:
|
|
213
|
+
"""Widen or deepen a workflow slice from stable flow handles."""
|
|
214
|
+
model, error = _try_load(project_root, active_config)
|
|
215
|
+
if error is not None:
|
|
216
|
+
return error
|
|
217
|
+
assert model is not None
|
|
218
|
+
expansion = _expand_workflow_slice_targets(
|
|
219
|
+
model,
|
|
220
|
+
flow_ids=flow_ids,
|
|
221
|
+
direction=direction,
|
|
222
|
+
depth=depth,
|
|
223
|
+
token_budget=token_budget,
|
|
224
|
+
)
|
|
225
|
+
if expansion["error_code"]:
|
|
226
|
+
return _slice_target_error(
|
|
227
|
+
"expand_slice",
|
|
228
|
+
expansion["error_code"],
|
|
229
|
+
expansion["message"],
|
|
230
|
+
slice_id=slice_id,
|
|
231
|
+
flow_ids=flow_ids,
|
|
232
|
+
)
|
|
233
|
+
pack = _selection_context_payload(
|
|
234
|
+
project_root,
|
|
235
|
+
active_config,
|
|
236
|
+
model,
|
|
237
|
+
question=f"expand workflow slice {direction}",
|
|
238
|
+
flow_ids=expansion["flow_ids"],
|
|
239
|
+
include_visual=include_visual,
|
|
240
|
+
token_budget=token_budget,
|
|
241
|
+
)
|
|
242
|
+
domain_payload = _domain_logic_map(
|
|
243
|
+
model,
|
|
244
|
+
domain=None,
|
|
245
|
+
value=None,
|
|
246
|
+
scope=None,
|
|
247
|
+
token_budget=token_budget,
|
|
248
|
+
)
|
|
249
|
+
workflow_slice = _workflow_slice_payload(
|
|
250
|
+
model,
|
|
251
|
+
pack,
|
|
252
|
+
question=f"expand workflow slice {direction}",
|
|
253
|
+
inputs={
|
|
254
|
+
"slice_id": slice_id,
|
|
255
|
+
"flow_ids": flow_ids or [],
|
|
256
|
+
"direction": direction,
|
|
257
|
+
"depth": depth,
|
|
258
|
+
"include_visual": include_visual,
|
|
259
|
+
"token_budget": token_budget,
|
|
260
|
+
},
|
|
261
|
+
domain_logic_payload=domain_payload,
|
|
262
|
+
token_budget=token_budget,
|
|
263
|
+
)
|
|
264
|
+
return {
|
|
265
|
+
"tool": "expand_slice",
|
|
266
|
+
"base_slice_id": slice_id,
|
|
267
|
+
"direction": expansion["direction"],
|
|
268
|
+
"depth": expansion["depth"],
|
|
269
|
+
"expansion": expansion,
|
|
270
|
+
"workflow_slice": workflow_slice,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
@server.tool()
|
|
274
|
+
def workflow_path(
|
|
275
|
+
source: str,
|
|
276
|
+
target: str,
|
|
277
|
+
scope: str | None = None,
|
|
278
|
+
include_visual: bool = False,
|
|
279
|
+
token_budget: int = 900,
|
|
280
|
+
) -> dict[str, Any]:
|
|
281
|
+
"""Trace a deterministic workflow path between two flows, symbols, or concepts."""
|
|
282
|
+
model, error = _try_load(project_root, active_config)
|
|
283
|
+
if error is not None:
|
|
284
|
+
return error
|
|
285
|
+
assert model is not None
|
|
286
|
+
source_seed = _resolve_workflow_path_seed(model, source, scope, token_budget)
|
|
287
|
+
target_seed = _resolve_workflow_path_seed(model, target, scope, token_budget)
|
|
288
|
+
if not source_seed["flow_ids"] or not target_seed["flow_ids"]:
|
|
289
|
+
return _workflow_path_error(source, target, source_seed, target_seed, token_budget)
|
|
290
|
+
path = _find_workflow_path(model, source_seed["flow_ids"], target_seed["flow_ids"])
|
|
291
|
+
selected_flow_ids = path["flow_ids"] or _unique_preserve_order(
|
|
292
|
+
[*source_seed["flow_ids"][:2], *target_seed["flow_ids"][:2]]
|
|
293
|
+
)
|
|
294
|
+
pack = _selection_context_payload(
|
|
295
|
+
project_root,
|
|
296
|
+
active_config,
|
|
297
|
+
model,
|
|
298
|
+
question=f"{source} -> {target}",
|
|
299
|
+
scope=scope,
|
|
300
|
+
flow_ids=selected_flow_ids,
|
|
301
|
+
include_visual=include_visual,
|
|
302
|
+
token_budget=token_budget,
|
|
303
|
+
)
|
|
304
|
+
domain_payload = _domain_logic_map(
|
|
305
|
+
model,
|
|
306
|
+
domain=None,
|
|
307
|
+
value=None,
|
|
308
|
+
scope=scope,
|
|
309
|
+
token_budget=token_budget,
|
|
310
|
+
)
|
|
311
|
+
workflow_slice = _workflow_slice_payload(
|
|
312
|
+
model,
|
|
313
|
+
pack,
|
|
314
|
+
question=f"{source} -> {target}",
|
|
315
|
+
inputs={
|
|
316
|
+
"source": source,
|
|
317
|
+
"target": target,
|
|
318
|
+
"scope": scope,
|
|
319
|
+
"include_visual": include_visual,
|
|
320
|
+
"token_budget": token_budget,
|
|
321
|
+
},
|
|
322
|
+
domain_logic_payload=domain_payload,
|
|
323
|
+
token_budget=token_budget,
|
|
324
|
+
)
|
|
325
|
+
return {
|
|
326
|
+
"tool": "workflow_path",
|
|
327
|
+
"source": source_seed,
|
|
328
|
+
"target": target_seed,
|
|
329
|
+
"path": path,
|
|
330
|
+
"workflow_slice": workflow_slice,
|
|
331
|
+
"guardrail": (
|
|
332
|
+
"A missing path means no static call path was modeled in the current "
|
|
333
|
+
"artifact; it does not prove runtime disconnection."
|
|
334
|
+
),
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
@server.tool()
|
|
338
|
+
def snapshot_slice(
|
|
339
|
+
slice_id: str | None = None,
|
|
340
|
+
flow_ids: list[str] | None = None,
|
|
341
|
+
format: str = "svg",
|
|
342
|
+
include_svg: bool = True,
|
|
343
|
+
token_budget: int = 900,
|
|
344
|
+
) -> dict[str, Any]:
|
|
345
|
+
"""Render a deterministic visual snapshot for a workflow slice."""
|
|
346
|
+
if format not in SNAPSHOT_FORMATS:
|
|
347
|
+
return unsupported_snapshot_format(format)
|
|
348
|
+
model, error = _try_load(project_root, active_config)
|
|
349
|
+
if error is not None:
|
|
350
|
+
return error
|
|
351
|
+
assert model is not None
|
|
352
|
+
normalized_flow_ids = _known_flow_ids(model, flow_ids)
|
|
353
|
+
if not normalized_flow_ids:
|
|
354
|
+
return _slice_target_error(
|
|
355
|
+
"snapshot_slice",
|
|
356
|
+
"slice_targets_missing",
|
|
357
|
+
"snapshot_slice requires at least one known flow_id.",
|
|
358
|
+
slice_id=slice_id,
|
|
359
|
+
flow_ids=flow_ids,
|
|
360
|
+
)
|
|
361
|
+
snapshot = render_subgraph_snapshot(
|
|
362
|
+
model,
|
|
363
|
+
flow_ids=normalized_flow_ids,
|
|
364
|
+
max_flows=_snapshot_flow_budget(token_budget),
|
|
365
|
+
max_nodes=_snapshot_node_budget(token_budget),
|
|
366
|
+
)
|
|
367
|
+
canonical_visual = _workflow_canonical_visual(
|
|
368
|
+
model,
|
|
369
|
+
normalized_flow_ids,
|
|
370
|
+
token_budget,
|
|
371
|
+
)
|
|
372
|
+
artifact = _write_snapshot_artifact(
|
|
373
|
+
project_root,
|
|
374
|
+
snapshot,
|
|
375
|
+
slice_id=slice_id,
|
|
376
|
+
flow_ids=normalized_flow_ids,
|
|
377
|
+
canonical_visual=canonical_visual,
|
|
378
|
+
write_svg=include_svg,
|
|
379
|
+
)
|
|
380
|
+
snapshot_payload = dict(snapshot)
|
|
381
|
+
if not include_svg and "svg" in snapshot_payload:
|
|
382
|
+
snapshot_payload["svg_omitted"] = True
|
|
383
|
+
snapshot_payload["svg_omitted_reason"] = "include_svg=false"
|
|
384
|
+
snapshot_payload["svg_byte_size"] = _snapshot_svg_byte_size(snapshot)
|
|
385
|
+
snapshot_payload.pop("svg", None)
|
|
386
|
+
return {
|
|
387
|
+
"tool": "snapshot_slice",
|
|
388
|
+
"slice_id": slice_id,
|
|
389
|
+
"format": format,
|
|
390
|
+
"flow_ids": normalized_flow_ids,
|
|
391
|
+
"snapshot": snapshot_payload,
|
|
392
|
+
"canonical_visual": canonical_visual,
|
|
393
|
+
"artifact": artifact,
|
|
394
|
+
"viewer_targets": _workflow_viewer_targets(
|
|
395
|
+
model,
|
|
396
|
+
normalized_flow_ids,
|
|
397
|
+
),
|
|
398
|
+
"guardrail": (
|
|
399
|
+
"Snapshots are deterministic visual context for the selected slice. "
|
|
400
|
+
"Omission counts must be preserved when explaining large slices. "
|
|
401
|
+
"For chat answers, prefer the returned canonical_visual Mermaid or "
|
|
402
|
+
"artifact mermaid_path/mermaid_markdown_path. Use SVG paths only when "
|
|
403
|
+
"the user explicitly asks for the SVG snapshot or for local inspection."
|
|
404
|
+
),
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
@server.tool()
|
|
408
|
+
def explain_flow(flow_id: str, token_budget: int = 900) -> dict[str, Any]:
|
|
409
|
+
"""Explain one flow with source anchors, decisions, calls, and next tools."""
|
|
410
|
+
model, error = _try_load(project_root, active_config)
|
|
411
|
+
if error is not None:
|
|
412
|
+
return error
|
|
413
|
+
assert model is not None
|
|
414
|
+
flow = _flow_by_id(model, flow_id)
|
|
415
|
+
if flow is None:
|
|
416
|
+
return _unknown_target_error("flow", flow_id)
|
|
417
|
+
return _focused_flow_explanation(model, flow, token_budget)
|
|
418
|
+
|
|
419
|
+
@server.tool()
|
|
420
|
+
def explain_node(
|
|
421
|
+
node_id: str,
|
|
422
|
+
flow_id: str | None = None,
|
|
423
|
+
token_budget: int = 900,
|
|
424
|
+
) -> dict[str, Any]:
|
|
425
|
+
"""Explain one flowchart node with local edge and source context."""
|
|
426
|
+
model, error = _try_load(project_root, active_config)
|
|
427
|
+
if error is not None:
|
|
428
|
+
return error
|
|
429
|
+
assert model is not None
|
|
430
|
+
resolved = _resolve_node(model, node_id, flow_id)
|
|
431
|
+
if resolved is None:
|
|
432
|
+
return _unknown_target_error("node", node_id)
|
|
433
|
+
flow, node = resolved
|
|
434
|
+
return _focused_node_explanation(model, flow, node, token_budget)
|
|
435
|
+
|
|
436
|
+
@server.tool()
|
|
437
|
+
def explain_edge(
|
|
438
|
+
edge_id: str,
|
|
439
|
+
flow_id: str | None = None,
|
|
440
|
+
token_budget: int = 900,
|
|
441
|
+
) -> dict[str, Any]:
|
|
442
|
+
"""Explain one flowchart edge or modeled call edge with source context."""
|
|
443
|
+
model, error = _try_load(project_root, active_config)
|
|
444
|
+
if error is not None:
|
|
445
|
+
return error
|
|
446
|
+
assert model is not None
|
|
447
|
+
resolved = _resolve_edge(model, edge_id, flow_id)
|
|
448
|
+
if resolved is None:
|
|
449
|
+
return _unknown_target_error("edge", edge_id)
|
|
450
|
+
flow, edge = resolved
|
|
451
|
+
return _focused_edge_explanation(model, flow, edge, token_budget)
|
|
452
|
+
|
|
453
|
+
@server.tool()
|
|
454
|
+
def validate_artifacts(
|
|
455
|
+
check_sync: bool = False,
|
|
456
|
+
include_quality: bool = False,
|
|
457
|
+
max_skipped_files: int | None = None,
|
|
458
|
+
max_parse_warnings: int | None = None,
|
|
459
|
+
min_call_resolution: float | None = None,
|
|
460
|
+
max_generic_label_ratio: float | None = None,
|
|
461
|
+
) -> dict[str, Any]:
|
|
462
|
+
"""Validate the generated model and optionally check source sync."""
|
|
463
|
+
thresholds: dict[str, float | int] = {}
|
|
464
|
+
if max_skipped_files is not None:
|
|
465
|
+
thresholds["max_skipped_files"] = max_skipped_files
|
|
466
|
+
if max_parse_warnings is not None:
|
|
467
|
+
thresholds["max_parse_warnings"] = max_parse_warnings
|
|
468
|
+
if min_call_resolution is not None:
|
|
469
|
+
thresholds["min_call_resolution"] = min_call_resolution
|
|
470
|
+
if max_generic_label_ratio is not None:
|
|
471
|
+
thresholds["max_generic_label_ratio"] = max_generic_label_ratio
|
|
472
|
+
report = validate_codedebrief(
|
|
473
|
+
project_root,
|
|
474
|
+
config=active_config,
|
|
475
|
+
check_sync=check_sync,
|
|
476
|
+
include_quality=include_quality,
|
|
477
|
+
quality_thresholds=thresholds,
|
|
478
|
+
)
|
|
479
|
+
return _validation_payload(report.to_dict())
|
|
480
|
+
|
|
481
|
+
@server.tool()
|
|
482
|
+
def update_codedebrief(full: bool = False) -> dict[str, Any]:
|
|
483
|
+
"""Refresh CodeDebrief after source changes and write JSON, Markdown, and HTML."""
|
|
484
|
+
result = ProjectAnalyzer(project_root, active_config).analyze(full=full)
|
|
485
|
+
json_path, markdown_path, html_path = write_artifacts(
|
|
486
|
+
project_root,
|
|
487
|
+
result.model,
|
|
488
|
+
config=active_config,
|
|
489
|
+
)
|
|
490
|
+
return {
|
|
491
|
+
"changed_files": result.changed_files,
|
|
492
|
+
"deleted_files": result.deleted_files,
|
|
493
|
+
"cache_hits": result.cache_hits,
|
|
494
|
+
"flows": len(result.model.flows),
|
|
495
|
+
"artifacts": [
|
|
496
|
+
str(json_path),
|
|
497
|
+
str(markdown_path),
|
|
498
|
+
str(html_path) if html_path else "",
|
|
499
|
+
],
|
|
500
|
+
**_update_workflow_payload(json_path, markdown_path, html_path),
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
server.run(transport="stdio")
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _impact_flow_summary(flow: Any, impact_reasons: dict[str, list[str]]) -> dict[str, Any]:
|
|
507
|
+
return {
|
|
508
|
+
**flow_summary(flow),
|
|
509
|
+
"reasons": impact_reasons.get(flow.id, []),
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _selection_context_payload(
|
|
514
|
+
root: Path,
|
|
515
|
+
config: CodeDebriefConfig,
|
|
516
|
+
model: ProjectModel,
|
|
517
|
+
*,
|
|
518
|
+
question: str | None = None,
|
|
519
|
+
changed_files: list[str] | None = None,
|
|
520
|
+
scope: str | None = None,
|
|
521
|
+
flow_ids: list[str] | None = None,
|
|
522
|
+
symbols: list[str] | None = None,
|
|
523
|
+
dependency_paths: list[str] | None = None,
|
|
524
|
+
language: str | None = None,
|
|
525
|
+
source_path: str | None = None,
|
|
526
|
+
domain: str | None = None,
|
|
527
|
+
value: str | None = None,
|
|
528
|
+
include_visual: bool = False,
|
|
529
|
+
token_budget: int = 600,
|
|
530
|
+
visual_byte_budget: int = _DEFAULT_CONTEXT_VISUAL_BYTE_BUDGET,
|
|
531
|
+
) -> dict[str, Any]:
|
|
532
|
+
scope_filter, scope_query_hint = _agent_scope_filter(model, scope)
|
|
533
|
+
effective_question = _question_with_scope_hint(question, scope_query_hint)
|
|
534
|
+
changes = _impact_changed_files(root, changed_files, flow_ids, symbols, dependency_paths)
|
|
535
|
+
impact = impact_model(
|
|
536
|
+
model,
|
|
537
|
+
changes,
|
|
538
|
+
scope_filter,
|
|
539
|
+
flow_ids=flow_ids,
|
|
540
|
+
symbols=symbols,
|
|
541
|
+
dependency_paths=dependency_paths,
|
|
542
|
+
)
|
|
543
|
+
query_filters = {
|
|
544
|
+
key: val
|
|
545
|
+
for key, val in {
|
|
546
|
+
"language": language,
|
|
547
|
+
"source_path": source_path,
|
|
548
|
+
"domain": domain,
|
|
549
|
+
"value": value,
|
|
550
|
+
}.items()
|
|
551
|
+
if val is not None
|
|
552
|
+
}
|
|
553
|
+
if scope_filter is not None:
|
|
554
|
+
query_filters["scope"] = scope_filter
|
|
555
|
+
if scope_query_hint is not None:
|
|
556
|
+
query_filters["scope_query_hint"] = scope_query_hint
|
|
557
|
+
matches_by_id = {
|
|
558
|
+
match.flow.id: match
|
|
559
|
+
for match in query_model(
|
|
560
|
+
model,
|
|
561
|
+
effective_question or " ".join(changes),
|
|
562
|
+
limit=80,
|
|
563
|
+
scope=scope_filter,
|
|
564
|
+
language=language,
|
|
565
|
+
source_path=source_path,
|
|
566
|
+
domain=domain,
|
|
567
|
+
value=value,
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
for action_term in sorted(_agent_action_terms(effective_question)):
|
|
571
|
+
for match in query_model(
|
|
572
|
+
model,
|
|
573
|
+
action_term,
|
|
574
|
+
limit=12,
|
|
575
|
+
scope=scope_filter,
|
|
576
|
+
language=language,
|
|
577
|
+
source_path=source_path,
|
|
578
|
+
domain=domain,
|
|
579
|
+
value=value,
|
|
580
|
+
):
|
|
581
|
+
matches_by_id.setdefault(match.flow.id, match)
|
|
582
|
+
matches = _agent_order_matches(list(matches_by_id.values()), effective_question)[:8]
|
|
583
|
+
return {
|
|
584
|
+
"query_filters": query_filters,
|
|
585
|
+
"query": _cap([match.to_dict() for match in matches], token_budget),
|
|
586
|
+
"impact": {
|
|
587
|
+
"changed_files": impact.changed_files,
|
|
588
|
+
"target_flow_ids": impact.target_flow_ids,
|
|
589
|
+
"target_symbols": impact.target_symbols,
|
|
590
|
+
"target_dependency_paths": impact.target_dependency_paths,
|
|
591
|
+
"unresolved_targets": impact.unresolved_targets,
|
|
592
|
+
"impact_reasons": impact.impact_reasons,
|
|
593
|
+
"direct": _cap(
|
|
594
|
+
[
|
|
595
|
+
_impact_flow_summary(item, impact.impact_reasons)
|
|
596
|
+
for item in impact.directly_impacted
|
|
597
|
+
],
|
|
598
|
+
token_budget,
|
|
599
|
+
),
|
|
600
|
+
"transitive": _cap(
|
|
601
|
+
[
|
|
602
|
+
_impact_flow_summary(item, impact.impact_reasons)
|
|
603
|
+
for item in impact.transitively_impacted
|
|
604
|
+
],
|
|
605
|
+
token_budget,
|
|
606
|
+
),
|
|
607
|
+
"subgraph_flow_ids": impact.subgraph_flow_ids,
|
|
608
|
+
},
|
|
609
|
+
"navigation": _context_navigation_pack(
|
|
610
|
+
model,
|
|
611
|
+
impact=impact,
|
|
612
|
+
matches=matches,
|
|
613
|
+
token_budget=token_budget,
|
|
614
|
+
),
|
|
615
|
+
"visual_context": _context_visual_pack(
|
|
616
|
+
model,
|
|
617
|
+
impact=impact,
|
|
618
|
+
matches=matches,
|
|
619
|
+
scope=scope_filter,
|
|
620
|
+
include_visual=include_visual,
|
|
621
|
+
token_budget=token_budget,
|
|
622
|
+
visual_byte_budget=visual_byte_budget,
|
|
623
|
+
),
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _workflow_slice_payload(
|
|
628
|
+
model: ProjectModel,
|
|
629
|
+
pack: dict[str, Any],
|
|
630
|
+
*,
|
|
631
|
+
question: str | None,
|
|
632
|
+
inputs: dict[str, Any],
|
|
633
|
+
domain_logic_payload: dict[str, Any],
|
|
634
|
+
token_budget: int,
|
|
635
|
+
) -> dict[str, Any]:
|
|
636
|
+
primary_flow_ids = _workflow_primary_flow_ids(pack)
|
|
637
|
+
selected_flow_ids = _workflow_selected_flow_ids(pack)
|
|
638
|
+
if not primary_flow_ids:
|
|
639
|
+
primary_flow_ids = selected_flow_ids[: _slice_primary_budget(token_budget)]
|
|
640
|
+
supporting_flow_ids = [
|
|
641
|
+
flow_id for flow_id in selected_flow_ids if flow_id not in set(primary_flow_ids)
|
|
642
|
+
][: _slice_supporting_budget(token_budget)]
|
|
643
|
+
visible_flow_ids = _unique_preserve_order([*primary_flow_ids, *supporting_flow_ids])
|
|
644
|
+
slice_id = _workflow_slice_id(model, inputs, visible_flow_ids)
|
|
645
|
+
primary_flows = _workflow_flow_rows(model, primary_flow_ids)
|
|
646
|
+
supporting_flows = _workflow_flow_rows(model, supporting_flow_ids)
|
|
647
|
+
ordered_steps = _workflow_ordered_steps(model, primary_flow_ids, token_budget)
|
|
648
|
+
decisions = _workflow_decisions(model, visible_flow_ids, token_budget)
|
|
649
|
+
viewer_targets = _workflow_viewer_targets(model, visible_flow_ids)
|
|
650
|
+
canonical_visual = _workflow_canonical_visual(
|
|
651
|
+
model,
|
|
652
|
+
visible_flow_ids,
|
|
653
|
+
token_budget,
|
|
654
|
+
)
|
|
655
|
+
next_tools = _workflow_slice_next_tools(
|
|
656
|
+
primary_flow_ids,
|
|
657
|
+
supporting_flow_ids,
|
|
658
|
+
token_budget,
|
|
659
|
+
)
|
|
660
|
+
return {
|
|
661
|
+
"schema_version": "workflow_slice.v1",
|
|
662
|
+
"id": slice_id,
|
|
663
|
+
"model_hash": model_hash(model),
|
|
664
|
+
"intent": {
|
|
665
|
+
"question": question,
|
|
666
|
+
"task_type": _workflow_task_type(question, inputs),
|
|
667
|
+
"include_visual": bool(inputs.get("include_visual")),
|
|
668
|
+
"token_budget": token_budget,
|
|
669
|
+
},
|
|
670
|
+
"handle": {
|
|
671
|
+
"slice_id": slice_id,
|
|
672
|
+
"model_hash": model_hash(model),
|
|
673
|
+
"flow_ids": visible_flow_ids,
|
|
674
|
+
"scope": inputs.get("scope"),
|
|
675
|
+
"question": question,
|
|
676
|
+
},
|
|
677
|
+
"selection": _workflow_selection(pack, inputs, primary_flow_ids, supporting_flow_ids),
|
|
678
|
+
"presentation": _workflow_presentation_contract(
|
|
679
|
+
primary_flows=primary_flows,
|
|
680
|
+
supporting_flows=supporting_flows,
|
|
681
|
+
ordered_steps=ordered_steps,
|
|
682
|
+
decisions=decisions,
|
|
683
|
+
viewer_targets=viewer_targets,
|
|
684
|
+
canonical_visual=canonical_visual,
|
|
685
|
+
next_tools=next_tools,
|
|
686
|
+
),
|
|
687
|
+
"primary_flows": primary_flows,
|
|
688
|
+
"supporting_flows": supporting_flows,
|
|
689
|
+
"ordered_steps": ordered_steps,
|
|
690
|
+
"decisions": decisions,
|
|
691
|
+
"calls": _workflow_calls(model, visible_flow_ids, token_budget),
|
|
692
|
+
"domain_logic": _workflow_domain_logic(
|
|
693
|
+
domain_logic_payload,
|
|
694
|
+
visible_flow_ids,
|
|
695
|
+
token_budget,
|
|
696
|
+
),
|
|
697
|
+
"source_ranges": _workflow_source_ranges(model, visible_flow_ids, pack, token_budget),
|
|
698
|
+
"visuals": _workflow_visuals(pack),
|
|
699
|
+
"viewer_targets": viewer_targets,
|
|
700
|
+
"omissions": _workflow_omissions(pack, selected_flow_ids, visible_flow_ids, token_budget),
|
|
701
|
+
"next_actions": _workflow_next_actions(primary_flow_ids, supporting_flow_ids),
|
|
702
|
+
"next_tools": next_tools,
|
|
703
|
+
"guardrail": (
|
|
704
|
+
"workflow_slice is deterministic, local, and source-grounded. Human-friendly "
|
|
705
|
+
"labels can be generated on demand as a separate presentation layer."
|
|
706
|
+
),
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _workflow_slice_id(
|
|
711
|
+
model: ProjectModel,
|
|
712
|
+
inputs: dict[str, Any],
|
|
713
|
+
flow_ids: list[str],
|
|
714
|
+
) -> str:
|
|
715
|
+
payload = {
|
|
716
|
+
"model_hash": model_hash(model),
|
|
717
|
+
"inputs": inputs,
|
|
718
|
+
"flow_ids": flow_ids,
|
|
719
|
+
}
|
|
720
|
+
raw = json.dumps(payload, sort_keys=True, default=str, separators=(",", ":"))
|
|
721
|
+
return f"slice-{hashlib.sha256(raw.encode('utf-8')).hexdigest()[:16]}"
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _workflow_presentation_contract(
|
|
725
|
+
*,
|
|
726
|
+
primary_flows: list[dict[str, Any]],
|
|
727
|
+
supporting_flows: list[dict[str, Any]],
|
|
728
|
+
ordered_steps: list[dict[str, Any]],
|
|
729
|
+
decisions: list[dict[str, Any]],
|
|
730
|
+
viewer_targets: dict[str, Any],
|
|
731
|
+
canonical_visual: dict[str, Any],
|
|
732
|
+
next_tools: dict[str, Any],
|
|
733
|
+
) -> dict[str, Any]:
|
|
734
|
+
primary_names = [str(flow.get("name")) for flow in primary_flows if flow.get("name")]
|
|
735
|
+
title = "Workflow slice"
|
|
736
|
+
if primary_names:
|
|
737
|
+
title = f"Workflow slice: {', '.join(primary_names[:2])}"
|
|
738
|
+
return {
|
|
739
|
+
"schema_version": "workflow_slice.presentation.v1",
|
|
740
|
+
"title": title,
|
|
741
|
+
"counts": {
|
|
742
|
+
"primary_flows": len(primary_flows),
|
|
743
|
+
"supporting_flows": len(supporting_flows),
|
|
744
|
+
"ordered_steps": len(ordered_steps),
|
|
745
|
+
"decisions": len(decisions),
|
|
746
|
+
"viewer_targets": viewer_targets.get("target_count", 0),
|
|
747
|
+
"canonical_visual_nodes": canonical_visual.get("node_count", 0),
|
|
748
|
+
"canonical_visual_edges": canonical_visual.get("edge_count", 0),
|
|
749
|
+
},
|
|
750
|
+
"default_sections": [
|
|
751
|
+
{"label": "Slice Identity", "source_fields": ["id", "model_hash", "handle"]},
|
|
752
|
+
{
|
|
753
|
+
"label": "Canonical Visual",
|
|
754
|
+
"source_fields": ["presentation.canonical_visual"],
|
|
755
|
+
},
|
|
756
|
+
{"label": "Primary Flows", "source_fields": ["primary_flows"]},
|
|
757
|
+
{"label": "Supporting Flows", "source_fields": ["supporting_flows"]},
|
|
758
|
+
{"label": "Ordered Steps", "source_fields": ["ordered_steps"]},
|
|
759
|
+
{"label": "Decision Nodes", "source_fields": ["decisions"]},
|
|
760
|
+
{"label": "Visual Targets", "source_fields": ["viewer_targets", "next_tools"]},
|
|
761
|
+
],
|
|
762
|
+
"agent_guidance": [
|
|
763
|
+
"When the user asks to show a workflow_slice or workflow, render "
|
|
764
|
+
"presentation.canonical_visual.diagram as-is before prose.",
|
|
765
|
+
"The agent may choose the visible depth and branches by asking for a "
|
|
766
|
+
"narrower or expanded slice, but block contents must stay grounded in "
|
|
767
|
+
"this payload.",
|
|
768
|
+
"Tell the user the shown diagram is a bounded summary of the selected "
|
|
769
|
+
"logic and can be expanded.",
|
|
770
|
+
"End visual answers with concise follow-up choices: simplify labels in "
|
|
771
|
+
"the user's language, expand omitted nodes or branches, or explore a "
|
|
772
|
+
"related area.",
|
|
773
|
+
"A separate human-friendly translation may rewrite labels in the user's "
|
|
774
|
+
"language only from returned node, edge, source, and decision fields.",
|
|
775
|
+
"Use ordered_steps as the canonical walkthrough and keep source anchors visible.",
|
|
776
|
+
"Keep flow_id and node_id values visible so the slice can be expanded.",
|
|
777
|
+
"Do not invent steps, constants, limits, error codes, or branches outside "
|
|
778
|
+
"this payload.",
|
|
779
|
+
"Show raw JSON or YAML only when the user explicitly asks for raw output.",
|
|
780
|
+
],
|
|
781
|
+
"depth_policy": {
|
|
782
|
+
"summary": (
|
|
783
|
+
"This is a bounded workflow_slice selected for the request. Use the "
|
|
784
|
+
"slice handle and recommended_next_tools to deepen, widen, or trace "
|
|
785
|
+
"specific paths instead of manually inventing omitted branches."
|
|
786
|
+
),
|
|
787
|
+
"agent_role": (
|
|
788
|
+
"Choose the amount of detail to display for the user's question, but "
|
|
789
|
+
"derive each displayed block from canonical_visual, ordered_steps, "
|
|
790
|
+
"decisions, source_ranges, or focused explain_* tool results."
|
|
791
|
+
),
|
|
792
|
+
},
|
|
793
|
+
"display_policy": {
|
|
794
|
+
"source_extraction": (
|
|
795
|
+
"Use CodeDebrief as the deterministic source for the workflow requested "
|
|
796
|
+
"by the user. Inspect the returned workflow_slice first; if relevant "
|
|
797
|
+
"nodes, branches, callers, callees, or paths are missing, use "
|
|
798
|
+
"recommended_next_tools before answering."
|
|
799
|
+
),
|
|
800
|
+
"first_response": (
|
|
801
|
+
"Show the clearest useful subset of the selected workflow. The agent "
|
|
802
|
+
"may omit low-signal implementation nodes from the first visible graph, "
|
|
803
|
+
"but it must preserve all displayed facts exactly from the selected "
|
|
804
|
+
"workflow_slice or focused follow-up tool payloads."
|
|
805
|
+
),
|
|
806
|
+
"closing_options": [
|
|
807
|
+
"Offer a language-friendly rewrite of the graph labels in the user's language.",
|
|
808
|
+
"Offer to expand the diagram with omitted nodes, branches, or adjacent flows.",
|
|
809
|
+
"Offer to explore another related area or deepen a specific path.",
|
|
810
|
+
],
|
|
811
|
+
},
|
|
812
|
+
"label_policy": {
|
|
813
|
+
"canonical": (
|
|
814
|
+
"For stable output, render canonical_visual.diagram exactly and keep "
|
|
815
|
+
"diagram_hash when useful."
|
|
816
|
+
),
|
|
817
|
+
"human_friendly": (
|
|
818
|
+
"A human-friendly translation may replace technical labels with "
|
|
819
|
+
"clearer wording in the language used by the user, only as a separate "
|
|
820
|
+
"presentation layer. Preserve ids or source anchors and do not add "
|
|
821
|
+
"facts absent from the workflow_slice payload."
|
|
822
|
+
),
|
|
823
|
+
},
|
|
824
|
+
"media_policy": {
|
|
825
|
+
"mermaid_canonical": (
|
|
826
|
+
"Use canonical_visual.diagram as the default chat visual. It is the "
|
|
827
|
+
"top-to-bottom Mermaid source of truth for repeated workflow answers. "
|
|
828
|
+
"When the client cannot render Mermaid inline, or Mermaid would appear as "
|
|
829
|
+
"a raw code block, call snapshot_slice with include_svg=false and provide "
|
|
830
|
+
"artifact.mermaid_path, artifact.mermaid_markdown_path, or "
|
|
831
|
+
"artifact.mermaid_open_command instead of pasting the Mermaid source as "
|
|
832
|
+
"the primary visual."
|
|
833
|
+
),
|
|
834
|
+
"svg_snapshot": (
|
|
835
|
+
"Use snapshot SVG artifacts only for explicit SVG requests or local "
|
|
836
|
+
"inspection. The SVG renderer is deterministic but is not the canonical "
|
|
837
|
+
"chat visual and may lay out text differently from Mermaid."
|
|
838
|
+
),
|
|
839
|
+
"manual_viewer": (
|
|
840
|
+
"Keep codedebrief view as the interactive manual UI; do not replace it "
|
|
841
|
+
"with a static Mermaid or screenshot-only experience."
|
|
842
|
+
),
|
|
843
|
+
},
|
|
844
|
+
"visual_guidance": (
|
|
845
|
+
"If the user asks to visualize the slice, render "
|
|
846
|
+
"presentation.canonical_visual.diagram exactly only when the client renders "
|
|
847
|
+
"Mermaid inline. If Mermaid would appear as raw code, call snapshot_slice with "
|
|
848
|
+
"include_svg=false to persist Mermaid artifacts, then provide "
|
|
849
|
+
"artifact.mermaid_path, artifact.mermaid_markdown_path, or "
|
|
850
|
+
"artifact.mermaid_open_command before prose. Do not paste a long Mermaid code "
|
|
851
|
+
"block as the primary visual unless the user asks for raw or copyable Mermaid. "
|
|
852
|
+
"Do not render snapshot.svg inline by default; use SVG only when explicitly "
|
|
853
|
+
"requested or for local inspection. Do not synthesize a new Mermaid diagram. "
|
|
854
|
+
"Explain that the diagram is a bounded summary and can be expanded. End with "
|
|
855
|
+
"options to simplify labels in the user's language, expand the graph with "
|
|
856
|
+
"omitted details, or explore a related area. Open viewer_targets with "
|
|
857
|
+
"codedebrief view for manual inspection."
|
|
858
|
+
),
|
|
859
|
+
"canonical_visual": canonical_visual,
|
|
860
|
+
"recommended_next_tools": {
|
|
861
|
+
key: value
|
|
862
|
+
for key, value in next_tools.items()
|
|
863
|
+
if key in {"expand_slice", "snapshot_slice", "workflow_path"}
|
|
864
|
+
},
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _workflow_canonical_visual(
|
|
869
|
+
model: ProjectModel,
|
|
870
|
+
flow_ids: list[str],
|
|
871
|
+
token_budget: int,
|
|
872
|
+
) -> dict[str, Any]:
|
|
873
|
+
flows = [cast(Flow, flow) for flow in _flows_by_ids(model, flow_ids)]
|
|
874
|
+
node_budget = max(12, _slice_item_budget(token_budget))
|
|
875
|
+
lines = ["flowchart TD", ' subgraph workflow_slice["workflow_slice"]', " direction TB"]
|
|
876
|
+
rendered_nodes: set[str] = set()
|
|
877
|
+
flow_node_ids: dict[str, list[str]] = {}
|
|
878
|
+
rendered_flow_ids: list[str] = []
|
|
879
|
+
omitted_nodes = 0
|
|
880
|
+
omitted_edges = 0
|
|
881
|
+
edge_count = 0
|
|
882
|
+
layout_constraint_count = 0
|
|
883
|
+
|
|
884
|
+
if not flows:
|
|
885
|
+
lines.append(' empty["No modeled flows selected for this workflow_slice"]')
|
|
886
|
+
|
|
887
|
+
for flow in flows:
|
|
888
|
+
flow_node_ids[flow.id] = []
|
|
889
|
+
if len(rendered_nodes) >= node_budget:
|
|
890
|
+
omitted_nodes += len(flow.nodes)
|
|
891
|
+
continue
|
|
892
|
+
rendered_flow_ids.append(flow.id)
|
|
893
|
+
lines.append(
|
|
894
|
+
f" subgraph {_workflow_mermaid_id(f'flow:{flow.id}')}"
|
|
895
|
+
f'["{_workflow_mermaid_label(flow.name)}"]'
|
|
896
|
+
)
|
|
897
|
+
lines.append(" direction TB")
|
|
898
|
+
if not flow.nodes:
|
|
899
|
+
summary_id = _workflow_mermaid_id(f"{flow.id}:summary")
|
|
900
|
+
lines.append(f' {summary_id}["{_workflow_mermaid_label(flow.name)}"]')
|
|
901
|
+
flow_node_ids[flow.id].append(summary_id)
|
|
902
|
+
for node in flow.nodes:
|
|
903
|
+
if len(rendered_nodes) >= node_budget:
|
|
904
|
+
omitted_nodes += 1
|
|
905
|
+
continue
|
|
906
|
+
node_id = _workflow_mermaid_id(node.id)
|
|
907
|
+
lines.append(f" {_workflow_mermaid_node(node, node_id)}")
|
|
908
|
+
rendered_nodes.add(node.id)
|
|
909
|
+
flow_node_ids[flow.id].append(node_id)
|
|
910
|
+
for edge in flow.edges:
|
|
911
|
+
if edge.source in rendered_nodes and edge.target in rendered_nodes:
|
|
912
|
+
lines.append(f" {_workflow_mermaid_edge(edge)}")
|
|
913
|
+
edge_count += 1
|
|
914
|
+
else:
|
|
915
|
+
omitted_edges += 1
|
|
916
|
+
lines.append(" end")
|
|
917
|
+
|
|
918
|
+
flows_by_id = {flow.id: flow for flow in flows}
|
|
919
|
+
for flow in flows:
|
|
920
|
+
source_nodes = flow_node_ids.get(flow.id, [])
|
|
921
|
+
if not source_nodes:
|
|
922
|
+
continue
|
|
923
|
+
for target_id in flow.calls:
|
|
924
|
+
target = flows_by_id.get(target_id)
|
|
925
|
+
target_nodes = flow_node_ids.get(target_id, [])
|
|
926
|
+
if target is None or not target_nodes:
|
|
927
|
+
omitted_edges += 1
|
|
928
|
+
continue
|
|
929
|
+
lines.append(
|
|
930
|
+
f" {source_nodes[-1]} -->"
|
|
931
|
+
f'|"{_workflow_mermaid_label(f"calls {target.name}", 64)}"| {target_nodes[0]}'
|
|
932
|
+
)
|
|
933
|
+
edge_count += 1
|
|
934
|
+
|
|
935
|
+
for previous_flow_id, next_flow_id in pairwise(rendered_flow_ids):
|
|
936
|
+
previous_nodes = flow_node_ids.get(previous_flow_id, [])
|
|
937
|
+
next_nodes = flow_node_ids.get(next_flow_id, [])
|
|
938
|
+
if not previous_nodes or not next_nodes:
|
|
939
|
+
continue
|
|
940
|
+
lines.append(f" {previous_nodes[-1]} ~~~ {next_nodes[0]}")
|
|
941
|
+
layout_constraint_count += 1
|
|
942
|
+
|
|
943
|
+
lines.append(" end")
|
|
944
|
+
diagram = "\n".join(lines)
|
|
945
|
+
return {
|
|
946
|
+
"schema_version": "workflow_slice.canonical_visual.v1",
|
|
947
|
+
"format": "mermaid",
|
|
948
|
+
"diagram": diagram,
|
|
949
|
+
"diagram_hash": hashlib.sha256(diagram.encode("utf-8")).hexdigest()[:16],
|
|
950
|
+
"source": "codedebrief graph nodes and edges",
|
|
951
|
+
"source_fields": [
|
|
952
|
+
"primary_flows",
|
|
953
|
+
"supporting_flows",
|
|
954
|
+
"ordered_steps",
|
|
955
|
+
"decisions",
|
|
956
|
+
"source_ranges",
|
|
957
|
+
],
|
|
958
|
+
"flow_ids": rendered_flow_ids,
|
|
959
|
+
"node_count": len(rendered_nodes),
|
|
960
|
+
"edge_count": edge_count,
|
|
961
|
+
"layout": {
|
|
962
|
+
"direction": "top_to_bottom",
|
|
963
|
+
"flow_direction": "top_to_bottom",
|
|
964
|
+
"flow_grouping": "vertical_parent_subgraph",
|
|
965
|
+
"constraint_count": layout_constraint_count,
|
|
966
|
+
"constraint_edge": "invisible_mermaid_link",
|
|
967
|
+
},
|
|
968
|
+
"omissions": {
|
|
969
|
+
"node_budget": node_budget,
|
|
970
|
+
"omitted_node_count": omitted_nodes,
|
|
971
|
+
"omitted_edge_count": omitted_edges,
|
|
972
|
+
},
|
|
973
|
+
"guardrail": (
|
|
974
|
+
"Render this diagram as-is when a text Mermaid fallback is needed. It is "
|
|
975
|
+
"derived from deterministic graph nodes and edges; do not add inferred "
|
|
976
|
+
"limits, error codes, branches, or service steps that are absent from the "
|
|
977
|
+
"workflow_slice payload. Invisible Mermaid links are layout constraints only. "
|
|
978
|
+
"Use a separate human-friendly view in the user's language if labels need "
|
|
979
|
+
"translation."
|
|
980
|
+
),
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _workflow_mermaid_node(node: FlowNode, node_id: str) -> str:
|
|
985
|
+
label = _workflow_mermaid_label(node.label)
|
|
986
|
+
if node.kind is NodeKind.DECISION:
|
|
987
|
+
return f'{node_id}{{"{label}"}}'
|
|
988
|
+
if node.kind is NodeKind.CALL:
|
|
989
|
+
return f'{node_id}[["{label}"]]'
|
|
990
|
+
if node.kind is NodeKind.ERROR:
|
|
991
|
+
return f'{node_id}{{{{"{label}"}}}}'
|
|
992
|
+
if node.kind in {NodeKind.ENTRY, NodeKind.TERMINAL}:
|
|
993
|
+
return f'{node_id}(["{label}"])'
|
|
994
|
+
return f'{node_id}["{label}"]'
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _workflow_mermaid_edge(edge: FlowEdge) -> str:
|
|
998
|
+
source = _workflow_mermaid_id(edge.source)
|
|
999
|
+
target = _workflow_mermaid_id(edge.target)
|
|
1000
|
+
label = f'|"{_workflow_mermaid_label(edge.label, 64)}"|' if edge.label else ""
|
|
1001
|
+
return f"{source} -->{label} {target}"
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _workflow_mermaid_id(value: str) -> str:
|
|
1005
|
+
return "m" + "".join(character if character.isalnum() else "_" for character in value)
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _workflow_mermaid_label(value: str, limit: int = 96) -> str:
|
|
1009
|
+
normalized = re.sub(r"\s+", " ", value).strip()
|
|
1010
|
+
if len(normalized) > limit:
|
|
1011
|
+
normalized = normalized[: max(0, limit - 3)].rstrip() + "..."
|
|
1012
|
+
return (
|
|
1013
|
+
normalized.replace("&", "&")
|
|
1014
|
+
.replace("\\", "\\\\")
|
|
1015
|
+
.replace('"', """)
|
|
1016
|
+
.replace("<", "<")
|
|
1017
|
+
.replace(">", ">")
|
|
1018
|
+
.replace("|", "/")
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _workflow_primary_flow_ids(pack: dict[str, Any]) -> list[str]:
|
|
1023
|
+
impact = pack.get("impact")
|
|
1024
|
+
direct = _list_dicts(impact.get("direct")) if isinstance(impact, dict) else []
|
|
1025
|
+
if direct:
|
|
1026
|
+
return _unique_preserve_order(str(item["id"]) for item in direct if item.get("id"))
|
|
1027
|
+
query = _list_dicts(pack.get("query"))
|
|
1028
|
+
return _unique_preserve_order(str(item["flow_id"]) for item in query if item.get("flow_id"))[:1]
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _workflow_selected_flow_ids(pack: dict[str, Any]) -> list[str]:
|
|
1032
|
+
ids: list[str] = []
|
|
1033
|
+
impact = pack.get("impact")
|
|
1034
|
+
if isinstance(impact, dict):
|
|
1035
|
+
for key in ("direct", "transitive"):
|
|
1036
|
+
ids.extend(str(item["id"]) for item in _list_dicts(impact.get(key)) if item.get("id"))
|
|
1037
|
+
ids.extend(_string_list(impact.get("subgraph_flow_ids")))
|
|
1038
|
+
ids.extend(
|
|
1039
|
+
str(item["flow_id"]) for item in _list_dicts(pack.get("query")) if item.get("flow_id")
|
|
1040
|
+
)
|
|
1041
|
+
navigation = pack.get("navigation")
|
|
1042
|
+
if isinstance(navigation, dict):
|
|
1043
|
+
for item in _list_dicts(navigation.get("flows")):
|
|
1044
|
+
flow = item.get("flow")
|
|
1045
|
+
if isinstance(flow, dict) and flow.get("id"):
|
|
1046
|
+
ids.append(str(flow["id"]))
|
|
1047
|
+
return _unique_preserve_order(ids)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _workflow_selection(
|
|
1051
|
+
pack: dict[str, Any],
|
|
1052
|
+
inputs: dict[str, Any],
|
|
1053
|
+
primary_flow_ids: list[str],
|
|
1054
|
+
supporting_flow_ids: list[str],
|
|
1055
|
+
) -> dict[str, Any]:
|
|
1056
|
+
query = _list_dicts(pack.get("query"))
|
|
1057
|
+
reasons = []
|
|
1058
|
+
for item in query[:5]:
|
|
1059
|
+
reasons.append(
|
|
1060
|
+
{
|
|
1061
|
+
"flow_id": item.get("flow_id"),
|
|
1062
|
+
"score": item.get("score"),
|
|
1063
|
+
"reasons": item.get("reasons", []),
|
|
1064
|
+
}
|
|
1065
|
+
)
|
|
1066
|
+
return {
|
|
1067
|
+
"inputs": inputs,
|
|
1068
|
+
"query_filters": pack.get("query_filters", {}),
|
|
1069
|
+
"primary_flow_ids": primary_flow_ids,
|
|
1070
|
+
"supporting_flow_ids": supporting_flow_ids,
|
|
1071
|
+
"selection_reasons": reasons,
|
|
1072
|
+
"impact_reasons": (pack.get("impact") or {}).get("impact_reasons", {})
|
|
1073
|
+
if isinstance(pack.get("impact"), dict)
|
|
1074
|
+
else {},
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _workflow_flow_rows(model: ProjectModel, flow_ids: list[str]) -> list[dict[str, Any]]:
|
|
1079
|
+
flows = {flow.id: flow for flow in model.flows}
|
|
1080
|
+
rows = []
|
|
1081
|
+
for flow_id in flow_ids:
|
|
1082
|
+
flow = flows.get(flow_id)
|
|
1083
|
+
if flow is None:
|
|
1084
|
+
continue
|
|
1085
|
+
rows.append(
|
|
1086
|
+
{
|
|
1087
|
+
**flow_summary(flow),
|
|
1088
|
+
"symbol": flow.symbol,
|
|
1089
|
+
"is_entrypoint": flow.is_entrypoint,
|
|
1090
|
+
"nodes": len(flow.nodes),
|
|
1091
|
+
"edges": len(flow.edges),
|
|
1092
|
+
"decisions": sum(node.kind is NodeKind.DECISION for node in flow.nodes),
|
|
1093
|
+
"calls": len(flow.calls),
|
|
1094
|
+
"callers": len(flow.called_by),
|
|
1095
|
+
"tests": list(flow.tests),
|
|
1096
|
+
}
|
|
1097
|
+
)
|
|
1098
|
+
return rows
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def _workflow_ordered_steps(
|
|
1102
|
+
model: ProjectModel,
|
|
1103
|
+
flow_ids: list[str],
|
|
1104
|
+
token_budget: int,
|
|
1105
|
+
) -> list[dict[str, Any]]:
|
|
1106
|
+
limit = _slice_item_budget(token_budget)
|
|
1107
|
+
steps: list[dict[str, Any]] = []
|
|
1108
|
+
for flow in _flows_by_ids(model, flow_ids):
|
|
1109
|
+
for index, node in enumerate(flow.nodes, start=1):
|
|
1110
|
+
steps.append(
|
|
1111
|
+
{
|
|
1112
|
+
"flow_id": flow.id,
|
|
1113
|
+
"flow": flow.name,
|
|
1114
|
+
"step_index": index,
|
|
1115
|
+
"node_id": node.id,
|
|
1116
|
+
"kind": _enum_value(node.kind),
|
|
1117
|
+
"label": node.label,
|
|
1118
|
+
"source": _source_anchor(node.location),
|
|
1119
|
+
"evidence": _enum_value(node.evidence),
|
|
1120
|
+
**_node_decision_context(node),
|
|
1121
|
+
}
|
|
1122
|
+
)
|
|
1123
|
+
if len(steps) >= limit:
|
|
1124
|
+
return steps
|
|
1125
|
+
return steps
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _workflow_decisions(
|
|
1129
|
+
model: ProjectModel,
|
|
1130
|
+
flow_ids: list[str],
|
|
1131
|
+
token_budget: int,
|
|
1132
|
+
) -> list[dict[str, Any]]:
|
|
1133
|
+
decisions: list[dict[str, Any]] = []
|
|
1134
|
+
for flow in _flows_by_ids(model, flow_ids):
|
|
1135
|
+
for node in flow.nodes:
|
|
1136
|
+
if node.kind is not NodeKind.DECISION:
|
|
1137
|
+
continue
|
|
1138
|
+
decisions.append(
|
|
1139
|
+
{
|
|
1140
|
+
"flow_id": flow.id,
|
|
1141
|
+
"flow": flow.name,
|
|
1142
|
+
"node_id": node.id,
|
|
1143
|
+
"label": node.label,
|
|
1144
|
+
"source": _source_anchor(node.location),
|
|
1145
|
+
"evidence": _enum_value(node.evidence),
|
|
1146
|
+
**_node_decision_context(node),
|
|
1147
|
+
}
|
|
1148
|
+
)
|
|
1149
|
+
return decisions[: _slice_item_budget(token_budget)]
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _workflow_calls(
|
|
1153
|
+
model: ProjectModel,
|
|
1154
|
+
flow_ids: list[str],
|
|
1155
|
+
token_budget: int,
|
|
1156
|
+
) -> list[dict[str, Any]]:
|
|
1157
|
+
flows = {flow.id: flow for flow in model.flows}
|
|
1158
|
+
calls: list[dict[str, Any]] = []
|
|
1159
|
+
for flow in _flows_by_ids(model, flow_ids):
|
|
1160
|
+
for target_id in flow.calls:
|
|
1161
|
+
target = flows.get(target_id)
|
|
1162
|
+
calls.append(
|
|
1163
|
+
{
|
|
1164
|
+
"source_flow_id": flow.id,
|
|
1165
|
+
"source_flow": flow.name,
|
|
1166
|
+
"target_flow_id": target_id,
|
|
1167
|
+
"target_flow": target.name if target else None,
|
|
1168
|
+
"resolved": target is not None,
|
|
1169
|
+
"confidence": "resolved" if target else "unresolved",
|
|
1170
|
+
"source": _source_anchor(flow.location),
|
|
1171
|
+
}
|
|
1172
|
+
)
|
|
1173
|
+
for caller_id in flow.called_by:
|
|
1174
|
+
caller = flows.get(caller_id)
|
|
1175
|
+
calls.append(
|
|
1176
|
+
{
|
|
1177
|
+
"source_flow_id": caller_id,
|
|
1178
|
+
"source_flow": caller.name if caller else None,
|
|
1179
|
+
"target_flow_id": flow.id,
|
|
1180
|
+
"target_flow": flow.name,
|
|
1181
|
+
"resolved": caller is not None,
|
|
1182
|
+
"confidence": "resolved" if caller else "unresolved",
|
|
1183
|
+
"source": _source_anchor(caller.location if caller else flow.location),
|
|
1184
|
+
"relationship": "caller",
|
|
1185
|
+
}
|
|
1186
|
+
)
|
|
1187
|
+
return calls[: _slice_item_budget(token_budget)]
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def _workflow_domain_logic(
|
|
1191
|
+
domain_logic_payload: dict[str, Any],
|
|
1192
|
+
flow_ids: list[str],
|
|
1193
|
+
token_budget: int,
|
|
1194
|
+
) -> dict[str, Any]:
|
|
1195
|
+
concepts = _list_dicts(domain_logic_payload.get("concepts"))
|
|
1196
|
+
selected = set(flow_ids)
|
|
1197
|
+
if selected:
|
|
1198
|
+
concepts = [
|
|
1199
|
+
concept
|
|
1200
|
+
for concept in concepts
|
|
1201
|
+
if selected.intersection(_string_list(concept.get("subgraph_flow_ids")))
|
|
1202
|
+
]
|
|
1203
|
+
return {
|
|
1204
|
+
"concepts": concepts[: max(1, min(5, _slice_item_budget(token_budget)))],
|
|
1205
|
+
"omitted_concept_count": max(
|
|
1206
|
+
0, len(concepts) - max(1, min(5, _slice_item_budget(token_budget)))
|
|
1207
|
+
),
|
|
1208
|
+
"source": "decision_metadata",
|
|
1209
|
+
"guardrail": domain_logic_payload.get("guardrail"),
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _workflow_source_ranges(
|
|
1214
|
+
model: ProjectModel,
|
|
1215
|
+
flow_ids: list[str],
|
|
1216
|
+
pack: dict[str, Any],
|
|
1217
|
+
token_budget: int,
|
|
1218
|
+
) -> list[dict[str, Any]]:
|
|
1219
|
+
ranges: list[dict[str, Any]] = []
|
|
1220
|
+
seen: set[tuple[str, int, int]] = set()
|
|
1221
|
+
|
|
1222
|
+
def add(location: Any, *, flow_id: str | None = None, node_id: str | None = None) -> None:
|
|
1223
|
+
if not hasattr(location, "path"):
|
|
1224
|
+
return
|
|
1225
|
+
key = (str(location.path), int(location.start_line), int(location.end_line))
|
|
1226
|
+
if key in seen:
|
|
1227
|
+
return
|
|
1228
|
+
seen.add(key)
|
|
1229
|
+
ranges.append(
|
|
1230
|
+
{
|
|
1231
|
+
"path": location.path,
|
|
1232
|
+
"start_line": location.start_line,
|
|
1233
|
+
"end_line": location.end_line,
|
|
1234
|
+
"flow_id": flow_id,
|
|
1235
|
+
"node_id": node_id,
|
|
1236
|
+
}
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
for flow in _flows_by_ids(model, flow_ids):
|
|
1240
|
+
add(flow.location, flow_id=flow.id)
|
|
1241
|
+
for node in flow.nodes:
|
|
1242
|
+
add(node.location, flow_id=flow.id, node_id=node.id)
|
|
1243
|
+
return ranges[: _slice_item_budget(token_budget)]
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _workflow_visuals(pack: dict[str, Any]) -> dict[str, Any]:
|
|
1247
|
+
visual = pack.get("visual_context")
|
|
1248
|
+
if not isinstance(visual, dict):
|
|
1249
|
+
return {"include_visual": False, "next_tools": {}}
|
|
1250
|
+
inline_keys = [
|
|
1251
|
+
key for key in ("impact_snapshot", "subgraph_snapshot", "flow_snapshots") if key in visual
|
|
1252
|
+
]
|
|
1253
|
+
return {
|
|
1254
|
+
"include_visual": visual.get("include_visual", False),
|
|
1255
|
+
"inline_payloads": inline_keys,
|
|
1256
|
+
"format": visual.get("format", "svg"),
|
|
1257
|
+
"snapshot_budget": visual.get("snapshot_budget", {}),
|
|
1258
|
+
"next_tools": visual.get("next_tools", {}),
|
|
1259
|
+
"omitted_visual_snapshot_count": visual.get("omitted_visual_snapshot_count", 0),
|
|
1260
|
+
"omitted_visual_snapshot_reasons": visual.get("omitted_visual_snapshot_reasons", {}),
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def _workflow_viewer_targets(
|
|
1265
|
+
model: ProjectModel,
|
|
1266
|
+
flow_ids: list[str],
|
|
1267
|
+
) -> dict[str, Any]:
|
|
1268
|
+
flow_targets = [
|
|
1269
|
+
{
|
|
1270
|
+
"type": "flow",
|
|
1271
|
+
"flow_id": flow.id,
|
|
1272
|
+
"name": flow.name,
|
|
1273
|
+
"hash_fragment": _viewer_flow_hash(flow.id),
|
|
1274
|
+
"source": _source_anchor(flow.location),
|
|
1275
|
+
}
|
|
1276
|
+
for flow in _flows_by_ids(model, flow_ids)
|
|
1277
|
+
]
|
|
1278
|
+
return {
|
|
1279
|
+
"command": "codedebrief view",
|
|
1280
|
+
"route": "flow-hash",
|
|
1281
|
+
"targets": flow_targets,
|
|
1282
|
+
"target_count": len(flow_targets),
|
|
1283
|
+
"guardrail": (
|
|
1284
|
+
"Use codedebrief view for manual inspection, then append a hash_fragment to "
|
|
1285
|
+
"the generated viewer URL or local HTML file. These links open visual context; "
|
|
1286
|
+
"they do not replace the deterministic workflow_slice payload."
|
|
1287
|
+
),
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def _viewer_flow_hash(flow_id: str) -> str:
|
|
1292
|
+
return f"#flow={quote(flow_id, safe='')}"
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def _workflow_omissions(
|
|
1296
|
+
pack: dict[str, Any],
|
|
1297
|
+
selected_flow_ids: list[str],
|
|
1298
|
+
visible_flow_ids: list[str],
|
|
1299
|
+
token_budget: int,
|
|
1300
|
+
) -> dict[str, Any]:
|
|
1301
|
+
navigation_value = pack.get("navigation")
|
|
1302
|
+
navigation: dict[str, Any] = navigation_value if isinstance(navigation_value, dict) else {}
|
|
1303
|
+
visual_value = pack.get("visual_context")
|
|
1304
|
+
visual: dict[str, Any] = visual_value if isinstance(visual_value, dict) else {}
|
|
1305
|
+
impact_value = pack.get("impact")
|
|
1306
|
+
impact: dict[str, Any] = impact_value if isinstance(impact_value, dict) else {}
|
|
1307
|
+
return {
|
|
1308
|
+
"token_budget": token_budget,
|
|
1309
|
+
"omitted_selected_flow_count": max(0, len(selected_flow_ids) - len(visible_flow_ids)),
|
|
1310
|
+
"omitted_flow_navigation_count": navigation.get("omitted_flow_navigation_count", 0),
|
|
1311
|
+
"omitted_flow_snapshot_count": visual.get("omitted_flow_snapshot_count", 0),
|
|
1312
|
+
"omitted_visual_snapshot_count": visual.get("omitted_visual_snapshot_count", 0),
|
|
1313
|
+
"unresolved_targets": impact.get("unresolved_targets", []),
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _workflow_next_actions(
|
|
1318
|
+
primary_flow_ids: list[str],
|
|
1319
|
+
supporting_flow_ids: list[str],
|
|
1320
|
+
) -> list[str]:
|
|
1321
|
+
actions = [
|
|
1322
|
+
"Use ordered_steps and source_ranges when answering the user.",
|
|
1323
|
+
"Use decision metadata and source anchors rather than inventing missing branches.",
|
|
1324
|
+
]
|
|
1325
|
+
if supporting_flow_ids:
|
|
1326
|
+
actions.append("Inspect supporting_flows when caller/callee context changes the answer.")
|
|
1327
|
+
if primary_flow_ids:
|
|
1328
|
+
actions.append(
|
|
1329
|
+
"Use snapshot_slice with include_svg=false when visual flowchart context "
|
|
1330
|
+
"would clarify the answer."
|
|
1331
|
+
)
|
|
1332
|
+
return actions
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def _workflow_slice_next_tools(
|
|
1336
|
+
primary_flow_ids: list[str],
|
|
1337
|
+
supporting_flow_ids: list[str],
|
|
1338
|
+
token_budget: int,
|
|
1339
|
+
) -> dict[str, Any]:
|
|
1340
|
+
flow_ids = _unique_preserve_order([*primary_flow_ids, *supporting_flow_ids])
|
|
1341
|
+
tools: dict[str, Any] = {
|
|
1342
|
+
"expand_slice": {
|
|
1343
|
+
"tool": "expand_slice",
|
|
1344
|
+
"arguments": {
|
|
1345
|
+
"flow_ids": flow_ids,
|
|
1346
|
+
"direction": "neighbors",
|
|
1347
|
+
"depth": 1,
|
|
1348
|
+
"token_budget": token_budget,
|
|
1349
|
+
},
|
|
1350
|
+
},
|
|
1351
|
+
"snapshot_slice": {
|
|
1352
|
+
"tool": "snapshot_slice",
|
|
1353
|
+
"arguments": {
|
|
1354
|
+
"flow_ids": flow_ids,
|
|
1355
|
+
"format": "svg",
|
|
1356
|
+
"include_svg": False,
|
|
1357
|
+
"token_budget": token_budget,
|
|
1358
|
+
},
|
|
1359
|
+
},
|
|
1360
|
+
}
|
|
1361
|
+
if primary_flow_ids:
|
|
1362
|
+
tools["explain_primary_flow"] = {
|
|
1363
|
+
"tool": "explain_flow",
|
|
1364
|
+
"arguments": {"flow_id": primary_flow_ids[0], "token_budget": token_budget},
|
|
1365
|
+
}
|
|
1366
|
+
if len(flow_ids) >= 2:
|
|
1367
|
+
tools["workflow_path"] = {
|
|
1368
|
+
"tool": "workflow_path",
|
|
1369
|
+
"arguments": {
|
|
1370
|
+
"source": flow_ids[0],
|
|
1371
|
+
"target": flow_ids[-1],
|
|
1372
|
+
"token_budget": token_budget,
|
|
1373
|
+
},
|
|
1374
|
+
}
|
|
1375
|
+
return tools
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
def _workflow_task_type(question: str | None, inputs: dict[str, Any]) -> str:
|
|
1379
|
+
text = (question or "").lower()
|
|
1380
|
+
if inputs.get("changed_files"):
|
|
1381
|
+
return "change_impact"
|
|
1382
|
+
if inputs.get("domain") or inputs.get("value"):
|
|
1383
|
+
return "inspect_domain_logic"
|
|
1384
|
+
if any(term in text for term in ("impact", "break", "affected", "modifica", "rompe")):
|
|
1385
|
+
return "change_impact"
|
|
1386
|
+
if any(term in text for term in ("test", "coverage", "verifica")):
|
|
1387
|
+
return "prepare_tests"
|
|
1388
|
+
if any(term in text for term in ("where", "handled", "status", "state", "role")):
|
|
1389
|
+
return "inspect_state_handling"
|
|
1390
|
+
if any(term in text for term in ("path", "trace", "from", "to", "percorso")):
|
|
1391
|
+
return "trace_behavior"
|
|
1392
|
+
return "explain_behavior"
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
def _node_decision_context(node: Any) -> dict[str, Any]:
|
|
1396
|
+
if node.kind is not NodeKind.DECISION:
|
|
1397
|
+
return {}
|
|
1398
|
+
return {
|
|
1399
|
+
"condition": node.metadata.get("condition"),
|
|
1400
|
+
"domain": node.metadata.get("domain"),
|
|
1401
|
+
"subject": node.metadata.get("subject"),
|
|
1402
|
+
"operator": node.metadata.get("operator"),
|
|
1403
|
+
"values": node.metadata.get("values", []),
|
|
1404
|
+
"branches": node.metadata.get("branches", []),
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def _expand_workflow_slice_targets(
|
|
1409
|
+
model: ProjectModel,
|
|
1410
|
+
*,
|
|
1411
|
+
flow_ids: list[str] | None,
|
|
1412
|
+
direction: str,
|
|
1413
|
+
depth: int,
|
|
1414
|
+
token_budget: int,
|
|
1415
|
+
) -> dict[str, Any]:
|
|
1416
|
+
normalized_direction = direction if direction in _slice_expansion_directions() else "neighbors"
|
|
1417
|
+
known_seed_flows = _known_flow_ids(model, flow_ids)
|
|
1418
|
+
known_seed_flows = _unique_preserve_order(known_seed_flows)
|
|
1419
|
+
unknown_flow_ids = [item for item in flow_ids or [] if item not in set(known_seed_flows)]
|
|
1420
|
+
if not known_seed_flows:
|
|
1421
|
+
return {
|
|
1422
|
+
"error_code": "slice_targets_missing",
|
|
1423
|
+
"message": "expand_slice requires at least one known flow_id.",
|
|
1424
|
+
"flow_ids": [],
|
|
1425
|
+
"unknown_flow_ids": unknown_flow_ids,
|
|
1426
|
+
}
|
|
1427
|
+
expanded = set(known_seed_flows)
|
|
1428
|
+
frontier = set(known_seed_flows)
|
|
1429
|
+
for _ in range(max(0, min(depth, 4))):
|
|
1430
|
+
next_frontier = set()
|
|
1431
|
+
for flow_id in frontier:
|
|
1432
|
+
next_frontier.update(_slice_neighbor_flow_ids(model, flow_id, normalized_direction))
|
|
1433
|
+
next_frontier.difference_update(expanded)
|
|
1434
|
+
expanded.update(next_frontier)
|
|
1435
|
+
frontier = next_frontier
|
|
1436
|
+
if not frontier:
|
|
1437
|
+
break
|
|
1438
|
+
budget = _slice_flow_budget(token_budget)
|
|
1439
|
+
ordered = _order_expanded_flow_ids(model, known_seed_flows, expanded)[:budget]
|
|
1440
|
+
return {
|
|
1441
|
+
"error_code": None,
|
|
1442
|
+
"message": None,
|
|
1443
|
+
"seed_flow_ids": known_seed_flows,
|
|
1444
|
+
"flow_ids": ordered,
|
|
1445
|
+
"direction": normalized_direction,
|
|
1446
|
+
"depth": max(0, min(depth, 4)),
|
|
1447
|
+
"unknown_flow_ids": unknown_flow_ids,
|
|
1448
|
+
"omitted_expanded_flow_count": max(0, len(expanded) - len(ordered)),
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
def _slice_neighbor_flow_ids(model: ProjectModel, flow_id: str, direction: str) -> set[str]:
|
|
1453
|
+
flow = _flow_by_id(model, flow_id)
|
|
1454
|
+
if flow is None:
|
|
1455
|
+
return set()
|
|
1456
|
+
neighbors: set[str] = set()
|
|
1457
|
+
if direction in {"neighbors", "callees", "all"}:
|
|
1458
|
+
neighbors.update(flow.calls)
|
|
1459
|
+
if direction in {"neighbors", "callers", "all"}:
|
|
1460
|
+
neighbors.update(flow.called_by)
|
|
1461
|
+
if direction in {"neighbors", "tests", "all"}:
|
|
1462
|
+
neighbors.update(flow.tests)
|
|
1463
|
+
if direction in {"domain", "all"}:
|
|
1464
|
+
domains = _flow_domain_keys(flow)
|
|
1465
|
+
if domains:
|
|
1466
|
+
for candidate in model.flows:
|
|
1467
|
+
if candidate.id != flow.id and domains.intersection(_flow_domain_keys(candidate)):
|
|
1468
|
+
neighbors.add(candidate.id)
|
|
1469
|
+
known = {item.id for item in model.flows}
|
|
1470
|
+
return {item for item in neighbors if item in known}
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
def _flow_domain_keys(flow: Any) -> set[str]:
|
|
1474
|
+
keys: set[str] = set()
|
|
1475
|
+
for node in getattr(flow, "nodes", []):
|
|
1476
|
+
if node.kind is NodeKind.DECISION:
|
|
1477
|
+
keys.update(_domain_keys(node.metadata))
|
|
1478
|
+
return keys
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
def _slice_expansion_directions() -> set[str]:
|
|
1482
|
+
return {"neighbors", "callers", "callees", "tests", "domain", "all"}
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def _resolve_workflow_path_seed(
|
|
1486
|
+
model: ProjectModel,
|
|
1487
|
+
text: str,
|
|
1488
|
+
scope: str | None,
|
|
1489
|
+
token_budget: int,
|
|
1490
|
+
) -> dict[str, Any]:
|
|
1491
|
+
normalized = text.strip()
|
|
1492
|
+
exact = _flow_by_id(model, normalized)
|
|
1493
|
+
if exact is not None and flow_in_agent_scope(exact, scope):
|
|
1494
|
+
return {
|
|
1495
|
+
"query": text,
|
|
1496
|
+
"flow_ids": [exact.id],
|
|
1497
|
+
"matches": [{**flow_summary(exact), "reason": "exact_flow_id"}],
|
|
1498
|
+
}
|
|
1499
|
+
exact_matches = [
|
|
1500
|
+
flow
|
|
1501
|
+
for flow in model.flows
|
|
1502
|
+
if flow_in_agent_scope(flow, scope)
|
|
1503
|
+
and (flow.symbol == normalized or flow.name == normalized)
|
|
1504
|
+
]
|
|
1505
|
+
if exact_matches:
|
|
1506
|
+
return {
|
|
1507
|
+
"query": text,
|
|
1508
|
+
"flow_ids": [flow.id for flow in exact_matches[: _slice_flow_budget(token_budget)]],
|
|
1509
|
+
"matches": [
|
|
1510
|
+
{**flow_summary(flow), "reason": "exact_symbol_or_name"}
|
|
1511
|
+
for flow in exact_matches[: _slice_flow_budget(token_budget)]
|
|
1512
|
+
],
|
|
1513
|
+
}
|
|
1514
|
+
matches = query_model(model, normalized, limit=5, scope=scope)
|
|
1515
|
+
rows = [match for match in matches if flow_in_agent_scope(match.flow, scope)]
|
|
1516
|
+
return {
|
|
1517
|
+
"query": text,
|
|
1518
|
+
"flow_ids": [match.flow.id for match in rows],
|
|
1519
|
+
"matches": [
|
|
1520
|
+
{
|
|
1521
|
+
**flow_summary(match.flow),
|
|
1522
|
+
"score": match.score,
|
|
1523
|
+
"reasons": match.reasons,
|
|
1524
|
+
}
|
|
1525
|
+
for match in rows
|
|
1526
|
+
],
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def _find_workflow_path(
|
|
1531
|
+
model: ProjectModel,
|
|
1532
|
+
source_flow_ids: list[str],
|
|
1533
|
+
target_flow_ids: list[str],
|
|
1534
|
+
) -> dict[str, Any]:
|
|
1535
|
+
targets = set(target_flow_ids)
|
|
1536
|
+
graph = _workflow_call_graph(model)
|
|
1537
|
+
queue: deque[tuple[str, list[str]]] = deque(
|
|
1538
|
+
(source_id, [source_id]) for source_id in source_flow_ids
|
|
1539
|
+
)
|
|
1540
|
+
visited = set(source_flow_ids)
|
|
1541
|
+
while queue:
|
|
1542
|
+
current, path = queue.popleft()
|
|
1543
|
+
if current in targets:
|
|
1544
|
+
return {
|
|
1545
|
+
"found": True,
|
|
1546
|
+
"flow_ids": path,
|
|
1547
|
+
"edges": _workflow_path_edges(model, path),
|
|
1548
|
+
"omitted_reason": None,
|
|
1549
|
+
}
|
|
1550
|
+
for neighbor in graph.get(current, []):
|
|
1551
|
+
if neighbor in visited:
|
|
1552
|
+
continue
|
|
1553
|
+
visited.add(neighbor)
|
|
1554
|
+
queue.append((neighbor, [*path, neighbor]))
|
|
1555
|
+
return {
|
|
1556
|
+
"found": False,
|
|
1557
|
+
"flow_ids": [],
|
|
1558
|
+
"edges": [],
|
|
1559
|
+
"omitted_reason": "no_static_call_path_in_model",
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def _workflow_call_graph(model: ProjectModel) -> dict[str, list[str]]:
|
|
1564
|
+
graph: dict[str, set[str]] = {flow.id: set() for flow in model.flows}
|
|
1565
|
+
known = set(graph)
|
|
1566
|
+
for flow in model.flows:
|
|
1567
|
+
for target in flow.calls:
|
|
1568
|
+
if target in known:
|
|
1569
|
+
graph[flow.id].add(target)
|
|
1570
|
+
graph[target].add(flow.id)
|
|
1571
|
+
for caller in flow.called_by:
|
|
1572
|
+
if caller in known:
|
|
1573
|
+
graph[flow.id].add(caller)
|
|
1574
|
+
graph[caller].add(flow.id)
|
|
1575
|
+
return {key: sorted(value) for key, value in graph.items()}
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
def _workflow_path_edges(model: ProjectModel, path: list[str]) -> list[dict[str, Any]]:
|
|
1579
|
+
flows = {flow.id: flow for flow in model.flows}
|
|
1580
|
+
edges = []
|
|
1581
|
+
for source_id, target_id in pairwise(path):
|
|
1582
|
+
source = flows.get(source_id)
|
|
1583
|
+
target = flows.get(target_id)
|
|
1584
|
+
if source is None or target is None:
|
|
1585
|
+
continue
|
|
1586
|
+
direction = "calls" if target_id in source.calls else "called_by"
|
|
1587
|
+
edges.append(
|
|
1588
|
+
{
|
|
1589
|
+
"source_flow_id": source_id,
|
|
1590
|
+
"target_flow_id": target_id,
|
|
1591
|
+
"relationship": direction,
|
|
1592
|
+
"source": _source_anchor(source.location),
|
|
1593
|
+
}
|
|
1594
|
+
)
|
|
1595
|
+
return edges
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
def _workflow_path_error(
|
|
1599
|
+
source: str,
|
|
1600
|
+
target: str,
|
|
1601
|
+
source_seed: dict[str, Any],
|
|
1602
|
+
target_seed: dict[str, Any],
|
|
1603
|
+
token_budget: int,
|
|
1604
|
+
) -> dict[str, Any]:
|
|
1605
|
+
return {
|
|
1606
|
+
"tool": "workflow_path",
|
|
1607
|
+
"error": "Could not resolve both workflow path endpoints.",
|
|
1608
|
+
"error_code": "workflow_path_endpoint_not_found",
|
|
1609
|
+
"source": source_seed,
|
|
1610
|
+
"target": target_seed,
|
|
1611
|
+
"recoverable": True,
|
|
1612
|
+
"guardrail": (
|
|
1613
|
+
"Endpoint resolution uses deterministic model query matches. Missing matches "
|
|
1614
|
+
"mean the current artifact lacks a modeled flow for the endpoint."
|
|
1615
|
+
),
|
|
1616
|
+
"next_tools": {
|
|
1617
|
+
"agent_context_source": {
|
|
1618
|
+
"tool": "agent_context",
|
|
1619
|
+
"arguments": {"question": source, "token_budget": token_budget},
|
|
1620
|
+
},
|
|
1621
|
+
"agent_context_target": {
|
|
1622
|
+
"tool": "agent_context",
|
|
1623
|
+
"arguments": {"question": target, "token_budget": token_budget},
|
|
1624
|
+
},
|
|
1625
|
+
},
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def _focused_flow_explanation(model: ProjectModel, flow: Any, token_budget: int) -> dict[str, Any]:
|
|
1630
|
+
return {
|
|
1631
|
+
"tool": "explain_flow",
|
|
1632
|
+
"flow": _workflow_flow_rows(model, [flow.id])[0],
|
|
1633
|
+
"ordered_steps": _workflow_ordered_steps(model, [flow.id], token_budget),
|
|
1634
|
+
"decisions": _workflow_decisions(model, [flow.id], token_budget),
|
|
1635
|
+
"calls": _workflow_calls(model, [flow.id], token_budget),
|
|
1636
|
+
"source_ranges": _workflow_source_ranges(
|
|
1637
|
+
model,
|
|
1638
|
+
[flow.id],
|
|
1639
|
+
{},
|
|
1640
|
+
token_budget,
|
|
1641
|
+
),
|
|
1642
|
+
"next_tools": {
|
|
1643
|
+
"snapshot_slice": {
|
|
1644
|
+
"tool": "snapshot_slice",
|
|
1645
|
+
"arguments": {
|
|
1646
|
+
"flow_ids": [flow.id],
|
|
1647
|
+
"format": "svg",
|
|
1648
|
+
"include_svg": False,
|
|
1649
|
+
"token_budget": token_budget,
|
|
1650
|
+
},
|
|
1651
|
+
},
|
|
1652
|
+
"expand_slice": {
|
|
1653
|
+
"tool": "expand_slice",
|
|
1654
|
+
"arguments": {"flow_ids": [flow.id], "direction": "neighbors"},
|
|
1655
|
+
},
|
|
1656
|
+
},
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
def _focused_node_explanation(
|
|
1661
|
+
model: ProjectModel,
|
|
1662
|
+
flow: Any,
|
|
1663
|
+
node: Any,
|
|
1664
|
+
token_budget: int,
|
|
1665
|
+
) -> dict[str, Any]:
|
|
1666
|
+
incoming = [edge for edge in flow.edges if edge.target == node.id]
|
|
1667
|
+
outgoing = [edge for edge in flow.edges if edge.source == node.id]
|
|
1668
|
+
return {
|
|
1669
|
+
"tool": "explain_node",
|
|
1670
|
+
"flow": flow_summary(flow),
|
|
1671
|
+
"node": {
|
|
1672
|
+
"id": node.id,
|
|
1673
|
+
"kind": _enum_value(node.kind),
|
|
1674
|
+
"label": node.label,
|
|
1675
|
+
"detail": node.detail,
|
|
1676
|
+
"source": _source_anchor(node.location),
|
|
1677
|
+
"evidence": _enum_value(node.evidence),
|
|
1678
|
+
"metadata": node.metadata,
|
|
1679
|
+
},
|
|
1680
|
+
"decision": _node_decision_context(node),
|
|
1681
|
+
"incoming_edges": [
|
|
1682
|
+
_edge_payload(edge) for edge in incoming[: _slice_item_budget(token_budget)]
|
|
1683
|
+
],
|
|
1684
|
+
"outgoing_edges": [
|
|
1685
|
+
_edge_payload(edge) for edge in outgoing[: _slice_item_budget(token_budget)]
|
|
1686
|
+
],
|
|
1687
|
+
"next_tools": {
|
|
1688
|
+
"explain_flow": {
|
|
1689
|
+
"tool": "explain_flow",
|
|
1690
|
+
"arguments": {"flow_id": flow.id, "token_budget": token_budget},
|
|
1691
|
+
},
|
|
1692
|
+
"snapshot_slice": {
|
|
1693
|
+
"tool": "snapshot_slice",
|
|
1694
|
+
"arguments": {
|
|
1695
|
+
"flow_ids": [flow.id],
|
|
1696
|
+
"format": "svg",
|
|
1697
|
+
"include_svg": False,
|
|
1698
|
+
"token_budget": token_budget,
|
|
1699
|
+
},
|
|
1700
|
+
},
|
|
1701
|
+
},
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
def _focused_edge_explanation(
|
|
1706
|
+
model: ProjectModel,
|
|
1707
|
+
flow: Any,
|
|
1708
|
+
edge: Any,
|
|
1709
|
+
token_budget: int,
|
|
1710
|
+
) -> dict[str, Any]:
|
|
1711
|
+
source_node = next((node for node in flow.nodes if node.id == edge.source), None)
|
|
1712
|
+
target_node = next((node for node in flow.nodes if node.id == edge.target), None)
|
|
1713
|
+
return {
|
|
1714
|
+
"tool": "explain_edge",
|
|
1715
|
+
"flow": flow_summary(flow),
|
|
1716
|
+
"edge": _edge_payload(edge),
|
|
1717
|
+
"source_node": _node_payload(source_node) if source_node is not None else None,
|
|
1718
|
+
"target_node": _node_payload(target_node) if target_node is not None else None,
|
|
1719
|
+
"source_ranges": [
|
|
1720
|
+
item
|
|
1721
|
+
for item in (
|
|
1722
|
+
_source_range_payload(source_node.location, flow.id, source_node.id)
|
|
1723
|
+
if source_node is not None
|
|
1724
|
+
else None,
|
|
1725
|
+
_source_range_payload(target_node.location, flow.id, target_node.id)
|
|
1726
|
+
if target_node is not None
|
|
1727
|
+
else None,
|
|
1728
|
+
)
|
|
1729
|
+
if item is not None
|
|
1730
|
+
],
|
|
1731
|
+
"next_tools": {
|
|
1732
|
+
"explain_flow": {
|
|
1733
|
+
"tool": "explain_flow",
|
|
1734
|
+
"arguments": {"flow_id": flow.id, "token_budget": token_budget},
|
|
1735
|
+
},
|
|
1736
|
+
"snapshot_slice": {
|
|
1737
|
+
"tool": "snapshot_slice",
|
|
1738
|
+
"arguments": {
|
|
1739
|
+
"flow_ids": [flow.id],
|
|
1740
|
+
"format": "svg",
|
|
1741
|
+
"include_svg": False,
|
|
1742
|
+
"token_budget": token_budget,
|
|
1743
|
+
},
|
|
1744
|
+
},
|
|
1745
|
+
},
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
def _slice_target_error(
|
|
1750
|
+
tool: str,
|
|
1751
|
+
error_code: str,
|
|
1752
|
+
message: str,
|
|
1753
|
+
*,
|
|
1754
|
+
slice_id: str | None,
|
|
1755
|
+
flow_ids: list[str] | None,
|
|
1756
|
+
) -> dict[str, Any]:
|
|
1757
|
+
return {
|
|
1758
|
+
"tool": tool,
|
|
1759
|
+
"ok": False,
|
|
1760
|
+
"error": message,
|
|
1761
|
+
"error_code": error_code,
|
|
1762
|
+
"slice_id": slice_id,
|
|
1763
|
+
"flow_ids": flow_ids or [],
|
|
1764
|
+
"recoverable": True,
|
|
1765
|
+
"guardrail": (
|
|
1766
|
+
"Slice tools operate only on ids in the current local model. Re-run "
|
|
1767
|
+
"agent_context if the artifact changed or ids are unavailable."
|
|
1768
|
+
),
|
|
1769
|
+
"next_tools": {
|
|
1770
|
+
"agent_context": {
|
|
1771
|
+
"tool": "agent_context",
|
|
1772
|
+
"arguments": {"token_budget": 900},
|
|
1773
|
+
},
|
|
1774
|
+
},
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
def _known_flow_ids(model: ProjectModel, flow_ids: list[str] | None) -> list[str]:
|
|
1779
|
+
known = {flow.id for flow in model.flows}
|
|
1780
|
+
return _unique_preserve_order(flow_id for flow_id in flow_ids or [] if flow_id in known)
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def _flows_by_ids(model: ProjectModel, flow_ids: list[str]) -> list[Any]:
|
|
1784
|
+
flows = {flow.id: flow for flow in model.flows}
|
|
1785
|
+
return [flows[flow_id] for flow_id in flow_ids if flow_id in flows]
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
def _flow_by_id(model: ProjectModel, flow_id: str) -> Any | None:
|
|
1789
|
+
return next((flow for flow in model.flows if flow.id == flow_id), None)
|
|
1790
|
+
|
|
1791
|
+
|
|
1792
|
+
def _resolve_node(model: ProjectModel, node_id: str, flow_id: str | None) -> tuple[Any, Any] | None:
|
|
1793
|
+
flows = _flows_by_ids(model, [flow_id]) if flow_id else model.flows
|
|
1794
|
+
for flow in flows:
|
|
1795
|
+
for node in flow.nodes:
|
|
1796
|
+
if node.id == node_id:
|
|
1797
|
+
return flow, node
|
|
1798
|
+
return None
|
|
1799
|
+
|
|
1800
|
+
|
|
1801
|
+
def _resolve_edge(model: ProjectModel, edge_id: str, flow_id: str | None) -> tuple[Any, Any] | None:
|
|
1802
|
+
flows = _flows_by_ids(model, [flow_id]) if flow_id else model.flows
|
|
1803
|
+
for flow in flows:
|
|
1804
|
+
for edge in flow.edges:
|
|
1805
|
+
if edge.id == edge_id:
|
|
1806
|
+
return flow, edge
|
|
1807
|
+
return None
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
def _order_expanded_flow_ids(
|
|
1811
|
+
model: ProjectModel,
|
|
1812
|
+
seed_flow_ids: list[str],
|
|
1813
|
+
expanded: set[str],
|
|
1814
|
+
) -> list[str]:
|
|
1815
|
+
seed = _unique_preserve_order(seed_flow_ids)
|
|
1816
|
+
by_id = {flow.id: flow for flow in model.flows}
|
|
1817
|
+
rest = sorted(
|
|
1818
|
+
(flow_id for flow_id in expanded if flow_id not in set(seed)),
|
|
1819
|
+
key=lambda flow_id: (
|
|
1820
|
+
by_id[flow_id].location.path if flow_id in by_id else "",
|
|
1821
|
+
by_id[flow_id].name if flow_id in by_id else flow_id,
|
|
1822
|
+
flow_id,
|
|
1823
|
+
),
|
|
1824
|
+
)
|
|
1825
|
+
return [*seed, *rest]
|
|
1826
|
+
|
|
1827
|
+
|
|
1828
|
+
def _slice_primary_budget(token_budget: int) -> int:
|
|
1829
|
+
if token_budget <= 0:
|
|
1830
|
+
return 3
|
|
1831
|
+
return max(1, min(4, token_budget // 300))
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
def _slice_supporting_budget(token_budget: int) -> int:
|
|
1835
|
+
if token_budget <= 0:
|
|
1836
|
+
return 6
|
|
1837
|
+
return max(1, min(8, token_budget // 180))
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
def _slice_flow_budget(token_budget: int) -> int:
|
|
1841
|
+
if token_budget <= 0:
|
|
1842
|
+
return 20
|
|
1843
|
+
return max(1, min(24, token_budget // 80))
|
|
1844
|
+
|
|
1845
|
+
|
|
1846
|
+
def _slice_item_budget(token_budget: int) -> int:
|
|
1847
|
+
if token_budget <= 0:
|
|
1848
|
+
return 40
|
|
1849
|
+
return max(4, min(40, token_budget // 40))
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
def _source_anchor(location: Any) -> dict[str, Any]:
|
|
1853
|
+
return {
|
|
1854
|
+
"path": location.path,
|
|
1855
|
+
"start_line": location.start_line,
|
|
1856
|
+
"end_line": location.end_line,
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
|
|
1860
|
+
def _source_range_payload(location: Any, flow_id: str, node_id: str) -> dict[str, Any]:
|
|
1861
|
+
return {
|
|
1862
|
+
**_source_anchor(location),
|
|
1863
|
+
"flow_id": flow_id,
|
|
1864
|
+
"node_id": node_id,
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
def _node_payload(node: Any) -> dict[str, Any]:
|
|
1869
|
+
return {
|
|
1870
|
+
"id": node.id,
|
|
1871
|
+
"kind": _enum_value(node.kind),
|
|
1872
|
+
"label": node.label,
|
|
1873
|
+
"source": _source_anchor(node.location),
|
|
1874
|
+
"evidence": _enum_value(node.evidence),
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
|
|
1878
|
+
def _edge_payload(edge: Any) -> dict[str, Any]:
|
|
1879
|
+
return {
|
|
1880
|
+
"id": edge.id,
|
|
1881
|
+
"source": edge.source,
|
|
1882
|
+
"target": edge.target,
|
|
1883
|
+
"label": edge.label,
|
|
1884
|
+
"evidence": _enum_value(edge.evidence),
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
def _enum_value(value: Any) -> str:
|
|
1889
|
+
return str(getattr(value, "value", value))
|
|
1890
|
+
|
|
1891
|
+
|
|
1892
|
+
def _coerce_int(value: Any, default: int) -> int:
|
|
1893
|
+
try:
|
|
1894
|
+
return int(value)
|
|
1895
|
+
except (TypeError, ValueError):
|
|
1896
|
+
return default
|
|
1897
|
+
|
|
1898
|
+
|
|
1899
|
+
def _unique_preserve_order(values: Any) -> list[str]:
|
|
1900
|
+
seen: set[str] = set()
|
|
1901
|
+
result: list[str] = []
|
|
1902
|
+
for value in values:
|
|
1903
|
+
if value is None:
|
|
1904
|
+
continue
|
|
1905
|
+
item = str(value)
|
|
1906
|
+
if not item or item in seen:
|
|
1907
|
+
continue
|
|
1908
|
+
seen.add(item)
|
|
1909
|
+
result.append(item)
|
|
1910
|
+
return result
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
def _context_visual_pack(
|
|
1914
|
+
model: ProjectModel,
|
|
1915
|
+
*,
|
|
1916
|
+
impact: Any,
|
|
1917
|
+
matches: list[Any],
|
|
1918
|
+
scope: str | None,
|
|
1919
|
+
include_visual: bool,
|
|
1920
|
+
token_budget: int,
|
|
1921
|
+
visual_byte_budget: int,
|
|
1922
|
+
) -> dict[str, Any]:
|
|
1923
|
+
flow_candidates = _context_visual_flows(impact, matches)
|
|
1924
|
+
flow_limit = _context_visual_item_budget(token_budget)
|
|
1925
|
+
visual_byte_limit = max(0, visual_byte_budget)
|
|
1926
|
+
subgraph_flow_ids = [flow.id for flow in flow_candidates[:flow_limit]]
|
|
1927
|
+
payload: dict[str, Any] = {
|
|
1928
|
+
"include_visual": include_visual,
|
|
1929
|
+
"format": "svg",
|
|
1930
|
+
"snapshot_budget": {
|
|
1931
|
+
"flow_snapshots": flow_limit,
|
|
1932
|
+
"node_budget": _snapshot_node_budget(token_budget),
|
|
1933
|
+
"flow_budget": _snapshot_flow_budget(token_budget),
|
|
1934
|
+
"visual_byte_budget": visual_byte_limit,
|
|
1935
|
+
"used_visual_bytes": 0,
|
|
1936
|
+
},
|
|
1937
|
+
"next_tools": {
|
|
1938
|
+
"snapshot_slice": {
|
|
1939
|
+
"tool": "snapshot_slice",
|
|
1940
|
+
"arguments": {
|
|
1941
|
+
"flow_ids": subgraph_flow_ids,
|
|
1942
|
+
"format": "svg",
|
|
1943
|
+
"include_svg": False,
|
|
1944
|
+
"token_budget": token_budget,
|
|
1945
|
+
},
|
|
1946
|
+
},
|
|
1947
|
+
},
|
|
1948
|
+
"omitted_flow_snapshot_count": max(0, len(flow_candidates) - flow_limit),
|
|
1949
|
+
"omitted_visual_snapshot_count": 0,
|
|
1950
|
+
"omitted_visual_snapshot_reasons": {},
|
|
1951
|
+
}
|
|
1952
|
+
if not include_visual:
|
|
1953
|
+
return payload
|
|
1954
|
+
used_visual_bytes = 0
|
|
1955
|
+
omitted_visual_reasons: dict[str, int] = {}
|
|
1956
|
+
|
|
1957
|
+
def include_snapshot(snapshot: dict[str, Any]) -> bool:
|
|
1958
|
+
nonlocal used_visual_bytes
|
|
1959
|
+
size = _snapshot_svg_byte_size(snapshot)
|
|
1960
|
+
if used_visual_bytes + size > visual_byte_limit:
|
|
1961
|
+
omitted_visual_reasons["visual_byte_budget"] = (
|
|
1962
|
+
omitted_visual_reasons.get("visual_byte_budget", 0) + 1
|
|
1963
|
+
)
|
|
1964
|
+
return False
|
|
1965
|
+
used_visual_bytes += size
|
|
1966
|
+
return True
|
|
1967
|
+
|
|
1968
|
+
if subgraph_flow_ids:
|
|
1969
|
+
subgraph_snapshot = render_subgraph_snapshot(
|
|
1970
|
+
model,
|
|
1971
|
+
flow_ids=subgraph_flow_ids,
|
|
1972
|
+
max_flows=_snapshot_flow_budget(token_budget),
|
|
1973
|
+
max_nodes=_snapshot_node_budget(token_budget),
|
|
1974
|
+
)
|
|
1975
|
+
if include_snapshot(subgraph_snapshot):
|
|
1976
|
+
payload["subgraph_snapshot"] = subgraph_snapshot
|
|
1977
|
+
else:
|
|
1978
|
+
payload["subgraph_snapshot_omitted_reason"] = "visual_byte_budget"
|
|
1979
|
+
|
|
1980
|
+
payload["snapshot_budget"]["used_visual_bytes"] = used_visual_bytes
|
|
1981
|
+
payload["omitted_visual_snapshot_count"] = sum(omitted_visual_reasons.values())
|
|
1982
|
+
payload["omitted_visual_snapshot_reasons"] = omitted_visual_reasons
|
|
1983
|
+
return payload
|
|
1984
|
+
|
|
1985
|
+
|
|
1986
|
+
def _context_visual_flows(impact: Any, matches: list[Any]) -> list[Any]:
|
|
1987
|
+
flows: dict[str, Any] = {}
|
|
1988
|
+
for flow in [*impact.directly_impacted, *impact.transitively_impacted]:
|
|
1989
|
+
flows.setdefault(flow.id, flow)
|
|
1990
|
+
for match in matches:
|
|
1991
|
+
flows.setdefault(match.flow.id, match.flow)
|
|
1992
|
+
return list(flows.values())
|
|
1993
|
+
|
|
1994
|
+
|
|
1995
|
+
def _agent_order_matches(matches: list[Any], question: str | None) -> list[Any]:
|
|
1996
|
+
"""Prefer application flows before tests in agent packs.
|
|
1997
|
+
|
|
1998
|
+
Tests are valuable evidence, but when an LLM explains "how X works" it should start
|
|
1999
|
+
from implementation flows and use test flows as secondary confirmation.
|
|
2000
|
+
"""
|
|
2001
|
+
action_terms = _agent_action_terms(question)
|
|
2002
|
+
return sorted(
|
|
2003
|
+
matches,
|
|
2004
|
+
key=lambda match: (
|
|
2005
|
+
bool(match.flow.metadata.get("test")),
|
|
2006
|
+
-_agent_action_hits(match, action_terms),
|
|
2007
|
+
-match.score,
|
|
2008
|
+
match.flow.name,
|
|
2009
|
+
match.flow.id,
|
|
2010
|
+
),
|
|
2011
|
+
)
|
|
2012
|
+
|
|
2013
|
+
|
|
2014
|
+
def _agent_action_hits(match: Any, action_terms: set[str]) -> int:
|
|
2015
|
+
if not action_terms:
|
|
2016
|
+
return 0
|
|
2017
|
+
reason_text = " ".join(str(reason) for reason in match.reasons)
|
|
2018
|
+
return sum(1 for term in action_terms if f"`{term}`" in reason_text)
|
|
2019
|
+
|
|
2020
|
+
|
|
2021
|
+
def _agent_action_terms(question: str | None) -> set[str]:
|
|
2022
|
+
if not question:
|
|
2023
|
+
return set()
|
|
2024
|
+
action_vocab = {
|
|
2025
|
+
"approve",
|
|
2026
|
+
"auth",
|
|
2027
|
+
"authenticate",
|
|
2028
|
+
"authorize",
|
|
2029
|
+
"cancel",
|
|
2030
|
+
"checkout",
|
|
2031
|
+
"complete",
|
|
2032
|
+
"create",
|
|
2033
|
+
"delete",
|
|
2034
|
+
"download",
|
|
2035
|
+
"export",
|
|
2036
|
+
"import",
|
|
2037
|
+
"login",
|
|
2038
|
+
"pay",
|
|
2039
|
+
"process",
|
|
2040
|
+
"save",
|
|
2041
|
+
"send",
|
|
2042
|
+
"start",
|
|
2043
|
+
"submit",
|
|
2044
|
+
"update",
|
|
2045
|
+
"upload",
|
|
2046
|
+
"validate",
|
|
2047
|
+
}
|
|
2048
|
+
aliases = {
|
|
2049
|
+
"aggiorna": "update",
|
|
2050
|
+
"aggiornamento": "update",
|
|
2051
|
+
"autenticazione": "authenticate",
|
|
2052
|
+
"autorizzazione": "authorize",
|
|
2053
|
+
"cancella": "delete",
|
|
2054
|
+
"cancellazione": "delete",
|
|
2055
|
+
"carica": "upload",
|
|
2056
|
+
"caricamento": "upload",
|
|
2057
|
+
"crea": "create",
|
|
2058
|
+
"creazione": "create",
|
|
2059
|
+
"elimina": "delete",
|
|
2060
|
+
"esporta": "export",
|
|
2061
|
+
"esportazione": "export",
|
|
2062
|
+
"importa": "import",
|
|
2063
|
+
"importazione": "import",
|
|
2064
|
+
"invio": "send",
|
|
2065
|
+
"pagamento": "pay",
|
|
2066
|
+
"salva": "save",
|
|
2067
|
+
"salvataggio": "save",
|
|
2068
|
+
"validazione": "validate",
|
|
2069
|
+
}
|
|
2070
|
+
terms: set[str] = set()
|
|
2071
|
+
for token in re.findall(r"\w+", question.lower()):
|
|
2072
|
+
if token in action_vocab:
|
|
2073
|
+
terms.add(token)
|
|
2074
|
+
if token in aliases:
|
|
2075
|
+
terms.add(aliases[token])
|
|
2076
|
+
return terms
|
|
2077
|
+
|
|
2078
|
+
|
|
2079
|
+
def _snapshot_svg_byte_size(snapshot: dict[str, Any]) -> int:
|
|
2080
|
+
svg = snapshot.get("svg", "")
|
|
2081
|
+
if not isinstance(svg, str):
|
|
2082
|
+
return 0
|
|
2083
|
+
return len(svg.encode("utf-8"))
|
|
2084
|
+
|
|
2085
|
+
|
|
2086
|
+
def _write_snapshot_artifact(
|
|
2087
|
+
project_root: Path,
|
|
2088
|
+
snapshot: dict[str, Any],
|
|
2089
|
+
*,
|
|
2090
|
+
slice_id: str | None,
|
|
2091
|
+
flow_ids: list[str],
|
|
2092
|
+
canonical_visual: dict[str, Any] | None = None,
|
|
2093
|
+
write_svg: bool = True,
|
|
2094
|
+
) -> dict[str, Any]:
|
|
2095
|
+
svg = snapshot.get("svg")
|
|
2096
|
+
has_svg = write_svg and isinstance(svg, str) and svg.startswith("<svg")
|
|
2097
|
+
mermaid = canonical_visual.get("diagram") if isinstance(canonical_visual, dict) else None
|
|
2098
|
+
if not isinstance(mermaid, str) or not mermaid.strip():
|
|
2099
|
+
mermaid = None
|
|
2100
|
+
if not mermaid and not has_svg:
|
|
2101
|
+
return {
|
|
2102
|
+
"written": False,
|
|
2103
|
+
"reason": "snapshot_visual_unavailable",
|
|
2104
|
+
}
|
|
2105
|
+
digest_parts = (mermaid, svg if has_svg and isinstance(svg, str) else None)
|
|
2106
|
+
digest_input = "\n".join(part for part in digest_parts if part)
|
|
2107
|
+
digest = hashlib.sha256(digest_input.encode("utf-8")).hexdigest()[:16]
|
|
2108
|
+
stem = _snapshot_artifact_stem(slice_id, flow_ids, digest)
|
|
2109
|
+
snapshot_dir = project_root / ".codedebrief" / "snapshots"
|
|
2110
|
+
svg_path = snapshot_dir / f"{stem}.svg"
|
|
2111
|
+
html_path = snapshot_dir / f"{stem}.html"
|
|
2112
|
+
mermaid_path = snapshot_dir / f"{stem}.mmd"
|
|
2113
|
+
mermaid_markdown_path = snapshot_dir / f"{stem}.md"
|
|
2114
|
+
try:
|
|
2115
|
+
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
2116
|
+
if mermaid:
|
|
2117
|
+
mermaid_path.write_text(mermaid + "\n", encoding="utf-8")
|
|
2118
|
+
mermaid_markdown_path.write_text(
|
|
2119
|
+
_snapshot_mermaid_markdown(mermaid, stem, canonical_visual),
|
|
2120
|
+
encoding="utf-8",
|
|
2121
|
+
)
|
|
2122
|
+
if has_svg and isinstance(svg, str):
|
|
2123
|
+
svg_path.write_text(svg, encoding="utf-8")
|
|
2124
|
+
html_path.write_text(_snapshot_artifact_html(svg, stem), encoding="utf-8")
|
|
2125
|
+
except OSError as exc:
|
|
2126
|
+
return {
|
|
2127
|
+
"written": False,
|
|
2128
|
+
"reason": "write_failed",
|
|
2129
|
+
"error": str(exc),
|
|
2130
|
+
}
|
|
2131
|
+
preferred_format = "mermaid" if mermaid else "svg"
|
|
2132
|
+
artifact: dict[str, Any] = {
|
|
2133
|
+
"written": True,
|
|
2134
|
+
"schema_version": "snapshot_artifact.v1",
|
|
2135
|
+
"format": "svg" if has_svg else preferred_format,
|
|
2136
|
+
"formats": [],
|
|
2137
|
+
"preferred_format": preferred_format,
|
|
2138
|
+
"digest": digest,
|
|
2139
|
+
"directory": str(snapshot_dir),
|
|
2140
|
+
"guardrail": (
|
|
2141
|
+
"Mermaid artifacts are the source of truth for chat visuals because they use "
|
|
2142
|
+
"the canonical workflow_slice diagram. SVG artifacts are local inspection "
|
|
2143
|
+
"snapshots and may not match Mermaid layout exactly."
|
|
2144
|
+
),
|
|
2145
|
+
}
|
|
2146
|
+
if mermaid:
|
|
2147
|
+
artifact.update(
|
|
2148
|
+
{
|
|
2149
|
+
"formats": [*artifact["formats"], "mermaid"],
|
|
2150
|
+
"mermaid_path": str(mermaid_path),
|
|
2151
|
+
"mermaid_markdown_path": str(mermaid_markdown_path),
|
|
2152
|
+
"relative_mermaid_path": mermaid_path.relative_to(project_root).as_posix(),
|
|
2153
|
+
"relative_mermaid_markdown_path": mermaid_markdown_path.relative_to(
|
|
2154
|
+
project_root
|
|
2155
|
+
).as_posix(),
|
|
2156
|
+
"mermaid_open_command": _open_file_command(mermaid_markdown_path),
|
|
2157
|
+
"preferred_open_command": _open_file_command(mermaid_markdown_path),
|
|
2158
|
+
}
|
|
2159
|
+
)
|
|
2160
|
+
if has_svg and isinstance(svg, str):
|
|
2161
|
+
relative_svg = svg_path.relative_to(project_root).as_posix()
|
|
2162
|
+
relative_html = html_path.relative_to(project_root).as_posix()
|
|
2163
|
+
artifact.update(
|
|
2164
|
+
{
|
|
2165
|
+
"formats": [*artifact["formats"], "svg"],
|
|
2166
|
+
"svg_path": str(svg_path),
|
|
2167
|
+
"html_path": str(html_path),
|
|
2168
|
+
"relative_svg_path": relative_svg,
|
|
2169
|
+
"relative_html_path": relative_html,
|
|
2170
|
+
"svg_open_command": _open_file_command(html_path),
|
|
2171
|
+
"open_command": _open_file_command(html_path),
|
|
2172
|
+
"markdown_image": f"",
|
|
2173
|
+
}
|
|
2174
|
+
)
|
|
2175
|
+
elif mermaid:
|
|
2176
|
+
artifact["open_command"] = _open_file_command(mermaid_markdown_path)
|
|
2177
|
+
return artifact
|
|
2178
|
+
|
|
2179
|
+
|
|
2180
|
+
def _snapshot_artifact_stem(
|
|
2181
|
+
slice_id: str | None,
|
|
2182
|
+
flow_ids: list[str],
|
|
2183
|
+
digest: str,
|
|
2184
|
+
) -> str:
|
|
2185
|
+
label = slice_id or "-".join(flow_ids[:2]) or "slice"
|
|
2186
|
+
safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", label).strip(".-")
|
|
2187
|
+
if not safe:
|
|
2188
|
+
safe = "slice"
|
|
2189
|
+
return f"snapshot-{safe[:64]}-{digest}"
|
|
2190
|
+
|
|
2191
|
+
|
|
2192
|
+
def _snapshot_artifact_html(svg: str, title: str) -> str:
|
|
2193
|
+
escaped_title = html.escape(title)
|
|
2194
|
+
return "\n".join(
|
|
2195
|
+
[
|
|
2196
|
+
"<!doctype html>",
|
|
2197
|
+
'<html lang="en">',
|
|
2198
|
+
"<head>",
|
|
2199
|
+
' <meta charset="utf-8">',
|
|
2200
|
+
" <title>CodeDebrief snapshot</title>",
|
|
2201
|
+
" <style>",
|
|
2202
|
+
" body { margin: 0; background: #101216; color: #f5f7fb; font-family: "
|
|
2203
|
+
"Inter, ui-sans-serif, system-ui, sans-serif; }",
|
|
2204
|
+
" header { padding: 12px 16px; border-bottom: 1px solid #2b3038; }",
|
|
2205
|
+
" main { padding: 16px; overflow: auto; }",
|
|
2206
|
+
" svg { max-width: none; height: auto; background: #151820; }",
|
|
2207
|
+
" </style>",
|
|
2208
|
+
"</head>",
|
|
2209
|
+
"<body>",
|
|
2210
|
+
f" <header>CodeDebrief snapshot: {escaped_title}</header>",
|
|
2211
|
+
" <main>",
|
|
2212
|
+
svg,
|
|
2213
|
+
" </main>",
|
|
2214
|
+
"</body>",
|
|
2215
|
+
"</html>",
|
|
2216
|
+
]
|
|
2217
|
+
)
|
|
2218
|
+
|
|
2219
|
+
|
|
2220
|
+
def _snapshot_mermaid_markdown(
|
|
2221
|
+
mermaid: str,
|
|
2222
|
+
title: str,
|
|
2223
|
+
canonical_visual: dict[str, Any] | None,
|
|
2224
|
+
) -> str:
|
|
2225
|
+
diagram_hash = ""
|
|
2226
|
+
if isinstance(canonical_visual, dict):
|
|
2227
|
+
diagram_hash = str(canonical_visual.get("diagram_hash") or "")
|
|
2228
|
+
header = ["# CodeDebrief Mermaid Snapshot", "", f"Source: `{title}`"]
|
|
2229
|
+
if diagram_hash:
|
|
2230
|
+
header.append(f"Diagram hash: `{diagram_hash}`")
|
|
2231
|
+
return "\n".join(
|
|
2232
|
+
[
|
|
2233
|
+
*header,
|
|
2234
|
+
"",
|
|
2235
|
+
"```mermaid",
|
|
2236
|
+
mermaid,
|
|
2237
|
+
"```",
|
|
2238
|
+
"",
|
|
2239
|
+
]
|
|
2240
|
+
)
|
|
2241
|
+
|
|
2242
|
+
|
|
2243
|
+
def _open_file_command(path: Path) -> str:
|
|
2244
|
+
quoted = shlex.quote(str(path))
|
|
2245
|
+
if sys.platform == "darwin":
|
|
2246
|
+
return f"open {quoted}"
|
|
2247
|
+
if sys.platform.startswith("win"):
|
|
2248
|
+
return f"start {quoted}"
|
|
2249
|
+
return f"xdg-open {quoted}"
|
|
2250
|
+
|
|
2251
|
+
|
|
2252
|
+
def _single_item_list(value: str | None) -> list[str] | None:
|
|
2253
|
+
if value is None or not value.strip():
|
|
2254
|
+
return None
|
|
2255
|
+
return [value.strip()]
|
|
2256
|
+
|
|
2257
|
+
|
|
2258
|
+
def _agent_scope_filter(model: ProjectModel, scope: str | None) -> tuple[str | None, str | None]:
|
|
2259
|
+
"""Resolve agent-provided scope into (strict_scope_filter, free_text_query_hint).
|
|
2260
|
+
|
|
2261
|
+
Coding agents often pass natural language such as "certificate upload" in ``scope``.
|
|
2262
|
+
CodeDebrief scopes are configured macro-parts like "frontend" or "backend"; unknown
|
|
2263
|
+
scope text should help ranking, not filter every flow out.
|
|
2264
|
+
"""
|
|
2265
|
+
if scope is None or not scope.strip():
|
|
2266
|
+
return None, None
|
|
2267
|
+
normalized = scope.strip()
|
|
2268
|
+
if normalized in _known_scope_names(model):
|
|
2269
|
+
return normalized, None
|
|
2270
|
+
return None, normalized
|
|
2271
|
+
|
|
2272
|
+
|
|
2273
|
+
def _known_scope_names(model: ProjectModel) -> set[str]:
|
|
2274
|
+
names: set[str] = set()
|
|
2275
|
+
scopes = model.metadata.get("scopes", {})
|
|
2276
|
+
if isinstance(scopes, Mapping):
|
|
2277
|
+
names.update(str(name) for name in scopes)
|
|
2278
|
+
for flow in model.flows:
|
|
2279
|
+
names.update(metadata_scope_names(flow.metadata))
|
|
2280
|
+
return names
|
|
2281
|
+
|
|
2282
|
+
|
|
2283
|
+
def _question_with_scope_hint(question: str | None, scope_query_hint: str | None) -> str | None:
|
|
2284
|
+
if scope_query_hint is None:
|
|
2285
|
+
return question
|
|
2286
|
+
if question and question.strip():
|
|
2287
|
+
return f"{question.strip()} {scope_query_hint}"
|
|
2288
|
+
return scope_query_hint
|
|
2289
|
+
|
|
2290
|
+
|
|
2291
|
+
def _selected_code_excerpt(selected_code: str | None, limit: int = 1200) -> str | None:
|
|
2292
|
+
if selected_code is None:
|
|
2293
|
+
return None
|
|
2294
|
+
stripped = selected_code.strip()
|
|
2295
|
+
if not stripped:
|
|
2296
|
+
return None
|
|
2297
|
+
if len(stripped) <= limit:
|
|
2298
|
+
return stripped
|
|
2299
|
+
return stripped[:limit].rstrip() + "..."
|
|
2300
|
+
|
|
2301
|
+
|
|
2302
|
+
def _agent_context_question(question: str | None, selected_code: str | None) -> str | None:
|
|
2303
|
+
if question and question.strip():
|
|
2304
|
+
return question.strip()
|
|
2305
|
+
excerpt = _selected_code_excerpt(selected_code, limit=400)
|
|
2306
|
+
if excerpt:
|
|
2307
|
+
return f"selected code: {excerpt}"
|
|
2308
|
+
return question
|
|
2309
|
+
|
|
2310
|
+
|
|
2311
|
+
def _agent_context_next_tools(pack: dict[str, Any], token_budget: int) -> dict[str, Any]:
|
|
2312
|
+
visual_context = pack.get("visual_context")
|
|
2313
|
+
visual_tools = visual_context.get("next_tools", {}) if isinstance(visual_context, dict) else {}
|
|
2314
|
+
next_tools: dict[str, Any] = {
|
|
2315
|
+
"validate_artifacts": {
|
|
2316
|
+
"tool": "validate_artifacts",
|
|
2317
|
+
"arguments": {"check_sync": True, "include_quality": True},
|
|
2318
|
+
},
|
|
2319
|
+
}
|
|
2320
|
+
if visual_tools:
|
|
2321
|
+
next_tools["visual_context"] = visual_tools
|
|
2322
|
+
impact = pack.get("impact")
|
|
2323
|
+
if isinstance(impact, dict):
|
|
2324
|
+
flow_ids = _string_list(impact.get("subgraph_flow_ids"))
|
|
2325
|
+
if flow_ids:
|
|
2326
|
+
next_tools["snapshot_slice"] = {
|
|
2327
|
+
"tool": "snapshot_slice",
|
|
2328
|
+
"arguments": {
|
|
2329
|
+
"flow_ids": flow_ids,
|
|
2330
|
+
"format": "svg",
|
|
2331
|
+
"include_svg": False,
|
|
2332
|
+
"token_budget": token_budget,
|
|
2333
|
+
},
|
|
2334
|
+
}
|
|
2335
|
+
return next_tools
|
|
2336
|
+
|
|
2337
|
+
|
|
2338
|
+
def _domain_logic_map(
|
|
2339
|
+
model: ProjectModel,
|
|
2340
|
+
*,
|
|
2341
|
+
domain: str | None,
|
|
2342
|
+
value: str | None,
|
|
2343
|
+
scope: str | None,
|
|
2344
|
+
token_budget: int,
|
|
2345
|
+
) -> dict[str, Any]:
|
|
2346
|
+
normalized_domain = domain.strip() if domain and domain.strip() else None
|
|
2347
|
+
normalized_value = value.strip() if value and value.strip() else None
|
|
2348
|
+
concepts: dict[str, dict[str, Any]] = {}
|
|
2349
|
+
|
|
2350
|
+
for flow in model.flows:
|
|
2351
|
+
if not flow_in_agent_scope(flow, scope):
|
|
2352
|
+
continue
|
|
2353
|
+
for node in flow.nodes:
|
|
2354
|
+
if node.kind is not NodeKind.DECISION:
|
|
2355
|
+
continue
|
|
2356
|
+
keys = _domain_keys(node.metadata)
|
|
2357
|
+
if normalized_domain is not None:
|
|
2358
|
+
keys = [key for key in keys if key == normalized_domain]
|
|
2359
|
+
if not keys:
|
|
2360
|
+
continue
|
|
2361
|
+
values = _metadata_string_values(node.metadata.get("values"))
|
|
2362
|
+
for key in keys:
|
|
2363
|
+
concept = concepts.setdefault(key, _empty_domain_concept(key))
|
|
2364
|
+
concept["subjects"].update(_metadata_string_values(node.metadata.get("subject")))
|
|
2365
|
+
concept["value_namespaces"].update(
|
|
2366
|
+
_metadata_string_values(node.metadata.get("value_namespace"))
|
|
2367
|
+
)
|
|
2368
|
+
concept["handled_values"].update(values)
|
|
2369
|
+
concept["flow_ids"].add(flow.id)
|
|
2370
|
+
concept["node_ids"].add(node.id)
|
|
2371
|
+
concept["decision_nodes"].append(
|
|
2372
|
+
{
|
|
2373
|
+
"flow_id": flow.id,
|
|
2374
|
+
"flow": flow.name,
|
|
2375
|
+
"node_id": node.id,
|
|
2376
|
+
"subject": node.metadata.get("subject"),
|
|
2377
|
+
"value_namespace": node.metadata.get("value_namespace"),
|
|
2378
|
+
"values": values,
|
|
2379
|
+
"source": f"{node.location.path}:{node.location.start_line}",
|
|
2380
|
+
"source_range": _source_anchor(node.location),
|
|
2381
|
+
}
|
|
2382
|
+
)
|
|
2383
|
+
|
|
2384
|
+
if normalized_value is not None:
|
|
2385
|
+
concepts = {
|
|
2386
|
+
key: concept
|
|
2387
|
+
for key, concept in concepts.items()
|
|
2388
|
+
if _metadata_value_matches(normalized_value, concept["handled_values"])
|
|
2389
|
+
}
|
|
2390
|
+
concept_rows = [_domain_concept_payload(item, token_budget) for item in concepts.values()]
|
|
2391
|
+
concept_rows.sort(
|
|
2392
|
+
key=lambda item: (
|
|
2393
|
+
-item["decision_count"],
|
|
2394
|
+
item["domain"],
|
|
2395
|
+
)
|
|
2396
|
+
)
|
|
2397
|
+
if token_budget > 0:
|
|
2398
|
+
concept_limit = max(1, token_budget // 300)
|
|
2399
|
+
omitted = max(0, len(concept_rows) - concept_limit)
|
|
2400
|
+
concept_rows = concept_rows[:concept_limit]
|
|
2401
|
+
else:
|
|
2402
|
+
omitted = 0
|
|
2403
|
+
return {
|
|
2404
|
+
"tool": "domain_logic",
|
|
2405
|
+
"guardrail": (
|
|
2406
|
+
"Domain maps are deterministic summaries of decision metadata. They show "
|
|
2407
|
+
"where values and state-like concepts are handled in modeled flows; they "
|
|
2408
|
+
"do not infer missing cases or defects."
|
|
2409
|
+
),
|
|
2410
|
+
"filters": {
|
|
2411
|
+
"domain": normalized_domain,
|
|
2412
|
+
"value": normalized_value,
|
|
2413
|
+
"scope": scope,
|
|
2414
|
+
"token_budget": token_budget,
|
|
2415
|
+
},
|
|
2416
|
+
"concepts": concept_rows,
|
|
2417
|
+
"omitted_concept_count": omitted,
|
|
2418
|
+
"next_tools": {
|
|
2419
|
+
"agent_context": {
|
|
2420
|
+
"tool": "agent_context",
|
|
2421
|
+
"arguments": {
|
|
2422
|
+
"domain": normalized_domain,
|
|
2423
|
+
"value": normalized_value,
|
|
2424
|
+
"scope": scope,
|
|
2425
|
+
"token_budget": token_budget,
|
|
2426
|
+
},
|
|
2427
|
+
},
|
|
2428
|
+
},
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
|
|
2432
|
+
def _empty_domain_concept(domain: str) -> dict[str, Any]:
|
|
2433
|
+
return {
|
|
2434
|
+
"domain": domain,
|
|
2435
|
+
"subjects": set(),
|
|
2436
|
+
"value_namespaces": set(),
|
|
2437
|
+
"handled_values": set(),
|
|
2438
|
+
"flow_ids": set(),
|
|
2439
|
+
"node_ids": set(),
|
|
2440
|
+
"decision_nodes": [],
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
|
|
2444
|
+
def _domain_concept_payload(concept: dict[str, Any], token_budget: int) -> dict[str, Any]:
|
|
2445
|
+
per_section_limit = max(1, token_budget // 240) if token_budget > 0 else 20
|
|
2446
|
+
flow_ids = sorted(concept["flow_ids"])
|
|
2447
|
+
visible_flow_ids = flow_ids[:per_section_limit] if token_budget > 0 else flow_ids
|
|
2448
|
+
return {
|
|
2449
|
+
"domain": concept["domain"],
|
|
2450
|
+
"subjects": sorted(concept["subjects"]),
|
|
2451
|
+
"value_namespaces": sorted(concept["value_namespaces"]),
|
|
2452
|
+
"handled_values": sorted(concept["handled_values"]),
|
|
2453
|
+
"decision_count": len(concept["decision_nodes"]),
|
|
2454
|
+
"flow_count": len(concept["flow_ids"]),
|
|
2455
|
+
"decision_nodes": concept["decision_nodes"][:per_section_limit],
|
|
2456
|
+
"omitted_decision_count": max(0, len(concept["decision_nodes"]) - per_section_limit),
|
|
2457
|
+
"omitted_subgraph_flow_count": max(0, len(flow_ids) - len(visible_flow_ids)),
|
|
2458
|
+
"subgraph_flow_ids": visible_flow_ids,
|
|
2459
|
+
"next_tools": {
|
|
2460
|
+
"agent_context": {
|
|
2461
|
+
"tool": "agent_context",
|
|
2462
|
+
"arguments": {"domain": concept["domain"]},
|
|
2463
|
+
},
|
|
2464
|
+
"snapshot_slice": {
|
|
2465
|
+
"tool": "snapshot_slice",
|
|
2466
|
+
"arguments": {
|
|
2467
|
+
"flow_ids": visible_flow_ids,
|
|
2468
|
+
"format": "svg",
|
|
2469
|
+
"include_svg": False,
|
|
2470
|
+
},
|
|
2471
|
+
},
|
|
2472
|
+
},
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
|
|
2476
|
+
def _domain_keys(metadata: dict[str, Any]) -> list[str]:
|
|
2477
|
+
keys = [
|
|
2478
|
+
*_metadata_string_values(metadata.get("domain")),
|
|
2479
|
+
*_metadata_string_values(metadata.get("value_namespace")),
|
|
2480
|
+
]
|
|
2481
|
+
return list(dict.fromkeys(key for key in keys if key))
|
|
2482
|
+
|
|
2483
|
+
|
|
2484
|
+
def _metadata_string_values(value: Any) -> list[str]:
|
|
2485
|
+
if value is None:
|
|
2486
|
+
return []
|
|
2487
|
+
if isinstance(value, str):
|
|
2488
|
+
stripped = value.strip()
|
|
2489
|
+
return [stripped] if stripped else []
|
|
2490
|
+
if isinstance(value, list | tuple | set):
|
|
2491
|
+
return [str(item) for item in value if str(item).strip()]
|
|
2492
|
+
return [str(value)]
|
|
2493
|
+
|
|
2494
|
+
|
|
2495
|
+
def _metadata_value_matches(target: str, values: Iterable[str]) -> bool:
|
|
2496
|
+
normalized_target = _normalized_domain_value(target)
|
|
2497
|
+
for value in values:
|
|
2498
|
+
normalized_value = _normalized_domain_value(value)
|
|
2499
|
+
if normalized_value == normalized_target:
|
|
2500
|
+
return True
|
|
2501
|
+
suffix = str(value).rsplit(".", maxsplit=1)[-1]
|
|
2502
|
+
if _normalized_domain_value(suffix) == normalized_target:
|
|
2503
|
+
return True
|
|
2504
|
+
return False
|
|
2505
|
+
|
|
2506
|
+
|
|
2507
|
+
def _normalized_domain_value(value: str) -> str:
|
|
2508
|
+
return value.strip().strip("\"'").lower()
|
|
2509
|
+
|
|
2510
|
+
|
|
2511
|
+
def _context_navigation_pack(
|
|
2512
|
+
model: ProjectModel,
|
|
2513
|
+
*,
|
|
2514
|
+
impact: Any,
|
|
2515
|
+
matches: list[Any],
|
|
2516
|
+
token_budget: int,
|
|
2517
|
+
) -> dict[str, Any]:
|
|
2518
|
+
flow_candidates = _context_visual_flows(impact, matches)
|
|
2519
|
+
flow_limit = _context_navigation_item_budget(token_budget)
|
|
2520
|
+
per_flow_budget = _context_navigation_token_budget(token_budget, flow_limit)
|
|
2521
|
+
selected = flow_candidates[:flow_limit]
|
|
2522
|
+
return {
|
|
2523
|
+
"flow_budget": flow_limit,
|
|
2524
|
+
"per_flow_token_budget": per_flow_budget,
|
|
2525
|
+
"flows": [flow_navigation(model, flow.id, per_flow_budget) for flow in selected],
|
|
2526
|
+
"next_tools": {
|
|
2527
|
+
"agent_context": [
|
|
2528
|
+
{
|
|
2529
|
+
"tool": "agent_context",
|
|
2530
|
+
"arguments": {
|
|
2531
|
+
"flow_id": flow.id,
|
|
2532
|
+
"token_budget": per_flow_budget,
|
|
2533
|
+
},
|
|
2534
|
+
}
|
|
2535
|
+
for flow in selected
|
|
2536
|
+
]
|
|
2537
|
+
},
|
|
2538
|
+
"omitted_flow_navigation_count": max(0, len(flow_candidates) - flow_limit),
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
|
|
2542
|
+
def _context_navigation_item_budget(token_budget: int) -> int:
|
|
2543
|
+
return _context_item_budget(token_budget)
|
|
2544
|
+
|
|
2545
|
+
|
|
2546
|
+
def _context_navigation_token_budget(token_budget: int, flow_limit: int) -> int:
|
|
2547
|
+
if token_budget <= 0:
|
|
2548
|
+
return 0
|
|
2549
|
+
return max(60, token_budget // max(1, flow_limit))
|
|
2550
|
+
|
|
2551
|
+
|
|
2552
|
+
def _context_visual_item_budget(token_budget: int) -> int:
|
|
2553
|
+
return _context_item_budget(token_budget)
|
|
2554
|
+
|
|
2555
|
+
|
|
2556
|
+
def _context_item_budget(token_budget: int) -> int:
|
|
2557
|
+
if token_budget <= 0:
|
|
2558
|
+
return 2
|
|
2559
|
+
return max(1, min(3, token_budget // 300))
|
|
2560
|
+
|
|
2561
|
+
|
|
2562
|
+
def _string_list(value: Any) -> list[str]:
|
|
2563
|
+
if not isinstance(value, list):
|
|
2564
|
+
return []
|
|
2565
|
+
return [item for item in value if isinstance(item, str)]
|
|
2566
|
+
|
|
2567
|
+
|
|
2568
|
+
def _validation_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
2569
|
+
next_tools: dict[str, dict[str, Any]] = {}
|
|
2570
|
+
if not payload.get("ok"):
|
|
2571
|
+
next_tools = {
|
|
2572
|
+
"update_model": {
|
|
2573
|
+
"tool": "update_codedebrief",
|
|
2574
|
+
"arguments": {"full": True},
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
return {
|
|
2578
|
+
**payload,
|
|
2579
|
+
"guardrail": (
|
|
2580
|
+
"Artifact validation checks generated model freshness, schema, and optional "
|
|
2581
|
+
"analyzer-quality thresholds."
|
|
2582
|
+
),
|
|
2583
|
+
"next_tools": next_tools,
|
|
2584
|
+
"next_cli": _validation_next_cli(bool(payload.get("ok"))),
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
|
|
2588
|
+
def _validation_next_cli(ok: bool) -> list[str]:
|
|
2589
|
+
if ok:
|
|
2590
|
+
return [
|
|
2591
|
+
"codedebrief validate --quality --json",
|
|
2592
|
+
"codedebrief view",
|
|
2593
|
+
]
|
|
2594
|
+
return [
|
|
2595
|
+
"codedebrief update --full",
|
|
2596
|
+
"codedebrief validate --check-sync --json",
|
|
2597
|
+
]
|
|
2598
|
+
|
|
2599
|
+
|
|
2600
|
+
def _update_workflow_payload(
|
|
2601
|
+
json_path: Path,
|
|
2602
|
+
markdown_path: Path,
|
|
2603
|
+
html_path: Path | None,
|
|
2604
|
+
) -> dict[str, Any]:
|
|
2605
|
+
return {
|
|
2606
|
+
"guardrail": (
|
|
2607
|
+
"The model has been regenerated from local source files. Validate sync and "
|
|
2608
|
+
"quality before relying on MCP context or committing generated artifacts."
|
|
2609
|
+
),
|
|
2610
|
+
"next_tools": {
|
|
2611
|
+
"validate_artifacts": {
|
|
2612
|
+
"tool": "validate_artifacts",
|
|
2613
|
+
"arguments": {"check_sync": True, "include_quality": True},
|
|
2614
|
+
},
|
|
2615
|
+
},
|
|
2616
|
+
"next_artifacts": {
|
|
2617
|
+
"commit": [str(json_path), str(markdown_path)],
|
|
2618
|
+
"local_html": str(html_path) if html_path else None,
|
|
2619
|
+
},
|
|
2620
|
+
"next_cli": [
|
|
2621
|
+
"codedebrief validate --check-sync --json",
|
|
2622
|
+
"codedebrief validate --quality",
|
|
2623
|
+
],
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
|
|
2627
|
+
def _list_dicts(value: Any) -> list[dict[str, Any]]:
|
|
2628
|
+
if not isinstance(value, list):
|
|
2629
|
+
return []
|
|
2630
|
+
return [item for item in value if isinstance(item, dict)]
|
|
2631
|
+
|
|
2632
|
+
|
|
2633
|
+
def _unknown_target_error(target_type: str, target_id: str) -> dict[str, Any]:
|
|
2634
|
+
next_tools: dict[str, dict[str, Any]] = {
|
|
2635
|
+
"agent_context": {
|
|
2636
|
+
"tool": "agent_context",
|
|
2637
|
+
"arguments": {"question": target_id, "token_budget": 600},
|
|
2638
|
+
},
|
|
2639
|
+
}
|
|
2640
|
+
return {
|
|
2641
|
+
"error": f"Unknown {target_type}: {target_id}",
|
|
2642
|
+
"error_code": f"{target_type}_not_found",
|
|
2643
|
+
"target_type": target_type,
|
|
2644
|
+
"target_id": target_id,
|
|
2645
|
+
"recoverable": True,
|
|
2646
|
+
"guardrail": (
|
|
2647
|
+
"This reports an invalid MCP target from the generated model. Re-run "
|
|
2648
|
+
"agent_context with a narrower question to resolve a current flow/node handle."
|
|
2649
|
+
),
|
|
2650
|
+
"next_tools": next_tools,
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
|
|
2654
|
+
def _try_load(
|
|
2655
|
+
project_root: Path,
|
|
2656
|
+
config: CodeDebriefConfig,
|
|
2657
|
+
) -> tuple[ProjectModel | None, dict[str, Any] | None]:
|
|
2658
|
+
"""Load the model, or return a clean error dict the tool can hand back.
|
|
2659
|
+
|
|
2660
|
+
Every MCP tool reads the persisted model first; without this a missing or corrupt
|
|
2661
|
+
model would propagate a raw traceback to the calling agent.
|
|
2662
|
+
"""
|
|
2663
|
+
try:
|
|
2664
|
+
return load_model(project_root, config), None
|
|
2665
|
+
except _LOAD_ERRORS as error:
|
|
2666
|
+
return None, _model_load_error(project_root, config, error)
|
|
2667
|
+
|
|
2668
|
+
|
|
2669
|
+
def _model_load_error(
|
|
2670
|
+
project_root: Path,
|
|
2671
|
+
config: CodeDebriefConfig,
|
|
2672
|
+
error: BaseException,
|
|
2673
|
+
) -> dict[str, Any]:
|
|
2674
|
+
json_path, markdown_path, html_path = output_paths(project_root, config)
|
|
2675
|
+
return {
|
|
2676
|
+
"error": "Could not load the CodeDebrief model.",
|
|
2677
|
+
"error_code": _model_load_error_code(error),
|
|
2678
|
+
"detail": str(error),
|
|
2679
|
+
"artifact": str(json_path),
|
|
2680
|
+
"related_artifacts": {
|
|
2681
|
+
"markdown": str(markdown_path),
|
|
2682
|
+
"html": str(html_path),
|
|
2683
|
+
},
|
|
2684
|
+
"recoverable": True,
|
|
2685
|
+
"guardrail": (
|
|
2686
|
+
"This reports missing or invalid generated artifacts. Run update_codedebrief "
|
|
2687
|
+
"and validate_artifacts before relying on MCP context."
|
|
2688
|
+
),
|
|
2689
|
+
"next_tools": {
|
|
2690
|
+
"update_model": {
|
|
2691
|
+
"tool": "update_codedebrief",
|
|
2692
|
+
"arguments": {"full": True},
|
|
2693
|
+
},
|
|
2694
|
+
"validate_artifacts": {
|
|
2695
|
+
"tool": "validate_artifacts",
|
|
2696
|
+
"arguments": {"check_sync": True, "include_quality": True},
|
|
2697
|
+
},
|
|
2698
|
+
},
|
|
2699
|
+
"next_cli": [
|
|
2700
|
+
"codedebrief update --full",
|
|
2701
|
+
"codedebrief validate --check-sync --json",
|
|
2702
|
+
],
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
|
|
2706
|
+
def _model_load_error_code(error: BaseException) -> str:
|
|
2707
|
+
if isinstance(error, FileNotFoundError):
|
|
2708
|
+
return "artifact_missing"
|
|
2709
|
+
if isinstance(error, PermissionError):
|
|
2710
|
+
return "artifact_unreadable"
|
|
2711
|
+
if isinstance(error, OSError):
|
|
2712
|
+
return "artifact_unreadable"
|
|
2713
|
+
detail = str(error)
|
|
2714
|
+
if isinstance(error, ValueError) and "invalid JSON" in detail:
|
|
2715
|
+
return "artifact_malformed_json"
|
|
2716
|
+
return "artifact_invalid"
|
|
2717
|
+
|
|
2718
|
+
|
|
2719
|
+
def flow_in_agent_scope(flow: Any, scope: str | None) -> bool:
|
|
2720
|
+
return scope is None or scope in metadata_scope_names(flow.metadata)
|