mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 03:09:50 +08:00
846 lines
36 KiB
Python
Executable File
846 lines
36 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Idempotent wave parser — builds `$WORK_DIR/declared_action_tree.json`.
|
|
|
|
Replaces old agent Phase 4 (wave-2 init) + Phase 5/6 re-parse + MAX_WAVE cap.
|
|
One script, one code path. The "init vs re-parse" distinction is keyed on the
|
|
presence of `declared_action_tree.json` at call time.
|
|
|
|
Flow:
|
|
1. Load bundle JSON, bot_definition JSON, existing tree (if any).
|
|
2. Walk every $WORK_DIR/sf_meta/*/unpackaged/ dir:
|
|
- flows/*.flow: parse actionCalls → APEX/PROMPT_TEMPLATE/STANDARD_ACTION/
|
|
UNKNOWN children; subflows → FLOW children. Record flow_children[flow_name].
|
|
Mark ("FLOW", flow_name) visited.
|
|
- classes/*.cls-meta.xml: mark ("APEX", name) visited.
|
|
- genAiPromptTemplates/*.genAiPromptTemplate: mark ("PROMPT_TEMPLATE", name).
|
|
3. Build tree if missing (Phase-4 init):
|
|
- agent metadata from bot_definition + bundle
|
|
- root BOT_DEFINITION node with children = topics[] only
|
|
(plannerActions[] retained in the bundle shape for back-compat but
|
|
always empty — a planner never has direct functions, 2026-05-05)
|
|
- each topic → TOPIC node with GEN_AI_FUNCTION children (from inline actions)
|
|
- each GEN_AI_FUNCTION → unwraps_to + leaf child (Flow/Apex/Prompt/Std/Unk)
|
|
4. Inflate every FLOW leaf recursively (depth ≤ 6) with flow_children data.
|
|
5. Recompute `_pending_fetches` (names not yet in visited set).
|
|
6. Walk tree for `node_count`, `depth`, `_kind_counts`.
|
|
7. Atomic write.
|
|
|
|
With --finalize-cap: after loading tree, move all remaining _pending_fetches
|
|
items to _unresolved[] with reason "max-wave-depth exceeded".
|
|
|
|
Usage:
|
|
python3 parse_wave.py # init or re-parse
|
|
python3 parse_wave.py --finalize-cap # drain _pending_fetches → _unresolved
|
|
|
|
Inputs (env):
|
|
WORK_DIR, AGENT_API_NAME, AGENT_VERSION, BOT_ID, BOT_MASTER_LABEL,
|
|
VERSION_AUTO_PICKED
|
|
|
|
Outputs:
|
|
$WORK_DIR/declared_action_tree.json (atomic write)
|
|
stderr: log lines (node counts, pending counts, etc.)
|
|
exit 0 on success, 1 on write failure
|
|
"""
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from collections import Counter
|
|
|
|
|
|
NS = {"sf": "http://soap.sforce.com/2006/04/metadata"}
|
|
|
|
|
|
def _t(el, p):
|
|
if el is None:
|
|
return None
|
|
x = el.find(p, NS)
|
|
return x.text if x is not None else None
|
|
|
|
|
|
def classify_bundle_action(action: dict) -> dict:
|
|
"""Bundle-action classifier — duplicated in plan_wave.py per 'no
|
|
intra-skill imports' convention. Matches old agent's Phase 3/4 logic.
|
|
|
|
Returns (unwraps_dict, leaf_dict) as in the old agent Phase 4's
|
|
action_node(). leaf_dict is the child appended under the GEN_AI_FUNCTION
|
|
node; unwraps_dict is the compact form stored on the function itself.
|
|
"""
|
|
tgt = action.get("invocationTarget")
|
|
ttype = (action.get("invocationTargetType") or "").lower()
|
|
if not tgt:
|
|
return None, None
|
|
if ttype == "flow":
|
|
return ({"kind": "FLOW", "api_name": tgt},
|
|
{"kind": "FLOW", "api_name": tgt, "children": []})
|
|
if ttype == "apex":
|
|
return ({"kind": "APEX", "api_name": tgt},
|
|
{"kind": "APEX", "api_name": tgt})
|
|
if ttype == "generatepromptresponse" or ttype.startswith("prompt") or ttype.startswith("genai"):
|
|
return ({"kind": "PROMPT_TEMPLATE", "api_name": tgt},
|
|
{"kind": "PROMPT_TEMPLATE", "api_name": tgt})
|
|
# Canonical field name is `invocation_type` (schema 3.1). Prior
|
|
# versions wrote `raw_invocation_type` on bundle-sourced nodes and
|
|
# `raw_action_type` on flow-actionCall-sourced nodes — downstream
|
|
# readers (render_architecture._display_name, summarize_tree) fall
|
|
# back to both legacy keys for one release.
|
|
if ttype == "standardinvocableaction":
|
|
return ({"kind": "STANDARD_ACTION", "api_name": tgt, "invocation_type": ttype},
|
|
{"kind": "STANDARD_ACTION", "api_name": tgt, "invocation_type": ttype})
|
|
return ({"kind": "UNKNOWN", "api_name": tgt, "invocation_type": ttype},
|
|
{"kind": "UNKNOWN", "api_name": tgt, "invocation_type": ttype})
|
|
|
|
|
|
def classify_action_call(at: str, an: str, element_name: str) -> dict:
|
|
"""Flow actionCall classifier (old Phase 4 logic).
|
|
|
|
Canonical field name is `invocation_type` (schema 3.1). Legacy readers
|
|
still fall back to `raw_action_type` for one release.
|
|
"""
|
|
at = at or ""
|
|
if at == "apex" and an:
|
|
return {"kind": "APEX", "element_name": element_name, "api_name": an}
|
|
if at == "generatePromptResponse" and an:
|
|
return {"kind": "PROMPT_TEMPLATE", "element_name": element_name, "api_name": an}
|
|
if at:
|
|
# Any other non-empty actionType → STANDARD_ACTION; preserve raw.
|
|
return {"kind": "STANDARD_ACTION", "element_name": element_name,
|
|
"api_name": an or at, "invocation_type": at}
|
|
return {"kind": "UNKNOWN", "element_name": element_name,
|
|
"api_name": an or "?", "invocation_type": at or ""}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# pure BFS step helper. Visited sets are tuple-keyed on
|
|
# (kind, canonical_name). Pending is a dict-by-kind so cross-kind collisions
|
|
# (Flow Foo vs Apex Foo) stay distinct.
|
|
#
|
|
# this is the single source of truth for wave-level BFS. Both
|
|
# `harvest_waves` and `main()` route through here so the tuple-keyed
|
|
# semantics are active on the production path — not just under unit tests.
|
|
#
|
|
# kind tokens match the runtime tree's `kind` field
|
|
# (FLOW/APEX/PROMPT_TEMPLATE/STANDARD_ACTION). The persisted
|
|
# `_pending_fetches` dict uses the SAME tokens so internal + on-disk
|
|
# conventions don't diverge.
|
|
#
|
|
# promoted from `_BFS_KINDS` → `BFS_KINDS` so
|
|
# cross-module callers (main.py's in-process orchestrator) import a stable
|
|
# public symbol instead of reaching across a leading-underscore boundary.
|
|
# Same pattern as rest_client.redact_text's promotion. The underscore
|
|
# alias below is retained for backwards compatibility and will be removed
|
|
# in the next minor version; new code MUST use `BFS_KINDS`.
|
|
# unified node-truncation annotation. When a node is not fully
|
|
# expanded (either because of a cycle back to an ancestor, or because the
|
|
# MAX_BFS_DEPTH cap tripped), we annotate it with a `_truncated` sub-
|
|
# object of shape `{"reason": <str>, "target": <str>}`. `reason` is one
|
|
# of the values below; `target` is a `"KIND:name"` path pointing at the
|
|
# first encounter (cycle) or the unreached leaf (depth-cap). Downstream
|
|
# consumers check `_truncated["reason"]` once; no per-reason pattern
|
|
# matching.
|
|
#
|
|
# Backcompat: `_cycle_back_to` is emitted alongside `_truncated` for any
|
|
# consumer that hasn't migrated (render_architecture, summarize_tree,
|
|
# third-party tooling). Will be removed in the next minor version.
|
|
TRUNCATION_CYCLE = "cycle"
|
|
TRUNCATION_MAX_DEPTH = "max-depth"
|
|
TRUNCATION_REASONS = frozenset({TRUNCATION_CYCLE, TRUNCATION_MAX_DEPTH})
|
|
|
|
|
|
BFS_KINDS = ("FLOW", "APEX", "PROMPT_TEMPLATE", "STANDARD_ACTION")
|
|
|
|
# STANDARD_ACTION is a Salesforce-owned builtin (e.g.
|
|
# `streamKnowledgeSearch`, `createRecord`, `sendEmail`). These are never
|
|
# fetched — they're declared-only leaves, with the action name carrying
|
|
# all the identity. Keeping STANDARD_ACTION in BFS_KINDS lets the tree's
|
|
# _kind_counts tally include them and visited-bookkeeping dedup against
|
|
# them, but they must never accumulate into `_pending_fetches`. A name
|
|
# like `streamKnowledgeSearch` landing in _pending_fetches.STANDARD_ACTION
|
|
# is pollution — the pipeline isn't "missing" anything; the action is
|
|
# simply declared and never materialized.
|
|
#
|
|
# Callers: when collecting refs into `new_refs`, gate on FETCHABLE_KINDS
|
|
# rather than BFS_KINDS to avoid polluting pending buckets with leaves.
|
|
FETCHABLE_KINDS = ("FLOW", "APEX", "PROMPT_TEMPLATE")
|
|
|
|
# deprecated alias. Retained so existing tests and any lingering
|
|
# `from parse_wave import _BFS_KINDS` imports keep working. New code MUST
|
|
# use `BFS_KINDS` (public). Planned removal in the next minor version.
|
|
_BFS_KINDS = BFS_KINDS
|
|
|
|
|
|
def bfs_step(
|
|
pending_by_kind: dict[str, set[str]],
|
|
visited_by_kind: dict[str, set[str]],
|
|
new_refs_by_kind: dict[str, set[str]],
|
|
) -> tuple[dict[str, set[str]], list[tuple[str, str]]]:
|
|
"""Advance one BFS wave. Returns (new_pending_by_kind, cycles).
|
|
|
|
`cycles` is a list of (kind, name) tuples for refs that were already
|
|
visited on this wave — callers use these to annotate `_cycle_back_to`.
|
|
|
|
Self-cycles (a ref pointing at something already visited) are filtered
|
|
out of pending. Cross-type tuples (Flow Foo + Apex Foo) are distinct —
|
|
both land in their respective pending buckets.
|
|
|
|
unknown kinds RAISE — this is an internal API; a typo in a caller
|
|
is a programming error, not a recoverable runtime condition. Silent drop
|
|
produced false confidence (the dropped ref never got fetched and no log
|
|
ever mentioned it). Callers must filter to `BFS_KINDS` upstream.
|
|
"""
|
|
new_pending: dict[str, set[str]] = {k: set() for k in BFS_KINDS}
|
|
cycles: list[tuple[str, str]] = []
|
|
for kind, refs in new_refs_by_kind.items():
|
|
if kind not in BFS_KINDS:
|
|
raise ValueError(
|
|
f"unknown BFS kind {kind!r}; must be one of {sorted(BFS_KINDS)}"
|
|
)
|
|
visited = visited_by_kind.get(kind, set())
|
|
for name in refs:
|
|
if name in visited:
|
|
cycles.append((kind, name))
|
|
continue
|
|
# Not yet visited AND not already pending on a prior wave:
|
|
# OR-in unconditionally — `pending |= (new - visited)` semantics.
|
|
new_pending[kind].add(name)
|
|
# Merge with the caller's existing pending. Caller passes the authoritative
|
|
# pending buckets; we return the delta merged in so they can just replace.
|
|
merged: dict[str, set[str]] = {k: set(pending_by_kind.get(k, set())) for k in BFS_KINDS}
|
|
for kind, names in new_pending.items():
|
|
merged[kind] |= names
|
|
return merged, cycles
|
|
|
|
|
|
def empty_kind_sets() -> dict[str, set[str]]:
|
|
"""Build a fresh {kind: set()} dict covering every BFS_KINDS entry.
|
|
|
|
callers use this to seed per-kind visited / pending
|
|
buckets. Keeping it centralized means new kinds added to BFS_KINDS
|
|
automatically flow through harvest_waves, bfs_step, and main().
|
|
|
|
promoted from `_empty_kind_sets` → public so
|
|
cross-module callers (main.py) can import a stable name instead of
|
|
reaching into a private symbol. The underscore alias below is kept
|
|
for backwards compatibility; it will be removed in the next minor
|
|
version.
|
|
"""
|
|
return {k: set() for k in BFS_KINDS}
|
|
|
|
|
|
# deprecated alias. Retained for backwards compatibility with any
|
|
# lingering `from parse_wave import _empty_kind_sets` imports; new code
|
|
# MUST use `empty_kind_sets` (public). Planned removal in the next minor
|
|
# version.
|
|
_empty_kind_sets = empty_kind_sets
|
|
|
|
|
|
def harvest_waves(
|
|
work_dir: pathlib.Path,
|
|
) -> tuple[dict[str, list[dict]], dict[str, set[str]], dict[str, set[str]], list[tuple[str, str]]]:
|
|
"""Walk sf_meta/*/unpackaged/ dirs.
|
|
|
|
Returns (flow_children, visited_by_kind, pending_by_kind, cycles).
|
|
|
|
every ref collected from Flow XML (actionCalls + subflows)
|
|
is routed through `bfs_step` per-flow. The tuple-keyed visited set is
|
|
the authoritative record; flat per-name sets have been removed.
|
|
|
|
the two dicts are keyed by `BFS_KINDS` tokens
|
|
(FLOW/APEX/PROMPT_TEMPLATE/STANDARD_ACTION) — matching the runtime
|
|
tree's `kind` field and the on-disk `_pending_fetches` layout.
|
|
"""
|
|
flow_children: dict[str, list[dict]] = {}
|
|
visited_by_kind: dict[str, set[str]] = empty_kind_sets()
|
|
pending_by_kind: dict[str, set[str]] = empty_kind_sets()
|
|
cycles: list[tuple[str, str]] = []
|
|
|
|
sf_meta = work_dir / "sf_meta"
|
|
if not sf_meta.exists():
|
|
return flow_children, visited_by_kind, pending_by_kind, cycles
|
|
|
|
all_wave_dirs = sorted(
|
|
p / "unpackaged" for p in sf_meta.iterdir()
|
|
if p.is_dir() and (p / "unpackaged").exists()
|
|
)
|
|
|
|
# Pass 1: flows (+ route per-flow refs through bfs_step).
|
|
for wave_dir in all_wave_dirs:
|
|
flows_dir = wave_dir / "flows"
|
|
if not flows_dir.exists():
|
|
continue
|
|
for f in sorted(flows_dir.glob("*.flow")):
|
|
flow_name = f.stem
|
|
if flow_name in flow_children:
|
|
continue # already harvested this wave dir (earlier iter)
|
|
visited_by_kind["FLOW"].add(flow_name)
|
|
try:
|
|
root = ET.parse(f).getroot()
|
|
except ET.ParseError:
|
|
flow_children[flow_name] = []
|
|
continue
|
|
children: list[dict] = []
|
|
# Collect per-flow refs into kind-bucketed sets for one bfs_step.
|
|
new_refs: dict[str, set[str]] = empty_kind_sets()
|
|
for ac in root.findall("sf:actionCalls", NS):
|
|
n = _t(ac, "sf:name")
|
|
at = _t(ac, "sf:actionType") or ""
|
|
an = _t(ac, "sf:actionName")
|
|
item = classify_action_call(at, an, n)
|
|
children.append(item)
|
|
k = item["kind"]
|
|
api = item["api_name"]
|
|
# Only route FETCHABLE kinds into `new_refs` — STANDARD_ACTION
|
|
# items stay as leaf children with no further fetching implied
|
|
# . UNKNOWN items are similarly skipped.
|
|
if k in FETCHABLE_KINDS and api:
|
|
new_refs[k].add(api)
|
|
for sub in root.findall("sf:subflows", NS):
|
|
n = _t(sub, "sf:name")
|
|
fn = _t(sub, "sf:flowName")
|
|
if fn:
|
|
children.append({"kind": "FLOW", "element_name": n, "api_name": fn})
|
|
new_refs["FLOW"].add(fn)
|
|
flow_children[flow_name] = children
|
|
# bfs_step is the canonical merge point.
|
|
pending_by_kind, step_cycles = bfs_step(
|
|
pending_by_kind, visited_by_kind, new_refs
|
|
)
|
|
cycles.extend(step_cycles)
|
|
|
|
# Pass 2: APEX classes (mark visited).
|
|
for wave_dir in all_wave_dirs:
|
|
apex_dir = wave_dir / "classes"
|
|
if apex_dir.exists():
|
|
for f in sorted(apex_dir.glob("*.cls-meta.xml")):
|
|
visited_by_kind["APEX"].add(f.name.replace(".cls-meta.xml", ""))
|
|
|
|
# Pass 3: Prompt templates (mark visited).
|
|
for wave_dir in all_wave_dirs:
|
|
prompt_dir = wave_dir / "genAiPromptTemplates"
|
|
if prompt_dir.exists():
|
|
for f in sorted(prompt_dir.glob("*.genAiPromptTemplate")):
|
|
visited_by_kind["PROMPT_TEMPLATE"].add(f.stem)
|
|
|
|
# Passes 2/3 may have added visited entries AFTER pass-1 pending routing;
|
|
# prune anything that's now visited. This preserves the invariant that
|
|
# `pending ∩ visited = ∅` without needing a second bfs_step.
|
|
for kind in BFS_KINDS:
|
|
pending_by_kind[kind] -= visited_by_kind[kind]
|
|
|
|
return flow_children, visited_by_kind, pending_by_kind, cycles
|
|
|
|
|
|
def init_tree(work_dir: pathlib.Path, bundle: dict) -> dict:
|
|
bd_rec = {}
|
|
bd_file = work_dir / "_bot_definition.json"
|
|
if bd_file.exists():
|
|
try:
|
|
recs = (json.loads(bd_file.read_text()).get("result") or {}).get("records") or []
|
|
if recs:
|
|
bd_rec = recs[0]
|
|
except (OSError, json.JSONDecodeError):
|
|
pass
|
|
|
|
tree = {
|
|
# 3.0 bumps the _pending_fetches key convention from
|
|
# Metadata-API tokens (Flow/ApexClass/GenAiPromptTemplate) to the
|
|
# runtime-tree `kind` tokens (FLOW/APEX/PROMPT_TEMPLATE/
|
|
# STANDARD_ACTION). `cache_check.py`'s schema-version gate busts any
|
|
# pre-3.0 cache on first run.
|
|
#
|
|
# 3.1 (2026-05-05) canonicalizes `invocation_type` on STANDARD_ACTION
|
|
# nodes (formerly split across `raw_invocation_type` on bundle-
|
|
# sourced nodes and `raw_action_type` on flow-actionCall-sourced
|
|
# nodes). Readers fall back to the two legacy keys for one release.
|
|
"_schema_version": "3.1",
|
|
"agent": {
|
|
"api_name": os.environ["AGENT_API_NAME"],
|
|
"version": os.environ["AGENT_VERSION"],
|
|
"bot_id": os.environ.get("BOT_ID", ""),
|
|
"master_label": bd_rec.get("MasterLabel") or os.environ.get("BOT_MASTER_LABEL", ""),
|
|
"description": bd_rec.get("Description"),
|
|
"agent_type": bd_rec.get("AgentType"),
|
|
"type": bd_rec.get("Type"),
|
|
"agent_template": bd_rec.get("AgentTemplate"),
|
|
"bot_source": bd_rec.get("BotSource"),
|
|
"generation": bundle.get("generation", "unknown"),
|
|
"planner_name": bundle.get("plannerName"),
|
|
"planner_type": bundle.get("plannerType"),
|
|
"_version_auto_picked": (os.environ.get("VERSION_AUTO_PICKED", "") == "true"),
|
|
},
|
|
"root": {
|
|
"kind": "BOT_DEFINITION",
|
|
"api_name": os.environ["AGENT_API_NAME"],
|
|
"children": [],
|
|
},
|
|
"node_count": 0,
|
|
"depth": 0,
|
|
# `_partial` + `_partial_reason` propagate from parse_wave's
|
|
# depth-cap detection through to emit_result's PARTIAL_OK status.
|
|
# `_partial_reason` values: "max-depth-cap" | "pending-refs" | null.
|
|
"_partial": True,
|
|
"_partial_reason": None,
|
|
"_pending_fetches": {k: [] for k in BFS_KINDS},
|
|
"_unresolved": [],
|
|
"_visited": [],
|
|
}
|
|
return tree
|
|
|
|
|
|
def build_root_children(
|
|
bundle: dict,
|
|
visited_by_kind: dict[str, set[str]],
|
|
aux_visited: set[tuple[str, str]],
|
|
) -> tuple[list[dict], dict[str, set[str]]]:
|
|
"""Build the root's topic / plannerAction children.
|
|
|
|
bundle-scoped FLOW/APEX/PROMPT_TEMPLATE refs are collected
|
|
into a kind-keyed `new_refs` dict and returned to the caller, which runs
|
|
them through `bfs_step` for uniform pending-merge + cycle tracking.
|
|
This used to directly mutate three flat sets — split out so the tuple-
|
|
keyed semantics apply to bundle-derived refs too, not just wave-derived
|
|
ones.
|
|
|
|
`aux_visited` tracks non-BFS node identity (GEN_AI_FUNCTION / TOPIC) so
|
|
the persisted `_visited` list still represents every node the walker
|
|
has laid hands on. It is NOT consulted by bfs_step.
|
|
"""
|
|
children: list[dict] = []
|
|
new_refs: dict[str, set[str]] = empty_kind_sets()
|
|
|
|
def action_node(action: dict) -> dict:
|
|
unwraps, leaf = classify_bundle_action(action)
|
|
aux_visited.add(("GEN_AI_FUNCTION", action["name"]))
|
|
node = {
|
|
"kind": "GEN_AI_FUNCTION",
|
|
"api_name": action["name"],
|
|
"unwraps_to": unwraps,
|
|
"children": [leaf] if leaf else [],
|
|
}
|
|
if unwraps:
|
|
tgt = unwraps.get("api_name")
|
|
kind = unwraps.get("kind")
|
|
# STANDARD_ACTION is declared-only, never
|
|
# fetched; routing it into `new_refs` would pollute
|
|
# `_pending_fetches.STANDARD_ACTION`. Gate on FETCHABLE_KINDS.
|
|
if kind in FETCHABLE_KINDS and tgt and tgt not in visited_by_kind[kind]:
|
|
new_refs[kind].add(tgt)
|
|
return node
|
|
|
|
for topic in bundle.get("topics", []) or []:
|
|
aux_visited.add(("TOPIC", topic["name"]))
|
|
children.append({
|
|
"kind": "TOPIC",
|
|
"api_name": topic["name"],
|
|
"children": [action_node(a) for a in topic.get("actions", []) or []],
|
|
})
|
|
|
|
# bundle["plannerActions"] is always [] now (a planner never has
|
|
# direct functions — 2026-05-05). The key stays in the bundle dict
|
|
# for back-compat with consumers that still call `.get("plannerActions")`.
|
|
|
|
return children, new_refs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cycle-safe inflate with (kind, canonical_name) tuple-keyed tracking.
|
|
#
|
|
# Visited tracking uses tuple keys so a Flow `Foo` and an Apex `Foo` are
|
|
# distinct nodes — a flat name-only set would silently drop the second one.
|
|
# Flow→subflow cycles (direct or cross-type A→B→A) are annotated with
|
|
# `_cycle_back_to` on the repeat node and recursion terminates immediately.
|
|
#
|
|
# , revised 2026-05-03: `MAX_BFS_DEPTH` is a DEFENSIVE termination
|
|
# guard, not a functional constraint on chain depth. Must stay in sync
|
|
# with `scripts/config.py::MAX_BFS_DEPTH` — duplicated (rather than
|
|
# imported) because this module runs as a standalone subprocess under
|
|
# `python3 parse_wave.py` (see `main()`) and the module has a long-standing
|
|
# "no intra-skill imports" convention (search `no intra-skill imports'
|
|
# convention`).
|
|
#
|
|
# Real cycle detection is per-branch via `visited_in_path`: a flow that
|
|
# appears on sibling branches is NOT a cycle (e.g. `handleFlowFault`,
|
|
# invoked from many parents), but the same flow recurring along its own
|
|
# ancestor chain IS. Textbook DFS cycle detection.
|
|
#
|
|
# Before the revision this was `5` and the cap itself tripped on shared
|
|
# utility flows (`handleFlowFault`, logging subflows) — those landed in
|
|
# `_pending_fetches["FLOW"]` and the user saw `PARTIAL_REASON=max-depth-cap`
|
|
# on trees that were in fact fully knowable. Bumping to 20 keeps a safety
|
|
# net for pathological graphs while letting per-branch cycle detection do
|
|
# the real work. On every real bot tree observed to date, the per-branch
|
|
# check terminates well below depth 20.
|
|
#
|
|
# When this cap *does* trip (truly exceptional), the unreached FLOW lands
|
|
# in `pending_out["FLOW"]` and the leaf is annotated `_truncated =
|
|
# {reason: "max-depth", target: "FLOW:<name>"}`, exactly as before.
|
|
MAX_BFS_DEPTH = 20
|
|
|
|
# Retained alias so the `MAX_INFLATE_DEPTH` symbol (referenced in older
|
|
# agent notes / docs) still resolves. Tracks `MAX_BFS_DEPTH` exactly; the
|
|
# 2026-05-03 revision reinterpreted this as a defensive guard rather than
|
|
# a functional cap, but the alias semantics are unchanged.
|
|
MAX_INFLATE_DEPTH = MAX_BFS_DEPTH
|
|
_FLOW_CYCLE_KINDS = frozenset({"FLOW"}) # the only kind that recurses here
|
|
|
|
|
|
def _cycle_key(node: dict) -> tuple[str, str]:
|
|
"""Tuple key for visited / cycle detection. Flows use canonical api_name.
|
|
|
|
keying on `(kind, api_name)` — NOT just name — so cross-kind
|
|
collisions (Flow Foo + Apex Foo) stay distinct.
|
|
"""
|
|
return (node.get("kind") or "", node.get("api_name") or "")
|
|
|
|
|
|
def inflate_flow_leaf(
|
|
leaf: dict,
|
|
flow_children: dict,
|
|
depth: int = 0,
|
|
visited_in_path: frozenset[tuple[str, str]] | None = None,
|
|
pending_out: dict[str, set[str]] | None = None,
|
|
) -> None:
|
|
"""Recursively expand a FLOW leaf's subflow tree.
|
|
|
|
`visited_in_path` is threaded through the recursion as an
|
|
IMMUTABLE frozenset — siblings at the same depth must NOT observe
|
|
each other's descent. Mutating a shared set would cause the second
|
|
sibling to be pruned as a false cycle.
|
|
|
|
`pending_out` is an OPTIONAL mutable accumulator keyed by
|
|
`BFS_KINDS` tokens. When the depth cap trips (`depth >= MAX_BFS_DEPTH`)
|
|
and the current leaf still has subflow children we'd normally expand,
|
|
those unreached targets are added to `pending_out[kind]` so a caller
|
|
can surface them as `_pending_fetches` on the tree with
|
|
`_partial=True` + `_partial_reason="max-depth-cap"`. When `pending_out`
|
|
is None (legacy callers), the cap behaves as before — silently skip.
|
|
"""
|
|
# depth-cap check. We use `>=` (not `>`) so the cap matches
|
|
# `MAX_BFS_DEPTH` exactly — at depth 5 we do NOT expand the current
|
|
# leaf, and the leaf's own (kind, api_name) is recorded in
|
|
# `pending_out` as "unreached". Rationale: this node was supposed
|
|
# to be fully explored but we hit the cap on the way in. Naming
|
|
# the leaf itself (not its kids) is what downstream callers need
|
|
# to surface as `_pending_fetches["FLOW"] = [<unreached>]`.
|
|
if depth >= MAX_BFS_DEPTH:
|
|
if leaf.get("kind") == "FLOW":
|
|
flow_name = leaf.get("api_name")
|
|
if flow_name:
|
|
if pending_out is not None:
|
|
pending_out.setdefault("FLOW", set()).add(flow_name)
|
|
# annotate the truncated leaf with the unified
|
|
# `_truncated` sub-object so rendered tree views can
|
|
# mark depth-capped nodes alongside cycle nodes using
|
|
# one predicate. The target points at the leaf itself
|
|
# (it was supposed to be fully explored but the cap
|
|
# tripped on the way in).
|
|
leaf["_truncated"] = {
|
|
"reason": TRUNCATION_MAX_DEPTH,
|
|
"target": f"FLOW:{flow_name}",
|
|
}
|
|
return
|
|
if leaf.get("kind") != "FLOW":
|
|
return
|
|
flow_name = leaf.get("api_name")
|
|
if not flow_name:
|
|
return
|
|
kids = flow_children.get(flow_name)
|
|
if not kids:
|
|
return # no data this wave; preserve any existing children
|
|
|
|
# Extend the path-visited set with this node. Frozenset keeps sibling
|
|
# branches independent — mutation would cross-contaminate.
|
|
leaf_key = _cycle_key(leaf)
|
|
path_set = (visited_in_path or frozenset()) | {leaf_key}
|
|
|
|
new_children: list[dict] = []
|
|
for k in kids:
|
|
item = {"kind": k["kind"], "element_name": k.get("element_name"), "api_name": k["api_name"]}
|
|
# Carry the STANDARD_ACTION / UNKNOWN invocation-type qualifier
|
|
# through to the expanded child. Canonical key is `invocation_type`
|
|
# (schema 3.1); legacy `raw_action_type` kept for one release so
|
|
# caches built by an older parse_wave still render cleanly.
|
|
if "invocation_type" in k:
|
|
item["invocation_type"] = k["invocation_type"]
|
|
if "raw_action_type" in k:
|
|
item["raw_action_type"] = k["raw_action_type"]
|
|
if k["kind"] == "FLOW":
|
|
item["children"] = []
|
|
child_key = _cycle_key(item)
|
|
if child_key in path_set:
|
|
# cycle detected — annotate and do NOT recurse.
|
|
# Path format: "<KIND>:<name>" of the first encounter.
|
|
# unified `_truncated` sub-object + legacy
|
|
# `_cycle_back_to` for backcompat.
|
|
target_path = f"{child_key[0]}:{child_key[1]}"
|
|
item["_truncated"] = {
|
|
"reason": TRUNCATION_CYCLE,
|
|
"target": target_path,
|
|
}
|
|
item["_cycle_back_to"] = target_path # deprecated alias
|
|
new_children.append(item)
|
|
continue
|
|
new_children.append(item)
|
|
inflate_flow_leaf(item, flow_children, depth + 1, path_set, pending_out)
|
|
else:
|
|
new_children.append(item)
|
|
leaf["children"] = new_children
|
|
|
|
|
|
def walk_and_inflate(
|
|
node: dict,
|
|
flow_children: dict,
|
|
depth: int = 0,
|
|
pending_out: dict[str, set[str]] | None = None,
|
|
) -> None:
|
|
"""Walk the tree and inflate every FLOW leaf we encounter.
|
|
|
|
Each inflate call starts with an empty path-visited set — tree-walk
|
|
descent is not itself a Flow recursion, so ancestor GEN_AI_FUNCTION
|
|
or TOPIC nodes don't count toward cycle detection.
|
|
|
|
`pending_out` (optional) is threaded into every `inflate_flow_leaf`
|
|
call so depth-cap truncations accumulate into a single shared dict,
|
|
which `main()` merges into `tree["_pending_fetches"]` + flips
|
|
`tree["_partial"]` with reason `max-depth-cap`.
|
|
"""
|
|
if node.get("kind") == "GEN_AI_FUNCTION":
|
|
for child in node.get("children", []) or []:
|
|
if child.get("kind") == "FLOW":
|
|
inflate_flow_leaf(child, flow_children, depth, pending_out=pending_out)
|
|
elif node.get("kind") == "FLOW":
|
|
inflate_flow_leaf(node, flow_children, depth, pending_out=pending_out)
|
|
for c in node.get("children", []) or []:
|
|
walk_and_inflate(c, flow_children, depth + 1, pending_out)
|
|
|
|
|
|
def compute_stats(root: dict) -> tuple[int, int, dict]:
|
|
counts: Counter = Counter()
|
|
|
|
def walk(node: dict, d: int = 0) -> int:
|
|
k = node.get("kind")
|
|
if k:
|
|
counts[k] += 1
|
|
max_d = d
|
|
for c in node.get("children", []) or []:
|
|
max_d = max(max_d, walk(c, d + 1))
|
|
return max_d
|
|
|
|
depth = walk(root)
|
|
return sum(counts.values()), depth, dict(counts)
|
|
|
|
|
|
def atomic_write_json(path: pathlib.Path, obj: dict) -> None:
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
tmp.write_text(json.dumps(obj, indent=2))
|
|
os.replace(tmp, path)
|
|
|
|
|
|
def finalize_cap(tree: dict) -> dict:
|
|
"""Move remaining _pending_fetches items to _unresolved[].
|
|
|
|
pending and unresolved both use the same kind tokens now
|
|
(FLOW/APEX/PROMPT_TEMPLATE/STANDARD_ACTION) — matching the runtime
|
|
tree's `kind` field.
|
|
|
|
depth-cap truncation writes `_partial=True` +
|
|
`_partial_reason="max-depth-cap"` earlier in the pipeline. Finalize
|
|
preserves those signals when it drains pending → unresolved so the
|
|
downstream emit_result.py can still surface PARTIAL_OK with the
|
|
correct reason. If BOTH the depth cap AND finalize-cap fire on the
|
|
same run, depth-cap wins priority (set first, not overwritten).
|
|
"""
|
|
pending = tree.get("_pending_fetches") or {}
|
|
unresolved = tree.setdefault("_unresolved", [])
|
|
drained_any = False
|
|
for kind, items in pending.items():
|
|
for n in items:
|
|
drained_any = True
|
|
unresolved.append({
|
|
"kind": kind,
|
|
"api_name": n,
|
|
"reason": "max-wave-depth exceeded",
|
|
})
|
|
tree["_pending_fetches"] = {k: [] for k in BFS_KINDS}
|
|
|
|
# if pending was drained but no `_partial_reason` is set yet,
|
|
# mark it as wave-depth exhaustion. DO NOT overwrite an existing
|
|
# `max-depth-cap` reason — the depth-cap trigger is strictly earlier
|
|
# and more specific.
|
|
if drained_any:
|
|
tree["_partial"] = True
|
|
if not tree.get("_partial_reason"):
|
|
tree["_partial_reason"] = "max-wave-depth"
|
|
return tree
|
|
|
|
|
|
def main() -> int:
|
|
finalize_cap_mode = "--finalize-cap" in sys.argv
|
|
|
|
try:
|
|
work_dir = pathlib.Path(os.environ["WORK_DIR"])
|
|
except KeyError as e:
|
|
sys.stderr.write(f"parse_wave.py: missing env {e}\n")
|
|
return 1
|
|
|
|
tree_path = work_dir / "declared_action_tree.json"
|
|
|
|
# --finalize-cap: trivial; just drain pending → unresolved on existing tree
|
|
if finalize_cap_mode:
|
|
if not tree_path.is_file():
|
|
sys.stderr.write("parse_wave.py: --finalize-cap with no tree file; noop\n")
|
|
return 0
|
|
try:
|
|
tree = json.loads(tree_path.read_text())
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
sys.stderr.write(f"parse_wave.py: cannot read tree: {e}\n")
|
|
return 1
|
|
tree = finalize_cap(tree)
|
|
try:
|
|
atomic_write_json(tree_path, tree)
|
|
except OSError as e:
|
|
sys.stderr.write(f"parse_wave.py: write failed: {e}\n")
|
|
return 1
|
|
sys.stderr.write(
|
|
f"[parse_wave] finalize-cap: {len(tree.get('_unresolved', []))} nodes unresolved\n"
|
|
)
|
|
return 0
|
|
|
|
# Standard init-or-reparse path
|
|
bundle_path = work_dir / "_bundle_parsed.json"
|
|
if not bundle_path.is_file():
|
|
sys.stderr.write(f"parse_wave.py: missing {bundle_path}\n")
|
|
return 1
|
|
try:
|
|
bundle = json.loads(bundle_path.read_text())
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
sys.stderr.write(f"parse_wave.py: bundle parse error: {e}\n")
|
|
return 1
|
|
|
|
# Harvest wave dirs. Returns kind-keyed visited + pending dicts plus
|
|
# the list of (kind, name) cycles detected during per-flow BFS merges.
|
|
flow_children, wave_visited_by_kind, pending_by_kind, _wave_cycles = harvest_waves(work_dir)
|
|
|
|
# Load-or-init tree
|
|
if tree_path.is_file():
|
|
try:
|
|
tree = json.loads(tree_path.read_text())
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
sys.stderr.write(f"parse_wave.py: cannot re-parse; reinitializing ({e})\n")
|
|
tree = init_tree(work_dir, bundle)
|
|
else:
|
|
tree = init_tree(work_dir, bundle)
|
|
|
|
# Rehydrate tuple-keyed visited from the persisted _visited list and
|
|
# union with wave-derived visited. Non-BFS kinds (TOPIC/GEN_AI_FUNCTION)
|
|
# live in `aux_visited` — they're persisted in _visited but do NOT
|
|
# participate in BFS routing.
|
|
visited_by_kind: dict[str, set[str]] = empty_kind_sets()
|
|
for kind, names in wave_visited_by_kind.items():
|
|
visited_by_kind[kind] |= names
|
|
aux_visited: set[tuple[str, str]] = set()
|
|
for pair in (tree.get("_visited") or []):
|
|
if not pair or len(pair) != 2:
|
|
continue
|
|
k, n = pair[0], pair[1]
|
|
if k in BFS_KINDS:
|
|
visited_by_kind[k].add(n)
|
|
else:
|
|
aux_visited.add((k, n))
|
|
|
|
# If the tree was just initialized, populate root children (only once).
|
|
# bundle-derived refs now flow through bfs_step via
|
|
# build_root_children's new_refs return value — same code path as
|
|
# wave-derived refs.
|
|
is_fresh = not (tree.get("root", {}).get("children"))
|
|
if is_fresh:
|
|
children, bundle_new_refs = build_root_children(
|
|
bundle, visited_by_kind, aux_visited
|
|
)
|
|
tree["root"]["children"] = children
|
|
pending_by_kind, _bundle_cycles = bfs_step(
|
|
pending_by_kind, visited_by_kind, bundle_new_refs
|
|
)
|
|
|
|
# Inflate Flow leaves wherever possible.
|
|
# `depth_cap_pending` captures subflow refs we skipped because
|
|
# the depth cap tripped — these get merged into `_pending_fetches`
|
|
# below so downstream emit_result.py can surface `PARTIAL_OK`.
|
|
depth_cap_pending: dict[str, set[str]] = empty_kind_sets()
|
|
walk_and_inflate(tree["root"], flow_children, pending_out=depth_cap_pending)
|
|
|
|
# Merge depth-cap-suppressed refs back into pending_by_kind so they
|
|
# land in `_pending_fetches`. Preserve any existing (already-visited)
|
|
# exclusion semantics — refs already fetched this run MUST NOT be
|
|
# re-reported as pending.
|
|
for kind in BFS_KINDS:
|
|
pending_by_kind[kind] |= depth_cap_pending.get(kind, set())
|
|
|
|
depth_cap_tripped = any(depth_cap_pending[k] for k in BFS_KINDS)
|
|
|
|
# Recompute pending (exclude anything now visited). the
|
|
# persisted dict uses the same `BFS_KINDS` tokens as the runtime tree
|
|
# and the in-memory buckets — no more Metadata-API-style aliasing.
|
|
tree["_pending_fetches"] = {
|
|
k: sorted(pending_by_kind.get(k, set()) - visited_by_kind.get(k, set()))
|
|
for k in BFS_KINDS
|
|
}
|
|
|
|
# surface a `_partial` signal when:
|
|
# (a) depth-cap-pending refs exist (we suppressed deep subflows), OR
|
|
# (b) any `_pending_fetches` bucket is non-empty at write time.
|
|
# `_partial_reason` identifies which of the two tripped — `max-depth-cap`
|
|
# takes priority when depth_cap_pending is non-empty, since the cap is
|
|
# the reason pending refs weren't drained to `_unresolved` yet.
|
|
any_pending = any(tree["_pending_fetches"][k] for k in BFS_KINDS)
|
|
if depth_cap_tripped:
|
|
tree["_partial"] = True
|
|
tree["_partial_reason"] = "max-depth-cap"
|
|
elif any_pending:
|
|
# Still incomplete but NOT because of the depth cap. Leave any
|
|
# existing reason intact if prior run set it; default to a neutral
|
|
# marker so emit_result.py can still flip to PARTIAL_OK.
|
|
tree["_partial"] = True
|
|
if not tree.get("_partial_reason"):
|
|
tree["_partial_reason"] = "pending-refs"
|
|
else:
|
|
tree["_partial"] = False
|
|
tree["_partial_reason"] = None
|
|
|
|
# Recompute node_count + depth + kind_counts
|
|
node_count, depth, kind_counts = compute_stats(tree["root"])
|
|
tree["node_count"] = node_count
|
|
tree["depth"] = depth
|
|
tree["_kind_counts"] = kind_counts
|
|
|
|
# Persist visited as sorted list of [kind, name] pairs. Includes aux
|
|
# kinds (TOPIC/GEN_AI_FUNCTION) so the replay surface is complete.
|
|
all_visited: set[tuple[str, str]] = set(aux_visited)
|
|
for kind, names in visited_by_kind.items():
|
|
for n in names:
|
|
all_visited.add((kind, n))
|
|
tree["_visited"] = [list(v) for v in sorted(all_visited)]
|
|
|
|
try:
|
|
atomic_write_json(tree_path, tree)
|
|
except OSError as e:
|
|
sys.stderr.write(f"parse_wave.py: write failed: {e}\n")
|
|
return 1
|
|
|
|
# log line mirrors the persisted dict's kind tokens.
|
|
pending_total = sum(len(v) for v in tree["_pending_fetches"].values())
|
|
sys.stderr.write(
|
|
f"[parse_wave] parsed: {node_count} nodes, depth {depth}, counts={kind_counts}, "
|
|
f"pending={pending_total} "
|
|
f"(flow={len(tree['_pending_fetches']['FLOW'])} "
|
|
f"apex={len(tree['_pending_fetches']['APEX'])} "
|
|
f"prompt_template={len(tree['_pending_fetches']['PROMPT_TEMPLATE'])} "
|
|
f"standard_action={len(tree['_pending_fetches']['STANDARD_ACTION'])})\n"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|