
Investigating Agentforce Architecture
- 848 installs
- 787 repo stars
- Updated August 5, 2026
- forcedotcom/sf-skills
investigating-agentforce-architecture is a Salesforce skill that generates a human-readable architecture document and Mermaid invocation graph from Agentforce design-time metadata for developers who need to inventory or
About
investigating-agentforce-architecture produces a declared architecture snapshot for one Salesforce Agentforce agent from design-time metadata—not runtime session traces. It reads planner configuration, topics, actions, flows, Apex classes, prompt templates, and NGA plugins, then renders a human-readable architecture document plus a Mermaid invocation graph. Developers reach for it when asked to describe, diagram, inventory, audit, document, or diff agent architecture—such as v3 versus v5—by agent API name in a specific org. The skill explicitly excludes runtime conversation transcripts, generation timings, and gateway audit chains, focusing solely on metadata-defined action trees and tool inventories.
- Captures planner, topics, actions, flows, Apex, prompt templates, and NGA plugins in one snapshot
- Renders both a readable architecture document and a Mermaid invocation graph
- Reads only design-time metadata (BotDefinition, GenAiPlanner*, GenAiPlugin*, GenAiFunction*, Flow, ApexClass, GenAiPromp
- Delivers 3–5× speedup versus sequential retrieval with parallel Tooling SOQL fan-out
- Runtime budget 30–45s typical, ≤60s hard cap
Investigating Agentforce Architecture by the numbers
- 848 all-time installs (skills.sh)
- +2 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,280 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/forcedotcom/sf-skills --skill investigating-agentforce-architectureAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 848 |
|---|---|
| repo stars | ★ 787 |
| Last updated | August 5, 2026 |
| Repository | forcedotcom/sf-skills ↗ |
How do you document Agentforce agent architecture from metadata?
Instantly generate a clear, human-readable architecture document and Mermaid diagram for any Salesforce Agentforce agent from its declared metadata.
Who is it for?
Salesforce developers auditing Agentforce agents who need design-time architecture docs and Mermaid diagrams from org metadata.
Skip if: Developers analyzing runtime session traces, conversation transcripts, or gateway audit timing chains instead of declared metadata.
When should I use this skill?
The user asks to diagram, inventory, audit, document, or diff an Agentforce agent's topics, actions, or tool tree by API name.
What you get
Human-readable architecture documents, Mermaid invocation graphs, topic and action inventories, and version diff snapshots.
- Architecture document
- Mermaid invocation graph
- Topic and action inventory
Files
investigating-agentforce-architecture — declared architecture snapshot
Design-time metadata tree for one Agentforce agent: planner → topics → actions → flows → Apex → prompts → NGA plugins. Reads declared metadata only — BotDefinition, GenAiPlanner*, GenAiPlugin*, GenAiFunction*, Flow, ApexClass, GenAiPromptTemplate. Does not read runtime audit rows.
Runtime budget: 30–45s typical, ≤60s hard cap on reference fixtures. Sequential baseline would be 90–220s; parallel Tooling SOQL fan-out delivers a 3–5× speedup. Large bots with many flows scale approximately linearly — each flow metadata retrieve is one round-trip.
Runs inline — no subagent. Every phase is deterministic file processing.
If the user hasn't given enough to proceed
When invoked with no agent_api_name AND no org alias, print the following block verbatim — do not paraphrase, do not pre-run any script. Trigger condition: $ARGUMENTS is empty OR names no agent (no --agent flag and no known agent API name in the prose) OR names no org (no --org flag and no known alias).
Which agent should I document, and in which org?
>
I need:
- Agent API name — theDeveloperNameof theBotDefinition(e.g.MyAgent,MySalesAgent). Not the label.
- Org alias — forsfCLI auth (the alias you configured withsf org login)
>
Optional:
- Version — anagent_version_api_namelikev5. If omitted, I'll resolve the activeBotVersion.
- `--force` — ignore cached tree; re-fetch everything.
- `--reprobe` — re-run the 7-day channel-probe cache (only needed after a Salesforce release).
>
I'll run the metadata pipeline inline. Artifacts land under~/.vibe/data/investigating-agentforce-architecture/<org_id15>/<agent_api_name>__<agent_version>/(overridable with--data-dir).
Pipeline invocation
When the user has supplied --org <alias> + --agent <api_name> (plus any optional flags), run this block. One python3 invocation drives the full pipeline. main.py writes .emit_ctx.json; emit_result.py reads it and prints the final === RESULT === block last to stdout.
set -euo pipefail
# zsh arrays are 1-indexed by default; bash arrays are 0-indexed.
# This block uses 0-indexed semantics throughout (_args[$i] starting at i=0),
# so under zsh + `set -u` the very first read of `_args[0]` would trip
# `parameter not set`. KSH_ARRAYS makes zsh treat arrays as 0-indexed,
# matching the bash shebang's expectation. No-op under bash.
[ -n "${ZSH_VERSION:-}" ] && setopt KSH_ARRAYS
SKILL_ROOT="${SKILL_ROOT:-${PLUGIN_ROOT:-$HOME/.vibe/skills}/investigating-agentforce-architecture}"
# Argument parser. Accepts both `--org foo` and `--org=foo`.
# `$ARGUMENTS` is the raw user input Claude Code substitutes.
ARG_ORG=""
ARG_AGENT=""
ARG_VERSION=""
ARG_FORCE=""
ARG_REPROBE=""
ARG_PARALLELISM=""
ARG_MAX_MERMAID=""
# shellcheck disable=SC2206
_args=($ARGUMENTS)
i=0
while [ $i -lt ${#_args[@]} ]; do
tok="${_args[$i]}"
case "$tok" in
--org=*) ARG_ORG="${tok#--org=}" ;;
--org) i=$((i+1)); ARG_ORG="${_args[$i]:-}" ;;
--agent=*) ARG_AGENT="${tok#--agent=}" ;;
--agent) i=$((i+1)); ARG_AGENT="${_args[$i]:-}" ;;
--version=*) ARG_VERSION="${tok#--version=}" ;;
--version) i=$((i+1)); ARG_VERSION="${_args[$i]:-}" ;;
--parallelism=*) ARG_PARALLELISM="${tok#--parallelism=}" ;;
--parallelism) i=$((i+1)); ARG_PARALLELISM="${_args[$i]:-}" ;;
--max-mermaid-nodes=*) ARG_MAX_MERMAID="${tok#--max-mermaid-nodes=}" ;;
--max-mermaid-nodes) i=$((i+1)); ARG_MAX_MERMAID="${_args[$i]:-}" ;;
--force) ARG_FORCE="1" ;;
--reprobe) ARG_REPROBE="1" ;;
esac
i=$((i+1))
done
# Usage block if required flags missing. Agent reads stderr,
# prints verbatim, and stops — does NOT pre-run main.py.
if [ -z "$ARG_ORG" ] || [ -z "$ARG_AGENT" ]; then
cat >&2 <<'USAGE'
> Which agent should I document, and in which org?
>
> I need:
> - **Agent API name** — the BotDefinition.DeveloperName (e.g. `MyAgent`)
> - **Org alias** — for `sf` CLI auth (the alias you configured with `sf org login`)
>
> Optional flags:
> - `--version v5` — pin a specific BotVersion (default: Active+highest)
> - `--force` — bypass cache
> - `--reprobe` — force channel-probe refresh
> - `--parallelism N` — ThreadPoolExecutor size (default 5)
> - `--max-mermaid-nodes N` — cap Mermaid node count (default 80)
USAGE
exit 2
fi
# Fresh work dir per invocation. Epoch + random suffix avoids collisions
# between concurrent runs on the same host.
WORK_DIR="/tmp/investigating-agentforce-architecture-$(date +%s)-$RANDOM"
mkdir -p "$WORK_DIR"
# Input validation at the boundary, BEFORE any python3 call.
# fs_guard exits 1 and prints an INVALID_INPUT RESULT block on failure;
# `|| exit 1` is mandatory — bare calls silently continue past failures.
python3 "$SKILL_ROOT/scripts/_shared/fs_guard.py" "$ARG_AGENT" agent_api_name api_name || exit 1
python3 "$SKILL_ROOT/scripts/_shared/fs_guard.py" "$ARG_ORG" org_alias not_empty || exit 1
python3 "$SKILL_ROOT/scripts/_shared/fs_guard.py" "$WORK_DIR" WORK_DIR symlink || exit 1
python3 "$SKILL_ROOT/scripts/_shared/fs_guard.py" "$WORK_DIR" WORK_DIR owned || exit 1
if [ -n "$ARG_VERSION" ]; then
python3 "$SKILL_ROOT/scripts/_shared/fs_guard.py" "$ARG_VERSION" agent_version api_name || exit 1
fi
# Single python3 call drives all pipeline phases. main.py writes
# `.emit_ctx.json` into $WORK_DIR — emit_result.py then renders the
# RESULT block from that ctx. No subprocess-per-phase.
_main_args=(--org-alias "$ARG_ORG" --agent "$ARG_AGENT" --work-dir "$WORK_DIR")
[ -n "$ARG_VERSION" ] && _main_args+=(--version "$ARG_VERSION")
[ -n "$ARG_FORCE" ] && _main_args+=(--force)
[ -n "$ARG_REPROBE" ] && _main_args+=(--reprobe)
[ -n "$ARG_PARALLELISM" ] && _main_args+=(--parallelism "$ARG_PARALLELISM")
[ -n "$ARG_MAX_MERMAID" ] && _main_args+=(--max-mermaid-nodes "$ARG_MAX_MERMAID")
# main.py returns nonzero on terminal failures; we DON'T short-circuit —
# emit_result still publishes the failure RESULT block. `set -e` is
# temporarily relaxed around this single call.
set +e
python3 "$SKILL_ROOT/scripts/main.py" "${_main_args[@]}"
_rc=$?
set -e
# Final RESULT block is emit_result.py's stdout — MUST be the last thing
# stdout sees. emit_result exits 0 on render success; the bash harness
# propagates main.py's rc for the agent's exit status.
WORK_DIR="$WORK_DIR" python3 "$SKILL_ROOT/tools/emit_result.py"
exit "$_rc"Inputs
| Input | Flag | Required | Default |
|---|---|---|---|
org_alias | --org | yes | — |
agent_api_name | --agent | yes | — |
agent_version_api_name | --version | no | active BotVersion |
force_refresh | --force | no | false (honor cache) |
reprobe | --reprobe | no | false (honor 7-day channel-probe cache) |
parallelism | --parallelism | no | 5 |
max_mermaid_nodes | --max-mermaid-nodes | no | 80 |
data_dir | --data-dir | no | ~/.vibe/data/investigating-agentforce-architecture |
cache_dir | --cache-dir | no | ~/.vibe/cache/investigating-agentforce-architecture |
Outputs
All artifacts under ~/.vibe/data/investigating-agentforce-architecture/<org_id15>/<agent_api_name>__<agent_version>/ (default; override with --data-dir <path>):
<agent>_<ver>_metadata_tree.json primary artifact — normalized planner/topic/action/flow/apex/prompt/plugin tree
<agent>_<ver>_architecture.md human-readable section-by-section rendering (H1 + 7 numbered sections, plus a conditional Dependency graph appendix). Mermaid diagrams are embedded inside the relevant sections (Action tree, Data flow, and Dependency graph)Pipeline — inline, no subagent
resolve_bot.py → BotDefinition + BotVersion + planner name lookup
retrieve_planner.py → Metadata API zip retrieve for GenAiPlannerBundle (+ NGA plugins if present)
parallel_retrieve.py → 6 parallel Tooling SOQL channels fan out from the planner id
(resolved by the `planner_definition_by_agent_chain` seed query):
- plugins_by_planner (GenAiPluginDefinition)
- planner_bundle_functions (GenAiPlannerFunctionDef join)
- functions_by_plugins (GenAiFunctionDefinition)
- planner_attrs_by_parent_ids (GenAiPlannerAttrDefinition)
- plugin_functions_by_plugin_ids (GenAiPluginFunctionDef join)
- plugin_instructions_by_plugin_ids (GenAiPluginInstructionDef)
parse_bundle.py → parse retrieved XML into normalized node shapes
parse_wave.py → BFS expansion: flow/apex/prompt refs discovered in nodes
→ SOQL for Flow/Apex bodies (batched by id list)
→ Metadata retrieve ONLY for GenAiPromptTemplate (+ NGA external plugins conditionally)
finalize.py → merge waves into metadata_tree.json
render_architecture.py → <agent>_<ver>_architecture.md + Mermaid invocation graph (capped at --max-mermaid-nodes)Channel strategy — SOQL-first.
- Tooling SOQL for every normalized tree node (planner, plugins, functions, plugin-functions, plugin-instructions, planner-functions, planner-attrs) — 6 parallel channels keyed on planner id, plus the
planner_definition_by_agent_chainseed query that resolves the planner id from the agent chain. - Data API SOQL for Flow (by id) and Apex (by id or name) bodies — batched.
- Metadata retrieve only for two cases: (a)
GenAiPromptTemplate(prompt bodies aren't cleanly exposed via Tooling SOQL), and (b) NGA external plugins when the planner is Native Generative Agent shape (skipped for classic ReAct).
This is where the 3–5× speedup comes from. A naive implementation would retrieve everything via Metadata API zips sequentially; parallel Tooling SOQL covers ~80% of the tree in a single fan-out.
Planner shapes — classic ReAct vs NGA
The skill normalizes two planner families into a single tree shape:
| Shape | GenAiPlannerDefinition.PlannerType | InvocationTarget style | NGA plugins? |
|---|---|---|---|
| Classic ReAct | ReactAiPlannerV1 / SequentialPlannerIntentClassifier / etc. | DeveloperName strings | no |
| NGA | ConcurrentMultiAgentOrchestration / AnthropicCompatibleV1 / etc. | Sometimes 15/18-char Ids (ID-prefix routed) | yes (external plugins via Metadata retrieve) |
The ID-prefix router in resolve_invocation_target.py distinguishes the two: NGA InvocationTargets that look like ids (01p… = ApexClass, 301… = Flow, etc.) get resolved via id-scoped SOQL; DeveloperName targets go through name-scoped SOQL. Unknown prefixes surface as _unresolved[] with reason="unknown-id-prefix:<prefix>" — never silently dropped.
Caching
- Tree cache:
metadata_tree.jsonis reused unless--forceis passed. Cache key includes the asset-hash of every.soql/.yaml/.mmdtemplate bundled with the skill — bump a template, the cache busts automatically. - Channel probe cache: 7-day TTL on the per-org
sf sobject describeresults that validate every field name the SOQL assets reference. A Salesforce quarterly release that renames / removes a field triggersstatus: PROBE_FAILED;--reprobeforces a refresh.
Prerequisites
| Tool | Required |
|---|---|
sf CLI (authenticated against the target org) | yes — sf org login web --alias <alias> |
| Python 3.10+ | yes |
Reference docs to load when needed
Do NOT load eagerly. Load when the user's question requires it:
references/soql_fields.md— per-sObject field reference for the 13 sObjects this skill touches (2 Data API + 11 Tooling), with[mandatory]vs[optional]tags. Load when the user asks about a specific field, or when debugging anINVALID_FIELDSOQL error.references/contract.json— machine-readable schema formetadata_tree.json. Load when writing downstream tooling that consumes the tree.references/architecture_sections.md— section-by-section structure of the rendered<agent>_<ver>_architecture.md.
Invariants worth knowing upfront
- Pipeline is deterministic. Same
(org, agent, version)+ static org metadata → byte-identical<agent>_<ver>_metadata_tree.jsonand<agent>_<ver>_architecture.md. Only manifest timestamps drift across re-runs. - Forward-only traversal. Every discovered ref goes forward from planner → children. No backward lookups.
- Partial results are surfaced, not silenced. Any unresolved reference lands in
_unresolved[]withreason=....STATUS=PARTIAL_OKif any channel failed;STATUS=OKonly on a clean run. - Cycle detection is per-branch. Same flow visited along its own ancestor chain emits
_cycle_back_to:<path>instead of recursing. A defensiveMAX_BFS_DEPTH=20guard backs the per-branch ancestor set; real-world agents bottom out well before either limit fires. (Earlier docs claimed a hard cap of 5; that was the historical limit and was abandoned because shared utility flows likehandleFlowFaulttripped it on every nested tree — seeconfig.MAX_BFS_DEPTHfor the rationale.) - Child ordering is alphabetical by `api_name` (case-insensitive). Topics come before non-topic plannerActions at the root level. Flow-actionCall order is NOT sorted — that's the flow author's execution sequence.
name: describe_sobject
argv:
- sf
- sobject
- describe
- --sobject
- "{{SOBJECT}}"
- --target-org
- "{{ORG_ALIAS}}"
- --json
timeout_seconds: 300
required_params: [ORG_ALIAS, SOBJECT]
success_check: stdout_json_status_zero
auth_required_stderr_patterns:
- NoOrgAuthenticationError
- AuthInfoError
name: describe_tooling_sobject
argv:
- sf
- sobject
- describe
- --sobject
- "{{SOBJECT}}"
- --use-tooling-api
- --target-org
- "{{ORG_ALIAS}}"
- --json
timeout_seconds: 300
required_params: [ORG_ALIAS, SOBJECT]
success_check: stdout_json_status_zero
auth_required_stderr_patterns:
- NoOrgAuthenticationError
- AuthInfoError
name: list_metadata_genaiprompttemplate
argv:
- sf
- org
- list
- metadata
- --metadata-type
- GenAiPromptTemplate
- --target-org
- "{{ORG_ALIAS}}"
- --json
timeout_seconds: 300
required_params: [ORG_ALIAS]
success_check: stdout_json_status_zero
auth_required_stderr_patterns:
- NoOrgAuthenticationError
- AuthInfoError
name: org_display
argv:
- sf
- org
- display
- --target-org
- "{{ORG_ALIAS}}"
- --json
- --verbose
timeout_seconds: 300
required_params: [ORG_ALIAS]
success_check: stdout_json_status_zero
auth_required_stderr_patterns:
- NoOrgAuthenticationError
- AuthInfoError
name: retrieve_genai_plugin
argv:
- sf
- project
- retrieve
- start
- --target-org
- "{{ORG_ALIAS}}"
- --target-metadata-dir
- "{{TARGET_DIR}}"
- --json
extra_argv_anchor: --target-metadata-dir
timeout_seconds: 300
required_params: [ORG_ALIAS, TARGET_DIR]
success_check: stdout_json_status_zero
auth_required_stderr_patterns:
- NoOrgAuthenticationError
- AuthInfoError
name: show_access_token
# Primary access-token retrieval path per forcedotcom/cli#3560 (effective
# 2026-05-27). The `sf org display --json` field is now redacted to a
# placeholder string by default; the dedicated `sf org auth show-access-token`
# command is the long-term replacement.
#
# `--no-prompt` skips the interactive confirmation banner so the command
# behaves identically under `--json`.
#
# The legacy fallback (`sf org display` with `SF_TEMP_SHOW_SECRETS=true`)
# stays wired for sf CLI versions that don't ship show-access-token; that
# fallback is orchestrated in `main.py::_resolve_creds`, not here.
argv:
- sf
- org
- auth
- show-access-token
- --target-org
- "{{ORG_ALIAS}}"
- --json
- --no-prompt
timeout_seconds: 60
required_params: [ORG_ALIAS]
success_check: stdout_json_status_zero
auth_required_stderr_patterns:
- NoOrgAuthenticationError
- AuthInfoError
%% action_tree.mmd - section 4 declared action tree (flowchart TB)
%%
%% Placeholders (substituted by render_architecture.load_mermaid):
%% SUBGRAPHS placeholder: one `subgraph <topic_id>[<topic_label>] ... end`
%% block per topic. Non-topic actions (plannerActions)
%% go in a synthetic subgraph keyed `_plannerActions`.
%% EDGES placeholder: tree edges, one per child->parent descent. Cycle-
%% back annotations render as dotted back-edges
%% `A -.->|cycle| B` (see
%% render_architecture._render_action_tree).
%%
%% placeholder names are written as bare tokens (no double-curly)
%% so load_mermaid's single-pass str.replace cannot corrupt this header.
%%
%% Node-cap enforcement: if the tree exceeds `max_mermaid_nodes["flowchart"]`,
%% the renderer emits a summary placeholder instead of this template.
flowchart TB
{{SUBGRAPHS}}
{{EDGES}}
%% data_flow.mmd - section 8 data flow / context propagation (flowchart LR)
%%
%% Placeholders (substituted by render_architecture.load_mermaid):
%% NODES placeholder: node declarations: `<id>[<label>]`, one per line.
%% EDGES placeholder: labeled-where-possible edges:
%% `A -->|<var>: <type>| B`, bare `A --> B` when no
%% parameter threads between the lanes.
%%
%% placeholder names are written as bare tokens (no double-curly)
%% so load_mermaid's single-pass str.replace cannot corrupt this header.
%%
%% Source: GenAiPlannerAttrDefinition rows on the planner drive which slots
%% populate which actions. Renderer (render_architecture._render_data_flow)
%% builds nodes/edges from the tree's planner-slot metadata; no slots ->
%% degenerate diagram with just `User --> Planner --> Actions` skeleton.
flowchart LR
{{NODES}}
{{EDGES}}
%% dependency_graph.mmd - conditional section (graph LR)
%%
%% Rendered ONLY when `_unresolved[]` is non-empty OR cycles exist in the
%% action tree. Otherwise render_architecture skips this template entirely.
%%
%% Placeholders (substituted by render_architecture.load_mermaid):
%% NODES placeholder: node declarations: `<id>[<label>]`. Unresolved refs
%% use a `:::unresolved` class marker for CSS styling.
%% EDGES placeholder: cross-artifact dependency edges harvested from the
%% tree. Cycle-back edges render as `A -.->|cycle| B`.
%%
%% placeholder names are written as bare tokens (no double-curly)
%% so load_mermaid's single-pass str.replace cannot corrupt this header.
graph LR
{{NODES}}
{{EDGES}}
classDef unresolved stroke-dasharray: 5 5
%% invocation_sequence.mmd - section 3 invocation sequence (sequenceDiagram)
%%
%% Placeholders (substituted by render_architecture.load_mermaid):
%% PARTICIPANTS placeholder: one `participant <Name>` line per lane,
%% newline-joined; deactivate with
%% `<Dst>-->>-<Src>: <response>`.
%% MESSAGES placeholder: one `<Src>->>+<Dst>: <label>` per message line.
%%
%% placeholder names above are written as bare tokens (no double-
%% curly) so load_mermaid's single-pass str.replace cannot substitute them
%% here. That keeps %% headers intact in the rendered output - Mermaid
%% ignores %% lines as comments at render time, and debuggability wins.
%%
%% Renderer (render_architecture.py::_render_invocation_sequence) is the sole
%% consumer. Generation-aware: classic ReAct uses Planner<->TopicClassifier
%% <->ActionExecutor; NGA orchestrations add one lane per sub-agent.
sequenceDiagram
autonumber
{{PARTICIPANTS}}
{{MESSAGES}}
%% planner_state.mmd - section 7 planner state machine (stateDiagram-v2)
%%
%% Placeholders (substituted by render_architecture.load_mermaid):
%% STATES placeholder: state declarations (`state Foo { ... }` or
%% bare `state`).
%% TRANSITIONS placeholder: transition list (`A --> B: <label>`).
%%
%% placeholder names are written as bare tokens (no double-curly)
%% so load_mermaid's single-pass str.replace cannot corrupt this header.
%%
%% Renderer selects one of a small library of per-generation state machines
%% (render_architecture._planner_state_for_generation) and fills in the
%% template. BYOP / SEARCH generations skip this section and emit a prose
%% placeholder at call site rather than rendering this template.
stateDiagram-v2
{{STATES}}
{{TRANSITIONS}}
SELECT Id, Name, Body, SymbolTable, ApiVersion, IsValid
FROM ApexClass
WHERE Id IN ({{APEX_IDS_LIST}})
SELECT Id, Name, Body, SymbolTable, ApiVersion, IsValid
FROM ApexClass
WHERE Name IN ({{NAMES_LIST}})
SELECT DeveloperName, MasterLabel, Description, AgentType, Type, AgentTemplate, BotSource
FROM BotDefinition
WHERE DeveloperName = '{{AGENT_API_NAME}}'
SELECT Id, DeveloperName, Status, BotDefinitionId,
BotDefinition.DeveloperName, BotDefinition.MasterLabel
FROM BotVersion
WHERE BotDefinition.DeveloperName = '{{AGENT_API_NAME}}'
SELECT Id, DeveloperName, ActiveVersionId, LatestVersionId
FROM FlowDefinition
WHERE Id IN ({{FLOW_DEF_IDS_LIST}})
SELECT Id, DeveloperName, NamespacePrefix, ActiveVersionId
FROM FlowDefinition
WHERE DeveloperName IN ({{NAMES_LIST}}) AND NamespacePrefix = NULL
SELECT DurableId, ApiName, Label, NamespacePrefix, ActiveVersionId,
IsActive, ManageableState, ProcessType
FROM FlowDefinitionView
WHERE DurableId IN ({{DURABLE_IDS_LIST}})
SELECT Id, FullName, Metadata
FROM Flow
WHERE Id = '{{FLOW_VERSION_ID}}'
SELECT Id, DeveloperName, MasterLabel, Description, InvocationTargetType, InvocationTarget,
IsLocal, IsConfirmationRequired, IsIncludeInProgressIndicator, ProgressIndicatorMessage,
Source, PluginId, PlannerId, ParentId, LocalDeveloperName
FROM GenAiFunctionDefinition
WHERE PluginId IN ({{PLUGIN_IDS}})
SELECT Id, ParentId, DeveloperName, MasterLabel, Description, MappingType, ParameterName
FROM GenAiPlannerAttrDefinition
WHERE ParentId IN ({{PARENT_IDS}})
SELECT Id, PlannerId, Plugin
FROM GenAiPlannerFunctionDef
WHERE PlannerId = '{{PLANNER_ID}}'
SELECT Id, DeveloperName, MasterLabel, Description, PlannerType, Capabilities, AgentGraph
FROM GenAiPlannerDefinition
WHERE DeveloperName LIKE '{{AGENT_NAME}}%\_{{VERSION}}'
SELECT Id, PluginId, Function
FROM GenAiPluginFunctionDef
WHERE PluginId IN ({{PLUGIN_IDS}})
SELECT Id, GenAiPluginDefinitionId, DeveloperName, MasterLabel, Description, SortOrder
FROM GenAiPluginInstructionDef
WHERE GenAiPluginDefinitionId IN ({{PLUGIN_IDS}})
SELECT Id, DeveloperName, MasterLabel, Description, PluginType, Scope,
IsLocal, CanEscalate, Source, ParentId, LocalDeveloperName
FROM GenAiPluginDefinition
WHERE PlannerId = '{{PLANNER_ID}}'
investigating-agentforce-architecture
Declared architecture snapshot for a single Agentforce agent: planner + topics + actions + flows + Apex + prompts + NGA plugins. Reads design-time metadata only (BotDefinition + GenAi* Tooling objects + Metadata API retrieve) — no runtime audit data.
Input: an agent_api_name (the BotDefinition.DeveloperName) and an org alias. Optional agent_version_api_name to pin a version; otherwise the active BotVersion resolves.
Output: two files under ~/.vibe/data/investigating-agentforce-architecture/<org_id15>/<agent>__<version>/ — a normalized <agent>_<ver>_metadata_tree.json and a human-readable <agent>_<ver>_architecture.md. Override with --data-dir <path> (other runtimes pass this to land artifacts under their own distribution layout).
---
Runtime budget
30–45s typical, ≤60s hard cap on the reference fixtures.
A naive sequential implementation (Metadata API retrieves only) would take 90–220s. Speedup: 3–5×.
Scaling note: large bots with many flows scale approximately linearly in flow count. Each Flow metadata retrieve is an individual SOQL round-trip; a 20-flow bot takes proportionally longer than a 5-flow bot. The 7 planner-side Tooling SOQL fan-outs are constant-cost (single fan-out regardless of bot size); the flow/apex body fetch wave scales with ref count.
---
Prerequisites
| Tool | Why |
|---|---|
sf CLI (authenticated against the target org) | Shells sf org display --target-org <alias> --json for access token, and sf sobject describe for the 7-day channel probe |
| Python 3.10+ | pathlib, dataclasses, `\ |
---
Usage
Invoked conversationally through whatever skill-aware runtime hosts it. Example prompts:
| User says | Skill does |
|---|---|
document the architecture of MyAgent in my-org-alias | Resolve active version, fetch tree, render architecture.md + Mermaid |
draw the invocation graph for MySalesAgent v5 in my-org-alias-3 | Same, pinned to v5 |
what tools does MyAgent2 have in my-org-alias-2 | Fetch tree, surface the plugin/function inventory from the rendered architecture.md |
re-fetch the architecture of MyAgent — I think metadata changed | Pass --force to bypass the cache |
See SKILL.md for the full flag table and sample prompts.
---
Directory layout
investigating-agentforce-architecture/
├── SKILL.md Skill contract (inputs, outputs, pipeline, invariants)
├── README.md This file
├── assets/
│ ├── soql/*.soql Tooling + Data SOQL templates
│ ├── cli/*.yaml sf CLI recipes (subprocess invocation specs)
│ └── mermaid/*.mmd Mermaid templates for the invocation graph
├── references/
│ ├── soql_fields.md Per-sObject field reference (13 sObjects)
│ ├── architecture_sections.md Section-by-section structure of the rendered architecture.md
│ └── contract.json metadata_tree.json schema contract
├── scripts/
│ ├── _shared/ Path helpers + fs_guard validators + sql escapers
│ ├── main.py Orchestrator entry point
│ ├── config.py Shared paths, cache TTLs, validated path builders
│ ├── soql_loader.py Template loader with fs_guard-validated substitution
│ ├── sf_cli.py sf CLI subprocess wrapper (yaml.safe_load + stderr redaction)
│ ├── rest_client.py urllib wrapper (Authorization-stripping redirect handler)
│ ├── resolve_bot.py BotDefinition + BotVersion + planner name lookup
│ ├── retrieve_planner.py Metadata retrieve for GenAiPlannerBundle + NGA plugins
│ ├── parallel_retrieve.py 7-channel parallel Tooling SOQL fan-out
│ ├── parse_bundle.py XML → normalized node shapes
│ ├── parse_wave.py BFS expansion of flow/apex/prompt refs
│ ├── probe_channels.py 7-day-TTL channel describe probe
│ ├── cache_check.py Asset-hash-aware cache freshness
│ ├── finalize.py Merge waves → metadata_tree.json
│ ├── render_architecture.py architecture.md + Mermaid graph
│ ├── resolve_invocation_target.py ID-prefix router for NGA InvocationTargets
│ └── tests/ Unit + integration tests (unittest)
└── tools/
├── emit_env.py Env-var emit helper (Phase 0.5)
├── emit_result.py Final RESULT block renderer
├── sanitize.py Stdin → safe-string filter
└── write_emit_ctx.py Per-phase ctx writer---
Architecture
Channel strategy — SOQL-first
Seed query: planner_definition_by_agent_chain (chain-LIKE lookup → planner id)
6 parallel Tooling SOQL channels (keyed on the resolved planner id):
- plugins_by_planner
- planner_bundle_functions (join)
- functions_by_plugins
- planner_attrs_by_parent_ids
- plugin_functions_by_plugin_ids (join)
- plugin_instructions_by_plugin_ids
+ Data API SOQL for Flow / Apex bodies (batched by id list)
+ Metadata retrieve ONLY for:
- GenAiPromptTemplate (prompt bodies)
- NGA external plugins (when planner is ConcurrentMultiAgentOrchestration etc.)Most of the 3–5× speedup over a naive Metadata-API-only implementation comes from collapsing a sequential zip-retrieve chain into a single Tooling SOQL fan-out.
Planner normalization — classic ReAct vs NGA
One tree shape, two planner families:
PlannerType examples | Family | InvocationTarget style |
|---|---|---|
ReactAiPlannerV1, SequentialPlannerIntentClassifier | Classic ReAct | DeveloperName strings |
ConcurrentMultiAgentOrchestration, AnthropicCompatibleV1 | NGA | Sometimes 15/18-char Ids (ID-prefix routed) |
resolve_invocation_target.py routes NGA InvocationTargets by Salesforce ID prefix (01p → ApexClass, 301 → Flow, etc.). Unknown prefixes become _unresolved[] entries with reason="unknown-id-prefix:<prefix>" — never silently dropped.
Cache layers
1. Tree cache — metadata_tree.json is reused unless --force. Cache key includes asset-hashes of every SOQL / YAML / Mermaid template shipped with the skill, so changing a template busts the cache automatically. 2. Channel probe cache — 7-day TTL on sf sobject describe results for the 13 sObjects the skill touches. --reprobe forces a refresh (needed after Salesforce quarterly releases that rename / remove fields). Mandatory-field gate: a probe that sees any mandatory field missing (per probe_channels.MANDATORY_FIELDS) flips status: PROBE_FAILED so the caller surfaces a clean error.
---
Key behaviors
Idempotence
Re-running the same (org, agent, version) overwrites prior artifacts in place. Safe to run repeatedly during development.
Partial-results surfacing
No silent drops. Any unresolved ref — unknown ID prefix, failed SOQL, missing describe field — lands in _unresolved[] with a reason=... string. Top-level STATUS is OK on a clean run, PARTIAL_OK when any channel degrades.
Cycle handling
Per-branch ancestor-path cycle detection is the primary termination primitive: the same flow visited along its own ancestor chain emits _cycle_back_to:<path> instead of recursing. MAX_BFS_DEPTH=20 is a defensive last-resort guard against pathological graphs that evade per-branch detection; real-world agents bottom out well before that.
---
Troubleshooting
| Symptom | Fix |
|---|---|
sf org display failed | Re-authenticate: sf org login web --alias <alias> |
INVALID_FIELD from a SOQL asset | Salesforce renamed / removed the field in a quarterly release. Run with --reprobe to refresh the 7-day channel cache and pick up the new schema |
STATUS=PROBE_FAILED on first run | Channel probe saw a mandatory field missing. Check channels.json under the probe cache dir for which sObject / field — may require org-side feature enablement |
Tree for classic ReAct agent shows _unresolved entries for NGA plugins | Expected — the NGA external-plugin retrieve is skipped when the planner shape is classic. Those entries can be ignored |
---
Author
Raghul Jayagopal (RJ), Salesforce ANZ FDE.
architecture.md section reference
Per-section rendering spec for scripts/render_architecture.py::render. One # Architecture H1, seven numbered ## N. ... sections, plus one conditional "Dependency graph" appendix. Source of truth: the metadata_tree.json schema produced by scripts/parse_wave.py at the end of phase 9.
Source of truth: the live tree — field shapes evolve as parse_wave learns new agent generations. When this doc disagrees with the metadata_tree.json written by parse_wave.py for an actual agent on your machine (under ~/.vibe/data/investigating-agentforce-architecture/<org>/<agent>__<ver>/), trust the live tree.
The Mermaid templates live at assets/mermaid/*.mmd and are the sole consumer of the renderer helpers; each {{PARAM}} contract is documented in the template's own header comment.
---
Per-diagram node caps
Default caps (override via render(..., max_mermaid_nodes={...})):
| Diagram kind | Default cap | Over-cap behaviour |
|---|---|---|
flowchart | 200 | summary placeholder + top-5 fan-out + catalog pointer |
stateDiagram | 40 | summary placeholder (no fan-out — states are not nodes) |
sequenceDiagram | 60 | summary placeholder + top-5 fan-out |
graph | 100 | summary placeholder + top-5 fan-out |
Cap is measured in rendered elements (nodes + edges for flowchart/graph, messages for sequenceDiagram, states + transitions for stateDiagram). Above cap, the renderer emits a > [diagram truncated: ...] blockquote with the element count, the top-5 nodes by fan-out (when applicable), and a pointer to the catalog section that enumerates the same data without compression.
---
1. Header + agent overview
Purpose: identify the agent and its planner in a single kv table.
Input fields (tree.agent.*):
api_name— agent developer nameversion—v<N>labelmaster_label— human-readable namedescription— prose summaryagent_type—EinsteinAgentKind/EinsteinCopilotForSalesforce/ …type—ExternalCopilot/InternalCopilot/ …agent_templatebot_sourcegeneration—classic/nga/search/byopplanner_nameplanner_typebot_id
Plus tree._schema_version.
Output shape: # <H1> with api_name + version, followed by a 2-col markdown table.
Empty/degenerate cases:
- Missing
api_name/versionrenders?in the H1 (notNone). - Any missing field renders
-in its row — not empty, because GitHub
table rendering collapses empty cells and the row loses alignment.
---
2. Anatomy summary
Purpose: one-paragraph executive summary plus health callouts.
Input fields:
tree._kind_counts— preferred source for topic / action / flow /
apex / prompt counts.
tree.depth,tree.node_count— size metrics.tree._partial,tree._partial_reason— health flag + prose reason.tree._pending_fetches— `{"FLOW":[...],"APEX":[...],
"PROMPT_TEMPLATE":[...],"STANDARD_ACTION":[...]}`. Count determines the "pending fetches: N" line in the callout.
tree._unresolved— triggers a second warn callout with the count.tree.agent.planner_name— missing -> warn callout.
Output shape: ## 2. Anatomy summary + paragraph + zero-to-three blockquote callouts. Callouts render as > **Health: PARTIAL.** / > **Health: WARN.** prefixes with bullet sub-lines.
Empty/degenerate cases: if _kind_counts is absent, the walker supplies counts from its own pass over the tree. Zero topics is valid (SequentialPlannerIntentClassifier).
---
3. Action tree
Purpose: the full declared action tree as a flowchart with per-topic subgraphs, plus a deterministic ASCII appendix for diff review.
Input fields:
- Walker-derived
topics[]-> one subgraph per topic. - Walker-derived
edges[]->parent_api_name --> child_api_name. node._cycle_back_to-> dotted back-edgeA -.->|cycle_back_to: X| B.- Planner-level actions (top-level
GEN_AI_FUNCTIONwith no parent
topic) -> synthetic _plannerActions subgraph.
Output shape: `mermaid block with flowchart TB + subgraphs + edges, followed by a <details> with ASCII-tree appendix. When above cap, the mermaid block is replaced with the truncation placeholder; the ASCII appendix still renders.
Empty/degenerate cases:
- No topics, no planner actions ->
%% no topics+%% no edges
mermaid comments; diagram still parses.
- Cycle annotations render in both the mermaid (dotted edge) and the
ASCII appendix ([cycle] marker on the node line).
---
4. Topic anatomy
Purpose: per-topic detail dump. One H3 per topic with a bullet list of actions.
Input fields:
- Walker-derived
topics[]withapi_name,label,actions. topic.raw.master_label(preferred) or api_name for the label.
Output shape: ## 4. Topic anatomy + one ### \<api_name>\` block per topic + kv list (- Label:, - Action count:, - Actions:` with sub-bullets).
Empty/degenerate cases: 0 topics -> _No topics defined (planner exposes actions directly)._ italic fallback. This is the expected case for SequentialPlannerIntentClassifier.
---
5. Action catalog
Purpose: flat markdown table of every declared action.
Input fields:
- Walker-derived
actions[]withapi_name,topic,raw.unwraps_to.
Output shape: 3-col table (Action | Topic | Unwraps to). Unwraps column renders as KIND \api_name\` when unwraps_to is present, - otherwise. Planner-level actions show (plannerAction)` in the topic column.
Empty/degenerate cases: no actions -> _No actions declared._ italic fallback.
---
6. Data flow / context propagation
Purpose: show which slot values propagate from user utterance through the planner into each action.
Input fields:
- Walker-derived
topics[]andactions[]. action.raw.planner_attr.variable_name+planner_attr.data_type—
labels the edge A -->|var: Type| B. When absent, the edge is bare.
Output shape: flowchart LR via load_mermaid("data_flow", ...). Skeleton: User -> Planner -> each Topic -> each Action under that topic.
Empty/degenerate cases: no planner_attr metadata -> every edge is bare. 0 topics -> only User --> Planner renders (no action-level edges). Above cap -> truncation placeholder.
---
7. Flow / Apex / Prompt catalogs
Purpose: per-artifact detail section for every backing flow, apex class, and prompt template referenced by any action.
Input fields:
- Walker-derived
flows{},apex{},prompts{}, keyed on api_name. node.signature(or_signaturefallback) — signature block.
Output shape: ### Flows / ### Apex classes / ### Prompt templates H3 buckets, each containing #### \<api_name>\` + a fenced signature block or _Signature not captured._` fallback. Prompt templates render as a flat bullet list (signature capture is per-template prose, not a flow/apex shape).
Empty/degenerate cases: no backing artifacts in the tree -> _No backing artifacts in tree._ italic fallback.
---
8. Unresolved refs + artifact pointers
Purpose: surface every _unresolved[] entry and point at the sidecar files the reader can open.
Input fields:
tree._unresolved[]— list of{kind, api_name, reason}records.
Output shape: Either a 3-col Kind | Api name | Reason table or an _No unresolved references._ italic fallback. Followed by an ### Artifact pointers sub-heading with a bullet list of relative paths (tree JSON, manifest, summary).
Empty/degenerate cases: always renders — the artifact-pointer list is unconditional.
---
Conditional: Dependency graph
Purpose: rendered only when tree._unresolved[] is non-empty OR the tree contains cycle annotations. Shows cross-artifact dependencies plus the unresolved nodes as dashed-outline markers.
Input fields:
- Walker-derived
edges[]. tree._unresolved[]— each becomes a:::unresolvedstyled node.- Walker-derived
cycles[]— each becomes a dotted back-edge.
Output shape: graph LR via load_mermaid("dependency_graph", ...). Includes a classDef unresolved class marker at the bottom of the template so unresolved nodes render with stroke-dasharray.
Empty/degenerate cases: not rendered at all when both conditions are false. Above the graph cap -> truncation placeholder replaces the mermaid block.
{
"_doc": "retired-name references replaced with plugin-scoped layout. Machine-readable I/O contract for investigating-agentforce-architecture. Orchestrator code reads this file (not SKILL.md prose) to discover the input schema, output keys, STATUS enum, and error-contract rules. The architecture skill runs inline (no subagent).",
"_schema_version": "3.1",
"_schema_notes": "3.1 (2026-05-05) canonicalizes `invocation_type` on STANDARD_ACTION / UNKNOWN nodes in the metadata tree. Prior versions split this across `raw_invocation_type` (bundle-sourced nodes) and `raw_action_type` (flow-actionCall-sourced nodes). Both legacy keys are still tolerated by readers for one release.",
"input": {
"required": {
"org_alias": {
"type": "string",
"description": "sf CLI alias (e.g. 'my-org-alias'). Auth is always derived via `sf org display --target-org $ORG_ALIAS --json`."
},
"agent_api_name": {
"type": "string",
"regex": "^[A-Za-z0-9_]+$",
"description": "BotDefinition.DeveloperName (e.g. 'MyAgent'). Must match the regex above; INVALID_INPUT otherwise."
}
},
"optional": {
"agent_version_api_name": {
"type": "string",
"description": "BotVersion.DeveloperName (e.g. 'v5'). When omitted, the agent auto-picks the Active version with the highest natural-key sort (v10 > v9); VERSION_AUTO_PICKED=true in the RESULT block."
},
"org_id_15": {
"type": "string",
"regex": "^[A-Za-z0-9]{15}$",
"description": "15-char org ID. If omitted, derived from `sf org display`."
},
"org_id_18": {
"type": "string",
"regex": "^[A-Za-z0-9]{18}$",
"description": "18-char org ID. If omitted, derived from `sf org display`."
},
"session_id": {
"type": "string",
"description": "Only used to name the ephemeral $WORK_DIR. Random UUID if omitted."
},
"work_dir": {
"type": "absolute_path",
"description": "Ephemeral scratch. Default: /tmp/investigating-agentforce-architecture-<epoch>-<rand>."
},
"cache_root": {
"type": "absolute_path",
"description": "Internal rebuildable cache root. Default: ~/.vibe/cache/investigating-agentforce-architecture. Override with --cache-dir <path>."
},
"data_root": {
"type": "absolute_path",
"description": "Durable user-facing output root. Default: ~/.vibe/data/investigating-agentforce-architecture. Override with --data-dir <path>."
},
"force_refresh": {
"type": "boolean",
"description": "If true, ignore cache and rewrite. Default: false."
}
},
"minimum_viable": {
"org_alias": "my-org-alias",
"agent_api_name": "MyAgent"
},
"notes": [
"Standalone and orchestrated modes accept the same input fields; no renames.",
"To bypass cache, delete the cache entry or pass force_refresh: true."
]
},
"output": {
"format": "Two-part return message: (1) one-line prose status, (2) blank line, (3) === RESULT === key-value block. The KV block is always LAST in the output and always present.",
"parse_rule": "Find the line '=== RESULT ===', then read subsequent lines as KEY=VALUE pairs until EOF. Values are raw strings; no quoting; one pair per line.",
"parse_examples": {
"bash": "awk -F= '/^STATUS=/{print $2; exit}' <<< \"$subagent_output\"",
"python": "import re; dict(line.split('=', 1) for line in output.splitlines() if re.match(r'^[A-Z_]+=', line))"
},
"keys": {
"STATUS": {
"type": "enum",
"presence": "always",
"description": "Terminal status. See error_contract for per-status required keys.",
"values": [
"OK",
"PARTIAL_OK",
"INVALID_INPUT",
"AUTH_REQUIRED",
"AGENT_NOT_FOUND",
"AGENT_VERSION_NOT_FOUND",
"RETRIEVE_FAILED",
"WRITE_FAILED"
]
},
"ERROR_DETAIL": {
"type": "string",
"presence": "on_error",
"description": "Human-readable explanation on any non-OK STATUS. Sanitized (no backticks/quotes/dollar signs/newlines)."
},
"AGENT_API_NAME": {
"type": "string",
"presence": "always",
"description": "Echo of input agent_api_name. May be empty on INVALID_INPUT if sanitize rejected before resolution."
},
"AGENT_VERSION": {
"type": "string",
"presence": "on_success_or_after_resolve",
"description": "Resolved BotVersion.DeveloperName (e.g. 'v5')."
},
"VERSION_AUTO_PICKED": {
"type": "boolean",
"presence": "on_success_or_after_resolve",
"description": "True iff agent_version_api_name was omitted and the agent auto-picked."
},
"AGENT_GENERATION": {
"type": "enum",
"presence": "always",
"values": ["classic", "nga", "unknown"],
"description": "classic = AiCopilot__ planner, nga = Atlas__ planner, unknown = anything else / not resolved."
},
"BOT_ID": {
"type": "string",
"presence": "on_success_or_after_resolve",
"description": "18-char BotDefinition.Id from SOQL."
},
"ORG_ID_15": {
"type": "string",
"presence": "on_success_or_after_auth",
"description": "15-char org ID. Cache-key prefix."
},
"ORG_ID_18": {
"type": "string",
"presence": "on_success_or_after_auth",
"description": "18-char org ID (from `sf org display`)."
},
"OUTPUT_JSON_PATH": {
"type": "absolute_path",
"presence": "on_success",
"description": "Durable {agent}_{version}_metadata_tree.json path under the resolved data_root."
},
"OUTPUT_SUMMARY_PATH": {
"type": "absolute_path",
"presence": "always_empty",
"description": "Reserved — historically pointed at {agent}_{version}_metadata_tree.summary.md. Dropped in ; field is now always emitted empty for RESULT-block shape stability."
},
"CACHE_PATH": {
"type": "absolute_path",
"presence": "on_success_or_after_cache_check",
"description": "Cache dir ($CACHE_ROOT/$ORG_ID_15/$AGENT__$VERSION/). Ends with /."
},
"CACHE_HIT": {
"type": "boolean",
"presence": "on_success_or_after_cache_check",
"description": "True iff the cache was served without any Metadata API retrieves."
},
"CACHED_AT_UTC": {
"type": "iso8601_utc_or_empty",
"presence": "on_success_or_after_cache_check",
"description": "ISO-8601 UTC built-at time. On cache hit this is the age of the cached snapshot; on cold build this is the just-computed timestamp."
},
"NODE_COUNT": {
"type": "integer",
"presence": "on_success",
"description": "Total nodes in the declared_action_tree."
},
"DEPTH": {
"type": "integer",
"presence": "on_success",
"description": "Max depth of the tree (BOT_DEFINITION = depth 0)."
},
"PARTIAL": {
"type": "boolean",
"presence": "on_success",
"description": "True iff the tree is incomplete (planner unresolved OR MAX_WAVE hit). When true, STATUS=PARTIAL_OK."
},
"UNRESOLVED_COUNT": {
"type": "integer",
"presence": "on_success",
"description": "Length of tree._unresolved[]."
},
"AVAILABLE_BOTS": {
"type": "csv",
"presence": "on_AGENT_NOT_FOUND",
"description": "Comma-separated list of all BotDefinition.DeveloperName values in the org."
},
"AVAILABLE_VERSIONS": {
"type": "csv",
"presence": "on_AGENT_VERSION_NOT_FOUND",
"description": "Format: 'v5(Active),v4(Inactive),...' — natural-key sorted DESC."
},
"RESULT_BLOCK_PATH": {
"type": "absolute_path",
"presence": "always",
"description": "Path to $DATA_DIR/last_result_block.txt — byte-for-byte tee written BEFORE stdout."
},
"WALL_TIME_SECONDS": {
"type": "float",
"presence": "always",
"description": "End-to-end wall-clock seconds. Two decimals. Target: 90-220s cold, <2s warm."
}
}
},
"error_contract": {
"OK": {
"description": "Tree built, zero unresolved, planner resolved.",
"required_keys": ["STATUS", "AGENT_API_NAME", "AGENT_VERSION", "VERSION_AUTO_PICKED", "AGENT_GENERATION", "BOT_ID", "ORG_ID_15", "ORG_ID_18", "OUTPUT_JSON_PATH", "OUTPUT_SUMMARY_PATH", "CACHE_PATH", "CACHE_HIT", "CACHED_AT_UTC", "NODE_COUNT", "DEPTH", "PARTIAL", "UNRESOLVED_COUNT", "RESULT_BLOCK_PATH", "WALL_TIME_SECONDS"],
"optional_keys": []
},
"PARTIAL_OK": {
"description": "Tree built but planner unresolved (Bot.bot had no planner) OR waves hit MAX_WAVE=5. tree._unresolved[] explains.",
"required_keys": ["STATUS", "AGENT_API_NAME", "AGENT_VERSION", "AGENT_GENERATION", "BOT_ID", "ORG_ID_15", "ORG_ID_18", "OUTPUT_JSON_PATH", "CACHE_PATH", "PARTIAL", "UNRESOLVED_COUNT", "WALL_TIME_SECONDS", "RESULT_BLOCK_PATH"],
"optional_keys": ["OUTPUT_SUMMARY_PATH", "CACHE_HIT", "CACHED_AT_UTC", "NODE_COUNT", "DEPTH", "VERSION_AUTO_PICKED", "ERROR_DETAIL"]
},
"INVALID_INPUT": {
"description": "agent_api_name failed [A-Za-z0-9_]+ regex, or other input validation (org_alias empty, ORG_ID_15 regex, $WORK_DIR symlink/foreign-owned).",
"required_keys": ["STATUS", "ERROR_DETAIL", "AGENT_API_NAME"],
"optional_keys": ["ORG_ID_15", "ORG_ID_18"]
},
"AUTH_REQUIRED": {
"description": "`sf org display` returned nothing, or a SOQL/Metadata call failed with an auth error. Caller must run `sf org login web --alias $ORG_ALIAS`.",
"required_keys": ["STATUS", "ERROR_DETAIL", "AGENT_API_NAME"],
"optional_keys": ["ORG_ID_15", "ORG_ID_18"]
},
"AGENT_NOT_FOUND": {
"description": "BotDefinition.DeveloperName not in the org. AVAILABLE_BOTS carries the CSV of what IS in the org.",
"required_keys": ["STATUS", "ERROR_DETAIL", "AGENT_API_NAME", "AVAILABLE_BOTS"],
"optional_keys": ["ORG_ID_15", "ORG_ID_18"]
},
"AGENT_VERSION_NOT_FOUND": {
"description": "No matching BotVersion under the bot (explicit version doesn't exist, or no Active version found during auto-pick).",
"required_keys": ["STATUS", "ERROR_DETAIL", "AGENT_API_NAME", "AVAILABLE_VERSIONS"],
"optional_keys": ["BOT_ID", "ORG_ID_15", "ORG_ID_18"]
},
"RETRIEVE_FAILED": {
"description": "`sf project retrieve start` failed (network, permissions, zip unreadable). ERROR_DETAIL carries the specifics.",
"required_keys": ["STATUS", "ERROR_DETAIL", "AGENT_API_NAME", "AGENT_VERSION", "BOT_ID"],
"optional_keys": ["ORG_ID_15", "ORG_ID_18", "OUTPUT_JSON_PATH"]
},
"WRITE_FAILED": {
"description": "Filesystem write failed during finalize (DATA_DIR rename, CACHE_DIR rename, or tree rewrite).",
"required_keys": ["STATUS", "ERROR_DETAIL", "AGENT_API_NAME", "AGENT_VERSION"],
"optional_keys": ["BOT_ID", "ORG_ID_15", "ORG_ID_18", "CACHE_HIT", "CACHED_AT_UTC"]
}
},
"stability": {
"key_names": "Stable. Key-name changes require a NOTICE.md entry and a skill reinstall; consumers read this contract to discover the current key set.",
"error_codes": "The STATUS enum is fixed at 8 values. A new status is a breaking change for consumers that switch on STATUS.",
"path_format": "All path values are absolute (no tilde expansion required). One KEY=VALUE per line. No trailing whitespace.",
"order": "Keys inside the RESULT block appear in a stable order — orchestrators should not depend on order but may use it for human readability."
},
"referenced_files": {
"skill_md": "<SKILL_ROOT>/SKILL.md"
}
}
sObject field reference — architecture skill
Field reference for the 13 sObjects this skill queries across the 15 SOQL templates under assets/soql/. Two are reached via the Data API (BotDefinition, BotVersion); the remaining 11 are Tooling-only.
Source of truth: the live org (sf sobject describe --sobject <Name> [--use-tooling-api]). Schemas verified against live Salesforce API v66.0 via sf sobject describe on my-org-alias + my-org-alias-2, 2026-05-02. Official Salesforce Help pages describe logical structures that frequently diverge from the physical fields the REST / Tooling API actually expose. When a Help page disagrees with a live describe, trust the live describe — the names in this reference are what you query.
The source of truth for "mandatory" is scripts/probe_channels.py's MANDATORY_FIELDS map. A probe that sees any [mandatory] field missing flips to status: "PROBE_FAILED" — the skill aborts with a clean error rather than producing a subtly-wrong tree. [optional] fields degrade gracefully — missing ones are recorded but don't block the run.
---
Casing gotchas
- *Mixed case is mandatory for `GenAi` sObjects.** API names are
GenAiPlannerDefinition, GenAiPluginDefinition, GenAiFunctionDefinition, GenAiPluginFunctionDef, GenAiPluginInstructionDef, GenAiPlannerFunctionDef, GenAiPlannerAttrDefinition. Lowercase variants (genai_planner_definition, genaiplannerdefinition) do not resolve. The SOQL parser is case-insensitive on keywords but sObject + field names must match the describe output for Tooling API calls to route correctly.
- **
GenAiPluginFunctionDefvsGenAiPlannerFunctionDefare different
tables.** Plugin-scope join (topic → function, via PluginId) vs planner-bundle-scope join (planner → function, via PlannerId). Both are 10-field join tables with nearly identical shape; keep them straight.
- **
BotVersion.DeveloperNameis the version id, not the version
label.** DeveloperName on BotVersion is the version API name (e.g. v5), used as the second path segment under <org_id15>/<agent>__<version>/. MasterLabel is the human-readable label (e.g. "Version 5 — ported from staging"). Never mix them — the data dir layout and the SKILL.md input contract both key on DeveloperName.
- `complexvalue` fields require single-row retrieval.
Metadata
(on Flow, FlowDefinition, GenAiPluginDefinition, GenAiFunctionDefinition), SymbolTable (on ApexClass), and AgentGraph (on GenAiPlannerDefinition, often null in practice) are complexvalue types. Salesforce enforces MALFORMED_QUERY: When retrieving results with Metadata or FullName fields, the query qualifications must specify no more than one row for retrieval. Batch IN-clause SELECTs that return ≥2 rows fail. Use a single-row equality (WHERE Id = '<id>') per fetch; parallelize across ids via concurrent.futures. ApexClass.Body + ApexClass.SymbolTable is the one notable exception: the Name IN (...) batch works even though SymbolTable is complex.
---
Cross-sObject join map
Every edge is strictly forward from the entry query (GenAiPlannerDefinition WHERE DeveloperName = :planner_name). No backward lookups.
BotDefinition (Data API, PK Id, matched by DeveloperName)
└── BotVersion (Data API, FK BotDefinitionId)
│ [resolved to planner name via Bot metadata retrieve]
▼
GenAiPlannerDefinition (Tooling, PK Id, matched by DeveloperName)
│
├── GenAiPluginDefinition ← WHERE PlannerId = :plannerId
│ ├── GenAiPluginInstructionDef ← WHERE GenAiPluginDefinitionId IN (:topic_ids)
│ ├── GenAiPluginFunctionDef ← WHERE PluginId IN (:topic_ids)
│ │ └── Function picklist → GenAiFunctionDefinition.Id
│ └── GenAiFunctionDefinition ← WHERE PluginId IN (:topic_ids)
│
├── GenAiPlannerFunctionDef ← WHERE PlannerId = :plannerId
│ └── Plugin picklist → GenAiPluginDefinition.Id (or external via retrieve)
│
├── GenAiFunctionDefinition ← WHERE PluginId IN (:topic_ids)
│ │ (single-query — see functions_by_plugins.soql)
│ │
│ └── InvocationTargetType + InvocationTarget route to:
│ ├── flow → FlowDefinition (batch IN) → Flow.Metadata (parallel single-row)
│ ├── apex → ApexClass (batch IN, Body + SymbolTable)
│ ├── standardInvocableAction → (no further fetch — declared only)
│ └── generatePromptResponse → GenAiPromptTemplate (Metadata API retrieve)
│
└── GenAiPlannerAttrDefinition ← WHERE ParentId IN (:function_ids, :planner_id)
(polymorphic ParentId: GenAiFunctionDefinition OR GenAiPlannerDefinition)Per-channel row counts + _unresolved[] reasons are recorded in metadata_tree.json under _channels and _unresolved keys.
---
Data API sObjects (2)
BotDefinition (Data API) — one row per agent
Root of the agent metadata. Matched by DeveloperName; the rest of the tree is resolved forward from the BotVersion child.
| Name | Type | Nillable | Filterable | Tag |
|---|---|---|---|---|
Id | id | no | yes | [mandatory] |
DeveloperName | string | no | yes | [mandatory] |
MasterLabel | string | yes | yes | [optional] |
Description | textarea | yes | no | [optional] |
AgentType | picklist | yes | yes | [optional] — discriminator for classic vs NGA in some orgs |
Type | picklist | yes | yes | [optional] |
AgentTemplate | string | yes | yes | [optional] — e.g. SvcCopilotTmpl__EinsteinAgentKind |
BotSource | picklist | yes | yes | [optional] |
AgentUser | reference | yes | yes | [optional] — FK to User |
IsDeleted | boolean | no | yes | [optional] |
CreatedById | reference | no | yes | [optional] |
CreatedDate | datetime | no | yes | [optional] |
LastModifiedById | reference | no | yes | [optional] |
LastModifiedDate | datetime | no | yes | [optional] |
SystemModstamp | datetime | no | yes | [optional] |
BotVersion (Data API) — one row per agent version
Resolves the active version (or user-pinned version) for a given bot. Parent relationship: BotDefinition via BotDefinitionId.
| Name | Type | Nillable | Filterable | Tag |
|---|---|---|---|---|
Id | id | no | yes | [mandatory] |
BotDefinitionId | reference | no | yes | [mandatory] |
DeveloperName | string | no | yes | [optional] — version id (e.g. v5) |
MasterLabel | string | yes | yes | [optional] — human label |
Status | picklist | yes | yes | [optional] — Active on the current published version |
VersionNumber | int | yes | yes | [optional] |
Description | textarea | yes | no | [optional] |
AiReplyRecordVisibility | picklist | yes | yes | [optional] |
ResponseDelayMilliseconds | int | yes | yes | [optional] |
IsDeleted | boolean | no | yes | [optional] |
CreatedById | reference | no | yes | [optional] |
CreatedDate | datetime | no | yes | [optional] |
LastModifiedById | reference | no | yes | [optional] |
LastModifiedDate | datetime | no | yes | [optional] |
---
Tooling API sObjects (11)
All sObjects in this section are reachable via the Tooling API only (sf data query --use-tooling-api or sf sobject describe --use-tooling-api).
ApexClass (Tooling) — Apex source + parsed AST
Source + full method/property AST for Apex referenced by GenAiFunctionDefinition.InvocationTarget when InvocationTargetType = 'apex'. Batch-safe on Name IN (...) or Id IN (...) (despite SymbolTable being complexvalue, Salesforce permits the batch for this particular sObject — verified live).
Note: SymbolTable is not part of `FIELDS(ALL)` and must be named explicitly in the SELECT.
| Name | Type | Nillable | Filterable | Tag |
|---|---|---|---|---|
Id | id | no | yes | [mandatory] |
Name | string | no | yes | [mandatory] |
Body | textarea | yes | no | [optional] — full Apex source |
SymbolTable | complexvalue | yes | no | [optional] — parsed AST (methods, params, annotations, line/col) |
ApiVersion | double | no | yes | [optional] |
IsValid | boolean | no | yes | [optional] |
Status | picklist | no | yes | [optional] |
LengthWithoutComments | int | yes | yes | [optional] |
NamespacePrefix | string | yes | yes | [optional] |
FullName | string | yes | no | [optional] — complexvalue companion |
Metadata | complexvalue | yes | no | [optional] |
ManageableState | picklist | yes | yes | [optional] |
IsDeleted | boolean | no | yes | [optional] |
CreatedById | reference | no | yes | [optional] |
CreatedDate | datetime | no | yes | [optional] |
LastModifiedById | reference | no | yes | [optional] |
LastModifiedDate | datetime | no | yes | [optional] |
SystemModstamp | datetime | no | yes | [optional] |
Single-row requirement for complexvalue columns: Metadata and FullName on ApexClass follow the standard MALFORMED_QUERY rule — batch IN-clause SELECTs that return ≥2 rows fail when these two are selected. Body + SymbolTable are the exception (batch works). Comments in assets/soql/apex_class_bodies_by_ids.soql + apex_class_bodies_by_names.soql pin this.
Flow (Tooling) — Flow version body
Single-row retrieval required — Metadata and FullName are both complexvalue. Fired once per activeVersionId returned by FlowDefinition; parallelized via ThreadPoolExecutor.
Filterability quirk: FullName is selectable but not filterable (INVALID_FIELD: field 'FullName' can not be filtered in a query call). Filter by Id instead.
| Name | Type | Nillable | Filterable | Tag |
|---|---|---|---|---|
Id | id | no | yes | [mandatory] |
DefinitionId | reference | no | yes | [mandatory] |
FullName | string | yes | no | [optional] — complexvalue companion; not filterable |
Metadata | complexvalue | yes | no | [optional] — full flow JSON (actionCalls, subflows, variables, decisions, formulas, assignments, apexPluginCalls) |
MasterLabel | string | yes | yes | [optional] |
Description | textarea | yes | no | [optional] |
VersionNumber | int | yes | yes | [optional] |
ProcessType | picklist | yes | yes | [optional] |
Status | picklist | yes | yes | [optional] |
ApiVersion | double | yes | yes | [optional] |
IsActive | boolean | no | yes | [optional] |
IsTemplate | boolean | no | yes | [optional] |
RunInMode | picklist | yes | yes | [optional] |
Environments | picklist | yes | yes | [optional] |
NamespacePrefix | string | yes | yes | [optional] |
ManageableState | picklist | yes | yes | [optional] |
OverriddenFlowId | reference | yes | yes | [optional] |
SourceTemplateId | reference | yes | yes | [optional] |
TriggerType | picklist | yes | yes | [optional] |
IsDeleted | boolean | no | yes | [optional] |
CreatedById | reference | no | yes | [optional] |
CreatedDate | datetime | no | yes | [optional] |
LastModifiedById | reference | no | yes | [optional] |
LastModifiedDate | datetime | no | yes | [optional] |
SystemModstamp | datetime | no | yes | [optional] |
InstalledPackageName | string | yes | yes | [optional] |
Single-row requirement: Metadata and FullName force WHERE Id = '<version_id>'. The SOQL asset flow_metadata_by_id.soql encodes this as a single Id = '...' predicate.
FlowDefinition (Tooling) — Flow versioning index
Two-hop feeder to Flow. Batch IN-clause works. Returns ActiveVersionId + LatestVersionId — the skill prefers active.
| Name | Type | Nillable | Filterable | Tag |
|---|---|---|---|---|
Id | id | no | yes | [mandatory] |
DeveloperName | string | no | yes | [mandatory] |
ActiveVersionId | reference | yes | yes | [optional] — FK to Flow.Id |
LatestVersionId | reference | yes | yes | [optional] — FK to Flow.Id |
MasterLabel | string | yes | yes | [optional] |
Description | textarea | yes | no | [optional] |
NamespacePrefix | string | yes | yes | [optional] |
ManageableState | picklist | yes | yes | [optional] |
Metadata | complexvalue | yes | no | [optional] — definition-level metadata (may carry what the Flow hop provides on some orgs) |
FullName | string | yes | no | [optional] |
IsDeleted | boolean | no | yes | [optional] |
CreatedById | reference | no | yes | [optional] |
CreatedDate | datetime | no | yes | [optional] |
LastModifiedById | reference | no | yes | [optional] |
LastModifiedDate | datetime | no | yes | [optional] |
SystemModstamp | datetime | no | yes | [optional] |
GenAiPlannerDefinition (Tooling) — planner root
Entry query for the tree. Matched by DeveloperName (the <genAiPlannerName> extracted from the Bot metadata retrieve). The PlannerType picklist is the classifier for classic ReAct vs NGA.
Seven-value `PlannerType` picklist (verified consistent across my-org-alias, my-org-alias-2, my-org-alias-3): grouped by namespace — AiCopilot__* = classic ReAct family, Atlas__* = NGA family. startswith("Atlas__") is a clean classic-vs-NGA discriminator.
| Name | Type | Nillable | Filterable | Tag |
|---|---|---|---|---|
Id | id | no | yes | [mandatory] |
DeveloperName | string | no | yes | [mandatory] |
PlannerType | picklist | yes | yes | [mandatory] |
MasterLabel | string | yes | yes | [optional] |
Description | textarea | yes | no | [optional] |
Capabilities | textarea | yes | no | [optional] — null in every row tested on both classic + NGA |
AgentGraph | complexvalue | yes | no | [optional] — null in every row tested; single-row rule still applies |
NamespacePrefix | string | yes | yes | [optional] |
ManageableState | picklist | yes | yes | [optional] |
Metadata | complexvalue | yes | no | [optional] |
FullName | string | yes | no | [optional] |
IsDeleted | boolean | no | yes | [optional] |
CreatedById | reference | no | yes | [optional] |
CreatedDate | datetime | no | yes | [optional] |
LastModifiedById | reference | no | yes | [optional] |
LastModifiedDate | datetime | no | yes | [optional] |
SystemModstamp | datetime | no | yes | [optional] |
Single-row requirement for complexvalue columns: AgentGraph, Metadata, FullName — select individually; entry query pins WHERE DeveloperName = '...' LIMIT 1.
GenAiPluginDefinition (Tooling) — topics
All topics for a planner via WHERE PlannerId = :plannerId. Carries PluginType + Scope for topic classification and CanEscalate / IsLocal for behavior flags.
| Name | Type | Nillable | Filterable | Tag |
|---|---|---|---|---|
Id | id | no | yes | [mandatory] |
DeveloperName | string | no | yes | [mandatory] |
PluginType | picklist | yes | yes | [optional] — topic type classifier |
Scope | textarea | yes | no | [optional] — natural-language topic scope |
MasterLabel | string | yes | yes | [optional] |
Description | textarea | yes | no | [optional] |
CanEscalate | boolean | yes | yes | [optional] |
IsLocal | boolean | yes | yes | [optional] |
Source | picklist | yes | yes | [optional] |
ParentId | reference | yes | yes | [optional] — planner FK (sometimes referred to as PlannerId in SOQL filter clauses) |
LocalDeveloperName | string | yes | yes | [optional] |
Language | picklist | yes | yes | [optional] |
NamespacePrefix | string | yes | yes | [optional] |
ManageableState | picklist | yes | yes | [optional] |
Metadata | complexvalue | yes | no | [optional] |
FullName | string | yes | no | [optional] |
ClassificationDescription | textarea | yes | no | [optional] |
GenAiFunctionInvoker | string | yes | yes | [optional] |
IsDeleted | boolean | no | yes | [optional] |
CreatedById | reference | no | yes | [optional] |
CreatedDate | datetime | no | yes | [optional] |
LastModifiedById | reference | no | yes | [optional] |
Single-row requirement: Metadata + FullName require per-id retrieval. The production path doesn't SELECT Metadata on this sObject (see plugins_by_planner.soql) — it pulls Scope + scalar fields instead, which batch-safely over PlannerId = :id.
GenAiPluginFunctionDef (Tooling) — plugin-function join
Join table: GenAiPluginDefinition → GenAiFunctionDefinition. Batch- safe on PluginId IN (...).
| Name | Type | Nillable | Filterable | Tag |
|---|---|---|---|---|
Id | id | no | yes | [mandatory] |
PluginId | reference | no | yes | [mandatory] |
Function | picklist | yes | yes | [optional] — references GenAiFunctionDefinition.Id |
NamespacePrefix | string | yes | yes | [optional] |
ManageableState | picklist | yes | yes | [optional] |
IsDeleted | boolean | no | yes | [optional] |
CreatedById | reference | no | yes | [optional] |
CreatedDate | datetime | no | yes | [optional] |
LastModifiedById | reference | no | yes | [optional] |
SystemModstamp | datetime | no | yes | [optional] |
GenAiPluginInstructionDef (Tooling) — per-topic instructions
Per-topic instruction text + ordering. Batch-safe on GenAiPluginDefinitionId IN (...).
| Name | Type | Nillable | Filterable | Tag |
|---|---|---|---|---|
Id | id | no | yes | [mandatory] |
GenAiPluginDefinitionId | reference | no | yes | [mandatory] |
DeveloperName | string | no | yes | [optional] |
MasterLabel | string | yes | yes | [optional] |
Description | textarea | yes | no | [optional] — the instruction text |
SortOrder | int | yes | yes | [optional] |
Language | picklist | yes | yes | [optional] |
NamespacePrefix | string | yes | yes | [optional] |
ManageableState | picklist | yes | yes | [optional] |
FullName | string | yes | no | [optional] |
Metadata | complexvalue | yes | no | [optional] |
IsDeleted | boolean | no | yes | [optional] |
CreatedById | reference | no | yes | [optional] |
CreatedDate | datetime | no | yes | [optional] |
LastModifiedById | reference | no | yes | [optional] |
Single-row requirement: Metadata + FullName complexvalue pair; the production SOQL omits them (scalar fields batch-safely).
GenAiFunctionDefinition (Tooling) — actions
The actions. Combined single-query fetches both bundle-scope (PlannerId) and topic-scope (PluginId IN) functions. InvocationTargetType + InvocationTarget route to the downstream fetch (Flow, Apex, prompt, standard invocable).
`InvocationTarget` format varies by planner shape (the ID-prefix router lives in scripts/resolve_invocation_target.py):
- Classic ReAct: DeveloperName string (e.g.
AGNT_SetUserSelectedOption). - NGA: Salesforce 15/18-char Id (e.g.
01pVF...= ApexClass,
300VF... = FlowDefinition, 0hf... = GenAiPromptTemplate).
| Name | Type | Nillable | Filterable | Tag |
|---|---|---|---|---|
Id | id | no | yes | [mandatory] |
DeveloperName | string | no | yes | [mandatory] |
InvocationTarget | string | yes | yes | [mandatory] |
InvocationTargetType | picklist | yes | yes | [optional] — flow / apex / standardInvocableAction / generatePromptResponse |
MasterLabel | string | yes | yes | [optional] |
Description | textarea | yes | no | [optional] |
IsLocal | boolean | yes | yes | [optional] |
IsConfirmationRequired | boolean | yes | yes | [optional] |
IsIncludeInProgressIndicator | boolean | yes | yes | [optional] |
ProgressIndicatorMessage | string | yes | yes | [optional] |
Source | picklist | yes | yes | [optional] |
PluginId | reference | yes | yes | [optional] — topic-scope FK (null for bundle-scope functions) |
PlannerId | reference | yes | yes | [optional] — bundle-scope FK (null on NGA — attachment is plugin-only) |
ParentId | reference | yes | yes | [optional] |
LocalDeveloperName | string | yes | yes | [optional] |
Language | picklist | yes | yes | [optional] |
InvocationTargetApiName | string | yes | yes | [optional] |
MissingValuePromptMessage | string | yes | yes | [optional] |
NamespacePrefix | string | yes | yes | [optional] |
ManageableState | picklist | yes | yes | [optional] |
Metadata | complexvalue | yes | no | [optional] |
FullName | string | yes | no | [optional] |
IsDeleted | boolean | no | yes | [optional] |
CreatedById | reference | no | yes | [optional] |
CreatedDate | datetime | no | yes | [optional] |
LastModifiedById | reference | no | yes | [optional] |
Single-row requirement: Metadata + FullName complexvalue columns; production SOQL omits both to keep the PlannerId / PluginId IN batch path intact.
GenAiPlannerFunctionDef (Tooling) — planner-bundle join
Bundle-scope join: planner → function. Analogous to GenAiPluginFunctionDef but keyed on PlannerId. Batch-safe on PlannerId = :id.
| Name | Type | Nillable | Filterable | Tag |
|---|---|---|---|---|
Id | id | no | yes | [mandatory] |
PlannerId | reference | no | yes | [mandatory] |
Plugin | picklist | yes | yes | [optional] — references plugin DeveloperName / id |
NamespacePrefix | string | yes | yes | [optional] |
ManageableState | picklist | yes | yes | [optional] |
IsDeleted | boolean | no | yes | [optional] |
CreatedById | reference | no | yes | [optional] |
CreatedDate | datetime | no | yes | [optional] |
LastModifiedById | reference | no | yes | [optional] |
SystemModstamp | datetime | no | yes | [optional] |
GenAiPlannerAttrDefinition (Tooling) — parameter mappings
The attributeMappings — I/O parameter bindings between actions and the planner. Polymorphic `ParentId`: points at either a GenAiFunctionDefinition (function-scope mappings) or GenAiPlannerDefinition (bundle-scope mappings). The production SOQL passes the union of function ids + planner id via WHERE ParentId IN (:function_ids, :planner_id).
MappingType is the input / output picklist; ParameterName identifies the bound variable on the planner side.
| Name | Type | Nillable | Filterable | Tag |
|---|---|---|---|---|
Id | id | no | yes | [mandatory] |
ParentId | reference | yes | yes | [optional] — polymorphic: GenAiFunctionDefinition \ |
DeveloperName | string | no | yes | [optional] |
MasterLabel | string | yes | yes | [optional] |
Description | textarea | yes | no | [optional] |
MappingType | picklist | yes | yes | [optional] — input / output |
ParameterName | string | yes | yes | [optional] |
Language | picklist | yes | yes | [optional] |
NamespacePrefix | string | yes | yes | [optional] |
ManageableState | picklist | yes | yes | [optional] |
IsDeleted | boolean | no | yes | [optional] |
CreatedById | reference | no | yes | [optional] |
CreatedDate | datetime | no | yes | [optional] |
LastModifiedById | reference | no | yes | [optional] |
---
Known picklist values (live-API verified, 2026-05-02)
| sObject | Field | Values |
|---|---|---|
GenAiPlannerDefinition | PlannerType | Seven values split by namespace. Classic family: AiCopilot__ReAct, AiCopilot__ReactAiPlannerV1, AiCopilot__SequentialPlannerIntentClassifier. NGA family: Atlas__ConcurrentMultiAgentOrchestration, Atlas__AnthropicCompatibleV1, Atlas__AtlasReactV1, Atlas__MainSubAgent (exact set may vary by release — startswith("Atlas__") is the stable classic-vs-NGA discriminator). |
GenAiFunctionDefinition | InvocationTargetType | flow, apex, standardInvocableAction, generatePromptResponse |
GenAiPlannerAttrDefinition | MappingType | input, output |
BotVersion | Status | Inactive, Active (active = currently published version) |
---
Mandatory-field enforcement
The [mandatory] tags above mirror scripts/probe_channels.py:
MANDATORY_FIELDS: Dict[str, set[str]] = {
"BotDefinition": {"Id", "DeveloperName"},
"BotVersion": {"Id", "BotDefinitionId"},
"ApexClass": {"Id", "Name"},
"Flow": {"Id", "MasterLabel", "DefinitionId"},
"FlowDefinition": {"Id", "DeveloperName"},
"GenAiPlannerDefinition": {"Id", "DeveloperName", "PlannerType"},
"GenAiPluginDefinition": {"Id", "DeveloperName"},
"GenAiFunctionDefinition": {"Id", "DeveloperName", "InvocationTarget"},
"GenAiPlannerAttrDefinition": {"Id"},
}GenAiPluginFunctionDef, GenAiPluginInstructionDef, and GenAiPlannerFunctionDef don't appear in MANDATORY_FIELDS — they're join tables whose Id + FK columns are present by construction on every query. The per-sObject tables above tag their FK columns as [mandatory] because the SOQL assets SELECT them; a probe that saw them missing would still flip PROBE_FAILED via the Id check, but the tag reflects "the skill's SOQL will fail if this column is gone."
When a Salesforce quarterly release renames or removes a [mandatory] field, run with --reprobe to force a fresh describe and surface the drift cleanly.
# Shared path / fs-guard / sql helpers for investigating-agentforce-architecture.
#!/usr/bin/env python3
r"""Filesystem-safety and input-validation guard.
One script, six check types. Emits a STATUS=INVALID_INPUT RESULT block on
failure + tees to $ERROR_TEE on disk + exits 1.
Guiding principle: the agent MUST suffix every call with `|| exit 1` — Python
`sys.exit(1)` only terminates this subprocess, not the parent bash. A bare
call without `|| exit 1` silently continues past a failed guard, which is
the worst possible failure mode for a security check.
Check types:
symlink — path must NOT be a symlink (rejects pre-planted attacker bait)
owned — path must be owned by current UID (rejects foreign-owned dirs)
uuid — value must match ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$
org_id_15 — value must match ^[A-Za-z0-9]{15}$ (Salesforce org ID slice)
api_name — value must match ^[A-Za-z0-9_]+$ (Salesforce API identifier)
api_version — value must match ^v[0-9]+\.[0-9]+$ (e.g. v60.0)
agent_version — value must match ^v[0-9]+$ (e.g. v5 — Agentforce agent version, no dot-minor)
not_empty — value must be non-empty string
Python-importable API:
validate_api_name(value, label="...") — raises ValidationError on bad input
validate_api_version(value, label="...") — raises ValidationError on bad input
validate_agent_version(value, label="...") — raises ValidationError on bad input
validate_org_id_15(value, label="...") — raises ValidationError on bad input
ValidationError — carries (label, reason)
The Python API is used by in-process callers (SOQL loader, path builders in
config.py) that need a raise-based boundary rather than the process-exit
CLI behavior. Regex is shared — do NOT duplicate API_NAME_RE / API_VERSION_RE.
Usage:
python3 fs_guard.py <value> <label> <check> || exit 1
# Input validation
python3 fs_guard.py "$AGENT_API_NAME" agent_api_name api_name || exit 1
python3 fs_guard.py "$ORG_ALIAS" org_alias not_empty || exit 1
# Filesystem safety
python3 fs_guard.py "$WORK_DIR" WORK_DIR symlink || exit 1
python3 fs_guard.py "$WORK_DIR" WORK_DIR owned || exit 1
python3 fs_guard.py "$ORG_ID_15" ORG_ID_15 org_id_15 || exit 1
Inputs:
argv[1] value or path to check
argv[2] label (appears in ERROR_DETAIL; used to identify which guard tripped)
argv[3] check type (one of the 6 above)
env $ERROR_TEE (optional): path to disk-tee file. Defaults to
$HOME/.vibe/data/investigating-agentforce-architecture/_last_error_result.txt.
Also reads $AGENT_API_NAME / $ORG_ID_18 / $ORG_ID_15 for
RESULT-block context if set.
Outputs:
on failure: STATUS=INVALID_INPUT RESULT block on stdout + tee to disk, exit 1
on success: silent, exit 0
on bad argv: exit 1 with a minimal ERROR_DETAIL ("fs_guard internal: ...")
"""
import os
import pathlib
import re
import sys
UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") # @rule-suppress starter-sec-002 — re.compile, not eval/exec
ORG_ID_15_RE = re.compile(r"^[A-Za-z0-9]{15}$") # @rule-suppress starter-sec-002 — re.compile, not eval/exec
API_NAME_RE = re.compile(r"^[A-Za-z0-9_]+$") # @rule-suppress starter-sec-002 — re.compile, not eval/exec
# api_version check — matches `v60.0`, `v66.0`, etc. Used before any
# Path composition that embeds the api_version segment.
API_VERSION_RE = re.compile(r"^v[0-9]+\.[0-9]+$") # @rule-suppress starter-sec-002 — re.compile, not eval/exec
# Agent-version check — matches Agentforce version identifiers (`v1`, `v5`,
# `v12`). Deliberately stricter than api_name (rejects free-form names like
# `release_1`) and looser than api_version (rejects `v66.0` which is a REST
# API version, never an agent version). Lives on its own so callers that
# embed agent_version in a filesystem path get the tight regex, not the
# permissive api_name fallback.
AGENT_VERSION_RE = re.compile(r"^v[0-9]+$") # @rule-suppress starter-sec-002 — re.compile, not eval/exec
VALID_CHECKS = {"symlink", "owned", "uuid", "org_id_15", "api_name", "api_version", "agent_version", "not_empty"}
# -----------------------------------------------------------------------------
# Python-importable API
# -----------------------------------------------------------------------------
# In-process callers (soql_loader.load_soql, config.build_*_dir) need a
# raise-based validation boundary, distinct from the CLI script's exit-1
# behavior. The regexes are shared with the CLI checks above — do NOT
# duplicate. If a regex changes, both surfaces update in lockstep.
class ValidationError(ValueError):
"""Raised by the Python-importable validators on bad input.
Carries the label (which field failed) + reason. Callers (e.g.
soql_loader.load_soql) convert this into `_unresolved[]` entries or
RESULT-level INVALID_INPUT responses as appropriate.
"""
def __init__(self, label: str, reason: str) -> None:
self.label = label
self.reason = reason
super().__init__(f"{label}: {reason}")
def validate_api_name(value, label: str = "value") -> None:
"""Validate `value` against ^[A-Za-z0-9_]+$ (Salesforce API identifier).
used by soql_loader.load_soql at the substitution boundary to catch
injection attempts in names pulled from Bot XML, Flow.Metadata, ApexClass
SymbolTable, etc.
used by config.build_*_dir helpers for path components that must
not contain `..`, `/`, or other traversal characters.
Raises ValidationError on any of: None, non-string, empty, regex miss.
"""
if value is None:
raise ValidationError(label, "is None")
if not isinstance(value, str):
raise ValidationError(label, f"must be str, got {type(value).__name__}")
if not value:
raise ValidationError(label, "must not be empty")
if not API_NAME_RE.match(value):
# Preview first 20 chars so logs/_unresolved entries carry enough
# context to debug, without dumping an unbounded attacker-controlled
# string into output.
preview = value[:20]
raise ValidationError(
label,
f"does not match [A-Za-z0-9_]+ (preview={preview!r})",
)
def validate_api_version(value, label: str = "api_version") -> None:
"""Validate `value` against ^v[0-9]+\\.[0-9]+$ (Salesforce API version).
used by config.build_*_dir helpers. api_version is returned by
`sf org display` and later embedded in cache paths; reject anything that
could escape the cache subtree.
"""
if value is None:
raise ValidationError(label, "is None")
if not isinstance(value, str):
raise ValidationError(label, f"must be str, got {type(value).__name__}")
if not value:
raise ValidationError(label, "must not be empty")
if not API_VERSION_RE.match(value):
preview = value[:20]
raise ValidationError(
label,
f"does not match v<major>.<minor> (preview={preview!r})",
)
def validate_agent_version(value, label: str = "agent_version") -> None:
"""Validate `value` against ^v[0-9]+$ (Agentforce agent version).
Strictly `v<digits>` with no dot-minor — matches the shape Agentforce
actually uses (`v1`, `v5`, `v12`). Rejects `release_1`, `v66.0`, `FOO`,
and anything else that could silently slip past a permissive api_name
check and land in a filesystem path.
used by path-builder helpers for the agent_version segment.
"""
if value is None:
raise ValidationError(label, "is None")
if not isinstance(value, str):
raise ValidationError(label, f"must be str, got {type(value).__name__}")
if not value:
raise ValidationError(label, "must not be empty")
if not AGENT_VERSION_RE.match(value):
preview = value[:20]
raise ValidationError(
label,
f"does not match v<digits> (preview={preview!r})",
)
def validate_org_id_15(value, label: str = "org_id_15") -> None:
"""Validate `value` against ^[A-Za-z0-9]{15}$ (Salesforce 15-char org ID).
stricter than validate_api_name — enforces exact 15 chars AND
no underscores. `org_id_15` always comes from a Salesforce-generated
field, never from free-form input.
"""
if value is None:
raise ValidationError(label, "is None")
if not isinstance(value, str):
raise ValidationError(label, f"must be str, got {type(value).__name__}")
if not value:
raise ValidationError(label, "must not be empty")
if not ORG_ID_15_RE.match(value):
preview = value[:20]
raise ValidationError(
label,
f"must be exactly 15 alphanumeric chars (preview={preview!r})",
)
def scrub(s: str) -> str:
# Same rules as sanitize.py; duplicated locally so this script has no
# intra-skill imports. Keeps the agent-script boundary clean.
bad = set("`$\"\\\r\t\0\n")
return "".join(c for c in (s or "") if c not in bad)
def emit_failure(reason: str, label: str) -> None:
agent_api_name = scrub(os.environ.get("AGENT_API_NAME", ""))
org_id_18 = scrub(os.environ.get("ORG_ID_18", ""))
org_id_15 = scrub(os.environ.get("ORG_ID_15", ""))
label_safe = scrub(label)
reason_safe = scrub(reason)
block = (
"=== RESULT ===\n"
"STATUS=INVALID_INPUT\n"
f"ERROR_DETAIL={label_safe}: {reason_safe}\n"
f"AGENT_API_NAME={agent_api_name}\n"
f"ORG_ID_18={org_id_18}\n"
f"ORG_ID_15={org_id_15}\n"
)
# Default ERROR_TEE — skill-scoped data dir. Runtime-agnostic default
# mirrors runtime.resolve_data_root() in the sibling runtime module
# (duplicated rather than imported because fs_guard.py is also invoked
# standalone from SKILL.md bash, where the override hook isn't active).
tee_default = str(
pathlib.Path.home()
/ ".vibe"
/ "data"
/ "investigating-agentforce-architecture"
/ "_last_error_result.txt"
)
tee_path = os.environ.get("ERROR_TEE") or tee_default
try:
p = pathlib.Path(tee_path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(block)
except OSError:
pass
sys.stdout.write(block)
sys.exit(1)
def check_symlink(value: str, label: str) -> None:
if pathlib.Path(value).is_symlink():
emit_failure(f"path is a symlink (refusing to follow): {value}", label)
def check_owned(value: str, label: str) -> None:
p = pathlib.Path(value)
if not p.exists():
return
try:
st = p.stat()
except OSError as e:
emit_failure(f"stat failed: {e}", label)
return
if st.st_uid != os.getuid():
emit_failure(f"foreign-owned (uid {st.st_uid} != current {os.getuid()}): {value}", label)
def check_uuid(value: str, label: str) -> None:
if not UUID_RE.match(value):
emit_failure("does not match UUID pattern (8-4-4-4-12 lowercase hex)", label)
def check_org_id_15(value: str, label: str) -> None:
if not ORG_ID_15_RE.match(value):
emit_failure("must be exactly 15 alphanumeric characters", label)
def check_api_name(value: str, label: str) -> None:
if not API_NAME_RE.match(value):
emit_failure("does not match [A-Za-z0-9_]+ (Salesforce API name rules)", label)
def check_api_version(value: str, label: str) -> None:
# api_version must be `vNN.N` — no slashes, no dots beyond the one,
# no path-traversal sequences.
if not API_VERSION_RE.match(value):
emit_failure("does not match v<major>.<minor> (e.g. v60.0)", label)
def check_agent_version(value: str, label: str) -> None:
# Agent version must be `v<digits>` — no dots, no slashes.
if not AGENT_VERSION_RE.match(value):
emit_failure("does not match v<digits> (e.g. v5)", label)
def check_not_empty(value: str, label: str) -> None:
if not value:
emit_failure("must not be empty", label)
CHECKS = {
"symlink": check_symlink,
"owned": check_owned,
"uuid": check_uuid,
"org_id_15": check_org_id_15,
"api_name": check_api_name,
"api_version": check_api_version,
"agent_version": check_agent_version,
"not_empty": check_not_empty,
}
def main() -> int:
if len(sys.argv) != 4:
sys.stdout.write(
"=== RESULT ===\n"
"STATUS=INVALID_INPUT\n"
"ERROR_DETAIL=fs_guard internal: wrong argv count (need value, label, check)\n"
)
return 1
value, label, check = sys.argv[1], sys.argv[2], sys.argv[3]
for arg in (value, label, check):
if any(c == "\0" or (ord(c) < 0x20 and c not in "\t\n") for c in arg):
emit_failure("argv contains control characters", label or "argv")
if check not in VALID_CHECKS:
emit_failure(f"unknown check type '{check}' (valid: {', '.join(sorted(VALID_CHECKS))})", label)
CHECKS[check](value, label)
return 0
if __name__ == "__main__":
sys.exit(main())
"""Canonical path helpers for investigating-agentforce-architecture.
Layout:
~/.vibe/data/investigating-agentforce-architecture/
└── <org_id_15>/
└── <agent_api_name>__<agent_version>/
├── <agent>_<ver>_metadata_tree.json ← rendered tree
└── .emit_ctx.json ← per-run shell ctx
Validation strategy
-------------------
The three "agent-identity" segments (org_id_15, agent_api_name, agent_version)
are validated by the regex helpers in the sibling ``fs_guard`` module. Any
segment containing ``..``, ``/``, or characters outside its regex charclass
raises ``PathValidationError`` — direct ``Path`` composition from unvalidated
input would be a path-traversal vulnerability and is prohibited.
Runtime override
----------------
``DATA_ROOT`` / ``CACHE_ROOT`` are resolved via the ``runtime`` module so
that entry scripts can override them via ``--data-dir`` / ``--cache-dir``
CLI flags. The default values (``~/.vibe/{data,cache}/...``)
are runtime-agnostic; AFV OOTB, Codex, Cursor, OpenCode each pass their
own override paths.
"""
from __future__ import annotations
from pathlib import Path
from . import fs_guard
from . import runtime
# -----------------------------------------------------------------------------
# Roots
# -----------------------------------------------------------------------------
DATA_ROOT: Path = runtime.resolve_data_root("investigating-agentforce-architecture")
CACHE_ROOT: Path = runtime.resolve_cache_root("investigating-agentforce-architecture")
class PathValidationError(ValueError):
"""Raised when a path segment fails validation.
Wraps fs_guard's ``ValidationError`` so callers have a single exception
type to catch.
"""
def __init__(self, label: str, reason: str) -> None:
self.label = label
self.reason = reason
super().__init__(f"{label}: {reason}")
# -----------------------------------------------------------------------------
# Validation helpers
# -----------------------------------------------------------------------------
def _validate_agent_triple(
org_id_15: str, agent_api_name: str, agent_version: str
) -> None:
"""Validate the three identity segments. Raises PathValidationError on
failure, wrapping fs_guard's ValidationError so callers don't need to
know about fs_guard internals."""
try:
fs_guard.validate_org_id_15(org_id_15, label="org_id_15")
fs_guard.validate_api_name(agent_api_name, label="agent_api_name")
fs_guard.validate_agent_version(agent_version, label="agent_version")
except fs_guard.ValidationError as e:
raise PathValidationError(e.label, e.reason) from e
# -----------------------------------------------------------------------------
# Path builders
# -----------------------------------------------------------------------------
def agent_dir(
org_id_15: str, agent_api_name: str, agent_version: str
) -> Path:
"""Return ``DATA_ROOT/<org_id_15>/<agent_api_name>__<agent_version>/``.
All three segments are regex-validated before being joined.
"""
_validate_agent_triple(org_id_15, agent_api_name, agent_version)
return DATA_ROOT / org_id_15 / f"{agent_api_name}__{agent_version}"
def architecture_tree_path(
org_id_15: str, agent_api_name: str, agent_version: str
) -> Path:
"""Return the path to the rendered metadata tree JSON.
Filename convention: ``<agent_api_name>_<agent_version>_metadata_tree.json``.
"""
base = agent_dir(org_id_15, agent_api_name, agent_version)
return base / f"{agent_api_name}_{agent_version}_metadata_tree.json"
def architecture_emit_ctx_path(
org_id_15: str, agent_api_name: str, agent_version: str
) -> Path:
"""Return the path to the per-run ``.emit_ctx.json``.
The file is dotfile-named to signal per-run shell state.
"""
base = agent_dir(org_id_15, agent_api_name, agent_version)
return base / ".emit_ctx.json"
"""Runtime override hook for DATA_ROOT / CACHE_ROOT.
Default layout is ``~/.vibe/data/<skill>`` and
``~/.vibe/cache/<skill>`` — runtime-agnostic branding that
follows the repo's "tool-agnostic language" guideline.
Other runtimes (AFV OOTB, Codex, Cursor, OpenCode) can override via
``--data-dir`` / ``--cache-dir`` CLI flags on entry scripts. Those flags
call ``set_*_override()`` BEFORE any pipeline module imports ``paths.py``,
so the resolution helpers below pick up the override.
Three-level rebind contract
---------------------------
Python's ``from X import Y`` snapshots ``Y`` into the importer's local
namespace. Mutating ``X.Y`` later does NOT update the importer's local
binding. Entry scripts that capture ``DATA_ROOT`` / ``CACHE_ROOT`` at
module top must therefore rebind in THREE places after override:
1. ``paths.DATA_ROOT`` — the source of truth
2. ``config.DATA_ROOT`` — the re-export
3. The entry script's own local ``DATA_ROOT`` (via ``global``)
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
_DATA_OVERRIDE: Optional[Path] = None
_CACHE_OVERRIDE: Optional[Path] = None
def set_data_root_override(p: Optional[Path]) -> None:
"""Set the data-root override. Pass ``None`` to clear."""
global _DATA_OVERRIDE
_DATA_OVERRIDE = p
def set_cache_root_override(p: Optional[Path]) -> None:
"""Set the cache-root override. Pass ``None`` to clear."""
global _CACHE_OVERRIDE
_CACHE_OVERRIDE = p
def resolve_data_root(skill_name: str) -> Path:
"""Resolve the data root for ``skill_name``.
Returns the override if set, else the runtime-agnostic default
``~/.vibe/data/<skill_name>``.
"""
return _DATA_OVERRIDE or (Path.home() / ".vibe" / "data" / skill_name)
def resolve_cache_root(skill_name: str) -> Path:
"""Resolve the cache root for ``skill_name``.
Returns the override if set, else the runtime-agnostic default
``~/.vibe/cache/<skill_name>``.
"""
return _CACHE_OVERRIDE or (Path.home() / ".vibe" / "cache" / skill_name)
"""SQL-escaping helpers for SOQL string literals.
"""
from __future__ import annotations
def _escape_sql_literal(s: str) -> str:
"""Double single quotes per SOQL escaping rule. Handles O'Brien →
O''Brien, `'; DROP --` → `''; DROP --` (still harmless because it's
wrapped in surrounding single quotes)."""
return s.replace("'", "''")
#!/usr/bin/env python3
"""Cache hit/miss check for investigating-agentforce-architecture.
Replaces old agent Phase 0.5 (manifest read, schema-version check, TTL age
check, path-existence check, env export on hit, RESULT-block assembly).
Flow:
1. If FORCE_REFRESH=true → emit CACHE_HIT=false, exit 0.
2. Read $CACHE_DIR/manifest.json. Any I/O or parse failure → miss.
3. if schema_version != config.SCHEMA_VERSION → miss,
emit CACHE_INVALIDATED_REASON=schema-version-mismatch, and
`shutil.rmtree` the cache dir (gated to paths under CACHE_ROOT).
4. If data_path file missing → miss (atomic rename failed mid-write).
5. If age > ttl_days (default 7) → miss (no rmtree — just stale).
6. Hit path:
- Copy {agent}_{ver}_metadata_tree.json from cache into DATA_DIR +
WORK_DIR.
- Copy `declared_action_tree.json` sidecar into WORK_DIR so emit ctx
+ downstream greps work.
- Stdout: eval-able lines (CACHE_HIT=true, CACHED_AT_UTC, NODE_COUNT,
DEPTH, AGENT_GENERATION, BOT_ID, VERSION_AUTO_PICKED, PARTIAL,
UNRESOLVED_COUNT, OUTPUT_JSON_PATH, OUTPUT_SUMMARY_PATH).
`{tree_base}.summary.md` was dropped from the output contract.
Cache-hit no longer copies or regenerates a summary file; OUTPUT_SUMMARY_PATH
is emitted empty for RESULT-block shape stability.
Usage:
eval "$(python3 cache_check.py)"
Inputs (env):
CACHE_DIR required — $CACHE_ROOT/$ORG_ID_15/$AGENT__$VER
DATA_DIR required — $DATA_ROOT/$ORG_ID_15/$AGENT__$VER
WORK_DIR required
AGENT_API_NAME required
AGENT_VERSION required
FORCE_REFRESH 'true'|'false' (default false)
Outputs:
stdout: eval-able K=V lines
files (hit only): DATA_DIR/{tree_base}.json and matching copy in WORK_DIR
exit 0 always (miss is not an error)
"""
import json
import os
import pathlib
import shlex
import shutil
import sys
import datetime as dt
import config # for SCHEMA_VERSION + CACHE_ROOT path-guard
# refuse to operate if CACHE_ROOT itself is a symlink.
# _safe_rmtree_under_cache_root calls resolve() on both target and root; a
# symlinked CACHE_ROOT collapses the is_relative_to check because both sides
# land on the symlink target, so rmtree would happily delete whatever lives
# there — outside the sanctioned cache subtree. The guard was designed to
# prevent rmtree escaping the cache; if the root itself can escape, the
# guard is moot. Fail fast at import time so the failure surfaces at the
# correct layer (configuration, not deletion).
#
# Path.is_symlink() returns False for non-existent paths, which is the
# correct behaviour for pristine installs (CACHE_ROOT hasn't been created
# yet; the script will mkdir it on first write and subsequent imports will
# validate the real directory).
if config.CACHE_ROOT.is_symlink():
raise RuntimeError(
f"CACHE_ROOT is a symlink ({config.CACHE_ROOT} -> "
f"{config.CACHE_ROOT.resolve()}). This is rejected for safety; the "
"_safe_rmtree_under_cache_root guard cannot protect against a "
"symlinked-root escape. Resolve the symlink or update "
"config.CACHE_ROOT to a real directory."
)
def miss(reason: str | None = None):
"""Emit CACHE_HIT=false and exit 0.
if `reason` is provided, emit a second K=V line
`CACHE_INVALIDATED_REASON=<reason>` so downstream emit_result can
surface the invalidation cause in the RESULT block.
"""
sys.stdout.write("CACHE_HIT=false\n")
if reason:
sys.stdout.write(f"CACHE_INVALIDATED_REASON={shlex.quote(reason)}\n")
sys.exit(0)
def _safe_rmtree_under_cache_root(target: pathlib.Path) -> bool:
"""`shutil.rmtree(target)` ONLY if `target` resolves under CACHE_ROOT.
Defence against a miscomputed cache_dir that could point outside the
sanctioned cache root (symlink shenanigans, env-var tampering, or a
future refactor that forgets to run through `build_agent_cache_dir`).
We resolve both sides to absolute paths and confirm CACHE_ROOT is a
prefix before handing to rmtree.
Returns True on successful deletion, False if the path was refused or
the rmtree itself failed. Never raises — callers on the miss() path
can't act on a failure anyway.
"""
try:
target_abs = target.resolve(strict=False)
root_abs = config.CACHE_ROOT.resolve(strict=False)
except (OSError, RuntimeError):
return False
# Path.is_relative_to exists on 3.9+. Explicit check is clearer than
# a try/except on relative_to().
try:
if not target_abs.is_relative_to(root_abs):
return False
except AttributeError: # pragma: no cover — safety for <3.9
try:
target_abs.relative_to(root_abs)
except ValueError:
return False
if not target_abs.exists():
return True # nothing to do, treat as successful no-op
try:
shutil.rmtree(target_abs)
except OSError:
return False
return True
def main() -> int:
try:
cache_dir = pathlib.Path(os.environ["CACHE_DIR"])
data_dir = pathlib.Path(os.environ["DATA_DIR"])
work_dir = pathlib.Path(os.environ["WORK_DIR"])
agent_api_name = os.environ["AGENT_API_NAME"]
agent_version = os.environ["AGENT_VERSION"]
except KeyError as e:
sys.stderr.write(f"cache_check.py: missing env {e}\n")
miss()
if (os.environ.get("FORCE_REFRESH", "").strip().lower() == "true"):
miss()
manifest_path = cache_dir / "manifest.json"
if not manifest_path.is_file():
miss()
try:
manifest = json.loads(manifest_path.read_text())
except (OSError, json.JSONDecodeError):
miss()
# Schema version gate.
# strict match against config.SCHEMA_VERSION. Any mismatch (too
# old OR unexpected future/unknown value) invalidates the cache, and
# we delete the cache directory so the next run starts clean — a
# stale tree under a legacy schema is worse than no cache, because
# downstream code may parse shapes it no longer understands. The
# legacy `< 2.4` check is subsumed (anything that doesn't equal the
# current SCHEMA_VERSION triggers this branch).
schema = str(manifest.get("schema_version") or "0")
if schema != config.SCHEMA_VERSION:
_safe_rmtree_under_cache_root(cache_dir)
miss("schema-version-mismatch")
# data_path existence
data_path_s = manifest.get("data_path") or ""
data_path = pathlib.Path(data_path_s) if data_path_s else None
if not data_path or not data_path.is_file():
miss()
# TTL age check
try:
built = dt.datetime.fromisoformat(
(manifest.get("built_at_utc") or "").replace("Z", "+00:00")
)
except ValueError:
miss()
age_days = (dt.datetime.now(dt.timezone.utc) - built).days
ttl = int(manifest.get("ttl_days") or 7)
if age_days > ttl:
miss()
# --- Hit path ---
tree_base = f"{agent_api_name}_{agent_version}_metadata_tree"
data_dir.mkdir(parents=True, exist_ok=True)
work_dir.mkdir(parents=True, exist_ok=True)
dst_json = data_dir / f"{tree_base}.json"
# The manifest's data_path points at the authoritative tree copy. Re-copy
# to DATA_DIR + WORK_DIR so the filesystem layout is stable for callers.
try:
shutil.copy(data_path, dst_json)
except OSError:
miss()
# Stage a declared_action_tree.json sidecar in WORK_DIR for any downstream
# script that expects the generic name.
try:
shutil.copy(dst_json, work_dir / "declared_action_tree.json")
except OSError:
pass
# no summary.md handling — dropped from the output contract.
agent_meta = manifest.get("agent") or {}
kind_counts = manifest.get("kind_counts") or {}
exports = [
("CACHE_HIT", "true"),
("CACHED_AT_UTC", manifest.get("built_at_utc", "")),
("NODE_COUNT", str(manifest.get("node_count", 0))),
("DEPTH", str(manifest.get("depth", 0))),
("AGENT_GENERATION", agent_meta.get("generation") or "unknown"),
("BOT_ID", agent_meta.get("bot_id") or ""),
("BOT_MASTER_LABEL", agent_meta.get("master_label") or ""),
("VERSION_AUTO_PICKED", "true" if agent_meta.get("_version_auto_picked") else "false"),
("PARTIAL", "true" if manifest.get("partial") else "false"),
("UNRESOLVED_COUNT", str(manifest.get("unresolved_count", 0))),
("OUTPUT_JSON_PATH", str(dst_json)),
# summary.md dropped from the output contract; field kept
# empty for RESULT-block shape stability.
("OUTPUT_SUMMARY_PATH", ""),
("PLANNER_NAME", agent_meta.get("planner_name") or ""),
]
# Pass kind counts too, in case finalize.py runs later without re-parsing
# (it won't on cache hit, but the values keep the RESULT block complete).
for k, v in kind_counts.items():
exports.append((f"KC_{k}", str(v)))
sys.stdout.write("\n".join(f"{k}={shlex.quote(v)}" for k, v in exports) + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())
"""Shared paths + constants for investigating-agentforce-architecture.
Resolves SKILL_ROOT relative to this file's location — works on every
runtime without env vars. SKILL.md bash blocks still read the
``PLUGIN_ROOT`` env var (with a default fallback) because bash has no
``__file__`` equivalent.
Path layout:
~/.vibe/data/investigating-agentforce-architecture/
└── <org_id_15>/
└── <agent_api_name>__<agent_version>/ ← architecture artifacts
Every path component embedded in a cache / data directory must be
regex-validated before being joined to a Path. Four components are in
scope: `api_version`, `org_id15`, `agent_api_name`, `agent_version`.
(`planner_name` is validated separately at the SOQL substitution
boundary by `soql_loader.load_soql` — it never appears in a filesystem
path.) The helpers below are the ONLY sanctioned way to build those
paths — direct Path composition from unvalidated strings is a
path-traversal vulnerability.
"""
from __future__ import annotations
import sys
from pathlib import Path
# SKILL_ROOT is the directory holding SKILL.md, derived from this file's location:
# <SKILL_ROOT>/scripts/config.py → Path(__file__).resolve().parent.parent
# Python doesn't read PLUGIN_ROOT — it's only for SKILL.md bash blocks
# (PR3 will replace those with a proper entry script).
SKILL_ROOT = Path(__file__).resolve().parent.parent
SOQL_DIR = SKILL_ROOT / "assets" / "soql"
CLI_DIR = SKILL_ROOT / "assets" / "cli"
MERMAID_DIR = SKILL_ROOT / "assets" / "mermaid"
REFERENCES_DIR = SKILL_ROOT / "references"
# -----------------------------------------------------------------------------
# Shared path helpers — sourced from scripts/_shared/.
# -----------------------------------------------------------------------------
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from _shared import paths as _paths # type: ignore # noqa: E402
from _shared import fs_guard # type: ignore # noqa: E402,F401 — re-exported for sibling scripts + tests
DATA_ROOT = _paths.DATA_ROOT
CACHE_ROOT = _paths.CACHE_ROOT
PROBE_CACHE_ROOT = CACHE_ROOT / "_channel_probe"
# Cache TTL — 7 days. Probe TTL same.
CACHE_TTL_DAYS = 7
PROBE_TTL_DAYS = 7
# BFS defensive termination guard.
#
# Historically this was `5` and acted as a functional constraint on
# chain depth. That was wrong: shared utility flows like `handleFlowFault`
# appear on every real flow's fault path, so any moderately nested tree
# (`[FLOW] A → [FLOW] B → [FLOW] handleFlowFault`) tripped the cap and
# surfaced the utility in `_pending_fetches` even though it is trivially
# expandable. Cycle detection now runs via a per-branch ancestor path set
# (`visited_in_path` in `inflate_flow_leaf`), which is the textbook-correct
# primitive for this problem: the same flow visited on two sibling branches
# is not a cycle, but the same flow visited along its own ancestor chain is.
#
# This constant is retained purely as a defensive last-resort termination
# guard for pathological graphs that somehow evade per-branch cycle detection
# (e.g. a bug regression in `_cycle_key`). In practice the per-branch set
# terminates every real bot tree long before this depth is reached.
MAX_BFS_DEPTH = 20
# Schema version — 3.1 (2026-05-05) canonicalizes `invocation_type` on
# STANDARD_ACTION nodes, formerly split across `raw_invocation_type` and
# `raw_action_type`. Must match `parse_wave.init_tree`'s `_schema_version`
# literal. Any bump forces a cache rebuild via `cache_check.py`'s gate.
SCHEMA_VERSION = "3.1"
# Default parallelism for ThreadPoolExecutor (Flow metadata fan-out).
DEFAULT_PARALLELISM = 5
# -----------------------------------------------------------------------------
# validated path builders
# -----------------------------------------------------------------------------
# fs_guard is re-exported above from `scripts/_shared/`.
# Sibling scripts (soql_loader, render_architecture, fetch_soql)
# and the test suite import it as ``from config import fs_guard`` so the
# resolution dance lives in one place.
def build_agent_data_dir(org_id_15: str, agent_api_name: str, agent_version: str) -> Path:
"""Return DATA_ROOT/<org_id_15>/<agent_api_name>__<agent_version>/.
Thin wrapper over ``_shared.paths.agent_dir`` — kept so existing
callers don't need to change import sites. The shared helper raises
``paths.PathValidationError`` (a ValueError subclass); we re-raise
the underlying ``fs_guard.ValidationError`` so existing tests that
catch that specific type keep passing.
"""
fs_guard.validate_org_id_15(org_id_15, label="org_id_15")
fs_guard.validate_api_name(agent_api_name, label="agent_api_name")
fs_guard.validate_api_name(agent_version, label="agent_version")
return _paths.DATA_ROOT / org_id_15 / f"{agent_api_name}__{agent_version}"
def build_agent_cache_dir(org_id_15: str, agent_api_name: str, agent_version: str) -> Path:
"""Return CACHE_ROOT/<org_id_15>/<agent_api_name>__<agent_version>/.
Mirrors ``build_agent_data_dir`` under the cache root. Validation gate
matches — every segment regex-checked before the join.
"""
fs_guard.validate_org_id_15(org_id_15, label="org_id_15")
fs_guard.validate_api_name(agent_api_name, label="agent_api_name")
fs_guard.validate_api_name(agent_version, label="agent_version")
return CACHE_ROOT / org_id_15 / f"{agent_api_name}__{agent_version}"
def build_probe_cache_dir(org_id_15: str, api_version: str) -> Path:
"""Return PROBE_CACHE_ROOT/<org_id_15>/<api_version>/.
org_id_15 via validate_api_name (regex tolerates 15-char alnum),
api_version via validate_api_version (enforces `vNN.N` shape — rejects
`..`, `/`, and any non-version string).
"""
fs_guard.validate_org_id_15(org_id_15, label="org_id_15")
fs_guard.validate_api_version(api_version, label="api_version")
return PROBE_CACHE_ROOT / org_id_15 / api_version
#!/usr/bin/env python3
"""Finalize: write DATA_DIR + CACHE_DIR atomically, build manifest.
Replaces old agent Phase 7. Steps:
1. Load $WORK_DIR/declared_action_tree.json.
2. Compute _partial = !(planner_ok && _pending_fetches empty); strip _visited.
3. Stage DATA_DIR at $DATA_DIR.tmp: copy tree as {api}_{ver}_metadata_tree.json.
4. rmtree final DATA_DIR; rename .tmp → DATA_DIR.
5. Stage CACHE_DIR at $CACHE_DIR.tmp: copy metadata/<wave> dirs + sidecars.
6. Write manifest.json.
7. rmtree final CACHE_DIR; rename .tmp → CACHE_DIR.
8. Seed .gitignore on data_root + cache_root parents.
9. Write $WORK_DIR/.built_at.txt (ISO-Z UTC).
the previously-emitted `{api}_{ver}_metadata_tree.summary.md` is
dropped from the output contract — it was a redundant summary of the tree
JSON. Consumers should read the JSON directly.
Usage:
python3 finalize.py
Inputs (env):
WORK_DIR required
CACHE_DIR required
DATA_DIR required
PLANNER_NAME optional — empty → tree marked partial
AGENT_API_NAME, AGENT_VERSION — used for filenames
Outputs:
$DATA_DIR/{api}_{ver}_metadata_tree.json
$DATA_DIR/last_built_at.txt
$CACHE_DIR/manifest.json + metadata/<wave>/... + parsed sidecars
$WORK_DIR/.built_at.txt
exit 0 success, 1 on any write failure → caller emits STATUS=WRITE_FAILED
"""
import datetime
import json
import os
import pathlib
import shutil
import sys
def sort_tree_in_place(root: dict) -> None:
"""Sort `root.children` and each TOPIC's children alphabetically.
Ordering rules (2026-05-05, schema 3.1):
- `BOT_DEFINITION.children`: TOPIC nodes first (by `api_name`
case-insensitive), then non-topic plannerActions (by kind, then
`api_name`). This preserves the "planner-level actions as a
distinct trailing group" convention for readers who scan the
rendered tree top-down.
- Each TOPIC's children: alphabetical by `api_name`
case-insensitive.
- FLOW children are NOT sorted — flow-actionCall / subflow order
is the flow author's execution sequence, not a set.
- GEN_AI_FUNCTION / APEX / PROMPT_TEMPLATE / STANDARD_ACTION leaves
don't have children that warrant sorting.
Applied as a final pass after tree assembly and before the
authoritative JSON write, so ordering is pinned at the single
source of truth — the renderer doesn't re-sort.
"""
if not isinstance(root, dict):
return
children = root.get("children") or []
if not children:
return
def _root_sort_key(node: dict) -> tuple:
kind = node.get("kind") or ""
api = (node.get("api_name") or "").casefold()
# TOPIC first (tier 0), everything else second (tier 1). Within
# each tier, stable by kind + api_name case-insensitive.
tier = 0 if kind == "TOPIC" else 1
return (tier, kind, api)
root["children"] = sorted(children, key=_root_sort_key)
for child in root["children"]:
if child.get("kind") == "TOPIC":
child_children = child.get("children") or []
child["children"] = sorted(
child_children,
key=lambda n: (n.get("api_name") or "").casefold(),
)
def main() -> int:
try:
work_dir = pathlib.Path(os.environ["WORK_DIR"])
cache_dir = pathlib.Path(os.environ["CACHE_DIR"])
data_dir = pathlib.Path(os.environ["DATA_DIR"])
agent_api_name = os.environ["AGENT_API_NAME"]
agent_version = os.environ["AGENT_VERSION"]
except KeyError as e:
sys.stderr.write(f"finalize.py: missing env {e}\n")
return 1
planner_name = os.environ.get("PLANNER_NAME", "")
tree_path = work_dir / "declared_action_tree.json"
try:
tree = json.loads(tree_path.read_text())
except (OSError, json.JSONDecodeError) as e:
sys.stderr.write(f"finalize.py: cannot read {tree_path}: {e}\n")
return 1
# Compute _partial. _pending_fetches drains as wave-B BFS resolves
# refs; failures (HTTP 4xx, iteration-cap exhaustion, managed-flow
# filter mismatch) move OUT of _pending_fetches and into _unresolved.
# If we only check _pending_fetches we'd silently call a run with
# _unresolved entries "converged" — STATUS=OK with hidden failures.
# Both buckets must be empty for a clean run.
planner_ok = bool(planner_name)
pending_total = sum(len(v) for v in (tree.get("_pending_fetches") or {}).values())
unresolved_count = len(tree.get("_unresolved") or [])
waves_converged = pending_total == 0 and unresolved_count == 0
tree["_partial"] = not (planner_ok and waves_converged)
# Bug F fix: parse_wave + main set _partial_reason from
# _pending_fetches only (legacy predicate). When _pending is empty
# but _unresolved is non-empty, those writers set reason=None. The
# promotion above flips _partial=True from the unresolved bucket, so
# the reason needs a matching promotion or PARTIAL_REASON= ends up
# blank in the RESULT block.
if tree["_partial"] and not tree.get("_partial_reason"):
if not planner_ok:
tree["_partial_reason"] = "no-planner"
elif unresolved_count > 0:
tree["_partial_reason"] = "unresolved-refs"
else:
tree["_partial_reason"] = "pending-refs"
# Strip _visited (internal state — not part of the durable artifact)
tree.pop("_visited", None)
# Pin deterministic child ordering as the last assembly step before
# the authoritative write — see `sort_tree_in_place` docstring for
# the ordering rules. Downstream readers (render_architecture,
# summarize_tree, third-party tooling) therefore see a single
# canonical order from disk; they don't re-sort.
sort_tree_in_place(tree.get("root") or {})
# Rewrite the authoritative tree before copying it anywhere else
try:
tmp = tree_path.with_suffix(tree_path.suffix + ".tmp")
tmp.write_text(json.dumps(tree, indent=2))
os.replace(tmp, tree_path)
except OSError as e:
sys.stderr.write(f"finalize.py: cannot rewrite tree: {e}\n")
return 1
built_at = datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
a = tree.get("agent", {}) or {}
tree_base = f"{agent_api_name}_{agent_version}_metadata_tree"
# --- DATA_DIR staging ---
# summary.md dropped from the output contract; DATA_DIR now
# holds only the tree JSON + last_built_at.txt (+ whatever the caller
# writes alongside, e.g. architecture.md from render_architecture).
#
# The prior pattern was `rmtree(data_dir); data_tmp.rename(data_dir)` —
# destructive of any co-tenant content under `<agent>__<ver>/`. We
# now iterate staging's children and overwrite each one into data_dir
# individually, leaving unrelated siblings intact. See
# `main.py:_swap_dir_atomic` for the production path with the same
# invariant.
data_tmp = data_dir.with_suffix(".tmp")
try:
if data_tmp.exists():
shutil.rmtree(data_tmp)
data_tmp.mkdir(parents=True)
shutil.copy(tree_path, data_tmp / f"{tree_base}.json")
(data_tmp / "last_built_at.txt").write_text(built_at + "\n")
data_dir.mkdir(parents=True, exist_ok=True)
for staged in list(data_tmp.iterdir()):
target = data_dir / staged.name
if target.exists() or target.is_symlink():
if target.is_dir() and not target.is_symlink():
shutil.rmtree(target)
else:
target.unlink()
shutil.move(str(staged), str(target))
shutil.rmtree(data_tmp, ignore_errors=True)
except OSError as e:
sys.stderr.write(f"finalize.py: DATA_DIR write failed: {e}\n")
try:
if data_tmp.exists():
shutil.rmtree(data_tmp)
except OSError:
pass
return 1
# --- CACHE_DIR staging ---
cache_tmp = cache_dir.with_suffix(".tmp")
try:
if cache_tmp.exists():
shutil.rmtree(cache_tmp)
cache_tmp.mkdir(parents=True)
# Mirror sf_meta/<wave>/ into metadata/<wave>/ verbatim so cache has:
# retrieve.json (sf CLI output — critical for debugging silent
# retrieves that produced zero members)
# unpackaged/ (parsed XML tree)
# unpackaged.zip is intentionally skipped — it's byte-for-byte the
# same as unpackaged/ and doubles cache size on every run.
meta_dir = cache_tmp / "metadata"
meta_dir.mkdir()
sf_meta = work_dir / "sf_meta"
if sf_meta.exists():
for wave_dir in sorted(sf_meta.iterdir()):
if not wave_dir.is_dir():
continue
dst = meta_dir / wave_dir.name
dst.mkdir(parents=True, exist_ok=True)
for item in wave_dir.iterdir():
if item.name == "unpackaged.zip":
continue # redundant with unpackaged/
if item.is_dir():
shutil.copytree(item, dst / item.name, dirs_exist_ok=True)
else:
shutil.copy(item, dst / item.name)
# Copy every WORK_DIR top-level artifact except:
# - .emit_ctx.json (per-run shell state, irrelevant to cache)
# - .built_at.txt (recorded in manifest.built_at_utc)
# - sf_meta/ (handled above)
# This catches declared_action_tree.json, _bundle_parsed.json,
# _agent_generation.txt, _bot_definition.json, _bot_versions.json,
# and anything future scripts write as top-level sidecars.
SKIP_NAMES = {".emit_ctx.json", ".built_at.txt", "sf_meta"}
for item in work_dir.iterdir():
if item.name in SKIP_NAMES:
continue
if item.name.endswith(".tmp"):
continue # atomic-write staging from some other script
if item.is_dir():
shutil.copytree(item, cache_tmp / item.name, dirs_exist_ok=True)
elif item.is_file():
shutil.copy(item, cache_tmp / item.name)
manifest = {
"built_at_utc": built_at,
"schema_version": tree.get("_schema_version", "2.4"),
"agent": a,
"node_count": tree.get("node_count", 0),
"depth": tree.get("depth", 0),
"kind_counts": tree.get("_kind_counts", {}),
"ttl_days": 7,
"data_path": str(data_dir / f"{tree_base}.json"),
"partial": tree.get("_partial", False),
"unresolved_count": len(tree.get("_unresolved", []) or []),
}
(cache_tmp / "manifest.json").write_text(json.dumps(manifest, indent=2))
if cache_dir.exists():
shutil.rmtree(cache_dir)
cache_tmp.rename(cache_dir)
except OSError as e:
sys.stderr.write(f"finalize.py: CACHE_DIR write failed: {e}\n")
try:
if cache_tmp.exists():
shutil.rmtree(cache_tmp)
except OSError:
pass
return 1
# --- .gitignore seeding on parent dirs (data_root + cache_root) ---
for root in (cache_dir.parent.parent, data_dir.parent.parent):
try:
gi = root / ".gitignore"
if not gi.exists():
root.mkdir(parents=True, exist_ok=True)
gi.write_text("*\n")
except OSError:
pass
# --- .built_at.txt for emit ctx ---
try:
(work_dir / ".built_at.txt").write_text(built_at + "\n")
except OSError as e:
sys.stderr.write(f"finalize.py: .built_at.txt write failed: {e}\n")
# Non-fatal — finalize succeeded; emit can recompute
print(f"[finalize] built_at: {built_at}")
print(f"[finalize] data written: {data_dir}/{tree_base}.json")
print(f"[finalize] cache written: {cache_dir}")
return 0
if __name__ == "__main__":
sys.exit(main())
"""ThreadPoolExecutor orchestrator for Wave B body fetches.
one failure MUST NOT abort the whole run. Callers merge failed kinds
into `_unresolved[]` with `reason=f'{kind}-fetch-failed:{redact_error(exc)}'`
and emit STATUS=PARTIAL_OK. We return a mixed list of (ok, result_or_exc)
tuples instead of raising on the first failure — which is what
`ThreadPoolExecutor.map` would do (and silently cancel remaining work in
the process).
Exception identity is preserved on the failure path: callers get the exact
exception object back so they can run it through `rest_client.redact_error`
at the point of logging. We intentionally do NOT stringify here — doing so
would lose structured info (urllib.error.HTTPError.code, etc.) and could
leak tokens into intermediate strings before redaction runs.
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Callable
def fetch_bodies_parallel(
tasks: list[Callable[[], object]],
*,
max_workers: int = 5,
) -> list[tuple[bool, object]]:
"""Run zero-arg callables in parallel; return (ok, result_or_exc) per task.
contract:
* Each `task` is a zero-arg callable (use `functools.partial` to bind
args at the call site).
* On success, `(True, return_value)` is appended.
* On any exception, `(False, exc)` is appended — the EXC object itself,
not a stringification. Callers run it through `rest_client.redact_error`
before logging.
* Results are returned in INPUT ORDER, not completion order. Callers
frequently zip results back to their input tasks to identify which
target failed; completion order would break that contract.
* Empty task list returns `[]` without spinning up a pool.
* `max_workers=1` serializes — tasks run sequentially but the as_completed
path is still used (deterministic ordering preserved via index map).
* This function NEVER raises on a task failure. It may propagate
programmer errors (e.g., a non-callable in `tasks`) at submit time;
that's a bug surface, not a runtime failure mode.
"""
if not tasks:
return []
# Pre-size the results list so we can assign by input index. This keeps
# output ordering deterministic and independent of completion timing,
# which matters for callers that identify failures positionally.
results: list[tuple[bool, object] | None] = [None] * len(tasks)
with ThreadPoolExecutor(max_workers=max_workers) as pool:
future_to_idx = {pool.submit(task): i for i, task in enumerate(tasks)}
for fut in as_completed(future_to_idx):
idx = future_to_idx[fut]
try:
results[idx] = (True, fut.result())
except Exception as exc: # noqa: BLE001 — see contract above
# Preserve exc identity; do NOT stringify here. Callers run
# this through rest_client.redact_error at log time .
# We catch Exception (not BaseException) so KeyboardInterrupt
# / SystemExit still propagate — those signal shutdown, not a
# task failure.
results[idx] = (False, exc)
# All slots filled by construction — futures/indexes are 1:1 with input.
return [r for r in results if r is not None] # type: ignore[return-value]
"""BFS graph builder — walks the cross-type reference graph.
(visited keys = (kind, canonical_name) tuples) + (MAX_DEPTH=5 cap
→ _partial + PARTIAL_OK) land in Batches 2 and 3.
"""
from __future__ import annotations
def build_fetch_graph() -> dict:
"""TODO + BFS with per-kind visited tuples, cycle-safe,
MAX_DEPTH cap surfaces as _partial=true in the tree.
"""
raise NotImplementedError("build_fetch_graph implements in P0 Batches 2+3")
"""Shared sys.path bootstrap for the test suite.
The scripts/ scripts expect to be imported as top-level modules (config,
soql_loader, rest_client, sf_cli) — that's how they behave when main.py
does `from config import ...`. Tests mirror that by inserting scripts/
onto sys.path.
fs_guard is now sourced via ``from config import fs_guard`` (config.py
re-exports it from the plugin _shared/ package). No tools/ entry on
sys.path is required — config.py's dev-fallback walks up to the repo's
``plugins/investigating-agentforce-architecture/shared/`` when ``scripts/_shared/``
hasn't yet been mirrored by install.sh / the build script.
"""
from __future__ import annotations
import sys
from pathlib import Path
_SCRIPTS_DIR = Path(__file__).resolve().parent.parent
s = str(_SCRIPTS_DIR)
if s not in sys.path:
sys.path.insert(0, s)
Related skills
FAQ
Does investigating-agentforce-architecture read runtime sessions?
investigating-agentforce-architecture reads design-time Agentforce metadata only—planner, topics, actions, flows, Apex, and prompt templates. It does not analyze runtime session traces, conversation transcripts, or gateway audit timing chains.
What outputs does investigating-agentforce-architecture produce?
investigating-agentforce-architecture renders a human-readable architecture document and a Mermaid invocation graph for one agent identified by API name, including topics, actions, flows, Apex, prompt templates, and NGA plugins.