diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cec8736 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# AGENTS.md — amplifier-bundle-context-intelligence + +Guidance for AI agents and developers working in **this** bundle repository. + +## Known validator false positive — do NOT "fix" it + +`validate-bundle-repo` (v3.6.0) reports a mode-advertising **ERROR**: + +> `unadvertised_but_referenced`: mode `context-intelligence` (`modes/context-intelligence.md`, +> `advertised: false`) is referenced by name in `context/safe-extraction-patterns.md` +> and `context/agents/session-storage-knowledge.md`. + +**This is a FALSE POSITIVE. Do not act on it.** The flagged occurrences are **not** mode +invocations — they are: + +- **disk paths** — `~/.amplifier/projects/{slug}/sessions/{id}/context-intelligence/` + (the CI storage subdirectory; the `/` before the name is a path separator, not a slash-command), +- **`@mention` prefixes** — `@context-intelligence:context/...`, and +- **skill names** — `context-intelligence-graph-query`, `context-intelligence-session-navigation`. + +The bundle, its on-disk storage subdirectory, its skills, **and** the internal design mode all +share the name `context-intelligence`. The validator's `/` + `name=""` regex cannot +disambiguate them. The **full-mode** validator (see below) re-reads the source files and itself +**confirms this as a false positive — overall verdict PASS**. + +**Therefore:** leave `modes/context-intelligence.md` at `advertised: false` (the mode is correctly +internal), and do **not** remove the path/skill references. The only proper fix, if any, is an +upstream tightening of the validator regex — never a change to this repo. + +## Running the bundle validator in FULL mode + +The validator runs its Python checks through a bash `python3` heredoc. In a default Amplifier +environment that `python3` lacks `amplifier_foundation` / `hatchling`, so the recipe self-downgrades +to `validation_mode: structural_only` — skipping BundleRegistry resolution of the layered includes +and the package build checks. To run **full** validation: + +```bash +scripts/validate-full.sh # validates this repo +scripts/validate-full.sh # or another bundle repo +``` + +It builds a throwaway `uv` venv with `hatchling` + `amplifier-foundation` + `amplifier-core` + +`pyyaml`, puts it first on `PATH`, and runs `validate-bundle-repo` so its `python3` resolves to an +interpreter that has the deps → `validation_mode: full`. + +**Last full run: ✅ PASS** — 10/10 bundles clean, all hygiene/structure/placement/freshness gates +green, the lone mode "error" confirmed a false positive (name collision). Only the build *dry-run* +is skipped (no `pip wheel` in the venv); the wheels build cleanly under `uv build`. + +## Architecture note + +This bundle ships **layered, composable behaviours** — `context-intelligence-navigation` ⊂ +`-analysis` ⊂ `-design`, plus an orthogonal `-logging` (the telemetry hook only) and the umbrella +`context-intelligence`. The telemetry hook is **pure telemetry** (it does not fetch skills); skill +acquisition lives on `tool-context-intelligence-query` behind the `skill_sync_enabled` knob +(**default `false`** — opt-in). See `docs/context-intelligence-skill-sync-flow.dot` and the README. diff --git a/README.md b/README.md index b0926fb..559156a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,20 @@ Two agents are included for querying session data: A **`/context-intelligence` mode** is also included for building new context intelligence-aware tooling. Activate it to enter a design workspace where you can investigate session data, explore the graph model, and produce reusable Amplifier components (skills, agents, context files, recipes, CLIs) for your project. +### Composition — pick the layer you need + +The bundle ships as **composable layered behaviors** rather than one monolith. Richer layers `include` the lighter ones, so you compose only what you need: + +| Behavior | Adds | Use when | +|----------|------|----------| +| `context-intelligence-logging` | the telemetry hook only (event capture + optional server fan-out) | you want **pure session telemetry/logging** — no agents, tools, skills, or mode | +| `context-intelligence-navigation` (Layer 1) | `session-navigator` (reads raw JSONL on disk; no graph server) | local/offline navigation fallback only | +| `context-intelligence-analysis` (Layer 2) | `graph-analyst` + graph/query skills + the query tools (⊃ navigation) | graph read/query/exploration, no design mode | +| `context-intelligence-design` (Layer 3) | the `/context-intelligence` design mode (⊃ analysis) | full read/query **plus** the tooling-design workflow | +| `context-intelligence` (umbrella) | `design` **+** `logging` together | the full drop-in: read/query/design **and** session instrumentation | + +The umbrella `context-intelligence.yaml` is the drop-in install (Quick Start below). Reach for a single layer when an integrator needs something leaner — e.g. compose `context-intelligence-logging` into an app that only needs telemetry, with zero analysis/skill machinery. + --- ## Understanding workspace @@ -336,6 +350,27 @@ overrides: > **Most users configure nothing here.** A single hook `destinations` entry already powers both upload and query. `sources` exists only for the read-replica / split-endpoint case. +#### Skill body sync — `skill_sync_enabled` (opt-in) + +The `graph-analyst` agent uses a `context-intelligence-graph-query` skill that documents the Cypher patterns it issues. The bundle ships a **SHA-pinned vendored copy** of that skill body (`bundled_skill/context-intelligence-graph-query.md`, inside the `tool-context-intelligence-query` module), so the skill works fully offline with **zero network traffic** for skill acquisition. The optional `skill_sync_enabled` knob controls whether — at session start — the skill body is *refreshed from a Context Intelligence server* instead of using the vendored copy. It lives in the same namespace as `sources` (`overrides.tool-context-intelligence-query.config`). + +| Key | Source | Default | Description | +|-----|--------|---------|-------------| +| `skill_sync_enabled` | tool config → `coordinator.config` → env `AMPLIFIER_CONTEXT_INTELLIGENCE_SKILL_SYNC_ENABLED` → default | **`false`** | When **`false`** (default): no skill network traffic at all. If a server source is resolved, the vendored offline body is swapped in (still zero network); if none is resolved, the shipped stub is retained. When **`true`** and a server source resolves: the skill body is version-gated and conditionally fetched from the server at session start (a `skill:unloaded` reload handler is also registered). When `true` but the server is offline, the agent degrades gracefully (no crash). | + +```yaml +# ~/.amplifier/settings.yaml — opt IN to refreshing the graph-query skill body from the server. +# Default is OFF: the bundled, SHA-pinned skill body is used and NO skill traffic occurs. +overrides: + tool-context-intelligence-query: + config: + skill_sync_enabled: true # default false — leave unset for the zero-network path +``` + +Skill sync resolves its server using the **same** `(server_url, api_key)` chain as the query tools above (`sources` → hook `destinations` → env). The vendored-body install **fails loud** if the on-disk body's SHA does not match the pin. See [`docs/context-intelligence-skill-sync-flow.png`](docs/context-intelligence-skill-sync-flow.png) for the full enabled/disabled decision flow. + +> **Telemetry hook does not fetch skills.** Skill acquisition belongs entirely to the query tool (above) and is opt-in. The `hook-context-intelligence` module is **pure telemetry** — event capture and `destinations` fan-out only — and performs no skill loading. + --- ## Server dispatch @@ -477,6 +512,13 @@ See [`context/dual-path-library-template.md`](context/dual-path-library-template ``` amplifier-bundle-context-intelligence/ ├── bundle.md ← root bundle definition +├── bundle.dot / bundle.png ← generated bundle structure diagram +├── behaviors/ ← composable layered behaviors (compose what you need) +│ ├── context-intelligence-navigation.yaml ← Layer 1: session-navigator only +│ ├── context-intelligence-analysis.yaml ← Layer 2: + graph-analyst + query tools (⊃ navigation) +│ ├── context-intelligence-design.yaml ← Layer 3: + design mode (⊃ analysis) +│ ├── context-intelligence-logging.yaml ← orthogonal: telemetry hook only +│ └── context-intelligence.yaml ← umbrella: design + logging (full drop-in) ├── modes/ │ └── context-intelligence.md ← design-time mode ├── agents/ @@ -495,15 +537,24 @@ amplifier-bundle-context-intelligence/ │ ├── dual-path-library-template.md ← copy-paste library template for dual-path tools │ └── jsonl-event-schema.md ← events.jsonl schema contract ├── modules/ -│ ├── hook-context-intelligence/ ← the Python hook module -│ └── tool-context-intelligence-query/ ← graph_query + blob_read tools +│ ├── hook-context-intelligence/ ← the Python hook module — PURE TELEMETRY +│ │ (no skill_fetcher.py / legacy_content/ — skills moved out) +│ └── tool-context-intelligence-query/ ← graph_query + blob_read tools + opt-in skill sync +│ └── amplifier_module_tool_context_intelligence_query/ +│ ├── graph_query_tool.py ← skill_sync_enabled knob (default false) +│ ├── skill_sync.py ← on_session_ready orchestration (opt-in) +│ ├── skill_fetcher.py ← server version-gate + conditional fetch (only when enabled) +│ └── bundled_skill/ +│ └── context-intelligence-graph-query.md ← SHA-pinned vendored offline body ├── docs/ │ ├── context-intelligence-exploration-guide.md ← what to explore and how to test +│ ├── context-intelligence-skill-sync-flow.dot ← skill-sync enabled/disabled decision flow │ ├── dispatch-circuit-breaker.dot ← dispatch flow and circuit breaker state machine │ └── logging-handler-flow.dot ← thin forwarder architecture ├── skills/ │ ├── context-intelligence-graph-query/ -│ └── context-intelligence-session-navigation/ +│ ├── context-intelligence-session-navigation/ +│ └── … ← additional graph/design skills └── tests/ ``` diff --git a/behaviors/context-intelligence-analysis.yaml b/behaviors/context-intelligence-analysis.yaml new file mode 100644 index 0000000..4161ad8 --- /dev/null +++ b/behaviors/context-intelligence-analysis.yaml @@ -0,0 +1,26 @@ +bundle: + name: context-intelligence-analysis-behavior + version: 0.1.0 + description: > + LAYER 2 of 3. Adds graph-analyst + graph skills (blob-reading, graph-query, + session-reconstruction, workflow-pattern-analysis). Includes + context-intelligence-navigation. Use for graph read/query/exploration + without the design mode. + +includes: + - bundle: context-intelligence:behaviors/context-intelligence-navigation + +agents: + include: + - context-intelligence:graph-analyst + +tools: + - module: tool-skills + source: git+https://github.com/microsoft/amplifier-bundle-skills@main#subdirectory=modules/tool-skills + config: + skills: + # Concatenates with the navigation layer's skill list (list-merge with dedup). + - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills/blob-reading" + - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills/context-intelligence-graph-query" + - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills/context-intelligence-session-reconstruction" + - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills/workflow-pattern-analysis" diff --git a/behaviors/context-intelligence-design.yaml b/behaviors/context-intelligence-design.yaml new file mode 100644 index 0000000..d8eff60 --- /dev/null +++ b/behaviors/context-intelligence-design.yaml @@ -0,0 +1,27 @@ +bundle: + name: context-intelligence-design-behavior + version: 0.1.0 + description: > + LAYER 3 of 3 (top). Adds the context-intelligence design MODE (gates + design-facilitator + tool-designer agents and design skills). Includes + context-intelligence-analysis. Use for full read/query + tooling-design + workflow. + +includes: + - bundle: context-intelligence:behaviors/context-intelligence-analysis + # Brings tool-mode + the approval hook + the default hooks-mode config (search_paths: []). + - bundle: git+https://github.com/microsoft/amplifier-bundle-modes@main#subdirectory=behaviors/modes.yaml + +# Register this bundle's modes/ directory with hooks-mode. This is the REAL +# registration mechanism (a bare `modes: include:` block is NOT a recognized +# foundation field and is silently dropped). The hooks-mode entry deep-merges by +# module ID with the one from behaviors/modes.yaml, replacing its empty +# search_paths with the CI modes directory. "@context-intelligence:modes" +# resolves to /modes/, so modes/context-intelligence.md is +# discovered and registered (visible in `mode list`). +hooks: + - module: hooks-mode + source: git+https://github.com/microsoft/amplifier-bundle-modes@main#subdirectory=modules/hooks-mode + config: + search_paths: + - "@context-intelligence:modes" diff --git a/behaviors/context-intelligence-logging.yaml b/behaviors/context-intelligence-logging.yaml new file mode 100644 index 0000000..9a696ef --- /dev/null +++ b/behaviors/context-intelligence-logging.yaml @@ -0,0 +1,78 @@ +bundle: + name: context-intelligence-logging-behavior + version: 0.1.0 + description: > + Context intelligence: event-capture hook ONLY. Instruments the session by + capturing all events as structured JSONL (and optionally dispatching them to + a graph server) — without any analysis agents, tools, skills, or design mode. + Compose this behavior into any app that needs pure session telemetry/logging. + Note: this is the producer side only. It does NOT include graph_query, blob_read, + or the navigation agents — pair it with context-intelligence-design (or use + the full context-intelligence behavior) if you also need to read events back. + +hooks: + - module: hook-context-intelligence + source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/hook-context-intelligence + config: + # DEPRECATED: use `destinations` below for multi-server fan-out. + # When set (and no `destinations` is given), these synthesize a single "default" + # destination that matches all sessions (include: ["**"]). + # Legacy-scalar behavior when unset: + # - both unset → no destination → local-only. + # - url set, api_key unset → dispatch disabled, local-only (a WARNING is logged). + # (NOTE: a `destinations` entry with an empty url/api_key is instead a hard + # mount error — the two paths differ. See validate_destinations.) + context_intelligence_server_url: "${AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL:}" + context_intelligence_api_key: "${AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY:}" + # workspace: auto-resolved from coordinator project_slug → config + workspace: "${AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE:}" + log_level: "${AMPLIFIER_CONTEXT_INTELLIGENCE_LOG_LEVEL:INFO}" + dispatch_timeout: "${AMPLIFIER_CONTEXT_INTELLIGENCE_DISPATCH_TIMEOUT:30}" + dispatch_failure_threshold: "${AMPLIFIER_CONTEXT_INTELLIGENCE_DISPATCH_FAILURE_THRESHOLD:3}" + additional_events: + - delegate:agent_spawned + - delegate:agent_resumed + - delegate:agent_completed + - delegate:agent_cancelled + - delegate:error + # base_path: ~/.amplifier/projects (auto-resolved; uncomment to override) + # project_slug: (auto-resolved from working directory; uncomment to override) + # exclude_events: [] (optional fnmatch patterns; uncomment and list events to suppress) + # + # --- Multi-server fan-out (preferred) ---------------------------------- + # Define fan-out destinations centrally; override per-project via a project + # .amplifier/settings.yaml under overrides.hook-context-intelligence.config. + # The legacy context_intelligence_server_url/api_key scalars above are + # DEPRECATED — when set (and no `destinations` is given) they synthesize a + # single "default" destination matching all sessions (include: ["**"]). + # Destination names are stable identifiers — renaming a destination silently + # drops any project-scope override that referenced the old name. + # + # Matching rules (include / exclude): patterns are matched against the + # session's working directory using .gitignore semantics (pathspec + # "gitignore"). A destination is active iff the working dir matches an + # `include` AND does not match an `exclude` (exclude wins, per destination). + # - "foo/" or "foo" or "**/foo/" → the directory foo AND everything beneath it + # - "foo/**" → the CONTENTS of foo only (not foo itself) + # - "**" → every session + # Prefer the trailing-slash directory form (e.g. "**/client-x/") to mean + # "this project and all its sessions" — it matches whether you launch from + # the project root or a subdirectory. + # + # IMPORTANT — default include semantics: + # A destination with NO `include` key has an empty pattern set and matches + # NOTHING (receives zero sessions). You must declare `include` explicitly. + # The legacy scalar path (context_intelligence_server_url + api_key, no + # `destinations:` key) still synthesizes include: ["**"] — existing + # single-server users are unaffected. + # + # destinations: + # personal: + # url: "${PERSONAL_CI_URL:}" + # api_key: "${PERSONAL_CI_KEY:}" + # include: ["**"] # all sessions... + # exclude: ["**/client-*/"] # ...except any client-* project dir and everything under it + # team: + # url: "${TEAM_CI_URL:}" + # api_key: "${TEAM_CI_KEY:}" + # include: ["**/client-x/"] # the client-x project dir and everything under it diff --git a/behaviors/context-intelligence-navigation.yaml b/behaviors/context-intelligence-navigation.yaml new file mode 100644 index 0000000..65592b5 --- /dev/null +++ b/behaviors/context-intelligence-navigation.yaml @@ -0,0 +1,20 @@ +bundle: + name: context-intelligence-navigation-behavior + version: 0.1.0 + description: > + LAYER 1 of 3 (innermost). Adds session-navigator (reads raw session JSONL + on disk, no graph server). Includes: nothing. Use alone for local/offline + navigation fallback. + +agents: + include: + - context-intelligence:session-navigator + +tools: + - module: tool-delegate + source: git+https://github.com/microsoft/amplifier-foundation@main#subdirectory=modules/tool-delegate + - module: tool-skills + source: git+https://github.com/microsoft/amplifier-bundle-skills@main#subdirectory=modules/tool-skills + config: + skills: + - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills/context-intelligence-session-navigation" diff --git a/behaviors/context-intelligence.yaml b/behaviors/context-intelligence.yaml index fa861ea..985c7f0 100644 --- a/behaviors/context-intelligence.yaml +++ b/behaviors/context-intelligence.yaml @@ -2,109 +2,16 @@ bundle: name: context-intelligence-behavior version: 0.1.0 description: > - Context intelligence capability: graph-powered session analysis - and event-driven telemetry capture for Amplifier sessions. + FULL drop-in context intelligence: graph-powered session analysis, + navigation agents, skills, the design mode, AND the event-capture hook for + session telemetry. Composes the design behavior (read/query/design) and the + logging behavior (event-capture hook) into one unit. Use this when you want + both read/query capabilities AND session instrumentation. For finer control, + compose a single layer directly: context-intelligence-navigation (LAYER 1 — + JSONL fallback navigation only), context-intelligence-analysis (LAYER 2 — + graph read/query/exploration, no design mode), context-intelligence-design + (LAYER 3 — adds the design mode), or context-intelligence-logging (hook only). -# Include the modes BEHAVIOR (not the full modes bundle). The full bundle -# transitively includes foundation, which would override session.orchestrator. -# The behavior provides the modes infrastructure (hooks-mode, tool-mode) and -# registers the "modes" namespace; modes:context/modes-instructions.md arrives -# automatically via the behavior's context.include accumulation. includes: - - bundle: git+https://github.com/microsoft/amplifier-bundle-modes@main#subdirectory=behaviors/modes.yaml - -agents: - include: - - context-intelligence:graph-analyst - - context-intelligence:session-navigator - -tools: - - module: tool-delegate - source: git+https://github.com/microsoft/amplifier-foundation@main#subdirectory=modules/tool-delegate - - module: tool-skills - source: git+https://github.com/microsoft/amplifier-bundle-skills@main#subdirectory=modules/tool-skills - config: - skills: - # General context-intelligence skills only — design-phase skills gated behind the context-intelligence mode - - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills/blob-reading" - - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills/context-intelligence-graph-query" - - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills/context-intelligence-session-navigation" - - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills/context-intelligence-session-reconstruction" - - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills/workflow-pattern-analysis" - -hooks: - # Register this bundle's modes/ directory with hooks-mode so the - # context-intelligence mode is discoverable even when the host does not - # otherwise compose the modes infrastructure. The modes behavior brings - # hooks-mode with search_paths: []; this declaration deep-merges on top - # (same module ID, later wins on lists) to add the deferred @mention path. - - module: hooks-mode - source: git+https://github.com/microsoft/amplifier-bundle-modes@main#subdirectory=modules/hooks-mode - config: - search_paths: - - "@context-intelligence:modes" - - module: hook-context-intelligence - source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/hook-context-intelligence - config: - # DEPRECATED: use `destinations` below for multi-server fan-out. - # When set (and no `destinations` is given), these synthesize a single "default" - # destination that matches all sessions (include: ["**"]). - # Legacy-scalar behavior when unset: - # - both unset → no destination → local-only. - # - url set, api_key unset → dispatch disabled, local-only (a WARNING is logged). - # (NOTE: a `destinations` entry with an empty url/api_key is instead a hard - # mount error — the two paths differ. See validate_destinations.) - context_intelligence_server_url: "${AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL:}" - context_intelligence_api_key: "${AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY:}" - # workspace: auto-resolved from coordinator project_slug → config - workspace: "${AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE:}" - log_level: "${AMPLIFIER_CONTEXT_INTELLIGENCE_LOG_LEVEL:INFO}" - dispatch_timeout: "${AMPLIFIER_CONTEXT_INTELLIGENCE_DISPATCH_TIMEOUT:30}" - dispatch_failure_threshold: "${AMPLIFIER_CONTEXT_INTELLIGENCE_DISPATCH_FAILURE_THRESHOLD:3}" - additional_events: - - delegate:agent_spawned - - delegate:agent_resumed - - delegate:agent_completed - - delegate:agent_cancelled - - delegate:error - # base_path: ~/.amplifier/projects (auto-resolved; uncomment to override) - # project_slug: (auto-resolved from working directory; uncomment to override) - # exclude_events: [] (optional fnmatch patterns; uncomment and list events to suppress) - # - # --- Multi-server fan-out (preferred) ---------------------------------- - # Define fan-out destinations centrally; override per-project via a project - # .amplifier/settings.yaml under overrides.hook-context-intelligence.config. - # The legacy context_intelligence_server_url/api_key scalars above are - # DEPRECATED — when set (and no `destinations` is given) they synthesize a - # single "default" destination matching all sessions (include: ["**"]). - # Destination names are stable identifiers — renaming a destination silently - # drops any project-scope override that referenced the old name. - # - # Matching rules (include / exclude): patterns are matched against the - # session's working directory using .gitignore semantics (pathspec - # "gitignore"). A destination is active iff the working dir matches an - # `include` AND does not match an `exclude` (exclude wins, per destination). - # - "foo/" or "foo" or "**/foo/" → the directory foo AND everything beneath it - # - "foo/**" → the CONTENTS of foo only (not foo itself) - # - "**" → every session - # Prefer the trailing-slash directory form (e.g. "**/client-x/") to mean - # "this project and all its sessions" — it matches whether you launch from - # the project root or a subdirectory. - # - # IMPORTANT — default include semantics: - # A destination with NO `include` key has an empty pattern set and matches - # NOTHING (receives zero sessions). You must declare `include` explicitly. - # The legacy scalar path (context_intelligence_server_url + api_key, no - # `destinations:` key) still synthesizes include: ["**"] — existing - # single-server users are unaffected. - # - # destinations: - # personal: - # url: "${PERSONAL_CI_URL:}" - # api_key: "${PERSONAL_CI_KEY:}" - # include: ["**"] # all sessions... - # exclude: ["**/client-*/"] # ...except any client-* project dir and everything under it - # team: - # url: "${TEAM_CI_URL:}" - # api_key: "${TEAM_CI_KEY:}" - # include: ["**/client-x/"] # the client-x project dir and everything under it + - bundle: context-intelligence:behaviors/context-intelligence-design + - bundle: context-intelligence:behaviors/context-intelligence-logging diff --git a/bundle.dot b/bundle.dot index f15e890..2fdb436 100644 --- a/bundle.dot +++ b/bundle.dot @@ -1,55 +1,58 @@ -// This repository packages tools and AI assistants that record, search, and analyze Amplifier's own work-session history. digraph context_intelligence { rankdir=LR fontname="Helvetica" fontsize=12 - label="Context Intelligence — Session History Toolkit\nA bundle that records and analyzes Amplifier's past work sessions (v0.1.0)" + label="context-intelligence v0.1.0 — bundle repo" labelloc=t labeljust=c nodesep=0.6 ranksep=0.7 bgcolor="white" - source_hash="4636e62f1ae10ff3f60e2b22dcb755c49a7851ad51669ebb1fcb900c54a33559" + source_hash="ff1d6a9692415633ceafa3389694c17aa82f8f3d8296b1f7c440c9fb815ea5d2" node [fontname="Helvetica", fontsize=11, style="filled,rounded"] edge [fontname="Helvetica", fontsize=9] - root_context_intelligence [label="Context Intelligence (Main Package)\n0 tools · 0 agents\n~98 tok aggregate", shape=box, fillcolor="#80cbc4", style="filled,rounded,bold", penwidth=2] + root_context_intelligence [label="context-intelligence v0.1.0\n0 tools · 0 agents\n~98 tok aggregate", shape=box, fillcolor="#80cbc4", style="filled,rounded,bold", penwidth=2] subgraph cluster_behaviors { - label="Capability Bundles" + label="Behaviors" style="filled" fillcolor="#f9f9f9" color="#999999" - beh_context_intelligence_behavior [label="Session Analysis Toolkit\n4 tools\n~2549 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] + beh_context_intelligence_analysis_behavior [label="context-intelligence-analysis-behavior\n1 tools\n~864 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] + beh_context_intelligence_design_behavior [label="context-intelligence-design-behavior\n1 tools\n~331 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] + beh_context_intelligence_logging_behavior [label="context-intelligence-logging-behavior\n1 tools\n~1184 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] + beh_context_intelligence_navigation_behavior [label="context-intelligence-navigation-behavior\n2 tools\n~561 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] + beh_context_intelligence_behavior [label="context-intelligence-behavior\n~236 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] } subgraph cluster_agents { - label="Specialist Assistants" + label="Agents" style="filled" fillcolor="#f9f9f9" color="#999999" - agt_context_intelligence_design_facilitator [label="Design Helper\n(guides new features)\n~187 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] - agt_context_intelligence_tool_designer [label="Tool Builder Helper\n~198 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] - agt_graph_analyst [label="Session Graph Analyst\n(answers questions about sessions)\n~543 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] - agt_session_navigator [label="Session File Navigator\n(reads raw session files)\n~366 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] + agt_context_intelligence_design_facilitator [label="context-intelligence-design-facilitator\n~187 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] + agt_context_intelligence_tool_designer [label="context-intelligence-tool-designer\n~198 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] + agt_graph_analyst [label="graph-analyst\n~543 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] + agt_session_navigator [label="session-navigator\n~366 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] } subgraph cluster_modules { - label="Building Blocks (Code Components)" + label="Modules" style="filled" fillcolor="#f9f9f9" color="#999999" - mod_hook_context_intelligence [label="Auto Session Recorder", shape=box, fillcolor="#bbdefb", style="filled,rounded"] - mod_tool_context_intelligence_query [label="Session Search Tool", shape=box, fillcolor="#bbdefb", style="filled,rounded"] - mod_tool_context_intelligence_upload [label="Session Upload Tool", shape=box, fillcolor="#bbdefb", style="filled,rounded"] + mod_hook_context_intelligence [label="hook-context-intelligence", shape=box, fillcolor="#bbdefb", style="filled,rounded"] + mod_tool_context_intelligence_query [label="tool-context-intelligence-query", shape=box, fillcolor="#bbdefb", style="filled,rounded"] + mod_tool_context_intelligence_upload [label="tool-context-intelligence-upload", shape=box, fillcolor="#bbdefb", style="filled,rounded"] } subgraph cluster_legend { - label="Legend (Color Key)" + label="Legend" style="filled" fillcolor="white" color="#cccccc" @@ -69,11 +72,11 @@ digraph context_intelligence { disclaimer [label="Token estimates: ~4 chars/token\nSolid border = local (counted)\nDashed + red = external, hidden cost (not counted)\nDashed + muted = external, no cost\nExcludes: sub-session costs, runtime-dynamic", shape=note, fillcolor="#eceff1", style="filled", fontsize=9] - ext_githttps___github_com_microsoft_amplifier_foundation_main [label="amplifier-foundation\n(shared base, external)", shape=box, fillcolor="#80cbc4", style="dashed", color="red", penwidth=2] + ext_githttps___github_com_microsoft_amplifier_foundation_main [label="amplifier-foundation\n(external, cost)", shape=box, fillcolor="#80cbc4", style="dashed", color="red", penwidth=2] root_context_intelligence -> ext_githttps___github_com_microsoft_amplifier_foundation_main [style=dashed] root_context_intelligence -> beh_context_intelligence_behavior [label="composes"] - beh_context_intelligence_behavior -> agt_graph_analyst [label="owns"] - beh_context_intelligence_behavior -> agt_session_navigator [label="owns"] - beh_context_intelligence_behavior -> mod_hook_context_intelligence [label="uses", penwidth=0.8] + beh_context_intelligence_analysis_behavior -> agt_graph_analyst [label="owns"] + beh_context_intelligence_logging_behavior -> mod_hook_context_intelligence [label="uses", penwidth=0.8] + beh_context_intelligence_navigation_behavior -> agt_session_navigator [label="owns"] } \ No newline at end of file diff --git a/bundle.png b/bundle.png index ee8de52..9723a87 100644 Binary files a/bundle.png and b/bundle.png differ diff --git a/context_intelligence/tool_resolver.py b/context_intelligence/tool_resolver.py index b64435b..56c9ba4 100644 --- a/context_intelligence/tool_resolver.py +++ b/context_intelligence/tool_resolver.py @@ -31,10 +31,57 @@ import logging from typing import Any, NamedTuple -from context_intelligence.config import SETTINGS_PATH, _env, _parse_settings_yaml +from context_intelligence.config import ( + SETTINGS_PATH, + _env, + _expand_env_placeholders, + _parse_settings_yaml, +) log = logging.getLogger(__name__) +#: Case-insensitive string tokens accepted for boolean config knobs. +_TRUE_TOKENS = frozenset({"true", "1", "yes", "on"}) +_FALSE_TOKENS = frozenset({"false", "0", "no", "off"}) + + +def _expand(value: Any) -> Any: + """Expand shell-style ``${VAR}`` placeholders in *value* if it is a string. + + Returns the expanded string, or *value* unchanged when it is not a string. + An unexpanded placeholder like ``${VAR:}`` with *VAR* unset expands to ``""`` + (falsy), letting the caller's ``or``-chain continue to the next source. + """ + return _expand_env_placeholders(value) if isinstance(value, str) else value + + +def _coerce_bool(value: Any) -> bool | None: + """Three-state boolean coercion for config knobs. + + Returns ``True`` / ``False`` only when *value* is a definite, recognized + boolean; returns ``None`` (meaning "absent — fall through to the next + source / the default") for every other case. + + Critically, an **empty / whitespace-only string** resolves to ``None``, + **never** ``False``. This is what makes an unexpanded YAML placeholder + (``"${AMPLIFIER_CONTEXT_INTELLIGENCE_SKILL_SYNC_ENABLED:}"`` with the env + var unset, which expands to ``""``) behave as *absent* rather than silently + disabling the knob for every user. An unrecognized string is likewise + treated as absent (safe fall-through) rather than guessed. + """ + if value is None: + return None + if isinstance(value, bool): + return value + text = str(value).strip().lower() + if not text: + return None # empty / placeholder / whitespace → absent + if text in _TRUE_TOKENS: + return True + if text in _FALSE_TOKENS: + return False + return None # unrecognized → absent (fall through to default) + # --------------------------------------------------------------------------- # Data model @@ -264,3 +311,34 @@ def sources(self) -> dict[str, Source]: else: self._sources = {} return self._sources + + @property + def skill_sync_enabled(self) -> bool: + """Whether the analytics path syncs watched skills on session start. + + Default ``False`` — opt-in; headless / single-command-series workflows + pay zero skill traffic per turn unless explicitly enabled. Set to + ``true`` to restore the full per-session sync (``GET /version`` ping + + conditional skill fetch + ``skill:unloaded`` reload handler). + + Resolution order (first *definite* value wins; empty / placeholder / + unrecognized values are treated as *absent* and fall through): + 1. config['skill_sync_enabled'] — mount() config dict + 2. coordinator.config['skill_sync_enabled'] — app-level override + 3. AMPLIFIER_CONTEXT_INTELLIGENCE_SKILL_SYNC_ENABLED — env var + 4. False — default (opt-in) + + Accepted string forms (case-insensitive): true/1/yes/on and + false/0/no/off. An unexpanded YAML placeholder that resolves to an + empty string resolves to the default (``False``), never ``True`` — it + cannot silently enable sync for everyone. + """ + for raw in ( + _expand(self._config.get("skill_sync_enabled")), + _expand(self._coordinator_config_get("skill_sync_enabled")), + _env("SKILL_SYNC_ENABLED"), + ): + resolved = _coerce_bool(raw) + if resolved is not None: + return resolved + return False diff --git a/docs/context-intelligence-skill-sync-flow.dot b/docs/context-intelligence-skill-sync-flow.dot new file mode 100644 index 0000000..02a403d --- /dev/null +++ b/docs/context-intelligence-skill-sync-flow.dot @@ -0,0 +1,621 @@ +// context-intelligence-skill-sync-flow.dot +// Context-Intelligence Skill-Sync Flow (with the skill_sync_enabled opt-IN) +// Owned by tool-context-intelligence-query (graph-analyst sub-session). +// The logging hook is now PURE TELEMETRY — skill-content sync was relocated here. +// +// DEFAULT POSTURE (skill_sync_enabled=FALSE — do not flip without reason): +// With default FALSE the bundle delivers the #283 fix immediately: a headless +// session with a server configured still makes ZERO skill requests. +// Setting skill_sync_enabled=TRUE opts the session into live server sync. +// +// SAFE-DEFAULT INVARIANT (do not delete the vendored body — see cluster 0/2b): +// The bundle ships skills/context-intelligence-graph-query/SKILL.md as a +// pessimistic "Server Unavailable" STUB. Sync (enabled) overwrites it with the +// real body fetched from the server. When sync is DISABLED but a server is +// configured, on_session_ready SWAPS the stub for the VENDORED real body in +// amplifier_module_tool_context_intelligence_query/bundled_skill/ (a local copy, +// ZERO network) so a working graph-analyst is never stranded on the stub. That +// vendored body is load-bearing; a prior refactor deleted its predecessor +// (legacy_content) and crippled this path (issue #283). Do not remove it. +// +// Grounded in: +// modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/skill_sync.py +// modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/skill_fetcher.py +// modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/graph_query_tool.py +// modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/bundled_skill/__init__.py +// context_intelligence/tool_resolver.py (skill_sync_enabled property) +// modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py +// +// Generated: 2026-06-26 +// Render: dot -Tpng context-intelligence-skill-sync-flow.dot -o context-intelligence-skill-sync-flow.png + +digraph SkillSyncFlow { + rankdir=TB; + fontname="Helvetica"; + fontsize=12; + compound=true; + nodesep=0.65; + ranksep=0.9; + pad=0.5; + label="Context-Intelligence: Skill-Sync-from-Server Flow\ntool-context-intelligence-query | graph-analyst sub-session (logging hook = pure telemetry; skill sync relocated here)"; + labelloc=t; + fontsize=14; + + node [fontname="Helvetica", fontsize=11]; + edge [fontname="Helvetica", fontsize=10]; + + // ═══════════════════════════════════════════════════════════════════════ + // CLUSTER 1 — Sub-session mount + // ═══════════════════════════════════════════════════════════════════════ + + subgraph cluster_mount { + label="1. graph-analyst Sub-Session: tool-context-intelligence-query mounts"; + style=filled; + fillcolor="#FAFAFA"; + color="#B0BEC5"; + fontsize=12; + fontname="Helvetica Bold"; + + node [shape=box, style="rounded,filled", penwidth=1.5]; + + mount_call [ + label="mount(coordinator, config)\n────────────────────────\nGraphQueryTool(coordinator, config)\n + ToolConfigResolver(config, coordinator)", + fillcolor="#E3F2FD", + color="#1565C0" + ]; + + mount_tool [ + label="coordinator.mount(\"tools\", tool, name=\"graph_query\")\ncoordinator.register_capability(\n \"context_intelligence._graph_query_tool\", tool\n)", + fillcolor="#E8F5E9", + color="#2E7D32" + ]; + + mount_osr [ + label="Kernel: on_session_ready(coordinator)\ncalled after ALL modules mount", + fillcolor="#FFF8E1", + color="#F57F17" + ]; + + mount_call -> mount_tool; + mount_tool -> mount_osr; + } + + // ═══════════════════════════════════════════════════════════════════════ + // CLUSTER 0 — skill_sync_enabled gate (on_session_ready entry) + // ═══════════════════════════════════════════════════════════════════════ + + subgraph cluster_gate { + label="0. on_session_ready — skill_sync_enabled gate"; + style=filled; + fillcolor="#FAFAFA"; + color="#B0BEC5"; + fontsize=12; + fontname="Helvetica Bold"; + + node [shape=diamond, style=filled, penwidth=2]; + + gate_enabled [ + label="tool.skill_sync_enabled?\n(config / coordinator /\nAMPLIFIER_CONTEXT_INTELLIGENCE_\nSKILL_SYNC_ENABLED env — default FALSE)", + fillcolor="#FFEBEE", + color="#C62828", + fontcolor="#C62828" + ]; + } + + // ═══════════════════════════════════════════════════════════════════════ + // CLUSTER 2b — DISABLED path: zero-network offline-body swap ← DEFAULT + // (default=FALSE delivers #283 fix; TRUE is opt-in sync-from-server) + // ═══════════════════════════════════════════════════════════════════════ + + subgraph cluster_disabled { + label="2b. skill_sync_enabled=FALSE → _apply_offline_skill_bodies (ZERO network)"; + style=filled; + fillcolor="#ECEFF1"; + color="#37474F"; + fontsize=12; + fontname="Helvetica Bold"; + + node [shape=diamond, style=filled, penwidth=2]; + + dis_server_check [ + label="server_url configured?\n(read from config only —\nNO reachability ping)", + fillcolor="#FFF8E1", + color="#F57F17", + fontcolor="#F57F17" + ]; + + node [shape=box, style="rounded,filled", penwidth=1.5]; + + dis_keep_stub [ + label="no server → RETAIN shipped stub\n'Server Unavailable' SKILL.md\n(graph genuinely absent — correct)", + fillcolor="#EEEEEE", + color="#757575" + ]; + + dis_swap [ + label="server configured → SWAP in vendored body\n────────────────────────────────────\n_install_vendored_body() — bundled_skill/\ncontext-intelligence-graph-query.md (pinned sha256)\n• fail loud if vendored body missing from wheel\n• runtime SHA verified against EXPECTED_BUNDLED_SKILL_SHA256\n (mismatch raises — refuses to install wrong body)\n• idempotent by sha256 (write only if different)\n• crash-atomic: remove .etag FIRST, then\n temp-write + os.replace, then write .content_hash\n• ZERO network: no GET /version, no GET /skills/\n• NO skill:unloaded handler registered", + fillcolor="#E8F5E9", + color="#2E7D32" + ]; + + dis_server_check -> dis_keep_stub [ + label=" NO ", color="#757575", fontcolor="#757575", penwidth=1.5 + ]; + dis_server_check -> dis_swap [ + label=" YES ", color="#2E7D32", fontcolor="#2E7D32", penwidth=2 + ]; + } + + // ═══════════════════════════════════════════════════════════════════════ + // CLUSTER 2 — on_session_ready / _resync_all_watched + // ═══════════════════════════════════════════════════════════════════════ + + subgraph cluster_osr { + label="2. on_session_ready → _resync_all_watched(coordinator)"; + style=filled; + fillcolor="#FAFAFA"; + color="#B0BEC5"; + fontsize=12; + fontname="Helvetica Bold"; + + node [shape=diamond, style=filled, penwidth=2]; + + osr_cap_check [ + label="skills_discovery\ncapability\navailable?", + fillcolor="#FFEBEE", + color="#C62828", + fontcolor="#C62828" + ]; + + node [shape=box, style="rounded,filled", penwidth=1.5]; + + osr_warn_cap [ + label="WARN + return\nskill_sync_skipped:\nskills_discovery not available", + fillcolor="#EEEEEE", + color="#757575" + ]; + + osr_get_tool [ + label="get_capability(\n \"context_intelligence._graph_query_tool\"\n)\n[tool instance, for config resolution]", + fillcolor="#E8F5E9", + color="#2E7D32" + ]; + + osr_for_each [ + label="for each name in WATCHED_SKILLS\n{\"context-intelligence-graph-query\"}", + fillcolor="#E3F2FD", + color="#1565C0" + ]; + + node [shape=diamond, style=filled, penwidth=2]; + + osr_find_check [ + label="discovery.find(name)\nreturned meta?", + fillcolor="#FFEBEE", + color="#C62828", + fontcolor="#C62828" + ]; + + node [shape=box, style="rounded,filled", penwidth=1.5]; + + osr_warn_find [ + label="WARN + skip\nskill_sync_skipped:\ndiscovery.find() returned None\n\nNote: tool-skills drops SKILL.md\nlacking leading \"---\" frontmatter", + fillcolor="#EEEEEE", + color="#757575" + ]; + + osr_skill_path [ + label="skill_path = Path(meta.path)\n[SKILL.md location on disk]", + fillcolor="#E8EAF6", + color="#283593" + ]; + + osr_cap_check -> osr_warn_cap [ + label=" NO ", color="#757575", fontcolor="#757575", penwidth=1.5 + ]; + osr_cap_check -> osr_get_tool [ + label=" YES ", color="#2E7D32", fontcolor="#2E7D32", penwidth=2 + ]; + osr_get_tool -> osr_for_each [penwidth=1.5]; + osr_for_each -> osr_find_check [penwidth=1.5]; + osr_find_check -> osr_warn_find [ + label=" None ", color="#757575", fontcolor="#757575", penwidth=1.5 + ]; + osr_find_check -> osr_skill_path [ + label=" found ", color="#2E7D32", fontcolor="#2E7D32", penwidth=2 + ]; + } + + // ═══════════════════════════════════════════════════════════════════════ + // CLUSTER 3 — Config resolution + // ═══════════════════════════════════════════════════════════════════════ + + subgraph cluster_config { + label="3. Config Resolution: tool._resolve_server_config(coordinator)"; + style=filled; + fillcolor="#FAFAFA"; + color="#B0BEC5"; + fontsize=12; + fontname="Helvetica Bold"; + + node [shape=diamond, style=filled, penwidth=2]; + + cfg_hook_check [ + label="hook_config_resolver\ncapability present?\n(context_intelligence.\nhook_config_resolver)", + fillcolor="#FFF8E1", + color="#F57F17", + fontcolor="#F57F17" + ]; + + node [shape=box, style="rounded,filled", penwidth=1.5]; + + cfg_hook [ + label="HookConfigResolver\n(logging hook is mounted — full behavior)\n────────────────────────────────────\nserver_url, api_key,\nworkspace (project_slug from\n session.working_dir)", + fillcolor="#F3E5F5", + color="#6A1B9A" + ]; + + cfg_tool [ + label="ToolConfigResolver (analytics-only — no hook)\n────────────────────────────────────\nPriority chain (each level expands\n${VAR:default} shell placeholders):\n 1. mount() config dict\n 2. coordinator.config\n 3. AMPLIFIER_CONTEXT_INTELLIGENCE_* env\n 4. ~/.amplifier/settings.yaml\n 5. built-in default", + fillcolor="#E8F5E9", + color="#2E7D32" + ]; + + cfg_result [ + label="→ (server_url, api_key, workspace)", + fillcolor="#E8EAF6", + color="#283593", + shape=box, + style="rounded,filled" + ]; + + cfg_hook_check -> cfg_hook [ + label=" YES ", color="#6A1B9A", fontcolor="#6A1B9A", penwidth=2 + ]; + cfg_hook_check -> cfg_tool [ + label=" NO ", color="#2E7D32", fontcolor="#2E7D32", penwidth=1.5 + ]; + cfg_hook -> cfg_result [penwidth=1.5]; + cfg_tool -> cfg_result [penwidth=1.5]; + } + + // ═══════════════════════════════════════════════════════════════════════ + // CLUSTER 4 — _sync_skill dispatch (server_url present? reachable?) + // ═══════════════════════════════════════════════════════════════════════ + + subgraph cluster_dispatch { + label="4. _sync_skill: server_url check + reachability"; + style=filled; + fillcolor="#FAFAFA"; + color="#B0BEC5"; + fontsize=12; + fontname="Helvetica Bold"; + + node [shape=diamond, style=filled, penwidth=2]; + + dispatch_url_check [ + label="server_url\nconfigured?", + fillcolor="#FFEBEE", + color="#C62828", + fontcolor="#C62828" + ]; + + node [shape=box, style="rounded,filled", penwidth=1.5]; + + dispatch_version [ + label="SkillFetcher(server_url, api_key)\ncheck_server_version()\nGET {server_url}/version", + fillcolor="#E0F7FA", + color="#00838F" + ]; + + node [shape=diamond, style=filled, penwidth=2]; + + dispatch_reach_check [ + label="server\nreachable?\n(HTTP response\nor connect error)", + fillcolor="#FFEBEE", + color="#C62828", + fontcolor="#C62828" + ]; + + dispatch_url_check -> dispatch_version [ + label=" YES ", color="#00838F", fontcolor="#00838F", penwidth=2 + ]; + dispatch_version -> dispatch_reach_check [penwidth=1.5]; + } + + // ═══════════════════════════════════════════════════════════════════════ + // CLUSTER 5 — OFFLINE path: _invalidate_if_drift + // ═══════════════════════════════════════════════════════════════════════ + + subgraph cluster_offline { + label="OFFLINE — _invalidate_if_drift"; + style=filled; + fillcolor="#FFF3E0"; + color="#E65100"; + fontsize=12; + fontname="Helvetica Bold"; + + node [shape=diamond, style=filled, penwidth=2]; + + off_exists [ + label="SKILL.md AND\n.content_hash\nexist?", + fillcolor="#FFF8E1", + color="#F57F17", + fontcolor="#F57F17" + ]; + + off_match [ + label="sha256(SKILL.md)\n==\nstored .content_hash?", + fillcolor="#FFF8E1", + color="#F57F17", + fontcolor="#F57F17" + ]; + + node [shape=box, style="rounded,filled", penwidth=1.5]; + + off_noop [ + label="noop\n(no baseline to compare)", + fillcolor="#EEEEEE", + color="#757575" + ]; + + off_insync [ + label="in sync\n(no-op — leave sidecars)", + fillcolor="#E8F5E9", + color="#2E7D32" + ]; + + off_invalidate [ + label="DRIFT DETECTED\n────────────────────────\ndelete .etag\ndelete .content_hash\nRETAIN SKILL.md content\n\nWARN: skill_offline_drift_invalidated\n→ next online GET is unconditional", + fillcolor="#FFEBEE", + color="#C62828" + ]; + + off_exists -> off_noop [ + label=" NO ", color="#757575", fontcolor="#757575", penwidth=1.5 + ]; + off_exists -> off_match [ + label=" YES ", color="#F57F17", fontcolor="#F57F17", penwidth=1.5 + ]; + off_match -> off_insync [ + label=" match ", color="#2E7D32", fontcolor="#2E7D32", penwidth=1.5 + ]; + off_match -> off_invalidate [ + label=" drift ", color="#C62828", fontcolor="#C62828", penwidth=2 + ]; + } + + // ═══════════════════════════════════════════════════════════════════════ + // CLUSTER 6 — ONLINE path: SkillFetcher.fetch + // ═══════════════════════════════════════════════════════════════════════ + + subgraph cluster_online { + label="ONLINE — SkillFetcher.fetch"; + style=filled; + fillcolor="#E0F7FA"; + color="#006064"; + fontsize=12; + fontname="Helvetica Bold"; + + node [shape=diamond, style=filled, penwidth=2]; + + on_etag_check [ + label=".etag exists\n& non-empty?", + fillcolor="#FFF8E1", + color="#F57F17", + fontcolor="#F57F17" + ]; + + on_drift_check [ + label="sha256(SKILL.md)\n==\nstored .content_hash?", + fillcolor="#FFF8E1", + color="#F57F17", + fontcolor="#F57F17" + ]; + + node [shape=box, style="rounded,filled", penwidth=1.5]; + + on_cond_get [ + label="Conditional GET\n────────────────────────\nHeaders:\n If-None-Match: {stored_etag}\n Authorization: Bearer {api_key}", + fillcolor="#E0F7FA", + color="#00838F" + ]; + + on_uncond_get [ + label="Unconditional GET\n────────────────────────\n(local drift or no .content_hash)\nHeaders:\n Authorization: Bearer {api_key}", + fillcolor="#E0F7FA", + color="#00838F" + ]; + + on_request [ + label="GET {server_url}/skills/{skill_name}", + fillcolor="#B2EBF2", + color="#006064" + ]; + + node [shape=diamond, style=filled, penwidth=2]; + + on_status [ + label="HTTP\nstatus?", + fillcolor="#FFF8E1", + color="#F57F17", + fontcolor="#F57F17" + ]; + + node [shape=box, style="rounded,filled", penwidth=1.5]; + + on_304 [ + label="304 Not Modified\n→ skill unchanged\n→ no write", + fillcolor="#E8F5E9", + color="#2E7D32" + ]; + + on_200 [ + label="200 OK — write to disk\n────────────────────────\nSKILL.md ← response body\n.etag ← ETag response header\n.content_hash ← sha256(new content)", + fillcolor="#E8F5E9", + color="#2E7D32" + ]; + + on_error [ + label="Error / unexpected status\n────────────────────────\nlog WARNING: skill_fetch_failed\ncontinue (session not broken)\none bad skill never breaks session", + fillcolor="#FFEBEE", + color="#C62828" + ]; + + on_etag_check -> on_drift_check [ + label=" YES ", color="#F57F17", fontcolor="#F57F17", penwidth=1.5 + ]; + on_etag_check -> on_uncond_get [ + label=" NO .etag ", color="#757575", fontcolor="#757575", penwidth=1.5 + ]; + on_drift_check -> on_cond_get [ + label=" in sync ", color="#00838F", fontcolor="#00838F", penwidth=2 + ]; + on_drift_check -> on_uncond_get [ + label=" local drift ", color="#C62828", fontcolor="#C62828", penwidth=1.5 + ]; + on_cond_get -> on_request [penwidth=1.5]; + on_uncond_get -> on_request [penwidth=1.5]; + on_request -> on_status [penwidth=2]; + on_status -> on_304 [ + label=" 304 ", color="#2E7D32", fontcolor="#2E7D32", penwidth=1.5 + ]; + on_status -> on_200 [ + label=" 200 ", color="#2E7D32", fontcolor="#2E7D32", penwidth=2 + ]; + on_status -> on_error [ + label=" other/error ", color="#C62828", fontcolor="#C62828", penwidth=1.5 + ]; + } + + // ═══════════════════════════════════════════════════════════════════════ + // CLUSTER 7 — Mid-session reload (skill:unloaded hook) + // ═══════════════════════════════════════════════════════════════════════ + + subgraph cluster_reload { + label="5. Mid-Session Reload (after initial sync)"; + style=filled; + fillcolor="#F3E5F5"; + color="#6A1B9A"; + fontsize=12; + fontname="Helvetica Bold"; + + node [shape=box, style="rounded,filled", penwidth=1.5]; + + reload_register [ + label="coordinator.hooks.register(\n \"skill:unloaded\",\n _on_skill_unloaded, priority=100\n)\n[registered once after initial _resync_all_watched]", + fillcolor="#F3E5F5", + color="#6A1B9A" + ]; + + reload_fire [ + label="skill:unloaded event fires\n(mid-session skill reload)", + fillcolor="#EDE7F6", + color="#4527A0" + ]; + + node [shape=diamond, style=filled, penwidth=2]; + + reload_check [ + label="skill_name in\nWATCHED_SKILLS?", + fillcolor="#FFF8E1", + color="#F57F17", + fontcolor="#F57F17" + ]; + + node [shape=box, style="rounded,filled", penwidth=1.5]; + + reload_skip [ + label="skip\n(other skill reloaded)", + fillcolor="#EEEEEE", + color="#757575" + ]; + + reload_resync [ + label="re-run\n_resync_all_watched(coordinator)\n[full re-sync of watched skills]", + fillcolor="#EDE7F6", + color="#4527A0" + ]; + + reload_register -> reload_fire [style=dashed, color="#9C27B0", penwidth=1.5, label=" event fires later "]; + reload_fire -> reload_check [penwidth=1.5]; + reload_check -> reload_skip [ + label=" NO ", color="#757575", fontcolor="#757575", penwidth=1.5 + ]; + reload_check -> reload_resync [ + label=" YES ", color="#6A1B9A", fontcolor="#6A1B9A", penwidth=2 + ]; + } + + // ═══════════════════════════════════════════════════════════════════════ + // Cross-cluster edges + // ═══════════════════════════════════════════════════════════════════════ + + // Mount → on_session_ready → skill_sync_enabled gate + mount_osr -> gate_enabled [ + penwidth=2, color="#F57F17", + ltail=cluster_mount, lhead=cluster_gate, + label=" kernel triggers " + ]; + + // Gate: DISABLED (default) → zero-network offline-body swap + gate_enabled -> dis_server_check [ + label=" FALSE (default) — zero per-turn network ", + color="#37474F", fontcolor="#37474F", penwidth=2, + ltail=cluster_gate, lhead=cluster_disabled + ]; + + // Gate: ENABLED (opt-in) → existing resync flow + gate_enabled -> osr_cap_check [ + label=" TRUE (opt-in) — sync from server ", + color="#2E7D32", fontcolor="#2E7D32", penwidth=2, + ltail=cluster_gate, lhead=cluster_osr + ]; + + // on_session_ready → config resolution + osr_skill_path -> cfg_hook_check [ + penwidth=1.5, + ltail=cluster_osr, lhead=cluster_config + ]; + + // Config resolution → _sync_skill dispatch + cfg_result -> dispatch_url_check [ + penwidth=1.5, + ltail=cluster_config, lhead=cluster_dispatch + ]; + + // Dispatch: NO server_url → OFFLINE + dispatch_url_check -> off_exists [ + label=" NO — offline ", color="#E65100", fontcolor="#E65100", + penwidth=2, ltail=cluster_dispatch, lhead=cluster_offline + ]; + + // Dispatch: server unreachable → OFFLINE + dispatch_reach_check -> off_exists [ + label=" unreachable ", color="#E65100", fontcolor="#E65100", + penwidth=2, ltail=cluster_dispatch, lhead=cluster_offline + ]; + + // Dispatch: server reachable → ONLINE + dispatch_reach_check -> on_etag_check [ + label=" reachable ", color="#00838F", fontcolor="#00838F", + penwidth=2, ltail=cluster_dispatch, lhead=cluster_online + ]; + + // on_session_ready → mid-session reload register + osr_warn_find -> reload_register [ + style=invis + ]; + osr_skill_path -> reload_register [ + style=dashed, color="#9C27B0", penwidth=1.5, + label=" after initial sync ", + constraint=false + ]; + + // Mid-session reload re-runs config path + reload_resync -> osr_cap_check [ + style=dashed, color="#9C27B0", penwidth=1.5, + label=" re-runs full sync ", + constraint=false + ]; +} diff --git a/docs/context-intelligence-skill-sync-flow.png b/docs/context-intelligence-skill-sync-flow.png new file mode 100644 index 0000000..27c73f4 Binary files /dev/null and b/docs/context-intelligence-skill-sync-flow.png differ diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py index 2345e41..271e24e 100644 --- a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py +++ b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py @@ -11,7 +11,7 @@ url : str — base URL of the CI server (app expands ${VAR} before mount). api_key : str — bearer token. include : list[str], optional — pathspec (gitwildmatch) patterns; default ["**"]. - exclude : list[str], optional — exclude-wins patterns; default []. + exclude : list[str], optional — exclude-wins patterns; default [] Configured via overrides.hook-context-intelligence.config.destinations in settings.yaml. App-cli deep-merges project-over-user, so per-project overrides patch individual destination sub-keys without clobbering others. @@ -27,7 +27,7 @@ Resolved automatically from the coordinator when not set (see ConfigResolver.workspace). log_level : str, optional - Logging level. Default ``"WARNING"``. + Logging level. Default ``"WARNING"`` base_path : str, optional Root directory for JSONL output. Defaults to the coordinator working directory. @@ -41,10 +41,6 @@ Event names to register unconditionally, regardless of capability discovery order. Use to capture events from modules that mount after this hook (e.g. ``delegate:agent_spawned``). - -Note: Skills are fetched from the first configured destination (insertion order), -typically "default" from the legacy-synthesized single-server path. This preserves -today's single-server behavior for skill fetching, which is not per-destination. """ from __future__ import annotations @@ -52,97 +48,12 @@ import fnmatch import logging from collections.abc import Callable, Coroutine -from pathlib import Path -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from .skill_fetcher import SkillFetcher +from typing import Any log = logging.getLogger(__name__) __amplifier_module_type__ = "hook" -# Path to the bundle root — works regardless of cache location or mounting order -# Path(__file__).parent = amplifier_module_hook_context_intelligence/ -# .parent = hook-context-intelligence/ -# .parent = modules/ -# .parent = bundle root (where skills/ lives) -_BUNDLE_ROOT = Path(__file__).parent.parent.parent.parent - - -def _resolve_skill_path(skill_name: str, coordinator: Any) -> Path | None: - """Resolve the filesystem path for a watched skill's SKILL.md file. - - Primary: queries the ``skills_discovery`` coordinator capability - (registered by the tool-skills module at mount time). Returns - ``metadata.path`` when the capability finds the skill. - - Fallback: returns ``_BUNDLE_ROOT / 'skills' / skill_name / 'SKILL.md'`` - when the parent directory exists on disk. - - Returns ``None`` when neither source can provide a valid path. - """ - from .skill_fetcher import TOOL_SKILLS_DISCOVERY_CAPABILITY - - # Primary: use skills_discovery capability - discovery = coordinator.get_capability(TOOL_SKILLS_DISCOVERY_CAPABILITY) - if discovery is not None: - metadata = discovery.find(skill_name) - if metadata is not None: - log.debug( - "skill_path_resolved: %s -> %s (via skills_discovery)", - skill_name, - metadata.path, - ) - return metadata.path - - # Fallback: check bundle root location - fallback = _BUNDLE_ROOT / "skills" / skill_name / "SKILL.md" - if fallback.parent.exists(): - log.debug( - "skill_path_resolved: %s -> %s (via bundle root fallback)", - skill_name, - fallback, - ) - return fallback - - log.warning( - "skill_path_unresolvable: %s — not found via skills_discovery or bundle root", skill_name - ) - return None - - -async def _refresh_watched_skills( - coordinator: Any, - fetcher: "SkillFetcher", - skills_capable: bool, -) -> None: - """Refresh all watched skills by resolving their paths and updating content. - - Branch B (not skills_capable): writes bundled legacy content via - ``fetcher.write_legacy_content``. - - Branch C (skills_capable): fetches live content from the server via - ``fetcher.fetch``, wrapped in a try/except to skip individual failures. - """ - from .skill_fetcher import WATCHED_SKILLS - - for skill_name in WATCHED_SKILLS: - skill_path = _resolve_skill_path(skill_name, coordinator) - if skill_path is None: - continue - - if not skills_capable: - # Branch B: old server — write bundled legacy content - fetcher.write_legacy_content(skill_name, skill_path) - else: - # Branch C: new server — fetch live content - try: - await fetcher.fetch(skill_name, skill_path) - except Exception as exc: - # Swallow per-skill failures — one bad skill must not block others - log.warning("skill_fetch_failed: %s — %s", skill_name, exc) - async def _discover_events(coordinator: Any) -> set[str]: """Union of ALL_EVENTS + module contributions + legacy capability.""" @@ -174,12 +85,6 @@ async def mount( """ from .config_resolver import HookConfigResolver from .handlers.logging_handler import LoggingHandler - from .skill_fetcher import ( - TOOL_SKILLS_DISCOVERY_CAPABILITY, - WATCHED_SKILLS, - SkillFetcher, - _is_skills_capable, - ) resolver = HookConfigResolver(config, coordinator) log.setLevel(resolver.log_level) @@ -200,70 +105,8 @@ async def mount( "overrides.hook-context-intelligence.config.destinations for multi-server fan-out." ) - # --- Skill-fetch server selection (spec §5.1.3) --- - # Skills are global, not per-destination. Use first destination with a non-empty url - # (insertion order). Synthesized "default" is always first on the legacy path. - skill_fetch_url = next((d.url for d in all_destinations.values() if d.url), None) - logging_handler = LoggingHandler(resolver) - # Skill fetch phase — deferred to skills:discovered event - fetcher: SkillFetcher | None = None - skills_capable: bool = False - - if not skill_fetch_url: - log.info("skill_fetch_skipped: no server_url in config") - else: - _tentative_fetcher = SkillFetcher(skill_fetch_url) - result = await _tentative_fetcher.check_server_version() - log.info( - "skill_version_check: server=%s reachable=%s version=%s", - skill_fetch_url, - result.reachable, - result.version, - ) - - if not result.reachable: - # Branch A: server unreachable — delegation fallback stays, SKILL.md untouched - log.info("skill_fetch_branch=A: server unreachable — SKILL.md unchanged") - else: - # Reachable: defer skill fetch to skills:discovered event - fetcher = _tentative_fetcher - skills_capable = _is_skills_capable(result.version) - - async def on_skills_discovered(event_name: str, data: dict[str, Any]) -> None: - await _refresh_watched_skills(coordinator, fetcher, skills_capable) # type: ignore[arg-type] - - unreg_skills_discovered = coordinator.hooks.register( - "skills:discovered", - on_skills_discovered, - priority=50, - name="SkillFetcher-trigger", - ) - unregister_fns.append(unreg_skills_discovered) - log.info("skill_fetch_deferred: registered skills:discovered handler") - # tools mount before hooks in Amplifier: if skills_discovery is - # already registered (tool-skills already ran), fetch immediately. - # The event handler above handles the reverse order if it ever occurs. - if coordinator.get_capability(TOOL_SKILLS_DISCOVERY_CAPABILITY) is not None: - log.info( - "skill_fetch_immediate: skills_discovery already registered " - "(tools mount before hooks) — fetching now" - ) - await _refresh_watched_skills(coordinator, fetcher, skills_capable) - - # skill:unloaded handler — re-fetches watched skills when they are reloaded - if fetcher is not None: - - async def on_skill_unloaded(event_name: str, data: dict[str, Any]) -> None: - if data.get("skill_name") in WATCHED_SKILLS: - await _refresh_watched_skills(coordinator, fetcher, skills_capable) # type: ignore[arg-type] - - unreg_skill = coordinator.hooks.register( - "skill:unloaded", on_skill_unloaded, priority=100, name="SkillFetcher" - ) - unregister_fns.append(unreg_skill) - # Share mutable state with on_session_ready via a private capability. # The cleanup closure closes over unregister_fns by reference — any entries # appended by on_session_ready() will be torn down automatically. diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/legacy_content/__init__.py b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/legacy_content/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/legacy_content/context-intelligence-graph-query.md b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/legacy_content/context-intelligence-graph-query.md deleted file mode 100644 index 221aced..0000000 --- a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/legacy_content/context-intelligence-graph-query.md +++ /dev/null @@ -1,1335 +0,0 @@ - - ---- -name: context-intelligence-graph-query -version: 1.0.0 -description: Cypher query patterns for the context-intelligence graph store via graph_query tool -license: MIT ---- - -# Context Intelligence Graph Query (Cypher Dialect) - -This skill teaches how to query the context-intelligence property graph using -the `graph_query` tool. All structural traversal — sessions, events, tool calls, -delegations — is done through Cypher queries executed via the -`graph_query` tool. - -Query patterns for searching and traversing the context-intelligence graph. -Covers workspace scoping, structural traversal, delegation chains, step -sequencing, and graph algorithm patterns using native Cypher. - ---- - -## When to Use Graph vs File Patterns - -Choose the right approach based on what you need to find: - -| Query Type | Tool | Example | -|-----------|------|---------| -| Structural navigation (sessions, events, tool calls, delegations) | `graph_query` | "Find all tool calls in this session" | -| Relationship traversal (parent-child, HAS_FORK, HAS_TOOL_CALL) | `graph_query` | "Find all child sessions" | -| Session statistics and aggregations | `graph_query` | "Count tool calls by tool name" | -| Prompt text keyword search | `bash`+`grep` or `graph_query` | "Find prompts containing 'authentication'" | -| Large payload inspection (messages, results) | `bash`+`jq` after `blob_read` | "Read tool result JSON" | -| Event log text search across sessions | `bash`+`grep` on events.jsonl | "Find all sessions with a specific error" | - -**Fallback guidance:** If `graph_query` returns no results, fall back to -`bash`+`grep`/`jq` on the raw events.jsonl file — the graph may not have -been populated yet for in-progress sessions. - ---- - -## Schema Reference — Data Layer 1 - -> **Scope:** This section describes **Data Layer 1** — the only schema that is actually -> implemented and queryable today. See the [Data Layer 2 Warning](#data-layer-2-warning) -> section before writing any Cypher queries. - -### Node Types - -Data Layer 1 contains exactly **three** node types. - -| Node Label | Sub-labels | Description | -|---|---|---| -| `:Session` | `:RootSession` — no parent; `:ForkedSession` — spawned via `session:fork` | One Amplifier session. MERGE key: `{node_id, workspace}`. | -| `:ToolCall` | _(none)_ | One tool invocation lifecycle (pre → post/error). Created by `ToolCallHandler` on `tool:pre`. | -| `:Event` | `:{Category}Event`, `:{Specific}Event` — see Triple-Label Rule below | Every event that reaches `DefaultHandler`. Triple-labeled. | - ---- - -### Edge Types - -Data Layer 1 contains exactly **three** edge types. - -| Edge | From → To | When Created | -|---|---|---| -| `HAS_FORK` | `:Session` → `:Session` | On `session:fork` — parent session → forked child. | -| `HAS_TOOL_CALL` | `:Session` → `:ToolCall` | On `tool:pre` — session owns the tool call lifecycle node. | -| `HAS_EVENT` | `:Session` → `:Event` | On every `DefaultHandler` event — session owns the event node. | -| `HAS_EVENT` | `:ToolCall` → `:Event` | On `tool:pre`, `tool:post`, `tool:error` — tool call owns each lifecycle event. | - ---- - -### Event Triple-Label Rule - -Every `Event` node carries exactly **three** labels derived from the raw event name -by `DefaultHandler.derive_labels()`: - -1. **Base label** — always `:Event` -2. **Category label** — `:{Category}Event` (prefix before the last `:`, PascalCased) -3. **Specific label** — `:{Full}Event` (all parts split on `:` and `_`, PascalCased, `Event` suffix) - -The full table of 24 known event types: - -| Event Name | Category Label | Specific Label | -|---|---|---| -| `session:start` | `:SessionEvent` | `:SessionStartEvent` | -| `session:fork` | `:SessionEvent` | `:SessionForkEvent` | -| `session:end` | `:SessionEvent` | `:SessionEndEvent` | -| `session:resume` | `:SessionEvent` | `:SessionResumeEvent` | -| `execution:start` | `:ExecutionEvent` | `:ExecutionStartEvent` | -| `execution:end` | `:ExecutionEvent` | `:ExecutionEndEvent` | -| `orchestrator:complete` | `:OrchestratorEvent` | `:OrchestratorCompleteEvent` | -| `prompt:submit` | `:PromptEvent` | `:PromptSubmitEvent` | -| `prompt:complete` | `:PromptEvent` | `:PromptCompleteEvent` | -| `provider:request` | `:ProviderEvent` | `:ProviderRequestEvent` | -| `provider:response` | `:ProviderEvent` | `:ProviderResponseEvent` | -| `llm:request` | `:LlmEvent` | `:LlmRequestEvent` | -| `llm:response` | `:LlmEvent` | `:LlmResponseEvent` | -| `tool:pre` | `:ToolEvent` | `:ToolPreEvent` | -| `tool:post` | `:ToolEvent` | `:ToolPostEvent` | -| `tool:error` | `:ToolEvent` | `:ToolErrorEvent` | -| `delegate:start` | `:DelegateEvent` | `:DelegateStartEvent` | -| `delegate:agent_spawned` | `:DelegateEvent` | `:DelegateAgentSpawnedEvent` | -| `delegate:complete` | `:DelegateEvent` | `:DelegateCompleteEvent` | -| `recipe:start` | `:RecipeEvent` | `:RecipeStartEvent` | -| `recipe:step` | `:RecipeEvent` | `:RecipeStepEvent` | -| `recipe:complete` | `:RecipeEvent` | `:RecipeCompleteEvent` | -| `recipe:loop_iteration` | `:RecipeEvent` | `:RecipeLoopIterationEvent` | -| `skill:load` | `:SkillEvent` | `:SkillLoadEvent` | - -Unknown events follow the same derivation automatically. Use `:Event` as the base -label when querying across all event types. - ---- - -### FieldLifter Properties - -`DefaultHandler` applies all matching `FieldLifter` instances to expose structured -fields as top-level node properties on every `:Event` node. All lifters fire (not -first-match-wins); specific lifters can override Universal. - -| Lifter | Applies To (pattern) | Lifted Properties | -|---|---|---| -| `UniversalLifter` | `*` (all events) | `session_id`, `parent_id` | -| `ToolLifter` | `tool:*` | `tool_name`, `tool_input`, `tool_call_id`, `parallel_group_id` | -| `LlmLifter` | `llm:*` | `model`, `provider` | -| `DelegateLifter` | `delegate:*` | `agent`, `sub_session_id`, `parent_session_id`, `tool_call_id`, `parallel_group_id` | -| `PromptLifter` | `prompt:*` | `prompt`, `response_preview` | -| `RecipeLifter` | `recipe:*` | `recipe_name`, `current_step`, `description`, `status`, `step_id`, `total_steps` | -| `SessionLifter` | `session:*` | `parent`; from `metadata` dict: `agent_name`, `tool_call_id`, `parallel_group_id`, `recipe_name`, `recipe_step`, `recipe_step_index` | -| `SkillLifter` | `skill:*` | `skill_directory`, `skill_name` | -| `ArtifactLifter` | `artifact:*` | `bytes`, `path` | - -`None` values and missing keys are silently skipped. `data` (full JSON payload) is -always written as a fallback, but prefer lifted properties for structured access. - ---- - -### Data Layer 2 Warning - -> ⚠️ **Do not write queries using any of the following labels or relationships.** -> They are either stub labels with no connected edges, or relationship types that -> do not exist in the graph. Queries referencing them will silently return no results. - -**Labels That Exist But Have No Connected Edges:** - -The following node labels may appear as orphan nodes in the database but are not -connected to the rest of the graph via any traversable relationship: - -- `OrchestratorRun` -- `Step` -- `ToolExecution` -- `Delegation` -- `RecipeRun` - -These are Data Layer 2 concepts that were planned but whose edge relationships -were never implemented. **Do not write queries that traverse to or from these labels.** - -**Relationship Types That Do Not Exist:** - -The following relationship types are referenced in older documentation or planning -documents but are **not present** in the graph: - -- `HAS_RUN` -- `HAS_STEP` -- `TRIGGERED` -- `PARALLEL_WITH` -- `NEXT` - -**Do not write queries using any of these relationship types.** They will match -nothing and silently produce empty result sets with no error. - ---- - -### Node ID Formats - -| Node Type | Format | Example | -|---|---|---| -| `:Session` (root) | Raw UUID | `f881e0a0-c055-4ee4-84ed-ff44703150ea` | -| `:Session` (forked) | `{hex}-{hex}_{agent-name}` | `a1b2c3d4-e5f6-7890-abcd-ef1234567890_foundation:explorer` | -| `:Event` | `{session_id}__{event_name_underscored}__{epoch_ms}` | `f881e0a0-...__tool_pre__1742018545123` | -| `:ToolCall` | `{session_id}__tool_call__{tool_call_id}` | `f881e0a0-...__tool_call__call_abc123` | - -**Separator:** Double underscore `__` — never a single colon. -**`event_name_underscored`:** Raw event name with `:` replaced by `_` (e.g. `tool:pre` → `tool_pre`). -**`epoch_ms`:** Unix epoch milliseconds from the ISO 8601 timestamp. -**Disambiguator:** `tool_call_id` is appended to Event node IDs for tool lifecycle events to prevent collisions when parallel calls share the same millisecond timestamp. - ---- - -### Two Paths to Tool Data - -There are two complementary ways to query tool call information: - -| Path | Pattern | Best For | -|---|---|---| -| **Flexible** — via Event | `(s:Session)-[:HAS_EVENT]->(e:ToolEvent)` | Filtering by tool name, reading lifted fields, querying all tool activity regardless of lifecycle state | -| **Structured** — via ToolCall | `(s:Session)-[:HAS_TOOL_CALL]->(tc:ToolCall)` | Getting the lifecycle node (start + end times), correlating pre/post/error events via `(tc)-[:HAS_EVENT]->(e)` | - -The `:ToolCall` node provides: -- `tool_name` — the tool being called -- `tool_call_id` — provider-assigned correlation ID -- `session_id` — owning session -- `parallel_group_id` — set when the call is part of a parallel group -- `started_at` / `ended_at` — lifecycle timestamps (from `tool:pre` and `tool:post`/`tool:error`) - -Both paths are valid. Use the flexible path for event-level queries; use the -structured path when you need the lifecycle view or duration calculations. - ---- - -## Workspace Scoping - -Every query is scoped to a **workspace** — an isolated partition identified -by the `workspace` property present on all nodes and relationships. - -The `graph_query` tool handles automatic injection of the `$workspace` -parameter. When querying within the current workspace, the tool injects -the workspace value for you. Write Cypher queries that reference `$workspace` -explicitly in node patterns or WHERE clauses. - -### 1. Default query (own workspace) - -The `graph_query` tool auto-injects `$workspace` from the current session -context. Write queries that filter on `$workspace`: - -```cypher -// $workspace auto-injected by graph_query tool -MATCH (s:Session {workspace: $workspace}) -RETURN s.node_id, s.occurred_at -ORDER BY s.occurred_at DESC -``` - -### 2. Explicit workspace query - -Pass `workspace="other-project"` to target a specific workspace: - -```cypher -MATCH (s:Session {workspace: $workspace}) -RETURN s.node_id, s.occurred_at -``` - -### 3. Cross-workspace (wildcard) query - -Pass `workspace="*"` — the tool skips parameter injection entirely. -Write queries without `$workspace` filter, or add your own: - -```cypher -// workspace="*" — no automatic injection -MATCH (s:Session) -RETURN s.workspace, s.node_id, s.occurred_at -ORDER BY s.workspace, s.occurred_at DESC -``` - ---- - -## Query Patterns - -### Pattern 1: Find All Sessions in a Workspace - -```cypher -MATCH (s:Session {workspace: $workspace}) -RETURN s.node_id AS session_id, - s.occurred_at AS started_at, - labels(s) AS session_labels -ORDER BY s.occurred_at DESC -``` - -To restrict to only top-level (root) sessions: - -```cypher -MATCH (s:Session:RootSession {workspace: $workspace}) -RETURN s.node_id AS session_id, s.started_at AS started_at -ORDER BY s.started_at DESC -``` - -### Pattern 2: Session Execution Brackets - -Find all execution brackets (one per user turn): - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_EVENT]->(e:ExecutionStartEvent) -RETURN e.node_id AS bracket_id, e.occurred_at AS turn_started -ORDER BY e.occurred_at -``` - -Brackets with duration (pair each start with its nearest end): - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_EVENT]->(start:ExecutionStartEvent) -OPTIONAL MATCH (s)-[:HAS_EVENT]->(end:ExecutionEndEvent) -WHERE end.occurred_at > start.occurred_at -WITH start, min(end.occurred_at) AS turn_ended -RETURN start.node_id AS bracket_id, - start.occurred_at AS turn_started, - turn_ended, - duration.between(datetime(start.occurred_at), datetime(turn_ended)) AS duration -ORDER BY start.occurred_at -``` - -### Pattern 3: Session Event Timeline - -Complete chronological event timeline for a session: - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_EVENT]->(e:Event) -RETURN e.event_name, labels(e), e.occurred_at -ORDER BY e.occurred_at -``` - -Filter to a specific event category (e.g., LLM events only): - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_EVENT]->(e:LlmEvent) -RETURN e.event_name, e.model, e.occurred_at -ORDER BY e.occurred_at -``` - -### Pattern 4: Session Tool Activity - -There are two complementary paths to tool data in Data Layer 1. Use the **flexible -path** (via `:ToolEvent`) for search and analysis — it lets you filter by tool name, -read lifted fields, and query all tool activity regardless of lifecycle state. Use the -**structured path** (via `:ToolCall`) when the lifecycle node itself is the natural -anchor — for example, when you need start + end timestamps or want to correlate -pre/post/error events via `(tc)-[:HAS_EVENT]->(e)`. - -**Variant 1 — Flexible path (preferred for search and analysis):** - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_EVENT]->(e:ToolEvent) -RETURN e.event_name AS event_type, - e.tool_name, - e.tool_call_id, - e.parallel_group_id, - e.occurred_at -ORDER BY e.occurred_at -``` - -**Variant 2 — Filter to tool:pre only:** - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_EVENT]->(e:ToolPreEvent) -RETURN e.tool_name, - e.tool_call_id, - e.occurred_at -``` - -**Variant 3 — Structured path (when ToolCall is the anchor):** - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_TOOL_CALL]->(tc:ToolCall) -RETURN tc.tool_name, - tc.tool_call_id, - tc.parallel_group_id, - tc.ended_at -ORDER BY tc.ended_at -``` - -### Pattern 5: Child Sessions and Delegation Metadata - -**Variant 1 — Direct child sessions (structural, via HAS_FORK):** - -```cypher -MATCH (parent:Session {workspace: $workspace, node_id: $session_id})-[:HAS_FORK]->(child:Session) -RETURN child.node_id AS child_session_id, - child.started_at AS started_at, - labels(child) AS session_labels -ORDER BY child.started_at -``` - -**Variant 2 — Delegation metadata (via DelegateAgentSpawnedEvent):** - -```cypher -MATCH (parent:Session {workspace: $workspace, node_id: $session_id})-[:HAS_EVENT]->(e:DelegateAgentSpawnedEvent) -RETURN e.agent AS agent, - e.sub_session_id AS sub_session_id, - e.tool_call_id AS tool_call_id, - e.parallel_group_id AS parallel_group_id, - e.occurred_at AS occurred_at -ORDER BY e.occurred_at -``` - -**Variant 3 — Combined (structural children with delegation metadata):** - -```cypher -MATCH (parent:Session {workspace: $workspace, node_id: $session_id})-[:HAS_FORK]->(child:Session) -OPTIONAL MATCH (parent)-[:HAS_EVENT]->(e:DelegateAgentSpawnedEvent) -WHERE e.sub_session_id = child.node_id -RETURN child.node_id AS child_session_id, - child.started_at AS started_at, - e.agent AS agent, - e.tool_call_id AS tool_call_id -ORDER BY child.started_at -``` - -### Pattern 6: Session Overview - -**Variant 1 — Flat summary (counts per session):** - -```cypher -MATCH (s:Session {workspace: $workspace}) -OPTIONAL MATCH (s)-[:HAS_EVENT]->(e:Event) -OPTIONAL MATCH (s)-[:HAS_TOOL_CALL]->(tc:ToolCall) -OPTIONAL MATCH (s)-[:HAS_FORK]->(child:Session) -RETURN s.node_id, - s.started_at, - s.status, - count(DISTINCT e) AS event_count, - count(DISTINCT tc) AS tool_call_count, - count(DISTINCT child) AS child_session_count -ORDER BY s.started_at DESC -``` - -**Variant 2 — Breakdown by event category:** - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_EVENT]->(e:Event) -WITH e, [lbl IN labels(e) WHERE lbl ENDS WITH 'Event' AND lbl <> 'Event'] AS sub_labels -WHERE size(sub_labels) > 0 -RETURN sub_labels[0] AS event_category, - count(e) AS event_count -ORDER BY event_count DESC -``` - -### Pattern 7: Parallel Tool Call Groups - -**Variant 1 — Via ToolCall (structured path):** - -```cypher -MATCH (s:Session {workspace: $workspace})-[:HAS_TOOL_CALL]->(tc:ToolCall) -WHERE tc.parallel_group_id <> '' -RETURN tc.parallel_group_id AS parallel_group_id, - collect(tc.tool_name) AS tool_names, - count(tc) AS group_size -ORDER BY group_size DESC -``` - -**Variant 2 — Via ToolPreEvent (flexible path):** - -```cypher -MATCH (s:Session {workspace: $workspace})-[:HAS_EVENT]->(e:ToolPreEvent) -WHERE e.parallel_group_id <> '' -RETURN e.parallel_group_id AS parallel_group_id, - collect(e.tool_name) AS tool_names, - count(e) AS group_size -ORDER BY group_size DESC -``` - -**Variant 3 — Peak parallelism across workspace:** - -```cypher -MATCH (s:Session:RootSession {workspace: $workspace})-[:HAS_TOOL_CALL]->(tc:ToolCall) -WHERE tc.parallel_group_id <> '' -WITH s.node_id AS session_id, - tc.parallel_group_id AS grp, - count(tc) AS grp_size -RETURN session_id, - max(grp_size) AS peak_parallelism, - count(DISTINCT grp) AS parallel_group_count -ORDER BY peak_parallelism DESC -LIMIT 20 -``` - -> **Note:** `parallel_group_id` is an empty string `""` (not null) when a tool runs -> alone. Use `tc.parallel_group_id <> ''` to filter parallel groups — not `IS NOT NULL`. - -### Pattern 8: Search Prompt Text - -`PromptSubmitEvent` nodes carry the `prompt` property (promoted by `PromptLifter`). Use -`PromptSubmitEvent` for submitted prompts and `PromptCompleteEvent` for completed ones. - -**Basic search:** - -```cypher -MATCH (e:PromptSubmitEvent {workspace: $workspace}) -WHERE e.prompt CONTAINS $search_term -RETURN e.session_id, e.prompt, e.occurred_at -ORDER BY e.occurred_at DESC -``` - -**Case-insensitive search using `toLower()`:** - -```cypher -MATCH (e:PromptSubmitEvent {workspace: $workspace}) -WHERE toLower(e.prompt) CONTAINS toLower($search_term) -RETURN e.session_id, e.prompt, e.occurred_at -ORDER BY e.occurred_at DESC -``` - -### Pattern 9: Count Nodes by Label - -```cypher -MATCH (n {workspace: $workspace}) -RETURN labels(n) AS node_labels, - count(n) AS node_count -ORDER BY node_count DESC -``` - -Count a specific label type: - -```cypher -MATCH (n:ToolCall {workspace: $workspace}) -RETURN count(n) AS tool_call_count -``` - -### Pattern 10: Find Child Sessions of a Parent - -**Variant 1 — Direct children only:** - -```cypher -MATCH (parent:Session {workspace: $workspace, node_id: $session_id})-[:HAS_FORK]->(child:Session) -RETURN child.node_id AS child_session_id, - child.started_at AS started_at, - labels(child) AS session_labels -ORDER BY child.started_at -``` - -**Variant 2 — All descendants (any depth):** - -```cypher -MATCH (parent:Session {workspace: $workspace, node_id: $session_id})-[:HAS_FORK*1..]->(descendant:Session) -RETURN descendant.node_id AS descendant_session_id, - descendant.started_at AS started_at, - labels(descendant) AS session_labels -ORDER BY descendant.started_at -``` - -### Pattern 11: Find Events Attached to a Session - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id}) - -[:HAS_EVENT]->(e:Event) -RETURN e.node_id AS event_id, - labels(e) AS event_labels, - e.occurred_at AS occurred_at -ORDER BY e.occurred_at -``` - -> **Note:** In Data Layer 1, all `HAS_EVENT` edges attach directly to the `Session` node. `ToolCall` nodes also carry `HAS_EVENT` edges for their `tool:pre` and `tool:post` events. - -Via ToolCall: - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_TOOL_CALL]->(tc:ToolCall)-[:HAS_EVENT]->(e:Event) -RETURN tc.tool_name AS tool_name, - tc.tool_call_id AS tool_call_id, - e.event_name AS event_name, - e.occurred_at AS occurred_at -ORDER BY e.occurred_at -``` - -### Pattern 12: Tool Activity Stats - -`:ToolCall` nodes have no `status` property — derive success/failure from event types: -`tool:pre` = initiated, `tool:post` = completed, `tool:error` = failed. - -**Per-tool event counts:** - -```cypher -MATCH (s:Session {workspace: $workspace})-[:HAS_EVENT]->(e:ToolEvent) -RETURN e.tool_name, e.event_name, count(e) AS n -ORDER BY e.tool_name, e.event_name -``` - -**Tool error rate:** - -```cypher -MATCH (s:Session {workspace: $workspace})-[:HAS_EVENT]->(e:ToolEvent) -WHERE e.event_name IN ['tool:post', 'tool:error'] -RETURN e.tool_name, - sum(CASE WHEN e.event_name = 'tool:error' THEN 1 ELSE 0 END) AS errors, - sum(CASE WHEN e.event_name = 'tool:post' THEN 1 ELSE 0 END) AS successes -ORDER BY errors DESC -``` - ---- - -## New Patterns — Data Layer 1 Capabilities - -The following patterns leverage Data Layer 1 graph nodes (`Session`, `Event`, -`ToolCall`, `HAS_FORK`, `HAS_EVENT`) and promoted event labels added by -PromptLifter, RecipeLifter, and other DL1 modules. - ---- - -### N1: Delegation Tree - -Traverse the full delegation chain from a root session to all its forked -descendants. Uses variable-length `HAS_FORK` traversal to build a complete -tree in one query. - -```cypher -MATCH path = (root:Session {workspace: $workspace, node_id: $session_id})-[:HAS_FORK*1..]->(child:Session) -RETURN [n IN nodes(path) | n.node_id] AS session_chain, - [n IN nodes(path) | labels(n)] AS label_chain, - length(path) AS depth -ORDER BY depth, child.started_at -``` - -**Acceptance check** — count paths per delegation depth (no `$session_id` -needed; walk the whole workspace): - -```cypher -MATCH path = (root:Session {workspace: $workspace})-[:HAS_FORK*1..]->(child:Session) -RETURN length(path) AS depth, count(*) AS paths_at_depth -ORDER BY depth -``` - ---- - -### N2: LLM Usage Per Session - -#### (a) Per-model call counts - -```cypher -MATCH (s:Session {workspace: $workspace})-[:HAS_EVENT]->(e:LlmResponseEvent) -RETURN e.model, e.provider, count(e) AS llm_calls -ORDER BY llm_calls DESC -``` - -#### (b) Session-level token summary - -Token totals are surfaced by `OrchestratorCompleteEvent`, which fires once -at the end of each session. - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_EVENT]->(e:OrchestratorCompleteEvent) -RETURN e.total_input_tokens, e.total_output_tokens, e.turn_count, e.occurred_at -``` - -> **Discovery note:** token property names may differ across versions. Run -> `MATCH (e:OrchestratorCompleteEvent) RETURN keys(e) LIMIT 1` to confirm -> the exact property names available on your graph. - ---- - -### N3: Recipe Progress - -#### (a) Step-level iteration tracking - -```cypher -MATCH (s:Session {workspace: $workspace})-[:HAS_EVENT]->(e:RecipeLoopIterationEvent) -RETURN e.recipe_name, e.step_id, e.iteration, e.occurred_at -ORDER BY e.occurred_at -``` - -#### (b) Recipe completion events - -```cypher -MATCH (s:Session {workspace: $workspace})-[:HAS_EVENT]->(e:RecipeLoopCompleteEvent) -RETURN e.recipe_name, e.occurred_at, s.node_id AS session_id -ORDER BY e.occurred_at DESC -``` - ---- - -### N4: ToolCall Lifecycle - -Retrieve all events attached to a specific `ToolCall` node in chronological -order. Each tool invocation gets its own `:ToolCall` node with `HAS_EVENT` -edges to `tool:pre`, `tool:post`, or `tool:error` events. - -```cypher -MATCH (tc:ToolCall {workspace: $workspace, node_id: $tool_call_node_id})-[:HAS_EVENT]->(e:Event) -RETURN e.event_name, e.occurred_at -ORDER BY e.occurred_at -``` - -**Acceptance check** — browse tool events without a specific node ID: - -```cypher -MATCH (tc:ToolCall {workspace: $workspace})-[:HAS_EVENT]->(e:Event) -RETURN tc.tool_name, e.event_name, e.occurred_at -LIMIT 5 -``` - ---- - -### N5: Event-Type Distribution - -Count every distinct event type across all sessions to understand what -activities are most frequent in the workspace. - -```cypher -MATCH (s:Session {workspace: $workspace})-[:HAS_EVENT]->(e:Event) -RETURN e.event_name, count(*) AS n -ORDER BY n DESC -``` - ---- - -## Graph Algorithm Examples - -> ⚠️ **Data Layer 2 Only — DL1 graphs will return zero results for most examples below.** -> The "All Paths from Session to a Specific Tool Execution" and "Variable-Length Traversal" -> examples use DL2 relationships (`HAS_RUN`, `HAS_STEP`, `TRIGGERED`, `SPAWNED`, -> `SUBSESSION_OF`) and the `ToolExecution` label that do not exist in Data Layer 1. -> Only the "Shortest Path" example works on DL1 (it uses no label/relationship filters). -> These examples will be updated in Phase 2. - -### Shortest Path Between Two Nodes - -Find the shortest undirected path between any two nodes by `node_id`: - -```cypher -MATCH (a {node_id: $source_id, workspace: $workspace}), - (b {node_id: $target_id, workspace: $workspace}), - path = shortestPath((a)-[*]-(b)) -RETURN [n IN nodes(path) | n.node_id] AS node_chain, - [r IN relationships(path) | type(r)] AS rel_chain, - length(path) AS hop_count -``` - -### All Paths from Session to a Specific Tool Execution - -```cypher -MATCH (s:Session {node_id: $session_id, workspace: $workspace}), - (tc:ToolCall {node_id: $tool_call_id, workspace: $workspace}), - path = (s)-[*]->(tc) -RETURN [n IN nodes(path) | n.node_id] AS path_nodes, - [r IN relationships(path) | type(r)] AS rel_types, - length(path) AS depth -ORDER BY depth -LIMIT 10 -``` - -### Variable-Length Traversal (Descendant Subgraph) - -Walk up to 6 hops outward from a session to find all reachable nodes: - -```cypher -MATCH (s:Session {node_id: $session_id, workspace: $workspace}) - -[:HAS_EVENT | HAS_TOOL_CALL | HAS_FORK*1..6]->(descendant) -RETURN descendant.node_id AS node_id, - labels(descendant) AS node_labels, - descendant.occurred_at AS occurred_at -ORDER BY descendant.occurred_at -``` - -Walk the delegation lineage (any depth): - -```cypher -MATCH path = (root:Session {workspace: $workspace})-[:HAS_FORK*1..]->(descendant:Session) -RETURN [n IN nodes(path) | n.node_id] AS session_chain, - length(path) AS depth -ORDER BY depth -LIMIT 50 -``` - ---- - -## Usage via graph_query Tool - -### Bootstrap Queries - -Use these queries to verify graph connectivity and explore session data. - -#### Health check - -```cypher -MATCH (s:Session) RETURN count(s) AS session_count -``` - -#### Recent sessions - -```cypher -MATCH (s:Session {workspace: $workspace}) -RETURN s.node_id AS session_id, s.started_at, labels(s) AS session_labels -ORDER BY s.started_at DESC -LIMIT 10 -``` - -#### Tool calls for a session - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_TOOL_CALL]->(tc:ToolCall) -RETURN tc.tool_name, tc.started_at, tc.ended_at -ORDER BY tc.started_at -``` - -#### Child sessions - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_FORK]->(child:Session) -RETURN child.node_id AS child_session_id, child.started_at, labels(child) AS labels -ORDER BY child.started_at -``` - ---- - -All patterns above are executed through the `graph_query` tool. Pass a Cypher -query string as the first argument; the tool handles workspace scoping and -returns results as a list of row dicts. - -Basic usage — find sessions in the current workspace: - -``` -graph_query( - "MATCH (s:Session {workspace: $workspace}) " - "RETURN s.node_id, s.occurred_at ORDER BY s.occurred_at DESC" -) -# Returns: list of dicts, one per row -``` - -With additional parameters — find tool events for a specific session: - -``` -graph_query( - "MATCH (s:Session {workspace: $workspace, node_id: $session_id})" - "-[:HAS_EVENT]->(e:ToolPreEvent) " - "RETURN e.tool_name AS tool_name, e.occurred_at AS started_at", - params={"session_id": "6afb3613-7041-4735-9c0f-c2171452ed18"} -) -``` - -Query another workspace explicitly: - -``` -graph_query( - "MATCH (s:Session {workspace: $workspace}) RETURN s.node_id", - workspace="project-alpha" -) -``` - -Cross-workspace query (wildcard — no `$workspace` injected): - -``` -graph_query( - "MATCH (s:Session) " - "RETURN s.workspace AS ws, count(s) AS session_count " - "ORDER BY session_count DESC", - workspace="*" -) -``` - -> **Note:** `graph_query` operates on the **persisted (flushed) store only**. -> In-memory buffered writes are not visible to Cypher queries until the store -> has been flushed. Use `get_node()` / `get_edge()` for buffer-aware reads. - ---- - -## ID Format Reference - -### Session nodes - -Session `node_id` is the raw UUID from the Amplifier session. No -transformation is applied — the UUID is used directly: - -``` -55c8841a-1234-4abc-8def-000000000001 -``` - -### All other nodes - -Non-session nodes follow the pattern `{session_id}__{event_name}__{epoch_ms}`, -using `__` (double underscore) as the separator: - -``` -55c8841a-1234-4abc-8def-000000000001__prompt_submit__1737972001000 -55c8841a-1234-4abc-8def-000000000001__tool_pre__1737972005000 -55c8841a-1234-4abc-8def-000000000001__execution_start__1737972000000 -``` - -Parsing the ID: - -```python -# Split on double underscore separator -parts = node_id.split("__") -# parts[0] = session_id UUID -# parts[1] = event_name (colons replaced with underscores) -# parts[2] = epoch_ms as string -``` - -### ToolCall nodes - -`ToolCall` node IDs use a three-segment format. Unlike `Event` nodes, there is -no epoch_ms timestamp — the `tool_call_id` is the third segment: - -``` -55c8841a-1234-4abc-8def-000000000001__tool_call__call_abc123 -``` - -Parsing the ID: - -```python -# Split on double underscore separator -parts = node_id.split("__") -# parts[0] = session_id UUID -# parts[1] = "tool_call" (literal) -# parts[2] = tool_call_id (provider-assigned correlation ID) -``` - -### Relationship identity - -Relationships have no stored ID property. Identity is composite: -`(source.node_id, target.node_id, type(r))`. To locate a specific -relationship, match by endpoint `node_id` values and relationship type. - ---- - -## Critical Gotchas - -### 1. `metadata` is a JSON string, not a map - -Node `metadata` properties are stored as JSON-encoded strings. You cannot -filter on nested fields directly in Cypher. Parse them in application code -after retrieving: - -```cypher -// Correct — retrieve and parse in code -MATCH (s:Session {workspace: $workspace}) -RETURN s.node_id, s.metadata -``` - -Do **not** attempt `s.metadata.some_key` — Cypher will return `null`. - -### 2. Silently dropped events - -Events written during the same millisecond with identical `node_id` values -are silently deduplicated on `MERGE`. If two events share `session_id`, -`event_name`, and `timestamp_ms`, only the first is stored. Use -`tool_call_id` (present on `ToolCall` nodes) to disambiguate parallel -tool calls. - -### 3. No ordering guarantee on HAS_EVENT edges - -`HAS_EVENT` edges carry no sequence number. When retrieving events for a session, -always use `ORDER BY e.occurred_at` to get chronological order: - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_EVENT]->(e:Event) -RETURN e.node_id, e.event_name, e.occurred_at -ORDER BY e.occurred_at ASC -``` - -### 4. Workspace scoping is manual - -`graph_query` injects `$workspace` automatically, but only if you reference -`$workspace` in your query. Omitting the filter from a MATCH clause silently -returns data from **all** workspaces. Always include `{workspace: $workspace}` -on the anchor node of every query. - -### 5. `HAS_EVENT` attaches directly to Session in DL1 - -All `HAS_EVENT` edges go directly from `Session` to `Event` — there is no -intermediate run-level node. `ToolCall` nodes also carry `HAS_EVENT` edges -to events scoped to that tool call. There is no run-level event routing in DL1. - -### 6. Node `MERGE` key is `{node_id, workspace}` - -All nodes are upserted using `MERGE (n {node_id: $node_id, workspace: $workspace})`. -Querying by `node_id` alone (without `workspace`) may match nodes from -other workspaces in a shared database. Always include `workspace` in -identity lookups. - ---- - -## Notes - -### Properties vs labels - -Labels are separate from properties. You can filter on both: - -```cypher -// Filter by label AND property -MATCH (s:RootSession {workspace: $workspace}) -RETURN s.node_id - -// Filter by property only (scans more nodes) -MATCH (n {workspace: $workspace}) -WHERE 'RootSession' IN labels(n) -RETURN n.node_id -``` - -Prefer label-based filters — they use index-backed label scans and are faster -than property-only filters. - -### Multi-label nodes - -Nodes carry both a base label and a sub-type label. Both can be used in MATCH: - -```cypher -// Matches any Session regardless of subtype -MATCH (s:Session {workspace: $workspace}) ... - -// Matches only root sessions (both labels present) -MATCH (s:Session:RootSession {workspace: $workspace}) ... - -// Equivalent WHERE form -MATCH (s:Session {workspace: $workspace}) -WHERE s:RootSession ... -``` - -### Workspace property on relationships - -Relationships also carry `workspace`. For cross-workspace queries where -you traverse relationships, add a relationship filter if needed: - -```cypher -// workspace="*" -MATCH (s:Session)-[r:HAS_FORK]->(child:Session) -WHERE r.workspace = $target_workspace -RETURN s.node_id, child.node_id -``` - -### Buffer visibility - -`graph_query` runs against the **persisted state only**. Nodes and -relationships buffered via `upsert_node`/`upsert_edge` but not yet flushed -will **not** appear in Cypher query results. Always flush before running -analysis queries when you need up-to-date results. - ---- - -## Foundational Traversal Primitive - -Data Layer 1 exposes three relationship types from a `Session` node. Use -`OPTIONAL MATCH` to combine all three in a single query: - -```cypher -MATCH (root:Session {node_id: $session_id, workspace: $workspace}) -OPTIONAL MATCH (root)-[:HAS_EVENT]->(e:Event) -OPTIONAL MATCH (root)-[:HAS_TOOL_CALL]->(tc:ToolCall) -OPTIONAL MATCH (root)-[:HAS_FORK*1..]->(child:Session) -RETURN - count(DISTINCT e) AS event_count, - count(DISTINCT tc) AS tool_call_count, - count(DISTINCT child) AS child_session_count -``` - -For deep delegation tree traversal (all descendant sessions, capped at 20 hops): - -```cypher -MATCH (root:Session {node_id: $session_id, workspace: $workspace}) - -[:HAS_FORK*1..20]->(descendant:Session) -RETURN descendant.node_id AS session_id, - labels(descendant) AS labels -ORDER BY descendant.started_at -``` - -**Note:** `parallel_group_id` is an empty string `""` (not null) when a tool -runs alone. Use `tc.parallel_group_id <> ""` to isolate parallel groups — not -`IS NOT NULL`. - ---- - -## Time-Activity Queries - -> All queries below use **Data Layer 1** constructs only: `Session:RootSession`, -> `HAS_EVENT`, `ExecutionStartEvent`, and `ExecutionEndEvent`. -> See [Data Layer 2 Warning](#data-layer-2-warning) for labels and relationship -> types that have no edges in the live graph and will return zero results. - -**Why `started_at <= T`:** For a session to be active at instant T, it must -have started at or before T and not yet ended. - -### 1. Session-Level: Active Sessions at a Point in Time - -Root sessions that were active at a specific instant. Uses `started_at` and -`ended_at` properties on the `Session` node (populated by `session:start` and -`session:end` events). - -```cypher -MATCH (s:Session:RootSession {workspace: $workspace}) -WHERE s.started_at <= $point_in_time - AND (s.ended_at IS NULL OR s.ended_at >= $point_in_time) -RETURN s.node_id AS root_session_id, - s.started_at AS root_started, - s.ended_at AS root_ended -ORDER BY s.started_at DESC -``` - -### 2. Session-Level: Sessions in a Time Range - -Root sessions that started within a time window [t1, t2]: - -```cypher -MATCH (s:Session:RootSession {workspace: $workspace}) -WHERE s.started_at >= $t1 AND s.started_at <= $t2 -RETURN s.node_id AS root_session_id, - s.started_at AS root_started, - s.ended_at AS root_ended -ORDER BY s.started_at DESC -``` - -### 3. Turn-Level: Execution Brackets Within a Session - -Each user turn produces an `ExecutionStartEvent` and (when complete) an -`ExecutionEndEvent`. Use these to find turn boundaries within a specific -session: - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_EVENT]->(start:ExecutionStartEvent) -OPTIONAL MATCH (s)-[:HAS_EVENT]->(end:ExecutionEndEvent) -WHERE end.occurred_at > start.occurred_at -WITH start, min(end.occurred_at) AS turn_ended -RETURN start.node_id AS bracket_id, - start.occurred_at AS turn_started, - turn_ended, - duration.between(datetime(start.occurred_at), datetime(turn_ended)) AS duration -ORDER BY start.occurred_at -``` - -### 4. Sessions with Any Turn in a Time Window - -Find root sessions that had at least one execution turn start within [t1, t2]: - -```cypher -MATCH (s:Session:RootSession {workspace: $workspace})-[:HAS_EVENT]->(e:ExecutionStartEvent) -WHERE e.occurred_at >= $t1 AND e.occurred_at <= $t2 -RETURN DISTINCT - s.node_id AS root_session_id, - s.started_at AS root_started, - count(e) AS turns_in_window -ORDER BY root_started DESC -``` - ---- - -## Recipe Analytics - -> **DL1 Note:** In Data Layer 1, recipe data is captured as `RecipeLoopIterationEvent` -> and `RecipeLoopCompleteEvent` nodes. There is no dedicated recipe wrapper node. - -**1. Sessions That Ran a Recipe** (via `RecipeLoopIterationEvent`): - -```cypher -MATCH (s:Session {workspace: $workspace})-[:HAS_EVENT]->(e:RecipeLoopIterationEvent) -RETURN DISTINCT s.node_id AS session_id, s.started_at, - e.recipe_name -ORDER BY s.started_at DESC -``` - -**2. Recipe Progress for a Session** (`recipe_name`, `step_id`, `iteration`, `occurred_at`): - -```cypher -MATCH (s:Session {node_id: $session_id, workspace: $workspace}) - -[:HAS_EVENT]->(e:RecipeLoopIterationEvent) -RETURN e.recipe_name, e.step_id, e.iteration, e.occurred_at -ORDER BY e.occurred_at -``` - -**3. Recipe Completion Events** (via `RecipeLoopCompleteEvent`): - -```cypher -MATCH (s:Session {workspace: $workspace})-[:HAS_EVENT]->(e:RecipeLoopCompleteEvent) -RETURN s.node_id AS session_id, - e.recipe_name, - e.occurred_at AS completed_at, - e.status -ORDER BY e.occurred_at DESC -``` - -**4. Recipe Duration** (start to complete, joining iteration and complete events): - -```cypher -MATCH (s:Session {node_id: $session_id, workspace: $workspace}) - -[:HAS_EVENT]->(iter:RecipeLoopIterationEvent) -MATCH (s)-[:HAS_EVENT]->(done:RecipeLoopCompleteEvent) -WHERE iter.recipe_name = done.recipe_name -RETURN iter.recipe_name, - min(iter.occurred_at) AS recipe_started, - done.occurred_at AS recipe_completed -``` - -> **Note:** Cypher implicitly groups by non-aggregated columns — no explicit -> `GROUP BY` needed. If `occurred_at` is stored as a Neo4j `datetime` type, -> you can wrap both values in `duration.between()` to compute elapsed time. - -**5. Loop Iteration Count per Recipe** (count and max iteration reached, grouped by recipe + step): - -```cypher -MATCH (s:Session {workspace: $workspace})-[:HAS_EVENT]->(e:RecipeLoopIterationEvent) -RETURN e.recipe_name, - e.step_id, - count(e) AS total_iterations, - max(e.iteration) AS max_iteration_reached -ORDER BY total_iterations DESC -``` - ---- - -## Parallelism Degree - -When the orchestrator fires multiple tool calls at once, each concurrent call -shares the same `parallel_group_id` (a UUID string). Tool calls that run alone -get `parallel_group_id = ""` (empty string — **never null**). Always filter -with `<> ""`, never with `IS NOT NULL`. - -**1. Parallel groups for a session — via ToolCall (structured path):** - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_TOOL_CALL]->(tc:ToolCall) -WHERE tc.parallel_group_id <> "" -RETURN tc.parallel_group_id, - collect(tc.tool_name) AS tools, - count(tc) AS parallel_degree -ORDER BY parallel_degree DESC -``` - -**2. Parallel groups for a session — via ToolPreEvent (flexible path, includes tool_input):** - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_EVENT]->(e:ToolPreEvent) -WHERE e.parallel_group_id <> "" -RETURN e.parallel_group_id, - collect(e.tool_name) AS tools, - collect(e.tool_input) AS tool_inputs, - count(e) AS parallel_degree -ORDER BY parallel_degree DESC -``` - -**3. Peak parallelism across workspace — via Session:RootSession and HAS_TOOL_CALL:** - -```cypher -MATCH (s:Session:RootSession {workspace: $workspace})-[:HAS_TOOL_CALL]->(tc:ToolCall) -WHERE tc.parallel_group_id <> "" -WITH s.node_id AS session_id, tc.parallel_group_id AS grp, count(tc) AS grp_size -RETURN session_id, - max(grp_size) AS peak_parallelism, - count(DISTINCT grp) AS parallel_groups -ORDER BY peak_parallelism DESC LIMIT 20 -``` - -**4. Delegation parallelism — parallel agent spawns via DelegateAgentSpawnedEvent:** - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_EVENT]->(e:DelegateAgentSpawnedEvent) -WHERE e.parallel_group_id <> "" -RETURN e.parallel_group_id, - collect(e.agent) AS agents, - collect(e.sub_session_id) AS sub_sessions, - count(e) AS parallel_degree -ORDER BY parallel_degree DESC -``` - ---- - -## Token Efficiency - -> **In Data Layer 1, token data lives on event nodes.** `LlmResponseEvent` nodes carry -> `model` and `provider` (via `LlmLifter`). Token counts may be at top level or in the -> `data` blob — run the discovery queries below to confirm what is available in your graph. - ---- - -### 1. Discovery: What Token Properties Exist - -Run these two queries first to confirm which properties are present on your graph before -writing any aggregation queries. - -**OrchestratorCompleteEvent properties:** - -```cypher -MATCH (e:OrchestratorCompleteEvent {workspace: $workspace}) -RETURN keys(e) AS properties -LIMIT 3 -``` - -**LlmResponseEvent properties:** - -```cypher -MATCH (e:LlmResponseEvent {workspace: $workspace}) -RETURN keys(e) AS properties -LIMIT 3 -``` - -> **Confirmed property names** (from FieldLifter documentation and live graph): -> - `OrchestratorCompleteEvent`: `total_input_tokens`, `total_output_tokens`, `turn_count` -> - `LlmResponseEvent`: `model`, `provider` (lifted by `LlmLifter`); token counts may be in -> the `data` blob — use `blob_read` + `jq` to extract them (see note at end of section). - ---- - -### 2. Session-Level Token Summary - -`OrchestratorCompleteEvent` fires once per session turn and carries cumulative token totals. -Use `Session` → `HAS_EVENT` → `OrchestratorCompleteEvent` to retrieve them. - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_EVENT]->(e:OrchestratorCompleteEvent) -RETURN e.total_input_tokens AS total_input_tokens, - e.total_output_tokens AS total_output_tokens, - e.turn_count AS turn_count, - e.occurred_at AS occurred_at -ORDER BY e.occurred_at -``` - ---- - -### 3. Per-Model Usage in a Session - -`LlmResponseEvent` nodes carry `model` and `provider` (promoted by `LlmLifter`). Group by -both columns to break down LLM call counts per model within a session. - -```cypher -MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[:HAS_EVENT]->(e:LlmResponseEvent) -RETURN e.model AS model, - e.provider AS provider, - count(e) AS llm_calls -ORDER BY llm_calls DESC -``` - ---- - -### 4. Model Distribution Across Workspace - -Same pattern as above but without the `node_id` filter — returns model usage across all -sessions in the workspace. - -```cypher -MATCH (s:Session {workspace: $workspace})-[:HAS_EVENT]->(e:LlmResponseEvent) -RETURN e.model AS model, - e.provider AS provider, - count(e) AS llm_calls -ORDER BY llm_calls DESC -``` - -> **Extracting token counts from the data blob:** If `total_input_tokens` / -> `total_output_tokens` are null on `OrchestratorCompleteEvent` nodes, the raw values are -> stored in the `data` blob. Use `blob_read` to resolve the `ci-blob://` URI on the `data` -> property, then use `jq` to extract the token fields: -> -> ``` -> # 1. Get the data blob URI -> graph_query("MATCH (s:Session {workspace: $workspace, node_id: $session_id}) -> -[:HAS_EVENT]->(e:OrchestratorCompleteEvent) -> RETURN e.data LIMIT 1") -> -> # 2. Resolve and inspect with jq -> blob_read("ci-blob://...") # returns local file path -> bash("jq '.total_input_tokens, .total_output_tokens' /path/to/blob") -> ``` - diff --git a/modules/hook-context-intelligence/tests/helpers.py b/modules/hook-context-intelligence/tests/helpers.py index ccce71e..706ef7a 100644 --- a/modules/hook-context-intelligence/tests/helpers.py +++ b/modules/hook-context-intelligence/tests/helpers.py @@ -1,22 +1,115 @@ """Shared lifecycle test helpers for hook-context-intelligence tests. These helpers eliminate duplication across test files that exercise the -two-phase mount() + on_session_ready() lifecycle. +two-phase mount() + on_session_ready() lifecycle, and provide composition-aware +helpers for validating bundle YAML structure across the layered behaviour split. Usage:: from tests.helpers import make_lifecycle_coordinator, mount_and_ready - -The ``config_resolver``-focused tests in ``test_config_resolver.py`` and the -skill-fetcher-specific tests in ``test_skill_fetcher_mount.py`` use different -coordinator shapes and should keep their own local helpers. + from tests.helpers import composed_behavior, ci_hook + +The ``config_resolver``-focused tests in ``test_config_resolver.py`` use a +different coordinator shape and keep their own local helpers. + +Known false positive (validator) +--------------------------------- +The bundle-validator's ``unadvertised_but_referenced`` heuristic flags +``context-intelligence`` as an unadvertised mode because the string appears in +disk paths (``…/sessions/{id}/context-intelligence/``), @mention prefixes +(``@context-intelligence:context/…``), and skill names +(``context-intelligence-graph-query``). All occurrences are paths / bundle +names / skill names — NOT slash-command invocations of the +``modes/context-intelligence.md`` design mode. The mode is correctly +``advertised: false`` (it is an internal design mode). Do NOT flip it to +``true`` and do NOT remove those path/skill references. This is a validator +limitation, not a bundle defect. """ from __future__ import annotations +from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock +import yaml + +# Repo root: modules/hook-context-intelligence/tests/helpers.py → 4 levels up +REPO_ROOT = Path(__file__).parent.parent.parent.parent + + +# --------------------------------------------------------------------------- +# Composition-aware behaviour helpers +# --------------------------------------------------------------------------- + + +def composed_behavior(start: str = "context-intelligence") -> dict: + """Resolve the umbrella behaviour's local includes chain and return a composed view. + + Starts at ``REPO_ROOT/behaviors/.yaml`` and recursively resolves + every ``includes:`` entry whose ``bundle:`` value starts with + ``"context-intelligence:behaviors/"``, mapping it to + ``REPO_ROOT/behaviors/.yaml`` (stem = part after the last ``/``). + External refs (``git+https://…``) are silently skipped. + Cycles are guarded via a visited-paths set. + + Returns a composed dict ``{"hooks": [...], "tools": [...]}`` that is the + union of every composed layer's ``hooks`` and ``tools`` lists (including + the umbrella's own, even if empty). + """ + result: dict[str, list] = {"hooks": [], "tools": []} + visited: set[Path] = set() + + def _resolve(stem: str) -> None: + path = REPO_ROOT / "behaviors" / f"{stem}.yaml" + if path in visited: + return + visited.add(path) + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return + result["hooks"].extend(data.get("hooks") or []) + result["tools"].extend(data.get("tools") or []) + for entry in data.get("includes") or []: + if not isinstance(entry, dict): + continue + bundle_ref = entry.get("bundle", "") + if bundle_ref.startswith("context-intelligence:behaviors/"): + # e.g. "context-intelligence:behaviors/context-intelligence-logging" + local_stem = bundle_ref.rsplit("/", 1)[-1] + _resolve(local_stem) + + _resolve(start) + return result + + +def ci_hook(composed: dict) -> dict: + """Return the ``hook-context-intelligence`` spec from a composed behaviour view. + + Enforces exactly-one semantics — fails loud on zero *and* on >1 matches so + that duplicate declarations are caught as early as bad wiring. + + Args: + composed: The dict returned by :func:`composed_behavior`. + + Returns: + The single hook entry whose ``module`` is ``"hook-context-intelligence"``. + + Raises: + AssertionError: If zero or more than one match is found. + """ + matches = [ + h for h in composed.get("hooks", []) if h.get("module") == "hook-context-intelligence" + ] + assert matches, ( + "no composed behaviour wires hook-context-intelligence (umbrella -> includes chain broken?)" + ) + assert len(matches) == 1, ( + f"hook-context-intelligence declared in multiple composed layers " + f"({len(matches)} occurrences)" + ) + return matches[0] + def make_lifecycle_coordinator( contributed_events: list[list[str]] | None = None, diff --git a/modules/hook-context-intelligence/tests/test_bundle.py b/modules/hook-context-intelligence/tests/test_bundle.py index 5556443..9047087 100644 --- a/modules/hook-context-intelligence/tests/test_bundle.py +++ b/modules/hook-context-intelligence/tests/test_bundle.py @@ -3,27 +3,31 @@ from pathlib import Path import yaml +from tests.helpers import ci_hook, composed_behavior REPO_ROOT = Path(__file__).parent.parent.parent.parent MODULE_ROOT = Path(__file__).parent.parent def _load_behavior() -> dict: - """Load and parse the behavior YAML file.""" - path = REPO_ROOT / "behaviors" / "context-intelligence.yaml" - return yaml.safe_load(path.read_text()) + """Return the composed behaviour view (umbrella + all local includes). + + The umbrella ``context-intelligence.yaml`` is now a thin includes-only + file — it has no ``hooks:`` of its own. The CI hook lives in + ``context-intelligence-logging.yaml``, reached via the includes chain. + This helper resolves that chain so callers see the full composed view. + """ + return composed_behavior() def _ci_hook(data: dict) -> dict: - """Return the hook-context-intelligence spec, located by module name. + """Return the hook-context-intelligence spec from the composed view. - The behavior may wire multiple hooks (e.g. hooks-mode for mode discovery), - so this must not assume a fixed position in the hooks list. + Delegates to the shared ``ci_hook`` helper which enforces exactly-one + semantics: fails loud on zero matches (chain broken) and on >1 matches + (duplicate declaration). """ - hooks = data.get("hooks", []) - matches = [h for h in hooks if h.get("module") == "hook-context-intelligence"] - assert matches, "behavior must wire the hook-context-intelligence hook" - return matches[0] + return ci_hook(data) class TestBundleRoot: @@ -84,8 +88,12 @@ def test_behavior_yaml_exists(self): assert (REPO_ROOT / "behaviors" / "context-intelligence.yaml").is_file() def test_behavior_has_hooks_section(self): + # The umbrella context-intelligence.yaml itself has no hooks: — the + # composed view must have at least one (the CI hook, via the logging layer). data = _load_behavior() - assert "hooks" in data, "Behavior YAML must have a hooks: section" + assert data.get("hooks"), ( + "Composed behaviour must wire at least one hook (includes chain broken?)" + ) def test_behavior_hook_module_name(self): data = _load_behavior() @@ -128,3 +136,13 @@ def test_no_graph_store_in_config(self): assert "enable_graph" not in config, ( "enable_graph must be removed from thin-forwarder config" ) + + def test_umbrella_composes_ci_hook(self): + """Regression: the CI hook must be reachable from the umbrella via the includes chain. + + Goes red if someone removes the logging-behaviour include from + context-intelligence.yaml — even if context-intelligence-logging.yaml + still exists on disk with the hook wired inside it. + """ + hook = ci_hook(composed_behavior()) + assert hook["module"] == "hook-context-intelligence" diff --git a/modules/hook-context-intelligence/tests/test_integration_mount.py b/modules/hook-context-intelligence/tests/test_integration_mount.py index f20a847..0251842 100644 --- a/modules/hook-context-intelligence/tests/test_integration_mount.py +++ b/modules/hook-context-intelligence/tests/test_integration_mount.py @@ -50,7 +50,6 @@ async def test_session_lifecycle_writes_files(self, tmp_path: Path) -> None: await on_session_ready(coordinator) # Extract LoggingHandler from registrations by name (canonical identifier). - # Priority alone is not unique — SkillFetcher also uses priority=100. # register() positional args: (event, handler) — index [1] is the handler callable. handler = None for call in coordinator.hooks.register.call_args_list: diff --git a/modules/hook-context-intelligence/tests/test_module_loading.py b/modules/hook-context-intelligence/tests/test_module_loading.py index 18619a8..a678f32 100644 --- a/modules/hook-context-intelligence/tests/test_module_loading.py +++ b/modules/hook-context-intelligence/tests/test_module_loading.py @@ -3,7 +3,7 @@ import importlib.metadata from pathlib import Path -import yaml +from tests.helpers import ci_hook, composed_behavior REPO_ROOT = Path(__file__).parent.parent.parent.parent MODULE_ROOT = Path(__file__).parent.parent @@ -62,12 +62,13 @@ def test_on_session_ready_exists_and_is_valid(self): class TestBundleYamlEntryPointConsistency: def _load_behavior_yaml(self) -> dict: - path = REPO_ROOT / "behaviors" / "context-intelligence.yaml" - return yaml.safe_load(path.read_text()) + # The umbrella context-intelligence.yaml is now a thin includes-only file. + # Resolve the full composition so hooks from all layers are visible. + return composed_behavior() def test_behavior_yaml_module_matches_entry_point(self): data = self._load_behavior_yaml() - # Located by module name, not position: the behavior also wires hooks-mode. + # Located by module name across all composed layers. hook_modules = [h["module"] for h in data.get("hooks", [])] assert "hook-context-intelligence" in hook_modules @@ -134,20 +135,18 @@ class TestBehaviorYamlConfigShape: """Validate the behavior YAML has the expected thin-forwarder config shape.""" def _load_behavior_yaml(self) -> dict: - path = REPO_ROOT / "behaviors" / "context-intelligence.yaml" - return yaml.safe_load(path.read_text()) + # The umbrella context-intelligence.yaml is now a thin includes-only file. + # Resolve the full composition so hooks from all layers are visible. + return composed_behavior() def _ci_hook(self, data: dict) -> dict: - """Return the hook-context-intelligence spec, located by module name. + """Return the hook-context-intelligence spec from the composed view. - The behavior may wire multiple hooks (e.g. hooks-mode for mode discovery), - so this must not assume a fixed position in the hooks list. + Delegates to the shared ci_hook helper which enforces exactly-one + semantics: fails loud on zero matches (chain broken) and on >1 matches + (duplicate declaration). """ - matches = [ - h for h in data.get("hooks", []) if h.get("module") == "hook-context-intelligence" - ] - assert matches, "behavior must wire the hook-context-intelligence hook" - return matches[0] + return ci_hook(data) def test_yaml_parses_correctly(self): """YAML must parse without errors via yaml.safe_load.""" diff --git a/modules/hook-context-intelligence/tests/test_resolve_skill_path.py b/modules/hook-context-intelligence/tests/test_resolve_skill_path.py deleted file mode 100644 index 6d9a30b..0000000 --- a/modules/hook-context-intelligence/tests/test_resolve_skill_path.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Tests for _resolve_skill_path and _refresh_watched_skills helpers.""" - -from __future__ import annotations - -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - - -class TestResolveSkillPath: - """_resolve_skill_path uses skills_discovery first, then _BUNDLE_ROOT fallback.""" - - def test_prefers_skills_discovery(self, tmp_path: Path) -> None: - """When skills_discovery capability is available, return metadata.path.""" - from amplifier_module_hook_context_intelligence import _resolve_skill_path # type: ignore[attr-defined] - - skill_path = tmp_path / "context-intelligence-graph-query" / "SKILL.md" - - # Build coordinator mock with skills_discovery capability - metadata = MagicMock() - metadata.path = skill_path - discovery = MagicMock() - discovery.find = MagicMock(return_value=metadata) - - coordinator = MagicMock() - coordinator.get_capability = MagicMock(return_value=discovery) - - result = _resolve_skill_path("context-intelligence-graph-query", coordinator) - - assert result == skill_path - coordinator.get_capability.assert_called_once_with("skills_discovery") - discovery.find.assert_called_once_with("context-intelligence-graph-query") - - def test_fallback_to_bundle_root(self, tmp_path: Path) -> None: - """When skills_discovery is unavailable, fall back to _BUNDLE_ROOT/skills/.""" - import amplifier_module_hook_context_intelligence as mod - from amplifier_module_hook_context_intelligence import _resolve_skill_path # type: ignore[attr-defined] - - skill_name = "context-intelligence-graph-query" - skill_dir = tmp_path / "skills" / skill_name - skill_dir.mkdir(parents=True) - - coordinator = MagicMock() - coordinator.get_capability = MagicMock(return_value=None) - - with patch.object(mod, "_BUNDLE_ROOT", tmp_path): - result = _resolve_skill_path(skill_name, coordinator) - - assert result == tmp_path / "skills" / skill_name / "SKILL.md" - - def test_returns_none_when_parent_missing(self, tmp_path: Path) -> None: - """Returns None when _BUNDLE_ROOT doesn't contain the expected skills directory.""" - import amplifier_module_hook_context_intelligence as mod - from amplifier_module_hook_context_intelligence import _resolve_skill_path # type: ignore[attr-defined] - - nonexistent = tmp_path / "does_not_exist" - - coordinator = MagicMock() - coordinator.get_capability = MagicMock(return_value=None) - - with patch.object(mod, "_BUNDLE_ROOT", nonexistent): - result = _resolve_skill_path("context-intelligence-graph-query", coordinator) - - assert result is None - - -class TestRefreshWatchedSkills: - """_refresh_watched_skills routes to write_legacy_content or fetch based on skills_capable.""" - - async def test_branch_b_legacy(self, tmp_path: Path) -> None: - """Branch B: skills_capable=False calls write_legacy_content, not fetch.""" - from amplifier_module_hook_context_intelligence import _refresh_watched_skills # type: ignore[attr-defined] - - skill_path = tmp_path / "context-intelligence-graph-query" / "SKILL.md" - - # Build coordinator mock with skills_discovery capability returning metadata with skill_path - metadata = MagicMock() - metadata.path = skill_path - discovery = MagicMock() - discovery.find = MagicMock(return_value=metadata) - - coordinator = MagicMock() - coordinator.get_capability = MagicMock(return_value=discovery) - - # Build fetcher mock - fetcher = MagicMock() - fetcher.fetch = AsyncMock() - - await _refresh_watched_skills(coordinator, fetcher, skills_capable=False) - - fetcher.write_legacy_content.assert_called_once_with( - "context-intelligence-graph-query", skill_path - ) - fetcher.fetch.assert_not_called() - - async def test_branch_c_fetch(self, tmp_path: Path) -> None: - """Branch C: skills_capable=True calls fetch, not write_legacy_content.""" - from amplifier_module_hook_context_intelligence import _refresh_watched_skills # type: ignore[attr-defined] - - skill_path = tmp_path / "context-intelligence-graph-query" / "SKILL.md" - - # Build coordinator mock with skills_discovery capability returning metadata with skill_path - metadata = MagicMock() - metadata.path = skill_path - discovery = MagicMock() - discovery.find = MagicMock(return_value=metadata) - - coordinator = MagicMock() - coordinator.get_capability = MagicMock(return_value=discovery) - - # Build fetcher mock — fetch returns True via AsyncMock - fetcher = MagicMock() - fetcher.fetch = AsyncMock(return_value=True) - - await _refresh_watched_skills(coordinator, fetcher, skills_capable=True) - - fetcher.fetch.assert_called_once_with("context-intelligence-graph-query", skill_path) - fetcher.write_legacy_content.assert_not_called() - - async def test_skips_when_path_none(self, tmp_path: Path) -> None: - """When skill_path resolves to None, neither fetch nor write_legacy_content is called.""" - import amplifier_module_hook_context_intelligence as mod - from amplifier_module_hook_context_intelligence import _refresh_watched_skills # type: ignore[attr-defined] - - # skills_discovery not available (get_capability returns None) - coordinator = MagicMock() - coordinator.get_capability = MagicMock(return_value=None) - - # Build fetcher mock - fetcher = MagicMock() - fetcher.fetch = AsyncMock() - - nonexistent = tmp_path / "does_not_exist" - - with patch.object(mod, "_BUNDLE_ROOT", nonexistent): - await _refresh_watched_skills(coordinator, fetcher, skills_capable=True) - - fetcher.fetch.assert_not_called() - fetcher.write_legacy_content.assert_not_called() diff --git a/modules/hook-context-intelligence/tests/test_skill_fetcher.py b/modules/hook-context-intelligence/tests/test_skill_fetcher.py deleted file mode 100644 index eb8771c..0000000 --- a/modules/hook-context-intelligence/tests/test_skill_fetcher.py +++ /dev/null @@ -1,520 +0,0 @@ -"""Tests for SkillFetcher — conditional HTTP GET with ETag sidecar.""" - -from __future__ import annotations - -import hashlib -import logging -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - -import httpx -import pytest - - -class TestConstants: - """Module constants have the correct values.""" - - def test_tool_skills_discovery_capability_value(self) -> None: - from amplifier_module_hook_context_intelligence.skill_fetcher import ( - TOOL_SKILLS_DISCOVERY_CAPABILITY, - ) - - # Must match exactly what tool-skills registers: - # coordinator.register_capability("skills_discovery", SkillsDiscovery(...)) - assert TOOL_SKILLS_DISCOVERY_CAPABILITY == "skills_discovery" - - def test_watched_skills_contains_only_graph_query(self) -> None: - from amplifier_module_hook_context_intelligence.skill_fetcher import WATCHED_SKILLS - - assert WATCHED_SKILLS == frozenset({"context-intelligence-graph-query"}) - - -def _make_http_mock(status_code: int, text: str, etag: str) -> MagicMock: - """Build a patch-ready mock for httpx.AsyncClient as async context manager.""" - response = MagicMock() - response.status_code = status_code - response.text = text - response.headers = {"etag": etag} if etag else {} - - client = AsyncMock() - client.get = AsyncMock(return_value=response) - client.__aenter__ = AsyncMock(return_value=client) - client.__aexit__ = AsyncMock(return_value=None) - - return MagicMock(return_value=client) - - -def _make_error_mock(exc: Exception) -> MagicMock: - """Build a patch-ready mock for httpx.AsyncClient that raises exc on get().""" - client = AsyncMock() - client.get = AsyncMock(side_effect=exc) - client.__aenter__ = AsyncMock(return_value=client) - client.__aexit__ = AsyncMock(return_value=None) - - return MagicMock(return_value=client) - - -def _make_version_http_mock(status_code: int, body: dict) -> MagicMock: - """Build a patch-ready mock for httpx.AsyncClient returning a JSON response. - - Unlike _make_http_mock, this mock intentionally omits __aenter__/__aexit__ - because check_server_version() calls AsyncClient().get() directly rather - than via ``async with``. The mock matches the production call pattern exactly. - """ - response = MagicMock() - response.status_code = status_code - response.json = MagicMock(return_value=body) - - client = AsyncMock() - client.get = AsyncMock(return_value=response) - - return MagicMock(return_value=client) - - -class TestSkillFetcher200: - """SkillFetcher returns True and writes files on 200 response.""" - - async def test_returns_true_on_200(self, tmp_path: Path) -> None: - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "SKILL.md" - fetcher = SkillFetcher("http://localhost:8000") - - with patch( - "httpx.AsyncClient", - _make_http_mock(200, "skill content", 'W/"abc123"'), - ): - result = await fetcher.fetch("my-skill", skill_path) - - assert result is True - - async def test_writes_content_to_skill_path(self, tmp_path: Path) -> None: - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "SKILL.md" - fetcher = SkillFetcher("http://localhost:8000") - - with patch( - "httpx.AsyncClient", - _make_http_mock(200, "skill content here", 'W/"abc123"'), - ): - await fetcher.fetch("my-skill", skill_path) - - assert skill_path.read_text() == "skill content here" - - async def test_writes_etag_sidecar(self, tmp_path: Path) -> None: - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "SKILL.md" - fetcher = SkillFetcher("http://localhost:8000") - - with patch( - "httpx.AsyncClient", - _make_http_mock(200, "skill content", 'W/"etag-value"'), - ): - await fetcher.fetch("my-skill", skill_path) - - etag_path = tmp_path / ".etag" - assert etag_path.exists() - assert etag_path.read_text() == 'W/"etag-value"' - - -class TestSkillFetcher304: - """SkillFetcher returns False and does not overwrite files on 304 response.""" - - async def test_returns_false_on_304(self, tmp_path: Path) -> None: - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "SKILL.md" - skill_path.write_text("# Existing Content") - etag_path = tmp_path / ".etag" - etag_path.write_text('W/"abc123"') - fetcher = SkillFetcher("http://localhost:8000") - - with patch( - "httpx.AsyncClient", - _make_http_mock(304, "", ""), - ): - result = await fetcher.fetch("my-skill", skill_path) - - assert result is False - - async def test_does_not_overwrite_skill_on_304(self, tmp_path: Path) -> None: - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "SKILL.md" - skill_path.write_text("# Existing Content") - etag_path = tmp_path / ".etag" - etag_path.write_text('W/"abc123"') - fetcher = SkillFetcher("http://localhost:8000") - - with patch( - "httpx.AsyncClient", - _make_http_mock(304, "", ""), - ): - await fetcher.fetch("my-skill", skill_path) - - assert skill_path.read_text() == "# Existing Content" - - -class TestSkillFetcherUnexpectedStatus: - """SkillFetcher returns False and logs a warning on unexpected HTTP status codes.""" - - async def test_returns_false_on_404(self, tmp_path: Path) -> None: - """fetch() returns False and logs a warning when the server returns 404.""" - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "SKILL.md" - fetcher = SkillFetcher("http://localhost:8000") - - with patch( - "httpx.AsyncClient", - _make_http_mock(404, "not found", ""), - ): - result = await fetcher.fetch("my-skill", skill_path) - - assert result is False - assert not skill_path.exists() - - async def test_logs_warning_on_unexpected_status( - self, tmp_path: Path, caplog: pytest.LogCaptureFixture - ) -> None: - """fetch() emits a skill_fetch_failed warning for any non-200/304 status.""" - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "SKILL.md" - fetcher = SkillFetcher("http://localhost:8000") - - with caplog.at_level(logging.WARNING): - with patch( - "httpx.AsyncClient", - _make_http_mock(500, "server error", ""), - ): - await fetcher.fetch("my-skill", skill_path) - - assert any("skill_fetch_failed" in record.getMessage() for record in caplog.records) - - -class TestSkillFetcherErrors: - """SkillFetcher returns False on connection errors and timeouts.""" - - async def test_returns_false_on_connect_error(self, tmp_path: Path) -> None: - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "SKILL.md" - fetcher = SkillFetcher("http://localhost:8000") - - with patch( - "httpx.AsyncClient", - _make_error_mock(httpx.ConnectError("refused")), - ): - result = await fetcher.fetch("my-skill", skill_path) - - assert result is False - assert not skill_path.exists() - - async def test_returns_false_on_timeout(self, tmp_path: Path) -> None: - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "SKILL.md" - fetcher = SkillFetcher("http://localhost:8000") - - with patch( - "httpx.AsyncClient", - _make_error_mock(httpx.TimeoutException("timed out", request=None)), - ): - result = await fetcher.fetch("my-skill", skill_path) - - assert result is False - assert not skill_path.exists() - - async def test_logs_warning_on_connect_error( - self, tmp_path: Path, caplog: pytest.LogCaptureFixture - ) -> None: - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "SKILL.md" - fetcher = SkillFetcher("http://localhost:8000") - - with caplog.at_level(logging.WARNING): - with patch( - "httpx.AsyncClient", - _make_error_mock(httpx.ConnectError("refused")), - ): - await fetcher.fetch("my-skill", skill_path) - - assert any("skill_fetch_failed" in record.getMessage() for record in caplog.records) - - -class TestSkillFetcherETagSidecar: - """SkillFetcher uses ETag sidecar for conditional GET requests.""" - - async def test_no_etag_sidecar_sends_unconditional_get(self, tmp_path: Path) -> None: - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "SKILL.md" - fetcher = SkillFetcher("http://localhost:8000") - - mock_cls = _make_http_mock(200, "skill content", "") - with patch( - "httpx.AsyncClient", - mock_cls, - ): - await fetcher.fetch("my-skill", skill_path) - - mock_client = mock_cls.return_value - sent_headers = mock_client.get.call_args.kwargs.get("headers", {}) - assert "If-None-Match" not in sent_headers - - async def test_existing_etag_sidecar_sends_if_none_match(self, tmp_path: Path) -> None: - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "SKILL.md" - # Drift detection requires both the skill file and a matching .content_hash - # to trust the stored ETag; create both so If-None-Match is sent. - skill_path.write_text("# Existing skill content") - content_hash_path = tmp_path / ".content_hash" - content_hash_path.write_text(hashlib.sha256(skill_path.read_bytes()).hexdigest()) - etag_path = tmp_path / ".etag" - etag_path.write_text("stored-etag-value") - fetcher = SkillFetcher("http://localhost:8000") - - mock_cls = _make_http_mock(304, "", "") - with patch( - "httpx.AsyncClient", - mock_cls, - ): - await fetcher.fetch("my-skill", skill_path) - - mock_client = mock_cls.return_value - sent_headers = mock_client.get.call_args.kwargs.get("headers", {}) - assert sent_headers.get("If-None-Match") == "stored-etag-value" - - async def test_no_etag_sidecar_written_when_response_omits_etag(self, tmp_path: Path) -> None: - """fetch() must NOT write a .etag sidecar when the server omits the ETag header. - - An empty .etag file would be indistinguishable from an intentional empty-string - ETag and can confuse debugging. When no ETag is returned, skip the write. - """ - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "SKILL.md" - etag_path = tmp_path / ".etag" - fetcher = SkillFetcher("http://localhost:8000") - - with patch( - "httpx.AsyncClient", - _make_http_mock(200, "skill content", ""), # no ETag in response - ): - result = await fetcher.fetch("my-skill", skill_path) - - assert result is True - assert skill_path.read_text() == "skill content" - assert not etag_path.exists(), ".etag must not be written when response has no ETag" - - async def test_etag_sidecar_updated_on_200(self, tmp_path: Path) -> None: - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "SKILL.md" - etag_path = tmp_path / ".etag" - etag_path.write_text("old-etag") - fetcher = SkillFetcher("http://localhost:8000") - - mock_cls = _make_http_mock(200, "new content", "new-etag") - with patch( - "httpx.AsyncClient", - mock_cls, - ): - await fetcher.fetch("my-skill", skill_path) - - assert etag_path.read_text() == "new-etag" - - -class TestVersionCapability: - """Tests for VersionCheckResult NamedTuple and _is_skills_capable() function.""" - - def test_is_skills_capable_none_returns_false(self) -> None: - """_is_skills_capable(None) returns False (no version information).""" - from amplifier_module_hook_context_intelligence.skill_fetcher import _is_skills_capable - - assert _is_skills_capable(None) is False - - def test_is_skills_capable_old_version_returns_false(self) -> None: - """_is_skills_capable('1.9.0') returns False (below minimum).""" - from amplifier_module_hook_context_intelligence.skill_fetcher import _is_skills_capable - - assert _is_skills_capable("1.9.0") is False - - def test_is_skills_capable_min_version_returns_true(self) -> None: - """_is_skills_capable('2.0.0') returns True (exactly at minimum).""" - from amplifier_module_hook_context_intelligence.skill_fetcher import _is_skills_capable - - assert _is_skills_capable("2.0.0") is True - - def test_is_skills_capable_newer_version_returns_true(self) -> None: - """_is_skills_capable('3.1.0') returns True (above minimum).""" - from amplifier_module_hook_context_intelligence.skill_fetcher import _is_skills_capable - - assert _is_skills_capable("3.1.0") is True - - def test_is_skills_capable_unparseable_returns_false(self) -> None: - """_is_skills_capable('invalid') returns False (unparseable string).""" - from amplifier_module_hook_context_intelligence.skill_fetcher import _is_skills_capable - - assert _is_skills_capable("invalid") is False - - def test_version_check_result_namedtuple_reachable(self) -> None: - """VersionCheckResult can be constructed with reachable=True, version='2.0.0'.""" - from amplifier_module_hook_context_intelligence.skill_fetcher import VersionCheckResult - - result = VersionCheckResult(reachable=True, version="2.0.0") - assert result.reachable is True - assert result.version == "2.0.0" - - def test_version_check_result_namedtuple_unreachable(self) -> None: - """VersionCheckResult can be constructed with reachable=False, version=None.""" - from amplifier_module_hook_context_intelligence.skill_fetcher import VersionCheckResult - - result = VersionCheckResult(reachable=False, version=None) - assert result.reachable is False - assert result.version is None - - -class TestCheckServerVersion: - """SkillFetcher.check_server_version() returns correct VersionCheckResult.""" - - async def test_connect_error_returns_unreachable(self) -> None: - """ConnectError maps to VersionCheckResult(reachable=False, version=None).""" - from amplifier_module_hook_context_intelligence.skill_fetcher import ( - SkillFetcher, - VersionCheckResult, - ) - - fetcher = SkillFetcher("http://localhost:8000") - - with patch( - "httpx.AsyncClient", - _make_error_mock(httpx.ConnectError("refused")), - ): - result = await fetcher.check_server_version() - - assert result == VersionCheckResult(reachable=False, version=None) - - async def test_timeout_returns_unreachable(self) -> None: - """TimeoutException maps to VersionCheckResult(reachable=False, version=None).""" - from amplifier_module_hook_context_intelligence.skill_fetcher import ( - SkillFetcher, - VersionCheckResult, - ) - - fetcher = SkillFetcher("http://localhost:8000") - - with patch( - "httpx.AsyncClient", - _make_error_mock(httpx.TimeoutException("timed out", request=None)), - ): - result = await fetcher.check_server_version() - - assert result == VersionCheckResult(reachable=False, version=None) - - async def test_404_returns_reachable_with_none_version(self) -> None: - """404 response maps to VersionCheckResult(reachable=True, version=None).""" - from amplifier_module_hook_context_intelligence.skill_fetcher import ( - SkillFetcher, - VersionCheckResult, - ) - - fetcher = SkillFetcher("http://localhost:8000") - - with patch( - "httpx.AsyncClient", - _make_version_http_mock(404, {}), - ): - result = await fetcher.check_server_version() - - assert result == VersionCheckResult(reachable=True, version=None) - - async def test_200_with_version_returns_reachable_with_version(self) -> None: - """200 with version field maps to VersionCheckResult(reachable=True, version='2.0.0').""" - from amplifier_module_hook_context_intelligence.skill_fetcher import ( - SkillFetcher, - VersionCheckResult, - ) - - fetcher = SkillFetcher("http://localhost:8000") - - with patch( - "httpx.AsyncClient", - _make_version_http_mock(200, {"version": "2.0.0"}), - ): - result = await fetcher.check_server_version() - - assert result == VersionCheckResult(reachable=True, version="2.0.0") - - async def test_200_without_version_returns_reachable_with_none(self) -> None: - """200 without version key maps to VersionCheckResult(reachable=True, version=None).""" - from amplifier_module_hook_context_intelligence.skill_fetcher import ( - SkillFetcher, - VersionCheckResult, - ) - - fetcher = SkillFetcher("http://localhost:8000") - - with patch( - "httpx.AsyncClient", - _make_version_http_mock(200, {}), - ): - result = await fetcher.check_server_version() - - assert result == VersionCheckResult(reachable=True, version=None) - - -class TestWriteLegacyContent: - """SkillFetcher.write_legacy_content() writes bundled legacy skill content to disk.""" - - def test_writes_content_to_skill_path(self, tmp_path: Path) -> None: - """write_legacy_content() writes the skill file; content is > 500 chars.""" - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "context-intelligence-graph-query.md" - fetcher = SkillFetcher("http://localhost:8000") - - fetcher.write_legacy_content("context-intelligence-graph-query", skill_path) - - assert skill_path.exists() - assert len(skill_path.read_text(encoding="utf-8")) > 500 - - def test_clears_existing_etag_sidecar(self, tmp_path: Path) -> None: - """write_legacy_content() removes an existing .etag sidecar file.""" - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "context-intelligence-graph-query.md" - etag_path = tmp_path / ".etag" - etag_path.write_text('W/"old-etag"', encoding="utf-8") - fetcher = SkillFetcher("http://localhost:8000") - - fetcher.write_legacy_content("context-intelligence-graph-query", skill_path) - - assert not etag_path.exists() - - def test_no_etag_created_when_none_existed(self, tmp_path: Path) -> None: - """write_legacy_content() does not create a .etag sidecar when none existed.""" - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "context-intelligence-graph-query.md" - etag_path = tmp_path / ".etag" - fetcher = SkillFetcher("http://localhost:8000") - - fetcher.write_legacy_content("context-intelligence-graph-query", skill_path) - - assert not etag_path.exists() - - def test_raises_file_not_found_for_unknown_skill(self, tmp_path: Path) -> None: - """write_legacy_content() raises FileNotFoundError for an unknown skill name.""" - from amplifier_module_hook_context_intelligence.skill_fetcher import SkillFetcher - - skill_path = tmp_path / "unknown-skill.md" - fetcher = SkillFetcher("http://localhost:8000") - - with pytest.raises(FileNotFoundError): - fetcher.write_legacy_content("unknown-skill-that-does-not-exist", skill_path) diff --git a/modules/hook-context-intelligence/tests/test_skill_fetcher_mount.py b/modules/hook-context-intelligence/tests/test_skill_fetcher_mount.py deleted file mode 100644 index 380d758..0000000 --- a/modules/hook-context-intelligence/tests/test_skill_fetcher_mount.py +++ /dev/null @@ -1,804 +0,0 @@ -"""Tests for mount() — skill fetch phase (happy path).""" - -from __future__ import annotations - -from pathlib import Path -from typing import overload -from unittest.mock import AsyncMock, MagicMock, patch - -_HookCalls = list[tuple[str, object, dict[str, object]]] - - -def _make_coordinator(server_url: str | None, skill_path: Path | None) -> MagicMock: - """Build a minimal coordinator mock for skill fetch phase tests. - - - coordinator.hooks.register returns MagicMock - - coordinator.collect_contributions is AsyncMock returning [] - - coordinator.get_capability('skills_discovery') returns a mock with - .find(skill_name) returning metadata (with .path = skill_path) when - skill_path is not None, else returns None. - """ - coordinator = MagicMock() - coordinator.hooks.register = MagicMock(return_value=MagicMock()) - coordinator.collect_contributions = AsyncMock(return_value=[]) - - # Configure skills_discovery capability - if skill_path is not None: - skills_discovery = MagicMock() - metadata = MagicMock() - metadata.path = skill_path - skills_discovery.find = MagicMock(return_value=metadata) - _skills_discovery_cap = skills_discovery - else: - _skills_discovery_cap = None - - def _get_capability(name: str) -> object: - if name == "skills_discovery": - return _skills_discovery_cap - return None - - coordinator.get_capability = MagicMock(side_effect=_get_capability) - # Put server_url in coordinator.config so ConfigResolver can find it - coordinator.config = {"context_intelligence_server_url": server_url} if server_url else {} - - return coordinator - - -@overload -def _capture_hooks_register() -> tuple[MagicMock, _HookCalls]: ... - - -@overload -def _capture_hooks_register(coordinator: MagicMock) -> _HookCalls: ... - - -def _capture_hooks_register( - coordinator: MagicMock | None = None, -) -> _HookCalls | tuple[MagicMock, _HookCalls]: - """Create a hooks.register mock that records all calls. - - When *coordinator* is supplied, wires ``coordinator.hooks.register`` automatically - and returns just the *calls* list. - - When called without arguments, returns ``(mock, calls)`` for callers that need - to wire the mock themselves. - """ - calls: _HookCalls = [] - - def _side_effect(event: str, handler: object, **kwargs: object) -> MagicMock: - calls.append((event, handler, dict(kwargs))) - return MagicMock() - - mock = MagicMock(side_effect=_side_effect) - if coordinator is not None: - coordinator.hooks.register = mock - return calls - return mock, calls - - -def _find_handler(calls: _HookCalls, event: str, name: str) -> object: - """Find a registered handler by event name and handler name (from kwargs). - - Asserts exactly 1 match found. - """ - matches = [ - handler for evt, handler, kwargs in calls if evt == event and kwargs.get("name") == name - ] - assert len(matches) == 1, ( - f"Expected 1 handler for event={event!r} name={name!r}, found {len(matches)}." - ) - return matches[0] - - -class TestMountSkillFetchHappyPath: - """mount() fetches watched skills when server_url is available and SKILL.md exists.""" - - async def test_fetch_called_for_watched_skill(self, tmp_path: Path) -> None: - """SkillFetcher.fetch is called via skills:discovered handler (deferred from mount). - - fetch must NOT be called during mount(), but must be called when the - skills:discovered handler fires. - """ - from amplifier_module_hook_context_intelligence import mount - from amplifier_module_hook_context_intelligence.skill_fetcher import VersionCheckResult - - # Place the SKILL.md at the expected bundle-root-relative location - skill_path = tmp_path / "skills" / "context-intelligence-graph-query" / "SKILL.md" - skill_path.parent.mkdir(parents=True) - skill_path.write_text("# test") - - coordinator = _make_coordinator( - server_url="http://localhost:8000", - skill_path=skill_path, - ) - - mock_register, calls = _capture_hooks_register() - coordinator.hooks.register = mock_register - - mock_fetcher_instance = MagicMock() - mock_fetcher_instance.check_server_version = AsyncMock( - return_value=VersionCheckResult(reachable=True, version="2.0.0") - ) - mock_fetcher_instance.fetch = AsyncMock(return_value=True) - mock_fetcher_cls = MagicMock(return_value=mock_fetcher_instance) - - with ( - patch("amplifier_module_hook_context_intelligence._BUNDLE_ROOT", tmp_path), - patch( - "amplifier_module_hook_context_intelligence.skill_fetcher.SkillFetcher", - mock_fetcher_cls, - ), - ): - await mount( - coordinator, - config={ - "context_intelligence_server_url": "http://localhost:8000", - "context_intelligence_api_key": "test-key", - }, - ) - - # fetch IS called immediately during mount — skills_discovery was already registered - mock_fetcher_instance.fetch.assert_called_once_with( - "context-intelligence-graph-query", skill_path - ) - mock_fetcher_instance.fetch.reset_mock() - - # Find and fire the skills:discovered handler — it must also trigger a refresh - handler = _find_handler(calls, "skills:discovered", "SkillFetcher-trigger") - await handler("skills:discovered", {}) # type: ignore[operator] - - # After the handler fires, fetch should have been called once more - mock_fetcher_instance.fetch.assert_called_once_with( - "context-intelligence-graph-query", skill_path - ) - - async def test_cleanup_is_still_callable_after_fetch(self, tmp_path: Path) -> None: - """cleanup() returned from mount() can be awaited without error after fetch.""" - from amplifier_module_hook_context_intelligence import mount - from amplifier_module_hook_context_intelligence.skill_fetcher import VersionCheckResult - - skill_path = tmp_path / "context-intelligence-graph-query" / "SKILL.md" - coordinator = _make_coordinator( - server_url="http://localhost:8000", - skill_path=skill_path, - ) - - mock_fetcher_instance = MagicMock() - mock_fetcher_instance.check_server_version = AsyncMock( - return_value=VersionCheckResult(reachable=True, version="2.0.0") - ) - mock_fetcher_instance.fetch = AsyncMock(return_value=True) - mock_fetcher_cls = MagicMock(return_value=mock_fetcher_instance) - - with patch( - "amplifier_module_hook_context_intelligence.skill_fetcher.SkillFetcher", - mock_fetcher_cls, - ): - cleanup = await mount( - coordinator, - config={ - "context_intelligence_server_url": "http://localhost:8000", - "context_intelligence_api_key": "test-key", - }, - ) - - # Should be awaitable without error - await cleanup() - - -class TestMountSkillFetchSkipsWhenUnconfigured: - """mount() skips skill fetch gracefully when server_url or skills_discovery is absent.""" - - async def test_no_fetch_when_server_url_is_none(self, tmp_path: Path) -> None: - """SkillFetcher.fetch is NOT called and SKILL.md is unchanged when server_url is None.""" - from amplifier_module_hook_context_intelligence import mount - - skill_path = tmp_path / "context-intelligence-graph-query" / "SKILL.md" - # skill_path is set so skills_discovery capability is available, but server_url=None - coordinator = _make_coordinator(server_url=None, skill_path=skill_path) - - mock_fetcher_instance = MagicMock() - mock_fetcher_instance.fetch = AsyncMock() - mock_fetcher_cls = MagicMock(return_value=mock_fetcher_instance) - - with patch( - "amplifier_module_hook_context_intelligence.skill_fetcher.SkillFetcher", - mock_fetcher_cls, - ): - cleanup = await mount(coordinator, config={}) - - mock_fetcher_instance.fetch.assert_not_called() - # SKILL.md was never written - assert not skill_path.exists() - assert callable(cleanup) - - async def test_no_fetch_when_skill_path_not_found(self, tmp_path: Path) -> None: - """SkillFetcher.fetch is NOT called when SKILL.md does not exist at the bundle root.""" - from amplifier_module_hook_context_intelligence import mount - from amplifier_module_hook_context_intelligence.skill_fetcher import VersionCheckResult - - # tmp_path has no skills/ subdirectory — SKILL.md will not be found - coordinator = _make_coordinator(server_url="http://localhost:8000", skill_path=None) - - mock_register, calls = _capture_hooks_register() - coordinator.hooks.register = mock_register - - mock_fetcher_instance = MagicMock() - mock_fetcher_instance.check_server_version = AsyncMock( - return_value=VersionCheckResult(reachable=True, version="2.0.0") - ) - mock_fetcher_instance.fetch = AsyncMock() - mock_fetcher_cls = MagicMock(return_value=mock_fetcher_instance) - - with ( - patch("amplifier_module_hook_context_intelligence._BUNDLE_ROOT", tmp_path), - patch( - "amplifier_module_hook_context_intelligence.skill_fetcher.SkillFetcher", - mock_fetcher_cls, - ), - ): - await mount( - coordinator, - config={ - "context_intelligence_server_url": "http://localhost:8000", - "context_intelligence_api_key": "test-key", - }, - ) - - # Find and fire the skills:discovered handler — path is unresolvable, fetch must not run. - # Handler must be invoked while _BUNDLE_ROOT is still patched to tmp_path (empty dir), - # otherwise the real bundle root fallback would resolve the skill path and call fetch. - handler = _find_handler(calls, "skills:discovered", "SkillFetcher-trigger") - await handler("skills:discovered", {}) # type: ignore[operator] - - mock_fetcher_instance.fetch.assert_not_called() - - async def test_mount_still_returns_cleanup_when_fetch_skipped(self, tmp_path: Path) -> None: - """mount() returns a callable, awaitable cleanup even when the fetch phase is skipped.""" - from amplifier_module_hook_context_intelligence import mount - - # Both server_url=None and skill_path=None — fetch is skipped on both counts - coordinator = _make_coordinator(server_url=None, skill_path=None) - - mock_fetcher_instance = MagicMock() - mock_fetcher_instance.fetch = AsyncMock() - mock_fetcher_cls = MagicMock(return_value=mock_fetcher_instance) - - with patch( - "amplifier_module_hook_context_intelligence.skill_fetcher.SkillFetcher", - mock_fetcher_cls, - ): - cleanup = await mount(coordinator, config={}) - - assert callable(cleanup) - # Must be awaitable without raising - await cleanup() - - -class TestSkillUnloadedHandler: - """mount() registers skill:unloaded handler that creates tasks for watched skills.""" - - async def test_skill_unloaded_triggers_fetch_for_watched_skill(self, tmp_path: Path) -> None: - """Handler fetches when skill:unloaded fires for a skill in WATCHED_SKILLS. - - After the refactor, the handler uses await _refresh_watched_skills directly - instead of asyncio.create_task. - """ - from amplifier_module_hook_context_intelligence import mount - from amplifier_module_hook_context_intelligence.skill_fetcher import VersionCheckResult - - skill_path = tmp_path / "context-intelligence-graph-query" / "SKILL.md" - coordinator = _make_coordinator( - server_url="http://localhost:8000", - skill_path=skill_path, - ) - - registered = _capture_hooks_register(coordinator) - - mock_fetcher_instance = MagicMock() - mock_fetcher_instance.check_server_version = AsyncMock( - return_value=VersionCheckResult(reachable=True, version="2.0.0") - ) - mock_fetcher_instance.fetch = AsyncMock(return_value=True) - mock_fetcher_cls = MagicMock(return_value=mock_fetcher_instance) - - with patch( - "amplifier_module_hook_context_intelligence.skill_fetcher.SkillFetcher", - mock_fetcher_cls, - ): - await mount( - coordinator, - config={ - "context_intelligence_server_url": "http://localhost:8000", - "context_intelligence_api_key": "test-key", - }, - ) - - # Reset calls from mount-time immediate check (skills_discovery already registered) - mock_fetcher_instance.fetch.reset_mock() - - handler = _find_handler(registered, "skill:unloaded", "SkillFetcher") - await handler( # type: ignore[operator] - "skill:unloaded", {"skill_name": "context-intelligence-graph-query"} - ) - - # fetch is called directly by the skill:unloaded handler (no asyncio.create_task) - mock_fetcher_instance.fetch.assert_awaited_once() - - async def test_skill_unloaded_skips_fetch_for_unwatched_skill(self, tmp_path: Path) -> None: - """Handler does nothing when skill:unloaded fires for a skill NOT in WATCHED_SKILLS.""" - from amplifier_module_hook_context_intelligence import mount - from amplifier_module_hook_context_intelligence.skill_fetcher import VersionCheckResult - - skill_path = tmp_path / "context-intelligence-graph-query" / "SKILL.md" - coordinator = _make_coordinator( - server_url="http://localhost:8000", - skill_path=skill_path, - ) - - registered = _capture_hooks_register(coordinator) - - # Use AsyncMock for the entire fetcher instance so that attribute access - # (e.g. .fetch) automatically returns awaitable AsyncMock children. - # Note: a RuntimeWarning about unawaited coroutines may appear during teardown - # in Python 3.13 — this is a known mock teardown artifact, not a bug. - mock_fetcher_instance = AsyncMock() - mock_fetcher_instance.check_server_version = AsyncMock( - return_value=VersionCheckResult(reachable=True, version="2.0.0") - ) - mock_fetcher_cls = MagicMock(return_value=mock_fetcher_instance) - - with patch( - "amplifier_module_hook_context_intelligence.skill_fetcher.SkillFetcher", - mock_fetcher_cls, - ): - await mount( - coordinator, - config={ - "context_intelligence_server_url": "http://localhost:8000", - "context_intelligence_api_key": "test-key", - }, - ) - - # Reset calls from mount-time immediate check (skills_discovery already registered) - mock_fetcher_instance.fetch.reset_mock() - - handler = _find_handler(registered, "skill:unloaded", "SkillFetcher") - await handler( # type: ignore[operator] - "skill:unloaded", {"skill_name": "some-other-unrelated-skill"} - ) - - # skill is unwatched — no additional fetch should be triggered by the handler - mock_fetcher_instance.fetch.assert_not_awaited() - - async def test_does_not_crash_when_metadata_not_found(self, tmp_path: Path) -> None: - """Handler returns cleanly when SKILL.md does not exist at the bundle root.""" - from amplifier_module_hook_context_intelligence import mount - from amplifier_module_hook_context_intelligence.skill_fetcher import VersionCheckResult - - # tmp_path has no skills/ subdirectory — SKILL.md will not be found - coordinator = _make_coordinator( - server_url="http://localhost:8000", - skill_path=None, - ) - - registered = _capture_hooks_register(coordinator) - - mock_fetcher_instance = MagicMock() - mock_fetcher_instance.check_server_version = AsyncMock( - return_value=VersionCheckResult(reachable=True, version="2.0.0") - ) - mock_fetcher_instance.fetch = AsyncMock(return_value=True) - mock_fetcher_cls = MagicMock(return_value=mock_fetcher_instance) - - with ( - patch("amplifier_module_hook_context_intelligence._BUNDLE_ROOT", tmp_path), - patch( - "amplifier_module_hook_context_intelligence.skill_fetcher.SkillFetcher", - mock_fetcher_cls, - ), - ): - await mount( - coordinator, - config={ - "context_intelligence_server_url": "http://localhost:8000", - "context_intelligence_api_key": "test-key", - }, - ) - - # SKILL.md absent at bundle root — handler must return without calling fetch - handler = _find_handler(registered, "skill:unloaded", "SkillFetcher") - await handler( # type: ignore[operator] - "skill:unloaded", {"skill_name": "context-intelligence-graph-query"} - ) - - mock_fetcher_instance.fetch.assert_not_awaited() - - -class TestMountNoOpWhenServerUrlAbsent: - """When server_url is not configured, mount() must not touch skills_discovery at all.""" - - async def test_get_capability_not_called_when_no_server_url(self, tmp_path: Path) -> None: - """coordinator.get_capability must NOT be called when server_url is None.""" - from amplifier_module_hook_context_intelligence import mount - - coordinator = _make_coordinator(server_url=None, skill_path=None) - await mount(coordinator, config={}) - - # get_capability should never have been called for skills_discovery - for call in coordinator.get_capability.call_args_list: - assert call.args[0] != "skills_discovery", ( - "get_capability('skills_discovery') was called even though server_url is None" - ) - - async def test_skill_unloaded_not_registered_when_no_server_url(self, tmp_path: Path) -> None: - """skill:unloaded handler must NOT be registered when server_url is None.""" - from amplifier_module_hook_context_intelligence import mount - - registered_events: list[str] = [] - - def capture(event: str, handler: object, **kwargs: object) -> object: - registered_events.append(event) - return MagicMock() - - coordinator = _make_coordinator(server_url=None, skill_path=None) - coordinator.hooks.register = MagicMock(side_effect=capture) - - await mount(coordinator, config={}) - - assert "skill:unloaded" not in registered_events, ( - "skill:unloaded handler was registered even though server_url is None" - ) - assert "skills:discovered" not in registered_events, ( - "skills:discovered handler was registered even though server_url is None" - ) - - -class TestMountThreeWayBranch: - """mount() routes to unreachable/old-server/new-server based on check_server_version.""" - - async def test_unreachable_server_no_op(self, tmp_path: Path) -> None: - """Unreachable server: SKILL.md untouched, fetch not called, write_legacy_content not called.""" - from amplifier_module_hook_context_intelligence import mount - from amplifier_module_hook_context_intelligence.skill_fetcher import VersionCheckResult - - skill_path = tmp_path / "context-intelligence-graph-query" / "SKILL.md" - coordinator = _make_coordinator( - server_url="http://localhost:8000", - skill_path=skill_path, - ) - - mock_fetcher_instance = MagicMock() - mock_fetcher_instance.check_server_version = AsyncMock( - return_value=VersionCheckResult(reachable=False, version=None) - ) - mock_fetcher_instance.fetch = AsyncMock() - mock_fetcher_instance.write_legacy_content = MagicMock() - mock_fetcher_cls = MagicMock(return_value=mock_fetcher_instance) - - with patch( - "amplifier_module_hook_context_intelligence.skill_fetcher.SkillFetcher", - mock_fetcher_cls, - ): - await mount( - coordinator, - config={ - "context_intelligence_server_url": "http://localhost:8000", - "context_intelligence_api_key": "test-key", - }, - ) - - assert not skill_path.exists() - mock_fetcher_instance.fetch.assert_not_called() - mock_fetcher_instance.write_legacy_content.assert_not_called() - - async def test_old_server_registers_skills_discovered_handler(self, tmp_path: Path) -> None: - """Old server (reachable=True, version=None): skills:discovered handler registered. - - After the refactor, both old and new servers register a skills:discovered handler - rather than calling write_legacy_content or fetch inline during mount(). - The handler fires later and uses skills_capable to decide which path to take. - """ - from amplifier_module_hook_context_intelligence import mount - from amplifier_module_hook_context_intelligence.skill_fetcher import VersionCheckResult - - # Place SKILL.md at the expected bundle-root-relative location - skill_path = tmp_path / "skills" / "context-intelligence-graph-query" / "SKILL.md" - skill_path.parent.mkdir(parents=True) - skill_path.write_text("# test") - - coordinator = _make_coordinator( - server_url="http://localhost:8000", - skill_path=skill_path, - ) - - mock_register, calls = _capture_hooks_register() - coordinator.hooks.register = mock_register - - mock_fetcher_instance = MagicMock() - mock_fetcher_instance.check_server_version = AsyncMock( - return_value=VersionCheckResult(reachable=True, version=None) - ) - mock_fetcher_instance.fetch = AsyncMock() - mock_fetcher_instance.write_legacy_content = MagicMock() - mock_fetcher_cls = MagicMock(return_value=mock_fetcher_instance) - - with ( - patch("amplifier_module_hook_context_intelligence._BUNDLE_ROOT", tmp_path), - patch( - "amplifier_module_hook_context_intelligence.skill_fetcher.SkillFetcher", - mock_fetcher_cls, - ), - ): - await mount( - coordinator, - config={ - "context_intelligence_server_url": "http://localhost:8000", - "context_intelligence_api_key": "test-key", - }, - ) - - # fetch is NOT called for old server (write_legacy_content is used instead) - mock_fetcher_instance.fetch.assert_not_called() - # write_legacy_content IS called immediately during mount — skills_discovery was already registered - mock_fetcher_instance.write_legacy_content.assert_called_once() - # skills:discovered SkillFetcher-trigger handler must still be registered - _find_handler(calls, "skills:discovered", "SkillFetcher-trigger") - - async def test_new_server_registers_skills_discovered_handler(self, tmp_path: Path) -> None: - """New server (reachable=True, version='2.0.0'): skills:discovered handler registered. - - After the refactor, fetch is deferred — mount() only registers the handler. - """ - from amplifier_module_hook_context_intelligence import mount - from amplifier_module_hook_context_intelligence.skill_fetcher import VersionCheckResult - - # Place SKILL.md at the expected bundle-root-relative location - skill_path = tmp_path / "skills" / "context-intelligence-graph-query" / "SKILL.md" - skill_path.parent.mkdir(parents=True) - skill_path.write_text("# test") - - coordinator = _make_coordinator( - server_url="http://localhost:8000", - skill_path=skill_path, - ) - - mock_register, calls = _capture_hooks_register() - coordinator.hooks.register = mock_register - - mock_fetcher_instance = MagicMock() - mock_fetcher_instance.check_server_version = AsyncMock( - return_value=VersionCheckResult(reachable=True, version="2.0.0") - ) - mock_fetcher_instance.fetch = AsyncMock(return_value=True) - mock_fetcher_instance.write_legacy_content = MagicMock() - mock_fetcher_cls = MagicMock(return_value=mock_fetcher_instance) - - with ( - patch("amplifier_module_hook_context_intelligence._BUNDLE_ROOT", tmp_path), - patch( - "amplifier_module_hook_context_intelligence.skill_fetcher.SkillFetcher", - mock_fetcher_cls, - ), - ): - await mount( - coordinator, - config={ - "context_intelligence_server_url": "http://localhost:8000", - "context_intelligence_api_key": "test-key", - }, - ) - - # fetch IS called immediately during mount — skills_discovery was already registered - mock_fetcher_instance.fetch.assert_called_once() - mock_fetcher_instance.write_legacy_content.assert_not_called() - # skills:discovered SkillFetcher-trigger handler must still be registered - _find_handler(calls, "skills:discovered", "SkillFetcher-trigger") - - -class TestSkillsDiscoveredHandler: - """mount() registers skills:discovered handler that triggers refresh on new server.""" - - async def test_skills_discovered_triggers_refresh(self, tmp_path: Path) -> None: - """skills:discovered handler calls fetch once for the watched skill. - - The handler should be registered during mount() and trigger a fetch - when fired — fetch must NOT be called during mount itself. - """ - from amplifier_module_hook_context_intelligence import mount - from amplifier_module_hook_context_intelligence.skill_fetcher import VersionCheckResult - - # Place skill_path at a non-standard location (no skills/ prefix) so that - # _BUNDLE_ROOT / "skills" / skill_name / "SKILL.md" won't resolve it during - # mount — only skills_discovery capability returns this path. - skill_path = tmp_path / "context-intelligence-graph-query" / "SKILL.md" - skill_path.parent.mkdir(parents=True) - skill_path.write_text("# test") - - coordinator = _make_coordinator( - server_url="http://localhost:8000", - skill_path=skill_path, - ) - - mock_register, calls = _capture_hooks_register() - coordinator.hooks.register = mock_register - - mock_fetcher_instance = MagicMock() - mock_fetcher_instance.check_server_version = AsyncMock( - return_value=VersionCheckResult(reachable=True, version="2.0.0") - ) - mock_fetcher_instance.fetch = AsyncMock(return_value=True) - mock_fetcher_cls = MagicMock(return_value=mock_fetcher_instance) - - with ( - patch("amplifier_module_hook_context_intelligence._BUNDLE_ROOT", tmp_path), - patch( - "amplifier_module_hook_context_intelligence.skill_fetcher.SkillFetcher", - mock_fetcher_cls, - ), - ): - await mount( - coordinator, - config={ - "context_intelligence_server_url": "http://localhost:8000", - "context_intelligence_api_key": "test-key", - }, - ) - - # fetch IS called immediately during mount — skills_discovery was already registered - mock_fetcher_instance.fetch.assert_called_once_with( - "context-intelligence-graph-query", skill_path - ) - mock_fetcher_instance.fetch.reset_mock() - - # Find the skills:discovered handler and fire it — handler must also trigger a refresh - handler = _find_handler(calls, "skills:discovered", "SkillFetcher-trigger") - await handler("skills:discovered", {}) # type: ignore[operator] - - # After the handler fires, fetch should have been called once more - mock_fetcher_instance.fetch.assert_called_once_with( - "context-intelligence-graph-query", skill_path - ) - - async def test_no_handler_when_server_unreachable(self) -> None: - """No skills:discovered SkillFetcher-trigger handler when server is unreachable.""" - from amplifier_module_hook_context_intelligence import mount - from amplifier_module_hook_context_intelligence.skill_fetcher import VersionCheckResult - - coordinator = _make_coordinator(server_url="http://localhost:8000", skill_path=None) - - mock_register, calls = _capture_hooks_register() - coordinator.hooks.register = mock_register - - mock_fetcher_instance = MagicMock() - mock_fetcher_instance.check_server_version = AsyncMock( - return_value=VersionCheckResult(reachable=False, version=None) - ) - mock_fetcher_cls = MagicMock(return_value=mock_fetcher_instance) - - with patch( - "amplifier_module_hook_context_intelligence.skill_fetcher.SkillFetcher", - mock_fetcher_cls, - ): - await mount( - coordinator, - config={ - "context_intelligence_server_url": "http://localhost:8000", - "context_intelligence_api_key": "test-key", - }, - ) - - # No SkillFetcher-trigger handler should be registered for skills:discovered - # when the server is unreachable (Branch A) - trigger_handlers = [ - (evt, hdlr, kw) - for evt, hdlr, kw in calls - if evt == "skills:discovered" and kw.get("name") == "SkillFetcher-trigger" - ] - assert len(trigger_handlers) == 0, ( - "skills:discovered SkillFetcher-trigger handler was registered even though " - "server is unreachable" - ) - - -class TestSkillUnloadedHandlerRefresh: - """skill:unloaded handler uses await _refresh_watched_skills (not asyncio.create_task).""" - - async def test_skill_unloaded_awaits_refresh_for_watched_skill(self, tmp_path: Path) -> None: - """skill:unloaded handler awaits _refresh_watched_skills for watched skills. - - After the refactor, the handler must NOT use asyncio.create_task. Instead, - it must directly await _refresh_watched_skills, which calls fetcher.fetch. - """ - from amplifier_module_hook_context_intelligence import mount - from amplifier_module_hook_context_intelligence.skill_fetcher import ( - VersionCheckResult, - WATCHED_SKILLS, - ) - - skill_name = next(iter(WATCHED_SKILLS)) - skill_path = tmp_path / skill_name / "SKILL.md" - skill_path.parent.mkdir(parents=True) - skill_path.write_text("# test") - - coordinator = _make_coordinator( - server_url="http://localhost:8000", - skill_path=skill_path, - ) - - registered = _capture_hooks_register(coordinator) - - mock_fetcher_instance = MagicMock() - mock_fetcher_instance.check_server_version = AsyncMock( - return_value=VersionCheckResult(reachable=True, version="2.0.0") - ) - mock_fetcher_instance.fetch = AsyncMock(return_value=True) - mock_fetcher_cls = MagicMock(return_value=mock_fetcher_instance) - - with patch( - "amplifier_module_hook_context_intelligence.skill_fetcher.SkillFetcher", - mock_fetcher_cls, - ): - await mount( - coordinator, - config={ - "context_intelligence_server_url": "http://localhost:8000", - "context_intelligence_api_key": "test-key", - }, - ) - - # Reset fetch calls after mount (mount should NOT have called fetch directly) - mock_fetcher_instance.fetch.reset_mock() - - handler = _find_handler(registered, "skill:unloaded", "SkillFetcher") - await handler( # type: ignore[operator] - "skill:unloaded", {"skill_name": skill_name} - ) - - # New behavior: fetcher.fetch IS called directly (via _refresh_watched_skills) - mock_fetcher_instance.fetch.assert_awaited_once() - - async def test_skill_unloaded_ignores_unwatched_skill(self, tmp_path: Path) -> None: - """skill:unloaded handler does nothing for skills NOT in WATCHED_SKILLS.""" - from amplifier_module_hook_context_intelligence import mount - from amplifier_module_hook_context_intelligence.skill_fetcher import VersionCheckResult - - skill_path = tmp_path / "some-skill" / "SKILL.md" - skill_path.parent.mkdir(parents=True) - skill_path.write_text("# test") - - coordinator = _make_coordinator( - server_url="http://localhost:8000", - skill_path=skill_path, - ) - - registered = _capture_hooks_register(coordinator) - - mock_fetcher_instance = MagicMock() - mock_fetcher_instance.check_server_version = AsyncMock( - return_value=VersionCheckResult(reachable=True, version="2.0.0") - ) - mock_fetcher_instance.fetch = AsyncMock(return_value=True) - mock_fetcher_cls = MagicMock(return_value=mock_fetcher_instance) - - with patch( - "amplifier_module_hook_context_intelligence.skill_fetcher.SkillFetcher", - mock_fetcher_cls, - ): - await mount( - coordinator, - config={ - "context_intelligence_server_url": "http://localhost:8000", - "context_intelligence_api_key": "test-key", - }, - ) - - mock_fetcher_instance.fetch.reset_mock() - - handler = _find_handler(registered, "skill:unloaded", "SkillFetcher") - await handler( # type: ignore[operator] - "skill:unloaded", {"skill_name": "some-unwatched-unrelated-skill"} - ) - - # Not a watched skill — no fetch should be triggered - mock_fetcher_instance.fetch.assert_not_called() diff --git a/modules/hook-context-intelligence/tests/test_smoke_storage_layout.py b/modules/hook-context-intelligence/tests/test_smoke_storage_layout.py index d88b36c..aa152fc 100644 --- a/modules/hook-context-intelligence/tests/test_smoke_storage_layout.py +++ b/modules/hook-context-intelligence/tests/test_smoke_storage_layout.py @@ -73,7 +73,7 @@ def _extract_logging_handler(coordinator: MagicMock) -> Any: """Extract the LoggingHandler instance from registrations. Searches by name="LoggingHandler" to avoid false matches with other - priority-100 handlers (e.g. SkillFetcher's skill:unloaded handler). + priority-100 handlers. """ for call in coordinator.hooks.register.call_args_list: if call.kwargs.get("name") == "LoggingHandler": diff --git a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py index 836682f..d39298f 100644 --- a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py +++ b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py @@ -11,7 +11,10 @@ from typing import Any +from .skill_sync import _GRAPH_QUERY_TOOL_CAPABILITY, on_session_ready + __amplifier_module_type__ = "tool" +__all__ = ["mount", "on_session_ready"] async def mount(coordinator: Any, config: Any) -> None: @@ -24,9 +27,11 @@ async def mount(coordinator: Any, config: Any) -> None: The hook resolver is NOT fetched here; each tool fetches it lazily at first execute() because tools mount before hooks (kernel phase order is orchestrator → context → providers → tools → hooks — CONTRACTS.md §Module - Lifecycle Methods). Using on_session_ready() here would force cross-callback - instance references (multi-session anti-pattern), so lazy fetch is the - correct and intentional design. + Lifecycle Methods). on_session_ready() IS now used, SAFELY, via the + module-level callback + capability indirection: it holds no cross-callback + instance reference because it re-fetches the GraphQueryTool from the + coordinator at call time (via get_capability). The execute-time lazy + hook-resolver fetch remains untouched. """ from context_intelligence.tool_resolver import ToolConfigResolver @@ -35,6 +40,7 @@ async def mount(coordinator: Any, config: Any) -> None: resolver = ToolConfigResolver(config or {}, coordinator) # built ONCE gq = GraphQueryTool(coordinator, resolver) + coordinator.register_capability(_GRAPH_QUERY_TOOL_CAPABILITY, gq) br = BlobReadTool(coordinator, resolver) await coordinator.mount("tools", gq, name=gq.name) # "graph_query" await coordinator.mount("tools", br, name=br.name) # "blob_read" diff --git a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/bundled_skill/__init__.py b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/bundled_skill/__init__.py new file mode 100644 index 0000000..7063ff3 --- /dev/null +++ b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/bundled_skill/__init__.py @@ -0,0 +1,52 @@ +"""Vendored offline skill bodies for the analytics path. + +DO NOT DELETE THE ``.md`` FILE(S) IN THIS PACKAGE. They are load-bearing. + +Why this exists (the safe-default invariant) +--------------------------------------------- +The bundle ships ``skills/context-intelligence-graph-query/SKILL.md`` as a +deliberately pessimistic **"Server Unavailable" stub** — it tells the +graph-analyst the graph is unreachable and to delegate to ``session-navigator``. +That stub is the *safe default*: a freshly installed bundle with **no** server +configured must never tell the agent "the graph is available" and invite Cypher +queries against a server that isn't there. + +When skill sync is ENABLED (the default), ``skill_sync.on_session_ready`` +overwrites that stub on session start with the real, full graph-query body +fetched from the live server (``GET /skills/context-intelligence-graph-query``). + +When skill sync is DISABLED (``skill_sync_enabled: false`` — the per-turn +network opt-out for headless / single-command-series workflows) **and a server +URL is configured**, we still must not leave the agent holding the "Server +Unavailable" stub while the graph is actually usable. Instead we **swap** in the +vendored real body from this package — a local file copy, zero network. That is +the only reason this vendored body exists. + +Provenance / how to refresh +--------------------------- +``context-intelligence-graph-query.md`` is a byte-for-byte copy of the canonical +skill body served by the context-intelligence server, sourced from +``microsoft/amplifier-context-intelligence`` at +``context_intelligence_server/skills/context-intelligence-graph-query/SKILL.md``. +Its SHA-256 is pinned by ``EXPECTED_BUNDLED_SKILL_SHA256`` below and asserted by +``tests/test_bundled_skill.py`` (fail-loud: the test breaks if the file is +missing from the wheel or drifts). To refresh: copy the latest canonical +``SKILL.md`` over the vendored file, update the pinned hash, and re-run the +tests + the DTU 4-cell proof. + +This package is the reincarnation of the ``legacy_content`` fallback that a +prior refactor deleted. It was re-introduced on purpose; a future "cleanup" +that deletes it will silently reintroduce the crippled-graph-analyst regression +issue #283 fixed. The DTU profile +``context-intelligence-skill-sync-disabled-behavioral-test.yaml`` and the unit +suite exist to make that deletion fail loud. +""" + +from __future__ import annotations + +#: SHA-256 of ``context-intelligence-graph-query.md`` — the vendored canonical +#: graph-query skill body (v2.0.0). Pinned so wheel-inclusion + drift is asserted +#: by tests rather than discovered in production. +EXPECTED_BUNDLED_SKILL_SHA256 = "d03a3f20df49b6ac05bdc92098e55edefaeae3a49c7457932703b9cceafa0533" + +__all__ = ["EXPECTED_BUNDLED_SKILL_SHA256"] diff --git a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/bundled_skill/context-intelligence-graph-query.md b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/bundled_skill/context-intelligence-graph-query.md new file mode 100644 index 0000000..1d43a59 --- /dev/null +++ b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/bundled_skill/context-intelligence-graph-query.md @@ -0,0 +1,1105 @@ +--- +name: context-intelligence-graph-query +description: > + Use when querying the context-intelligence property graph for session history, + tool call traces, LLM iteration analysis, execution scale metrics, agent + delegation trees, skill loading, and recipe orchestration. Covers all graph + layers, cross-layer SOURCED_FROM joins, SST navigation, blob handling, and + verified Cypher patterns. +license: MIT +metadata: + version: "2.0.0" +--- + +# Context Intelligence Graph Query + +This skill equips you to navigate and extract insights from the context-intelligence +property graph using the `graph_query` tool. The graph holds a complete record of +every Amplifier session — what happened, when, how things connect, and at what scale. + +--- + +## Section 1 — What the Graph Gives You + +The graph holds two complementary views of every session. + +**Data layer 1** is the raw event stream. Every kernel event is preserved as a node, +queryable by type, field, and time. It answers: *what happened and when.* Complete +timeline, exact field values, every tool call and LLM exchange recorded as-is. + +**Data layer 2** is the semantic layer. Events are assembled into meaningful runtime +entities — turns (OrchestratorRun), LLM iterations (Iteration), content blocks +(ContentBlock), tool calls (ToolCall), prompts (Prompt), and more. Connected by 15 +typed relationships. It answers: *what ran, how, and at what scale.* Conversation +structure, execution scale, tool correlation, turn-level reasoning. + +**The foundation layer** surfaces what happens above the kernel: delegation trees (Delegation, Agent), skill loading snapshots (SkillLoad), and recipe orchestration (RecipeRun, RecipeStep, Recipe). It answers: *who delegated to whom, which skills were active, and how recipe steps connect to the tool calls and delegations they triggered.* + +All layers coexist in the same graph and are bridged by **SOURCED_FROM** edges — the +canonical cross-layer connection. Every data layer 2 entity carries one or more +SOURCED_FROM edges back to the raw data layer 1 events that produced it, giving every +semantic node a direct provenance link into the original event stream. Use data layer 1 +when you need exact event fields or the raw timeline. Use data layer 2 when you need +structure, scale, or causation. Navigate between them with SOURCED_FROM. + +**Layer identification signal:** The `node_id` separator tells you which layer a node +came from. `__` (double underscore) = data layer 1 node. `::` (double colon) = +data layer 2 node. A few data layer 2 types use plain identifiers (ToolCall uses +the provider's tool_call_id directly; Orchestrator uses the orchestrator name string). Foundation layer entities use the same `::` separator; concept nodes (`Agent`, `Recipe`) use their name string directly as `node_id`, like `Orchestrator` in data layer 2. + +--- + +## Section 2 — Schema Reference + +### Temporal Property Types: ZONED DATETIME, Not Strings + +**Read this before writing any query that touches a timestamp.** Every `*_at` property (`started_at`, `ended_at`, `occurred_at`, `completed_at`, `resumed_at`, `cancelled_at`, `last_loop_iteration_at`, `loop_completed_at`) and the non-`*_at` field `last_updated` — on nodes AND on the three edge types that carry `occurred_at` (`HAS_EVENT`, `HAS_SUBSESSION`, `FORKED`) — are stored as native Neo4j **`ZONED DATETIME`** values. They are NOT strings. + +❌ Wrong: `WHERE s.started_at > '2026-05-01'` — silently returns no results (comparing ZONED DATETIME to string literal always evaluates false; Neo4j raises no error). + +✅ Correct: `WHERE s.started_at > datetime('2026-05-01')` — wrap every literal in `datetime(...)`. + +✅ `ORDER BY s.started_at` — correct as-is. + +✅ `duration.between(s.started_at, s.ended_at)` — now works, returns a Neo4j DURATION value (e.g. PT1H30M). + +See Gotcha #12 for the same warning at the point of use, and Section 6 for temporal query patterns. + +### Data Layer 1 Nodes + +| Node Label | Description | node_id Format | +|---|---|---| +| `:Session` | One Amplifier session. Sub-labels: `:RootSession`, `:SubSession`, `:ForkedSession`, `:IncompleteSession`. | Raw UUID | +| `:Event` | Every kernel event. Triple-labeled: `:Event` + `:{Category}Event` + `:{Specific}Event`. | `{session_id}__{event_name}__{epoch_ms}` | + +Key properties on `:Event` nodes: +- `occurred_at` — **`ZONED DATETIME`** (native Neo4j temporal; compare with `datetime(...)`, not string literals — see "Temporal Property Types" above) +- `session_id` — owning session UUID +- `workspace` — workspace partition key +- `event_name` — raw event name (e.g. `tool:pre`) +- **`data`** — **JSON string** of the complete raw kernel event payload from the session JSONL. Not a Cypher map. Dot notation (`e.data.tool_name`) does not work in Cypher. Use lifted properties (`tool_name`, `model`, `tool_call_id`, etc.) which are extracted at ingest time as first-class node properties. When raw payload fields not lifted are needed, retrieve the `data` string and parse with `jq` outside Cypher (see Section 5). May contain `ci-blob://` URI references for large payloads. +- Plus event-specific lifted properties (e.g. `tool_name`, `tool_call_id` on `:ToolPreEvent`; `model`, `provider` on `:LlmResponseEvent`). + +Common event labels: `:ToolPreEvent`, `:ToolPostEvent`, `:ToolErrorEvent`, `:LlmRequestEvent`, `:LlmResponseEvent`, `:PromptSubmitEvent`, `:ExecutionStartEvent`, `:ExecutionEndEvent`, `:DelegateAgentSpawnedEvent`, `:SessionStartEvent`, `:SessionEndEvent`. + +### Data Layer 1 Edges + +| Edge | From → To | Meaning | +|---|---|---| +| `HAS_FORK` | Session → Session | Parent session forked a child | +| `HAS_TOOL_CALL` | Session → ToolCall | Session owns a data layer 1 tool call lifecycle node | +| `HAS_EVENT` | Session → Event | Session owns an event node. Carries edge property `occurred_at` (ZONED DATETIME). | +| `HAS_EVENT` | ToolCall → Event | Tool call owns its lifecycle events | + +### Data Layer 2 Entity Types + +All data layer 2 nodes carry a `workspace` property and an SST type label. + +| Entity | Labels | SST Type | node_id Format | Key Properties | +|---|---|---|---|---| +| Session | `:Session:SST_EVENT` (+ `:RootSession`/`:SubSession`/`:ForkedSession`/`:IncompleteSession`) | Temporal | Raw UUID | `started_at` (ZONED DATETIME), `ended_at` (ZONED DATETIME), `last_updated` (ZONED DATETIME), `status` | +| OrchestratorRun | `:OrchestratorRun:SST_EVENT` | Temporal | `{session_id}::orch_run::{started_at}` | `started_at` (ZONED DATETIME), `ended_at` (ZONED DATETIME), `completed_at` (ZONED DATETIME, when present), `orchestrator_name` | +| Iteration | `:Iteration:SST_EVENT` | Temporal | `{session_id}::iteration::{N}` | `iteration_number`, `started_at` (ZONED DATETIME) | +| ContentBlock | `:ContentBlock:SST_EVENT` | Temporal | `{session_id}::block::{iteration_N}::{index}` | `block_type`, `block_index`, `started_at` (ZONED DATETIME, when present) | +| ToolCall | `:ToolCall:SST_EVENT` | Temporal | `{tool_call_id}` (provider UUID directly) | `tool_name`, `tool_call_id`, `result_success`, `result_error`, `result_output`, `started_at` (ZONED DATETIME), `ended_at` (ZONED DATETIME), `parallel_group_id` | +| Prompt | `:Prompt:SST_EVENT` | Temporal | `{session_id}::prompt::{timestamp}` | `prompt_text`, `started_at` (ZONED DATETIME) | +| Cancellation | `:Cancellation:SST_EVENT` | Temporal | `{session_id}::cancellation::{timestamp}` | `occurred_at` (ZONED DATETIME) | +| ContextCompaction | `:ContextCompaction:SST_EVENT` | Temporal | `{session_id}::compaction::{timestamp}` | `occurred_at` (ZONED DATETIME) | +| MountPlan | `:MountPlan:SST_THING` | Resource | `{session_id}::mount_plan` | `mount_plan_data` | +| Orchestrator | `:Orchestrator:SST_CONCEPT` | Abstract | Orchestrator name string (e.g. `loop-streaming`) | `name` | + +### Data Layer 2 Edge Types + +All edges carry an `sst_semantic` property that expresses the relationship's meaning. + +| Edge Type | `sst_semantic` | From → To | What It Means | +|---|---|---|---| +| `HAS_EXECUTION` | `CONTAINS` | Session → OrchestratorRun | Session contains this orchestrator run (one per user turn) | +| `FORKED` | `LEADS_TO` | Session → ForkedSession | Session forked a child session. Carries edge property `occurred_at` (ZONED DATETIME). | +| `HAS_ATTRIBUTE` | `EXPRESSES` | Session → Orchestrator | Session describes its orchestrator type | +| `HAS_PART` | `CONTAINS` | Session → MountPlan/Prompt/Cancellation | Session contains these parts | +| `HAS_PART` | `CONTAINS` | OrchestratorRun → Iteration | Run contains these LLM iterations | +| `HAS_PART` | `CONTAINS` | Iteration → ContentBlock | Iteration contains these content blocks | +| `HAS_TOOL_CALL` | `CONTAINS` | Iteration → ToolCall | Iteration contains these tool calls | +| `HAS_COMPACTION` | `CONTAINS` | Session → ContextCompaction | Session contains this compaction event | +| `HAS_SUBSESSION` | `LEADS_TO` | Session → SubSession | Session leads to a sub-session. Carries edge property `occurred_at` (ZONED DATETIME). | +| `CAUSED` | `LEADS_TO` | ContentBlock → ToolCall | This content block triggered this tool call | +| `PARALLEL_EXECUTION` | `NEAR` | ToolCall ↔ ToolCall | These tool calls ran concurrently in the same parallel group | +| `TRIGGERS` | `LEADS_TO` | Prompt → OrchestratorRun | This prompt started this orchestrator run | +| `ENABLES` | `LEADS_TO` | OrchestratorRun → Prompt | This run's completion enabled the next prompt | +| `SOURCED_FROM` | (none) | data_layer_2 entity → data_layer_1 Event | Cross-layer provenance bridge. Every data layer 2 entity has one SOURCED_FROM edge per contributing raw event. No `sst_semantic` — infrastructure, not SST model. | + +### Foundation Layer Entity Types + +All foundation layer nodes carry a `workspace` property and an SST type label. + +| Entity | Labels | SST Type | node_id Format | Key Properties | +|---|---|---|---|---| +| Delegation | `:Delegation:SST_EVENT` | Temporal | `{parent_session_id}::delegation::{tool_call_id\|sub_session_id}` | `agent`, `sub_session_id`, `parent_session_id`, `started_at` (ZONED DATETIME), `ended_at` (ZONED DATETIME), `resumed_at` (ZONED DATETIME, when present), `cancelled_at` (ZONED DATETIME, when present), `context_depth`, `context_scope` | +| Agent | `:Agent:SST_CONCEPT` | Abstract | Agent name string (e.g. `foundation:explorer`) | `agent` | +| SkillLoad | `:SkillLoad:SST_EVENT` | Temporal | `{session_id}::skill::{skill_name}::{loaded_at_ts}` | `skill_name`, `content_length`, `loaded_at` | +| RecipeRun | `:RecipeRun:SST_EVENT` | Temporal | `{session_id}::recipe_run::{timestamp}` | `name`, `status`, `current_step`, `total_steps`, `last_loop_iteration_at` (ZONED DATETIME, when present), `loop_completed_at` (ZONED DATETIME, when present) | +| RecipeStep | `:RecipeStep:SST_EVENT` | Temporal | `{session_id}::recipe_run::{ts}::step::{N}` | `name`, `status`, `step_id` | +| Recipe | `:Recipe:SST_CONCEPT` | Abstract | Recipe name string | `name` | + +### Foundation Layer Edge Types + +| Edge Type | `sst_semantic` | From → To | What It Means | +|---|---|---|---| +| `HAS_AGENT` | `EXPRESSES` | Session(sub) → Agent | Sub-session describes its agent type | +| `ENCOMPASSES` | `CONTAINS` | Delegation → Session(sub) | Delegation encompasses the sub-session lifecycle | +| `TRIGGERED` | `LEADS_TO` | ToolCall → Delegation | Tool call triggered this delegation | +| `PARALLEL_AGENT` | `NEAR` | Delegation ↔ Delegation | These delegations ran concurrently | +| `HAS_SKILL_LOAD` | `CONTAINS` | Iteration → SkillLoad | Iteration contains this skill load | +| `HAS_RECIPE_RUN` | `CONTAINS` | Session → RecipeRun | Session contains this recipe run | +| `HAS_RECIPE` | `EXPRESSES` | RecipeRun → Recipe | RecipeRun describes its recipe type | +| `HAS_STEP` | `CONTAINS` | RecipeRun → RecipeStep | RecipeRun contains these steps | +| `TRIGGERED` | `LEADS_TO` | RecipeStep → RecipeRun(child) | Step spawned a nested recipe | +| `TRIGGERED` | `LEADS_TO` | RecipeStep → Delegation | Step triggered this delegation | +| `TRIGGERED` | `LEADS_TO` | RecipeStep → ToolCall | Step triggered this tool call | + +--- + +## Section 3 — SST Navigation (Reasoning by Semantic Type) + +The data layer 2 schema uses SST type labels to classify every node by its fundamental +character. These labels let you query across entity boundaries without knowing specific +node labels in advance. + +### Querying by SST Type Label + +Three SST type labels partition the semantic layer: + +| SST Label | Meaning | Entities | +|---|---|---| +| `:SST_EVENT` | Temporal, bounded occurrence | Session, OrchestratorRun, Iteration, ContentBlock, ToolCall, Prompt, Cancellation, ContextCompaction, Delegation, SkillLoad, RecipeRun, RecipeStep | +| `:SST_THING` | Persistent resource or artifact | MountPlan | +| `:SST_CONCEPT` | Abstract, reusable identity | Orchestrator, Agent, Recipe | + +**Example — find all temporal events in the last session:** + +```cypher +MATCH (s:Session {workspace: $workspace}) +WITH s ORDER BY s.started_at DESC LIMIT 1 +MATCH (s)-[:HAS_EXECUTION|HAS_PART*1..3]->(e:SST_EVENT) +RETURN labels(e) AS types, e.node_id, e.started_at +ORDER BY e.started_at +LIMIT 50 +``` + +**Example — find all abstract concepts referenced by a session:** + +```cypher +MATCH (s:Session {workspace: $workspace})-[:HAS_ATTRIBUTE]->(c:SST_CONCEPT) +RETURN c.name AS orchestrator_name +``` + +**Example — find all persistent resources (things) attached to sessions:** + +```cypher +MATCH (s:Session {workspace: $workspace})-[:HAS_PART]->(t:SST_THING) +RETURN s.node_id AS session, labels(t) AS resource_type, t.node_id +``` + +### Querying by Edge Semantic + +Every data layer 2 edge carries an `sst_semantic` property that expresses the relationship's +abstract meaning, independent of the concrete edge type. This lets you query causation, +containment, and concurrence uniformly. + +| `sst_semantic` Value | Meaning | Concrete edges that carry it | +|---|---|---| +| `CONTAINS` | Part-of / containment relationship | `HAS_EXECUTION`, `HAS_PART`, `HAS_TOOL_CALL`, `HAS_COMPACTION` | +| `LEADS_TO` | Causal / sequential relationship | `FORKED`, `HAS_SUBSESSION`, `CAUSED`, `TRIGGERS`, `ENABLES` | +| `EXPRESSES` | Description / attribution relationship | `HAS_ATTRIBUTE` | +| `NEAR` | Concurrent / proximity relationship | `PARALLEL_EXECUTION` | + +**Example — find all causal relationships emanating from a session:** + +```cypher +MATCH (s:Session {workspace: $workspace, node_id: $session_id})-[r]->(target) +WHERE r.sst_semantic = 'LEADS_TO' +RETURN type(r) AS edge_type, r.sst_semantic, labels(target) AS target_type, target.node_id +LIMIT 50 +``` + +**Example — find all concurrent tool calls in the session:** + +```cypher +MATCH (tc1:ToolCall)-[r:PARALLEL_EXECUTION]-(tc2:ToolCall) +WHERE r.sst_semantic = 'NEAR' + AND tc1.workspace = $workspace +RETURN tc1.tool_name, tc2.tool_name, tc1.parallel_group_id +``` + +### Hierarchical Traversal Pattern + +Use variable-length paths with `HAS_EXECUTION|HAS_PART*` to traverse the full session +containment hierarchy in a single query. This pattern reaches any depth of the +Session → OrchestratorRun → Iteration → ContentBlock tree. + +**Pattern — reach all descendants of a session:** + +```cypher +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EXECUTION|HAS_PART*]->(descendant) +RETURN labels(descendant) AS type, descendant.node_id +ORDER BY descendant.started_at +``` + +**Pattern — reach tool calls specifically (three-hop max):** + +```cypher +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EXECUTION|HAS_PART*1..3]->(iteration:Iteration) + -[:HAS_TOOL_CALL]->(tc:ToolCall) +RETURN tc.tool_name, tc.started_at, tc.result_success +ORDER BY tc.started_at +``` + +The `*1..3` bound prevents runaway traversal on large sessions. Use `*` (unbounded) only +when the session hierarchy depth is known to be shallow. + +### Turn Chain Pattern + +The `TRIGGERS` and `ENABLES` edges form a chain that represents the conversation flow: +each user prompt triggers an orchestrator run, and each completed run enables the next +prompt. Traversing this chain reconstructs the turn-by-turn progression of a session. + +**Pattern — walk the turn chain forward from the first prompt:** + +```cypher +MATCH path = (p:Prompt {workspace: $workspace}) + -[:TRIGGERS]->(run:OrchestratorRun) + -[:ENABLES]->(next_prompt:Prompt) +WHERE p.session_id = $session_id +RETURN [node IN nodes(path) | {type: labels(node), id: node.node_id, at: node.started_at}] + AS turn_chain +ORDER BY p.started_at +``` + +**Pattern — count turns in a session:** + +```cypher +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_PART]->(p:Prompt) + -[:TRIGGERS]->(run:OrchestratorRun) +RETURN count(run) AS turn_count +``` + +--- + +## Section 4 — Cross-Layer Queries + +Data layer 1 (raw events) and data layer 2 (semantic entities) coexist in the same graph. +The canonical way to move between them is the `SOURCED_FROM` edge. Two additional fallback +strategies cover cases where SOURCED_FROM edges are absent (older sessions ingested before +the SOURCED_FROM handler was deployed). + +### Join 1 — SOURCED_FROM (Canonical) + +Every data layer 2 entity is linked back to the raw data layer 1 event(s) that produced +it via `SOURCED_FROM` edges. This is the preferred join strategy because it is exact, +direction-aware, and does not require shared scalar keys. + +```cypher +// Navigate from a ToolCall entity back to its source raw event +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EXECUTION]->(run:OrchestratorRun) + -[:HAS_PART]->(iter:Iteration) + -[:HAS_TOOL_CALL]->(tc:ToolCall) +MATCH (tc)-[:SOURCED_FROM]->(pre:ToolPreEvent) +RETURN tc.tool_name AS tool_name, + tc.result_success AS succeeded, + pre.occurred_at AS event_fired_at, + pre.data AS raw_payload +ORDER BY pre.occurred_at +``` + +```cypher +// Navigate in the reverse direction — from a raw event to the semantic entity it produced +MATCH (pre:ToolPreEvent {workspace: $workspace, session_id: $session_id}) +MATCH (tc:ToolCall)-[:SOURCED_FROM]->(pre) +RETURN pre.tool_name AS event_name, + pre.occurred_at AS fired_at, + tc.result_success AS succeeded, + tc.result_output AS output +ORDER BY pre.occurred_at +``` + +Use this join when you want to retrieve the raw event payload for a semantic entity, or +when you want the structured result for a raw event. + +### Join 2 — ToolCall Direct Match (Fallback) + +The `:ToolCall` data layer 2 node uses the provider's `tool_call_id` directly as its +`node_id`. The `:ToolPreEvent` data layer 1 node lifts the same identifier as its +`tool_call_id` property. This shared key is a direct join between the layers. + +```cypher +// Find the semantic ToolCall entity for a given raw ToolPreEvent +MATCH (e:ToolPreEvent {workspace: $workspace, tool_call_id: $tool_call_id}) +MATCH (tc:ToolCall {node_id: e.tool_call_id}) +RETURN e.tool_name AS event_tool_name, + e.occurred_at AS event_time, + tc.result_success AS succeeded, + tc.result_output AS output, + tc.ended_at AS completed_at +``` + +Use this join when SOURCED_FROM edges are absent (older sessions) and you have a +ToolPreEvent. It works only for ToolCall entities — other data layer 2 types do not +share a direct key with data layer 1. + +### Join 3 — Session Containment (Fallback) + +When you need to correlate raw events with the semantic structure of a session, join +through the shared `:Session` node. Data layer 1 uses `HAS_EVENT` to attach raw event +nodes. Data layer 2 uses `HAS_EXECUTION` and `HAS_PART` to attach semantic entities. + +```cypher +// Correlate raw LLM response events with semantic iterations +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) +MATCH (s)-[:HAS_EVENT]->(lre:LlmResponseEvent) // data layer 1 +MATCH (s)-[:HAS_EXECUTION]->(run:OrchestratorRun) + -[:HAS_PART]->(iter:Iteration) // data layer 2 +WHERE lre.iteration_number = iter.iteration_number +RETURN iter.iteration_number, + lre.model AS model, + lre.occurred_at AS responded_at, + iter.started_at AS iter_started +ORDER BY iter.iteration_number +``` + +The `s` (Session) node is the bridge: traverse `HAS_EVENT` to reach data layer 1 nodes, +traverse `HAS_EXECUTION`/`HAS_PART` to reach data layer 2 entities, then join on shared +scalar properties (`iteration_number`, `tool_call_id`, etc.). + +### Workspace Scoping + +Every query must be scoped to a workspace. The workspace is a partition key that prevents +results from bleeding across unrelated projects or users. + +**Default workspace** — the `graph_query` tool automatically injects the configured +workspace as `$workspace`. Most queries use it without any explicit parameter: + +```cypher +MATCH (s:Session {workspace: $workspace}) +RETURN s.node_id, s.started_at +ORDER BY s.started_at DESC +LIMIT 10 +``` + +**Explicit workspace parameter** — when the workspace differs from the default, pass it +explicitly in the `params` dict of the `graph_query` call: + +```cypher +// Query with explicit workspace override +MATCH (s:Session {workspace: $workspace}) +RETURN count(s) AS session_count +``` + +Invoke with `params: {"workspace": "my-other-project"}` to override the default. + +**Cross-workspace queries** — pass `workspace: '*'` to query across all workspaces. Use +sparingly; cross-workspace queries skip the partition index and can be slow on large graphs: + +```cypher +MATCH (s:Session) +WHERE s.workspace <> '' +RETURN s.workspace, count(s) AS sessions_per_workspace +ORDER BY sessions_per_workspace DESC +``` + +**Mandatory workspace placement** — always place `{workspace: $workspace}` on the anchor +node (the first `MATCH` pattern that establishes the starting point of the query). Do not +rely on downstream nodes or `WHERE` clauses alone to scope results. Placing the workspace +constraint on the anchor node allows the graph engine to use the workspace index and +avoids full graph scans: + +```cypher +// CORRECT — workspace on anchor node Session +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EXECUTION]->(run:OrchestratorRun) +RETURN run.orchestrator_name, run.started_at + +// INCORRECT — workspace on a downstream node (misses the index) +MATCH (s:Session {node_id: $session_id}) + -[:HAS_EXECUTION]->(run:OrchestratorRun {workspace: $workspace}) +RETURN run.orchestrator_name, run.started_at +``` + +--- + +## Section 5 — Blob Handling (Critical) + +### The `data` Field Is a JSON String, Not a Cypher Map + +The `data` property on every `:Event` node holds the original kernel event payload as a +**JSON-encoded string**. It is not a Cypher map. You cannot use dot notation (`e.data.tool_name`) +to access sub-fields from Cypher. The entire payload is stored as an opaque string and must +be parsed in application code or with a post-processing tool like `jq`. + +```cypher +// Returns the raw JSON string — you must parse it outside Cypher +MATCH (e:ToolPreEvent {workspace: $workspace, tool_call_id: $tool_call_id}) +RETURN e.data AS raw_payload +``` + +### ci-blob:// URI Replacement for Large Payloads + +When an event payload exceeds the graph storage threshold, the server replaces the full +`data` string with a `ci-blob://` URI reference. The URI points to a blob store entry +that holds the original payload. The `data` field in this case looks like: + +``` +ci-blob://SESSION_ID/EVENT_KEY +``` + +The presence of a `ci-blob://` value in `data` means the full payload is too large to +store inline and must be retrieved separately using `blob_read`. + +### Agent Workflow for Blob-Aware Data Extraction + +When writing queries that access the `data` field, always follow this four-step workflow: + +**Step 1 — Run the Cypher query and retrieve the `data` field:** + +```cypher +MATCH (e:LlmResponseEvent {workspace: $workspace}) +WHERE e.session_id = $session_id +RETURN e.node_id, e.data +ORDER BY e.occurred_at DESC +LIMIT 5 +``` + +**Step 2 — Inspect each `data` value. If it starts with `ci-blob://`, it is a blob +reference. Do NOT try to parse it as JSON.** + +**Step 3 — For blob references, call `blob_read` with the URI. `blob_read` returns a +file path on the local filesystem — it does NOT return the content directly:** + +```python +# blob_read returns {"file_path": "/tmp/ci-blobs/SESSION_ID/EVENT_KEY.json"} +# The content is at the file_path, not in the return value +result = blob_read(uri="ci-blob://SESSION_ID/EVENT_KEY") +file_path = result["file_path"] +``` + +**Step 4 — Extract the fields you need with `jq`. Never load the full blob into the +agent's context — large blobs can be tens of thousands of tokens:** + +```bash +# Extract a specific field from the blob file using jq +jq '.messages[-1].content' /tmp/ci-blobs/SESSION_ID/EVENT_KEY.json + +# Extract just the top-level keys to understand the structure +jq 'keys' /tmp/ci-blobs/SESSION_ID/EVENT_KEY.json + +# Extract a nested field safely with a fallback +jq '.response.usage // "no usage data"' /tmp/ci-blobs/SESSION_ID/EVENT_KEY.json +``` + +### Rules for Blob Handling + +- **Never load the full blob** — always use `jq` to extract only the fields you need. +- **`blob_read` returns a file path**, not content — dereference the path, then read. +- **Check for `ci-blob://` before parsing** — treat any `data` value that starts with + `ci-blob://` as a URI, not as JSON. +- **Lifted properties bypass blobs** — commonly needed fields (e.g. `tool_name`, + `tool_call_id`, `model`, `provider`) are lifted onto the node as top-level properties + during ingestion. Query lifted properties directly from Cypher rather than fetching + blobs when the lifted field is sufficient. + +--- + +## Section 6 — Discovery Patterns (Verified Cypher) + +The following patterns are verified to work against the data layer 2 schema. All use +`$workspace` as the workspace parameter automatically injected by `graph_query`. + +### Pattern 1 — Full Conversation Turn Trace + +Reconstructs the complete turn-by-turn flow of a session: each prompt, the orchestrator +run it triggered, the iterations within that run, and the tool calls in each iteration. + +```cypher +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_PART]->(p:Prompt) + -[:TRIGGERS]->(run:OrchestratorRun) + -[:HAS_PART]->(iter:Iteration) + -[:HAS_TOOL_CALL]->(tc:ToolCall) +RETURN p.started_at AS turn_start, + run.orchestrator_name AS orchestrator, + iter.iteration_number AS iteration, + tc.tool_name AS tool, + tc.result_success AS succeeded, + tc.started_at AS tool_start +ORDER BY p.started_at, iter.iteration_number, tc.started_at +LIMIT 100 +``` + +> **Size note:** Run a count query first (`count(tc)`) if the session has more than a few +> turns. Raise the limit only after confirming the total is manageable. Use SKIP to paginate. + +### Pattern 2 — Tool Usage Per LLM Iteration + +Counts and lists every tool call grouped by which LLM iteration fired it. Useful for +understanding how many tools each iteration invoked and what they were. + +```cypher +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EXECUTION]->(run:OrchestratorRun) + -[:HAS_PART]->(iter:Iteration) + -[:HAS_TOOL_CALL]->(tc:ToolCall) +RETURN iter.iteration_number AS iteration, + collect(tc.tool_name) AS tools_called, + count(tc) AS tool_count +ORDER BY iter.iteration_number +LIMIT 50 +``` + +### Pattern 3 — Parallel Tool Groups + +Finds all tool calls that executed concurrently within the same parallel group. The +`parallel_group_id` property identifies the group; `PARALLEL_EXECUTION` edges connect +the members directly. + +```cypher +MATCH (tc1:ToolCall {workspace: $workspace}) + -[:PARALLEL_EXECUTION]-(tc2:ToolCall) +WHERE tc1.node_id < tc2.node_id // deduplicate undirected pairs + AND tc1.session_id = $session_id +RETURN tc1.parallel_group_id AS group_id, + tc1.tool_name AS tool_a, + tc2.tool_name AS tool_b, + tc1.started_at AS started_at +ORDER BY tc1.started_at +LIMIT 50 +``` + +### Pattern 4 — ContentBlock → ToolCall Causation + +Traces which content block in the LLM response caused each tool call to be issued. +The `CAUSED` edge from `ContentBlock` to `ToolCall` expresses this direct causation. + +```cypher +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EXECUTION]->(run:OrchestratorRun) + -[:HAS_PART]->(iter:Iteration) + -[:HAS_PART]->(block:ContentBlock) + -[:CAUSED]->(tc:ToolCall) +RETURN iter.iteration_number AS iteration, + block.block_index AS block_index, + block.block_type AS block_type, + tc.tool_name AS tool_triggered, + tc.result_success AS succeeded +ORDER BY iter.iteration_number, block.block_index +LIMIT 100 +``` + +### Pattern 5 — Session Comparison + +Compares two sessions side by side: total turns, total iterations, total tool calls, +and success rate. Useful for comparing agent behavior across sessions. + +```cypher +MATCH (s:Session {workspace: $workspace}) +WHERE s.node_id IN [$session_id_a, $session_id_b] +OPTIONAL MATCH (s)-[:HAS_PART]->(p:Prompt)-[:TRIGGERS]->(run:OrchestratorRun) +OPTIONAL MATCH (run)-[:HAS_PART]->(iter:Iteration) +OPTIONAL MATCH (iter)-[:HAS_TOOL_CALL]->(tc:ToolCall) +RETURN s.node_id AS session, + count(DISTINCT p) AS turns, + count(DISTINCT iter) AS iterations, + count(DISTINCT tc) AS tool_calls, + sum(CASE WHEN tc.result_success THEN 1 ELSE 0 END) AS successful_tools +ORDER BY s.node_id +``` + +### Pattern 6 — Failed Tool Calls + +Lists every tool call that failed (result_success is false), including the error message +and the session it belongs to. Useful for diagnosing error-prone sessions. + +```cypher +MATCH (s:Session {workspace: $workspace}) + -[:HAS_EXECUTION]->(run:OrchestratorRun) + -[:HAS_PART]->(iter:Iteration) + -[:HAS_TOOL_CALL]->(tc:ToolCall) +WHERE tc.result_success = false +RETURN s.node_id AS session, + iter.iteration_number AS iteration, + tc.tool_name AS tool, + tc.result_error AS error, + tc.started_at AS failed_at +ORDER BY tc.started_at +LIMIT 50 +``` + +> **Size note:** This query spans ALL sessions in the workspace. Scope to a single session +> with `AND s.node_id = $session_id` to limit exposure, or add `ORDER BY tc.started_at DESC` +> to retrieve the most recent failures first. + +### Pattern 7 — Data Layer 1 / Data Layer 2 Cross-Layer Join + +Joins raw `:ToolPreEvent` nodes (data layer 1) with semantic `:ToolCall` entities +(data layer 2) using the shared `tool_call_id` key. Returns both the raw event timestamp +and the structured result from the semantic layer. + +```cypher +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EVENT]->(pre:ToolPreEvent) +MATCH (tc:ToolCall {node_id: pre.tool_call_id}) +RETURN pre.tool_name AS tool_name, + pre.occurred_at AS event_fired_at, + tc.result_success AS succeeded, + tc.result_error AS error, + tc.result_output AS output_preview, + tc.ended_at AS completed_at +ORDER BY pre.occurred_at +LIMIT 50 +``` + +### Pattern 8 — SOURCED_FROM Cross-Layer Navigation + +Navigates from a semantic `:ToolCall` entity (data layer 2) through its `SOURCED_FROM` +edge to the originating `:ToolPreEvent` (data layer 1). Returns both the structured +result stored on the semantic entity and the raw event timestamp from the event stream. +Use this pattern as the canonical cross-layer join when SOURCED_FROM edges are present. + +```cypher +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EXECUTION]->(run:OrchestratorRun) + -[:HAS_PART]->(iter:Iteration) + -[:HAS_TOOL_CALL]->(tc:ToolCall) +MATCH (tc)-[:SOURCED_FROM]->(pre:ToolPreEvent) +RETURN iter.iteration_number AS iteration, + tc.tool_name AS tool, + tc.result_success AS succeeded, + pre.occurred_at AS event_fired_at, + pre.data AS raw_payload +ORDER BY pre.occurred_at +LIMIT 25 +``` + +> **Size note:** `pre.data` may be a `ci-blob://` URI or a large JSON string. Limit to 25 rows +> and follow the blob handling workflow (Section 5) before loading any `data` field. + +### Delegation Tree + +Lists every agent delegation in a session: which tool call triggered it, which agent was spawned, and the resulting sub-session. + +```cypher +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EXECUTION]->(:OrchestratorRun) + -[:HAS_PART]->(:Iteration) + -[:HAS_TOOL_CALL]->(tc:ToolCall) + -[:TRIGGERED]->(d:Delegation) +RETURN d.agent, d.sub_session_id, d.context_depth, + d.started_at, d.ended_at, tc.tool_name AS via_tool +ORDER BY d.started_at +LIMIT 50 +``` + +### Skills Active Per Iteration + +```cypher +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EXECUTION]->(:OrchestratorRun) + -[:HAS_PART]->(iter:Iteration) + -[:HAS_SKILL_LOAD]->(sl:SkillLoad) +RETURN iter.iteration_number, sl.skill_name, sl.content_length, sl.loaded_at +ORDER BY iter.iteration_number, sl.loaded_at +LIMIT 100 +``` + +### Recipe Run Trace + +```cypher +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_RECIPE_RUN]->(rr:RecipeRun) + -[:HAS_STEP]->(step:RecipeStep) +OPTIONAL MATCH (step)-[:TRIGGERED]->(target) +RETURN rr.name, step.name, step.status, + labels(target) AS triggered_type, target.node_id AS triggered_id +ORDER BY step.step_id +LIMIT 50 +``` + +--- + +## Section 7 — Result Size Management and Pagination + +The graph can hold hundreds or thousands of sessions, each containing many events, tool +calls, and semantic nodes. Returning results without limits is the most common way to +destroy your context window. Every query must be designed with size in mind. + +--- + +### The Cardinal Rule: Always LIMIT + +**Every query that traverses unbounded data MUST include a `LIMIT` clause.** There are +no exceptions. A session with 50 turns and 300 tool calls will return 300+ rows from an +unguarded Pattern 1 query. Multiplied across even 10 sessions, that is 3,000+ rows — +enough to saturate the context window before you have read a single result. + +```cypher +// WRONG — no LIMIT, will return everything +MATCH (s:Session {workspace: $workspace}) + -[:HAS_EXECUTION]->(:OrchestratorRun) + -[:HAS_PART]->(:Iteration) + -[:HAS_TOOL_CALL]->(tc:ToolCall) +RETURN tc.tool_name, tc.started_at + +// CORRECT — bounded +MATCH (s:Session {workspace: $workspace}) + -[:HAS_EXECUTION]->(:OrchestratorRun) + -[:HAS_PART]->(:Iteration) + -[:HAS_TOOL_CALL]->(tc:ToolCall) +RETURN tc.tool_name, tc.started_at +ORDER BY tc.started_at +LIMIT 50 +``` + +--- + +### Safe Default LIMIT Values by Query Type + +Use these defaults when you do not know the expected result size in advance. Reduce +further if the query is part of a larger multi-step analysis. + +| Query type | Safe default LIMIT | Notes | +|---|---|---| +| Session listing | 10 | Very wide rows (many properties) | +| Tool call listing | 50 | One row per call; can be large per session | +| Event listing | 25 | `data` field makes rows wide | +| Iteration listing | 25 | One row per LLM round-trip | +| Delegation listing | 25 | Usually sparse, but can be large in recipe sessions | +| Cross-layer joins | 25 | Double the data per row | +| Aggregation / GROUP BY | 50 | Aggregated rows are lean | +| Path / hierarchy traversal | 25 | Variable row width | +| Full conversation trace | 50 | One row per tool call across all turns | + +If you need more rows than the safe default, always run a COUNT query first (see below) +to understand the actual result size before raising the limit. + +--- + +### Count-First Pattern (Always Run Before Wide Queries) + +Before executing any query that returns multi-field rows over an unknown population, +run a count-first query to understand the scale. This costs almost nothing and prevents +context overflow. + +```cypher +// Step 1 — count first (cheap) +MATCH (s:Session {workspace: $workspace}) + -[:HAS_EXECUTION]->(:OrchestratorRun) + -[:HAS_PART]->(:Iteration) + -[:HAS_TOOL_CALL]->(tc:ToolCall) +WHERE s.node_id = $session_id +RETURN count(tc) AS total_tool_calls +``` + +```cypher +// Step 2 — retrieve data only after you know the count +// If total_tool_calls > 50, use pagination (see SKIP/LIMIT below) +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EXECUTION]->(:OrchestratorRun) + -[:HAS_PART]->(iter:Iteration) + -[:HAS_TOOL_CALL]->(tc:ToolCall) +RETURN iter.iteration_number, tc.tool_name, tc.result_success, tc.started_at +ORDER BY tc.started_at +LIMIT 50 +``` + +Apply this pattern whenever you are querying a session you have not seen before, or when +querying across multiple sessions at once. + +--- + +### SKIP + LIMIT Pagination Pattern + +When you need more results than the safe default, paginate using `SKIP` and `LIMIT`. +Never raise the limit beyond 200 rows per page — the context cost of wide rows +compounds quickly. + +```cypher +// Page 1 — first 50 results +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EXECUTION]->(:OrchestratorRun) + -[:HAS_PART]->(iter:Iteration) + -[:HAS_TOOL_CALL]->(tc:ToolCall) +RETURN iter.iteration_number, tc.tool_name, tc.result_success, tc.started_at +ORDER BY tc.started_at +SKIP 0 LIMIT 50 + +// Page 2 — next 50 +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EXECUTION]->(:OrchestratorRun) + -[:HAS_PART]->(iter:Iteration) + -[:HAS_TOOL_CALL]->(tc:ToolCall) +RETURN iter.iteration_number, tc.tool_name, tc.result_success, tc.started_at +ORDER BY tc.started_at +SKIP 50 LIMIT 50 +``` + +**Pagination rules:** +- Always include `ORDER BY` before `SKIP`/`LIMIT` — without it, page boundaries are + non-deterministic and you may see duplicate or missing rows across pages. +- Use a stable, unique sort key (`started_at` + `node_id` as tiebreaker) to guarantee + consistent ordering across pages. +- Stop paginating when the returned row count is less than the page size — that signals + the last page. + +--- + +### Progressive Exploration Strategy + +For unfamiliar sessions or multi-session queries, always follow a three-phase funnel. +Going straight to full detail is almost always a mistake. + +**Phase 1 — Orient (counts and summaries only)** + +```cypher +// How many sessions, how large? +MATCH (s:Session {workspace: $workspace}) +OPTIONAL MATCH (s)-[:HAS_EXECUTION]->(:OrchestratorRun)-[:HAS_PART]->(iter:Iteration) +RETURN s.node_id, s.started_at, s.status, count(iter) AS iteration_count +ORDER BY s.started_at DESC +LIMIT 10 +``` + +**Phase 2 — Scope (aggregated view of the target session)** + +```cypher +// What happened in this session, at a glance? +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) +OPTIONAL MATCH (s)-[:HAS_EXECUTION]->(:OrchestratorRun)-[:HAS_PART]->(iter:Iteration) +OPTIONAL MATCH (iter)-[:HAS_TOOL_CALL]->(tc:ToolCall) +RETURN count(DISTINCT iter) AS iterations, + count(DISTINCT tc) AS tool_calls, + sum(CASE WHEN tc.result_success = false THEN 1 ELSE 0 END) AS failures +``` + +**Phase 3 — Drill (filtered, bounded detail)** + +```cypher +// Now retrieve the specific rows you need, filtered and limited +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EXECUTION]->(:OrchestratorRun) + -[:HAS_PART]->(iter:Iteration) + -[:HAS_TOOL_CALL]->(tc:ToolCall) +WHERE tc.result_success = false // focus on failures only +RETURN iter.iteration_number, tc.tool_name, tc.result_error, tc.started_at +ORDER BY tc.started_at +LIMIT 25 +``` + +This funnel ensures you only load detailed rows for the subset you actually need. + +--- + +### Bounding Variable-Length Path Traversal + +Variable-length path patterns (`*`, `*1..N`) can fanout explosively on large or deeply +nested graphs. Always bound them. + +```cypher +// DANGEROUS — unbounded path, will traverse everything reachable +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EXECUTION|HAS_PART*]->(descendant) +RETURN labels(descendant), descendant.node_id + +// SAFE — bounded depth (3 hops covers the full Session→Run→Iter→Block hierarchy) +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EXECUTION|HAS_PART*1..3]->(descendant) +RETURN labels(descendant), descendant.node_id +ORDER BY descendant.started_at +LIMIT 100 +``` + +**Recommended depth bounds:** +- `*1..2` — Session → Run → Iteration (stops before ContentBlock/ToolCall) +- `*1..3` — Session → Run → Iteration → ContentBlock (full semantic hierarchy) +- `*1..4` — includes ToolCall via ContentBlock (only if you need CAUSED edges) +- Avoid `*` or `*1..10` entirely — use explicit typed-edge chains instead. + +--- + +### Filtering Before Returning (Reduce in Graph, Not in Client) + +Apply `WHERE` filters inside the Cypher query rather than retrieving all rows and +filtering in the calling code. Every unneeded row is context tokens wasted. + +```cypher +// INEFFICIENT — retrieve all tool calls, filter in code +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EXECUTION]->(:OrchestratorRun) + -[:HAS_PART]->(iter:Iteration) + -[:HAS_TOOL_CALL]->(tc:ToolCall) +RETURN tc.tool_name, tc.result_success, tc.started_at +LIMIT 200 + +// EFFICIENT — filter in Cypher, return only what you need +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + -[:HAS_EXECUTION]->(:OrchestratorRun) + -[:HAS_PART]->(iter:Iteration) + -[:HAS_TOOL_CALL]->(tc:ToolCall) +WHERE tc.tool_name = 'delegate' + AND tc.started_at > $cutoff_time +RETURN tc.tool_name, tc.result_success, tc.started_at +ORDER BY tc.started_at +LIMIT 50 +``` + +**Common filter strategies:** +- Filter by `session_id` first to scope to one session before retrieving detailed data. +- Use `tool_name` filters to narrow tool call queries to the tool you care about. +- Use `started_at` range filters to limit time-based queries. +- Use `result_success = false` to focus on error analysis. +- Use `LIMIT 1` with `ORDER BY ... DESC` to get the single most recent item. + +--- + +### Multi-Session Queries: Extra Caution + +Queries that span multiple sessions multiply the row count by the number of sessions +matched. Always add an explicit session count guard or use `WHERE s.node_id IN [...]` +to constrain to a known set. + +```cypher +// DANGEROUS — matches all sessions in workspace, multiplies rows +MATCH (s:Session {workspace: $workspace}) + -[:HAS_EXECUTION]->(:OrchestratorRun) + -[:HAS_PART]->(iter:Iteration) + -[:HAS_TOOL_CALL]->(tc:ToolCall) +RETURN s.node_id, tc.tool_name, tc.result_success +LIMIT 50 // 50 rows across ALL sessions — almost certainly not what you want + +// SAFE — one session at a time +MATCH (s:Session {workspace: $workspace, node_id: $session_id}) + ... + +// SAFE — explicit set of sessions +MATCH (s:Session {workspace: $workspace}) +WHERE s.node_id IN [$session_a, $session_b, $session_c] + ... +LIMIT 50 // 50 rows across 3 known sessions — controlled +``` + +When you do need cross-session analysis, use aggregation (COUNT, collect, GROUP BY) +to collapse results before returning them, then drill into specific sessions. + +--- + +## Gotchas + +**1. Data layer 2 nodes only exist if handlers ran.** +Semantic entities (OrchestratorRun, Iteration, ContentBlock, ToolCall, etc.) are +created by data layer 2 handlers during event ingestion. If a session was ingested +before data layer 2 was deployed, or if the handler for a specific event type is +disabled, those nodes will not exist. Always use `OPTIONAL MATCH` when joining +data layer 2 entities against unknown sessions. + +**2. `result_success: false` signals the error path.** +A `:ToolCall` node with `result_success = false` means the tool returned an error. +The `result_error` property holds the error message. A missing `result_success` +property (null) means the `ToolPostEvent` or `ToolErrorEvent` has not been processed +yet — the tool call is still in-flight or the handler did not run. + +**3. `data` is a JSON string, not a Cypher map.** +The `data` property on `:Event` nodes is a serialized JSON string. You cannot access +`e.data.tool_name` in Cypher. Lifted properties (`tool_name`, `tool_call_id`, `model`, +etc.) are your first resort. When you need raw payload fields not lifted, retrieve the +`data` string and parse it with `jq` outside Cypher (see Section 5). + +**4. `ENABLES` edges are sparse.** +The `ENABLES` edge from `OrchestratorRun` to the next `Prompt` is only written when +the session has a multi-turn chain. Single-turn sessions and sessions where the run +ended without a follow-up prompt will have no `ENABLES` edge. For a session with N +prompts, there are exactly N−1 `ENABLES` edges (each run connects to the next prompt, +but the last run has no successor). Do not rely on `ENABLES` existing to determine if +a session ended cleanly. + +**5. Workspace scoping is mandatory.** +Every query must include `{workspace: $workspace}` on the anchor node. Omitting the +workspace filter causes a full graph scan and may return results from unrelated projects +or users. The `graph_query` tool automatically injects `$workspace` — always include +it in the first `MATCH` pattern. + +**6. The node MERGE key is `{node_id, workspace}`.** +Data layer 2 nodes are merged using the composite key `{node_id, workspace}`. This +means the same logical entity (e.g. an Orchestrator named `loop-streaming`) can exist +as separate nodes in different workspaces. Cross-workspace queries (passing `workspace: +'*'`) will return one node per workspace, not one node per unique `node_id`. Account +for this when aggregating across workspaces. + +**7. `SOURCED_FROM` edges may be absent on older sessions.** +Sessions ingested before the SOURCED_FROM handler was deployed will not have any +cross-layer provenance edges. To check which data layer 2 nodes are missing their +source link, run: + +```cypher +MATCH (n:SST_EVENT) WHERE NOT (n)-[:SOURCED_FROM]->() AND NOT n:Session RETURN labels(n), count(*) +``` + +If this returns results, fall back to Join 2 (ToolCall Direct Match) or Join 3 +(Session Containment) for those sessions. + +**8. Foundation layer nodes only exist when those features were used.** +A session with no delegation, no skills, and no recipes will have no `Delegation`, `SkillLoad`, `RecipeRun`, or `RecipeStep` nodes. Always use `OPTIONAL MATCH` when joining foundation layer entities against arbitrary sessions. + +**9. `Agent` and `Recipe` are concept nodes shared across sessions.** +Unlike `SST_EVENT` entities, `Agent` and `Recipe` nodes are merged by name across the entire workspace. Querying `(a:Agent)` without a session anchor will span all sessions. Scope through the session: reach `Agent` via `HAS_AGENT` from the sub-session, or `Recipe` via `HAS_RECIPE` from a `RecipeRun`. + +**10. `SkillLoad` may attach to `Session` directly, not `Iteration`.** +Skills loaded before the first `provider:request` have no active `Iteration`. The `HAS_SKILL_LOAD` edge then comes from `Session` rather than `Iteration`. Pattern "Skills Active Per Iteration" only returns skills tied to an iteration — add `OPTIONAL MATCH (s)-[:HAS_SKILL_LOAD]->(sl:SkillLoad)` to catch session-level loads. + +**11. Unbounded queries will destroy your context window.** +A graph with many sessions is NOT like a small in-memory dataset. Each session can have +hundreds of tool calls, thousands of events, and dozens of iterations. A query with no +`LIMIT` clause against the whole workspace can return tens of thousands of rows, saturating +the context window before any result can be processed. Three mandatory habits: + +1. **Always LIMIT.** Every query that traverses tool calls, events, or iterations must have + `LIMIT N`. Start at the safe defaults from Section 7. Raise only after counting. + +2. **Count before widening.** If you need to understand the full extent of a dataset, run a + `count()` aggregation first. The count result is a single number — it costs almost nothing. + Then decide whether the actual rows are safe to retrieve. + +3. **Anchor on a session before traversing.** The pattern `MATCH (s:Session {workspace: $workspace})` + without a `node_id` filter spans every session. Add `node_id: $session_id` or + `WHERE s.node_id IN [...]` to constrain the starting set before any traversal. + +See Section 7 for the complete size management and pagination reference. + +**12. Temporal comparisons require the `datetime()` wrapper.** Comparing a ZONED DATETIME +property to a string literal (`WHERE s.started_at > '2026-05-01'`) always evaluates false — +no error is raised, no results are returned, the query silently produces nothing. Use +`datetime('2026-05-01')` instead. ORDER BY on temporal columns requires no change. See +"Temporal Property Types" at the top of Section 2 for the full list of ZONED DATETIME +properties. + +- `duration.between(s.started_at, s.ended_at)` computes elapsed time (session length, + tool-call duration) and returns a Neo4j DURATION value (e.g. PT1H30M). +- `WHERE s.started_at > datetime() - duration('P30D')` enables rolling time-window queries + (sessions started in the last 30 days). Both were impossible with string storage. + +**13. `IncompleteSession` is a health marker, not a terminal label.** +A session node carrying `:IncompleteSession` reached `session:end` with no prior `session:start` +or `session:fork` event captured. It carries **none** of the terminal labels (`:RootSession`, +`:SubSession`, `:ForkedSession`) and has `has_terminal: false`. It is not a stub for a lost root +session — it is a health signal. A spike in the count indicates upstream event loss. Count them +with: + +```cypher +MATCH (s:Session:IncompleteSession) RETURN count(s) +``` + +Do not treat `:IncompleteSession` nodes as `:RootSession`. Filter them out of normal terminal- +session queries with `WHERE NOT s:IncompleteSession`, or check `has_terminal: false` on the +session node. A WARNING is logged at ingest time: "reached end with no start/fork event; marked +IncompleteSession (recovered)". diff --git a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/graph_query_tool.py b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/graph_query_tool.py index 2016f38..85a65db 100644 --- a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/graph_query_tool.py +++ b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/graph_query_tool.py @@ -86,6 +86,16 @@ def input_schema(self) -> dict[str, Any]: "required": ["query"], } + @property + def skill_sync_enabled(self) -> bool: + """Pass-through to the resolver's skill_sync_enabled knob. + + Consumed by skill_sync.on_session_ready via the coordinator capability; + returning False (the resolver default) makes the sync path a complete + no-op (zero GET /version, zero skill fetch, no reload handler). + """ + return self._tool_resolver.skill_sync_enabled + def _resolve_server_config(self, coordinator: Any) -> tuple[str | None, str | None, str]: """Resolve (server_url, api_key, workspace) using the three-tier fallback chain. diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/skill_fetcher.py b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/skill_fetcher.py similarity index 51% rename from modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/skill_fetcher.py rename to modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/skill_fetcher.py index 15f865d..7048a83 100644 --- a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/skill_fetcher.py +++ b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/skill_fetcher.py @@ -1,4 +1,10 @@ -"""SkillFetcher — conditional HTTP GET for dynamic skill population.""" +"""SkillFetcher — conditional HTTP GET for dynamic skill population. + +Relocated from hook-context-intelligence into tool-graph-query: skill-content +sync is an analytics-path concern, consumed by the graph-analyst sub-session, +NOT a logging concern. The ETag + content-hash drift logic is unchanged; the +deprecated bundled-legacy-content writer was dropped during relocation. +""" from __future__ import annotations @@ -11,11 +17,6 @@ WATCHED_SKILLS: frozenset[str] = frozenset({"context-intelligence-graph-query"}) -# Coordinator capability key registered by the tool-skills module at mount time. -# tool-skills populates this with a SkillsDiscovery object that exposes -# .find(skill_name) -> SkillMetadata with the absolute filesystem path for each skill. -TOOL_SKILLS_DISCOVERY_CAPABILITY: str = "skills_discovery" - # Sidecar filenames stored alongside SKILL.md _ETAG_FILENAME: str = ".etag" _CONTENT_HASH_FILENAME: str = ".content_hash" @@ -37,10 +38,7 @@ class VersionCheckResult(NamedTuple): def _is_skills_capable(version: str | None) -> bool: - """Return True if *version* is >= 2.0.0, False otherwise. - - Returns False for None, unparseable strings, and versions below 2.0.0. - """ + """Return True if *version* is >= 2.0.0, False otherwise (incl. None/unparseable).""" try: parsed = tuple(int(part) for part in version.split(".")) # type: ignore[union-attr] except (ValueError, AttributeError): @@ -59,47 +57,34 @@ class SkillFetcher: Drift detection --------------- tool-skills loads skills from git at mount time, potentially overwriting a - SKILL.md that was previously fetched from the server. To avoid the fetcher - incorrectly trusting a stale ETag after such an external write, a - ``.content_hash`` sidecar (SHA-256 of the last server-written content) is - stored alongside the ``.etag`` sidecar. Before sending ``If-None-Match``, - the fetcher verifies that the local file's hash still matches the stored - hash. A mismatch means the file drifted (git, manual edit, etc.) and an - unconditional GET is performed instead. + SKILL.md that was previously fetched from the server. A ``.content_hash`` + sidecar (SHA-256 of the last server-written content) is stored alongside the + ``.etag`` sidecar. Before sending ``If-None-Match`` the fetcher verifies the + local file's hash still matches the stored hash. A mismatch means the file + drifted (git, manual edit, etc.) and an unconditional GET is performed. """ - def __init__(self, server_url: str, timeout: float = 3.0) -> None: + def __init__(self, server_url: str, timeout: float = 3.0, api_key: str | None = None) -> None: self._server_url = server_url.rstrip("/") self._timeout = timeout + self._api_key = api_key async def check_server_version(self) -> VersionCheckResult: - """Check the server version via GET /version. - - Returns - ------- - VersionCheckResult with reachable=False, version=None on network errors. - VersionCheckResult with reachable=True, version=None on 404. - VersionCheckResult with reachable=True, version= on 200. - VersionCheckResult with reachable=False, version=None on any other status. - Never raises — all exceptions are caught. - """ + """Check the server version via GET /version. Never raises.""" import httpx # noqa: PLC0415 — lazy import to avoid loading httpx at module init time url = f"{self._server_url}/version" try: - # Single GET — no context manager needed; httpx cleans up via __del__. response = await httpx.AsyncClient().get(url, timeout=self._timeout) except httpx.RequestError as exc: logger.debug("check_server_version: unreachable — %s", exc) return VersionCheckResult(reachable=False, version=None) if response.status_code == 404: - logger.debug("check_server_version: server reachable, /version absent (404)") return VersionCheckResult(reachable=True, version=None) if response.status_code == 200: version = response.json().get("version") - logger.debug("check_server_version: server at %s reported version=%s", url, version) return VersionCheckResult(reachable=True, version=version) logger.debug( @@ -108,57 +93,13 @@ async def check_server_version(self) -> VersionCheckResult: ) return VersionCheckResult(reachable=False, version=None) - # DEPRECATED: Remove once all servers >= 2.0.0. - def write_legacy_content(self, skill_name: str, skill_path: Path) -> None: - """Write bundled legacy skill content to *skill_path*. - - Reads the corresponding .md file from the ``legacy_content`` package - directory and writes it to *skill_path*. Any existing ``.etag`` sidecar - alongside *skill_path* is removed so the next session performs an - unconditional GET once the server is upgraded. The ``.content_hash`` - sidecar is updated to reflect what was written so drift detection - remains accurate. - - Raises - ------ - FileNotFoundError - If no legacy content exists for *skill_name* (packaging error — - must not be silenced). - - .. deprecated:: - Remove this method once all servers are >= 2.0.0. - """ - legacy_path = Path(__file__).parent / "legacy_content" / f"{skill_name}.md" - content = legacy_path.read_text(encoding="utf-8") - skill_path.write_text(content, encoding="utf-8") - - etag_path = skill_path.parent / _ETAG_FILENAME - if etag_path.exists(): - etag_path.unlink() - - # Keep .content_hash in sync with what was written so the next - # fetch() can detect if git later overwrites the file again. - content_hash_path = skill_path.parent / _CONTENT_HASH_FILENAME - content_hash_path.write_text(_sha256(skill_path)) - - logger.debug("legacy_skill_written: skill=%s [DEPRECATED]", skill_name) - async def fetch(self, skill_name: str, skill_path: Path) -> bool: - """Fetch a skill file from the server. - - Performs a conditional HTTP GET using If-None-Match when an ETag sidecar - exists alongside *skill_path* **and** the local file's SHA-256 still - matches the stored ``.content_hash`` sidecar. A mismatch between the - local file and the stored hash means the file was modified externally - (e.g. tool-skills loaded a newer version from git) — in that case the - ETag is stale relative to the local state and an unconditional GET is - performed to re-align the local file with the server. + """Fetch a skill file from the server (conditional GET via If-None-Match). Returns ------- - True — 200 received; *skill_path*, ``.etag``, and ``.content_hash`` - sidecars were all updated. - False — 304 (not modified), connection/timeout error, or unexpected status. + True — 200 received; *skill_path*, ``.etag``, and ``.content_hash`` updated. + False — 304, connection/timeout error, or unexpected status. """ import httpx # noqa: PLC0415 — lazy import to avoid loading httpx at module init time @@ -167,6 +108,8 @@ async def fetch(self, skill_name: str, skill_path: Path) -> bool: content_hash_path = skill_path.parent / _CONTENT_HASH_FILENAME headers: dict[str, str] = {} + if self._api_key: + headers["Authorization"] = f"Bearer {self._api_key}" if etag_path.exists(): stored_etag = etag_path.read_text().strip() if stored_etag: @@ -174,13 +117,8 @@ async def fetch(self, skill_name: str, skill_path: Path) -> bool: stored_hash = content_hash_path.read_text().strip() current_hash = _sha256(skill_path) if current_hash == stored_hash: - # Local file unchanged since last server fetch — safe to - # use the cached ETag for a conditional GET. headers["If-None-Match"] = stored_etag else: - # Local file drifted (e.g. git overwrote it). The stored - # ETag no longer corresponds to local content; skip it to - # force an unconditional GET and re-align with the server. logger.info( "skill_local_drift: %s — local content modified externally " "(stored hash %s… → current %s…); " @@ -190,10 +128,6 @@ async def fetch(self, skill_name: str, skill_path: Path) -> bool: current_hash[:8], ) else: - # No content_hash sidecar yet (first run after upgrade, or - # legacy session). We cannot verify whether the local file - # still matches the server's ETag, so skip If-None-Match and - # let the server decide authoritatively. logger.debug( "skill_hash_missing: %s — no .content_hash sidecar; " "skipping If-None-Match for unconditional GET", @@ -212,8 +146,6 @@ async def fetch(self, skill_name: str, skill_path: Path) -> bool: etag = response.headers.get("etag", "") if etag: etag_path.write_text(etag) - # Record the hash of exactly what we wrote so drift detection works - # on the next session start. content_hash_path.write_text(_sha256(skill_path)) return True diff --git a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/skill_sync.py b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/skill_sync.py new file mode 100644 index 0000000..56fb2ab --- /dev/null +++ b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/skill_sync.py @@ -0,0 +1,352 @@ +"""skill_sync — offline integrity + per-skill sync helpers. + +Provides two helpers consumed by the graph-analyst sub-session: + +_invalidate_if_drift + Q2 offline-drift sidecar invalidation: compares the stored content hash + against the current SKILL.md content and removes both sidecars when they + no longer match (drift). Content is always preserved. + +_sync_skill + Integrity pre-flight + conditional fetch: runs offline integrity when no + server is reachable, or delegates to SkillFetcher when the server responds. + One bad skill must not break the session — all fetch errors are logged and + swallowed. +""" + +from __future__ import annotations + +import hashlib +import logging +import os +from importlib import resources +from pathlib import Path + +from .bundled_skill import EXPECTED_BUNDLED_SKILL_SHA256 +from .skill_fetcher import ( + _CONTENT_HASH_FILENAME, + _ETAG_FILENAME, + WATCHED_SKILLS, + SkillFetcher, + _sha256, +) + +log = logging.getLogger(__name__) + +# Capability identifiers consumed by graph_query_tool.py +TOOL_SKILLS_DISCOVERY_CAPABILITY: str = "skills_discovery" +_GRAPH_QUERY_TOOL_CAPABILITY: str = "context_intelligence._graph_query_tool" + +#: Package holding the vendored offline skill bodies (see bundled_skill/__init__.py). +_BUNDLED_SKILL_PACKAGE: str = "amplifier_module_tool_context_intelligence_query.bundled_skill" + +__all__ = [ + "TOOL_SKILLS_DISCOVERY_CAPABILITY", + "WATCHED_SKILLS", + "_GRAPH_QUERY_TOOL_CAPABILITY", + "on_session_ready", +] + + +def _sha256_text(text: str) -> str: + """Return the hex SHA-256 digest of *text* (UTF-8).""" + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _vendored_body(skill_name: str) -> str | None: + """Return the vendored offline body for *skill_name*, or ``None`` if absent. + + The body is packaged inside ``bundled_skill/.md``. A missing + file (e.g. dropped from the wheel by a faulty build) returns ``None`` so the + caller can fail loud rather than silently doing the wrong thing. + """ + try: + resource = resources.files(_BUNDLED_SKILL_PACKAGE).joinpath(f"{skill_name}.md") + if resource.is_file(): + return resource.read_text(encoding="utf-8") + except (FileNotFoundError, ModuleNotFoundError, OSError) as exc: + log.error("vendored_skill_body_error: %s — %s", skill_name, exc) + return None + + +def _install_vendored_body(skill_name: str, skill_path: Path) -> None: + """Swap *skill_path*'s content for the vendored offline body — zero network. + + Used on the ``skill_sync_enabled=false`` path when a server IS configured: + the shipped ``SKILL.md`` is the pessimistic "Server Unavailable" stub, so we + replace it with the real bundled body, otherwise a working graph-analyst is + handed a skill that tells it the graph is dead. + + Correctness properties (see issue #283 council review): + - **Fail loud**: a missing vendored body logs an ERROR and leaves the + on-disk file untouched — never a silent wrong result. + - **Idempotent by SHA-256**: rewrites only when the on-disk content differs, + so a single-command series writes once and then no-ops (zero disk churn). + - **Crash-atomic, ETag-first**: the stale ``.etag`` sidecar is removed FIRST + (a vendored body is not an ETag-validated server fetch), then the content + is replaced via a temp-file + ``os.replace`` atomic rename, then the + ``.content_hash`` sidecar is written. Any crash window therefore leaves + the skill in a clean "no ETag → next enabled sync does an unconditional + GET" state — never a stale-ETag→304 freeze of the vendored body. + """ + body = _vendored_body(skill_name) + if body is None: + log.error( + "skill_swap_unavailable: %s — vendored offline body missing from the " + "tool-graph-query package; leaving on-disk skill unchanged (the " + "graph-analyst may see the 'Server Unavailable' stub). This indicates " + "a broken build — the vendored body must ship in the wheel.", + skill_name, + ) + return + + # --- SHA-256 integrity gate (fail loud, write nothing on mismatch) ---------- + # A tampered or corrupted wheel body must never reach disk silently. + # EXPECTED_BUNDLED_SKILL_SHA256 is the authoritative pin burned into the package + # at build time. Any deviation means the wheel is broken or compromised. + new_hash = _sha256_text(body) + if new_hash != EXPECTED_BUNDLED_SKILL_SHA256: + raise ValueError( + f"skill_install_sha_mismatch: {skill_name!r} — " + f"vendored body SHA {new_hash[:8]}… does not match expected pin " + f"{EXPECTED_BUNDLED_SKILL_SHA256[:8]}…; refusing to install a " + f"corrupted or tampered body. " + f"(got={new_hash} expected={EXPECTED_BUNDLED_SKILL_SHA256})" + ) + # --------------------------------------------------------------------------- + etag_path = skill_path.parent / _ETAG_FILENAME + content_hash_path = skill_path.parent / _CONTENT_HASH_FILENAME + + # ETag-first: a vendored body has no server ETag; drop any stale one so a + # later re-enabled sync issues a clean unconditional GET. + try: + etag_path.unlink() + except FileNotFoundError: + pass + except OSError as exc: + log.debug("skill_swap_etag_unlink_failed: %s — %s", skill_name, exc) + + if skill_path.exists() and _sha256(skill_path) == new_hash: + # Already the vendored body — keep the content-hash sidecar honest, no rewrite. + if not content_hash_path.exists() or content_hash_path.read_text().strip() != new_hash: + content_hash_path.write_text(new_hash) + log.debug("skill_swap_noop: %s already matches vendored offline body", skill_name) + return + + tmp_path = skill_path.parent / f".{skill_path.name}.swap.{os.getpid()}.tmp" + tmp_path.write_text(body, encoding="utf-8") + os.replace(tmp_path, skill_path) # atomic on the same filesystem + content_hash_path.write_text(new_hash) + log.info( + "skill_swap_applied: %s — installed vendored offline body (%d bytes, zero network)", + skill_name, + len(body), + ) + + +async def _apply_offline_skill_bodies(coordinator: object, tool: object) -> None: + """Disabled-sync path: ensure each watched skill has a usable body, no network. + + For each watched skill: + - **server configured** → swap the pessimistic stub for the vendored real + body (``_install_vendored_body``). ``server_url`` is read from config + only — no reachability ping — so this stays strictly zero-network. + - **no server configured** → retain the shipped "Server Unavailable" stub + (correct: the graph genuinely is not there). + + Empty / whitespace / unexpanded-placeholder ``server_url`` resolves to + ``None`` via the resolver and is treated as "not configured". + """ + discovery = coordinator.get_capability(TOOL_SKILLS_DISCOVERY_CAPABILITY) # type: ignore[union-attr] + if discovery is None: + log.info( + "skill_sync_disabled: skills_discovery capability not available — " + "nothing to swap; skipping (zero network)" + ) + return + + server_url, _api_key, _workspace = tool._resolve_server_config(coordinator) # type: ignore[attr-defined] + server_configured = bool(server_url) + + for skill_name in WATCHED_SKILLS: + meta = discovery.find(skill_name) + if meta is None: + log.debug( + "skill_sync_disabled: %s — discovery.find() returned None; skipping", + skill_name, + ) + continue + skill_path = Path(meta.path) + if server_configured: + log.info( + "skill_sync_disabled: server configured — installing vendored offline " + "body for %s without any network (no GET /version, no GET /skills/)", + skill_name, + ) + _install_vendored_body(skill_name, skill_path) + else: + log.info( + "skill_sync_disabled: no server configured — retaining shipped " + "'Server Unavailable' stub for %s (graph genuinely absent)", + skill_name, + ) + + +def _invalidate_if_drift( + skill_name: str, + skill_path: Path, + etag_path: Path, + content_hash_path: Path, +) -> None: + """Remove both sidecar files when offline content has drifted. + + Returns immediately (noop) when: + - *skill_path* does not exist, or + - *content_hash_path* does not exist (no baseline to compare against). + + When the stored hash matches the current file hash the skill is in sync + and both sidecars are left untouched. When the hashes diverge, both + *etag_path* and *content_hash_path* are deleted so that the next + online sync will perform an unconditional GET rather than send a stale + ``If-None-Match``. The content file is never deleted. + """ + if not (skill_path.exists() and content_hash_path.exists()): + return + + stored_hash = content_hash_path.read_text().strip() + current_hash = _sha256(skill_path) + + if stored_hash == current_hash: + return # In sync — nothing to do. + + # Drift detected: remove both sidecars so the next online GET is unconditional. + for path in (etag_path, content_hash_path): + try: + path.unlink() + except OSError as exc: + log.debug("skill_sidecar_unlink_failed: %s — %s", path.name, exc) + + log.warning( + "skill_offline_drift_invalidated: %s — stored hash %s… != current %s…; " + "ETag and content-hash sidecars removed", + skill_name, + stored_hash[:8], + current_hash[:8], + ) + + +async def _sync_skill( + skill_name: str, + skill_path: Path, + server_url: str | None, + api_key: str | None, +) -> None: + """Integrity pre-flight + conditional fetch for a single skill. + + Offline path (no server_url or server unreachable): + Run _invalidate_if_drift so stale ETag sidecars are cleaned up before + the next online session. + + Online path (server reachable): + Delegate to SkillFetcher.fetch which handles conditional GET (ETag / + If-None-Match) and content-hash drift internally. Any exception is + caught and logged so that one bad skill cannot break the session. + """ + etag_path = skill_path.parent / _ETAG_FILENAME + content_hash_path = skill_path.parent / _CONTENT_HASH_FILENAME + + if not server_url: + _invalidate_if_drift(skill_name, skill_path, etag_path, content_hash_path) + return + + fetcher = SkillFetcher(server_url, api_key=api_key) + version = await fetcher.check_server_version() + + if not version.reachable: + _invalidate_if_drift(skill_name, skill_path, etag_path, content_hash_path) + return + + try: + await fetcher.fetch(skill_name, skill_path) + except Exception as exc: # noqa: BLE001 — one bad skill must not break the session + log.warning("skill_sync_failed: %s — %s", skill_name, exc) + + +async def _resync_all_watched(coordinator: object) -> None: + """Re-sync all watched skills using coordinator capabilities. + + Hard guards: + - Logs a WARNING and returns when skills_discovery capability is absent. + - Logs a WARNING and skips a skill when discovery.find() returns None. + + Config is resolved via the tool's _resolve_server_config so that the + correct server URL and API key are used for the current session. + """ + discovery = coordinator.get_capability(TOOL_SKILLS_DISCOVERY_CAPABILITY) # type: ignore[union-attr] + if discovery is None: + log.warning( + "skill_sync_skipped: skills_discovery capability not available — " + "skill sync will be deferred until the capability is registered" + ) + return + + tool = coordinator.get_capability(_GRAPH_QUERY_TOOL_CAPABILITY) # type: ignore[union-attr] + + for skill_name in WATCHED_SKILLS: + meta = discovery.find(skill_name) + if meta is None: + log.warning( + "skill_sync_skipped: %s — discovery.find() returned None; " + "skill may not be registered in this session", + skill_name, + ) + continue + + skill_path = Path(meta.path) + + if tool is not None: + server_url, api_key, _workspace = tool._resolve_server_config(coordinator) + else: + server_url, api_key = None, None + + await _sync_skill(skill_name, skill_path, server_url, api_key) + + +async def on_session_ready(coordinator: object) -> None: + """Orchestrate skill sync on session start and register a reload handler. + + Performs an initial sync of all watched skills, then registers a + ``skill:unloaded`` hook so that mid-session skill reloads trigger a + re-sync automatically. + + Opt-out gate: when the graph-query tool capability is present and reports + ``skill_sync_enabled is False`` (the ``skill_sync_enabled`` config knob / + ``AMPLIFIER_CONTEXT_INTELLIGENCE_SKILL_SYNC_ENABLED`` env var), this performs + **zero per-turn network** — no ``GET /version`` ping, no skill fetch — and + does **not** register the ``skill:unloaded`` reload handler. It does NOT, + however, leave a working graph-analyst stranded on the pessimistic "Server + Unavailable" stub: when a server IS configured it swaps in the vendored + offline body (a local copy, still zero network); when no server is + configured it retains the stub. See ``_apply_offline_skill_bodies``. This + lets headless / single-command-series workflows pay zero skill traffic per + turn while keeping the graph-analyst usable. When the tool capability is + absent the gate does not fire and the existing offline-integrity path runs + unchanged. + """ + tool = coordinator.get_capability(_GRAPH_QUERY_TOOL_CAPABILITY) # type: ignore[union-attr] + if tool is not None and not getattr(tool, "skill_sync_enabled", True): + await _apply_offline_skill_bodies(coordinator, tool) + return + + await _resync_all_watched(coordinator) + + async def _on_skill_unloaded(event_name: str, data: dict) -> None: # type: ignore[type-arg] + if data.get("skill_name") in WATCHED_SKILLS: + await _resync_all_watched(coordinator) + + coordinator.hooks.register( # type: ignore[union-attr] + "skill:unloaded", + _on_skill_unloaded, + priority=100, + name="SkillSync", + ) diff --git a/modules/tool-context-intelligence-query/pyproject.toml b/modules/tool-context-intelligence-query/pyproject.toml index 2aeb2bb..642eb3e 100644 --- a/modules/tool-context-intelligence-query/pyproject.toml +++ b/modules/tool-context-intelligence-query/pyproject.toml @@ -24,6 +24,9 @@ package = true [tool.hatch.build.targets.wheel] packages = ["amplifier_module_tool_context_intelligence_query"] +[tool.hatch.build.targets.wheel.force-include] +"amplifier_module_tool_context_intelligence_query/bundled_skill/context-intelligence-graph-query.md" = "amplifier_module_tool_context_intelligence_query/bundled_skill/context-intelligence-graph-query.md" + [tool.hatch.metadata] # Required to build a wheel that carries a PEP 508 direct-reference (git+https) dependency. allow-direct-references = true diff --git a/modules/tool-context-intelligence-query/tests/test_bundled_skill.py b/modules/tool-context-intelligence-query/tests/test_bundled_skill.py new file mode 100644 index 0000000..09fe0a8 --- /dev/null +++ b/modules/tool-context-intelligence-query/tests/test_bundled_skill.py @@ -0,0 +1,58 @@ +"""Fail-loud guard for the vendored offline skill body. + +The vendored ``bundled_skill/context-intelligence-graph-query.md`` is consumed at +runtime on the ``skill_sync_enabled=false`` path: when a server is configured we +swap the pessimistic "Server Unavailable" stub for this real body so the +graph-analyst is not stranded. A prior refactor already deleted the equivalent +``legacy_content`` fallback once; these tests make any future deletion, wheel +omission, or silent drift FAIL LOUD in CI instead of in production. + +Ported from spike branch modules/tool-graph-query/tests/test_bundled_skill.py. +Package retargeted: amplifier_module_tool_graph_query + → amplifier_module_tool_context_intelligence_query +""" + +from __future__ import annotations + +import hashlib +from importlib import resources + +_PKG = "amplifier_module_tool_context_intelligence_query.bundled_skill" +_SKILL_FILE = "context-intelligence-graph-query.md" + + +def test_vendored_body_is_packaged_and_importable() -> None: + resource = resources.files(_PKG).joinpath(_SKILL_FILE) + assert resource.is_file(), ( + f"vendored offline body {_SKILL_FILE!r} is missing from the " + f"{_PKG} package — it must ship in the wheel (see pyproject force-include)" + ) + + +def test_vendored_body_hash_is_pinned() -> None: + from amplifier_module_tool_context_intelligence_query.bundled_skill import ( + EXPECTED_BUNDLED_SKILL_SHA256, + ) + + data = resources.files(_PKG).joinpath(_SKILL_FILE).read_text(encoding="utf-8") + actual = hashlib.sha256(data.encode("utf-8")).hexdigest() + assert actual == EXPECTED_BUNDLED_SKILL_SHA256, ( + "vendored offline body drifted from its pinned hash. If this was an " + "intentional refresh from the canonical " + "microsoft/amplifier-context-intelligence skill, update " + "EXPECTED_BUNDLED_SKILL_SHA256 in bundled_skill/__init__.py and re-run the " + "DTU proof." + ) + + +def test_vendored_body_is_the_real_skill_not_the_stub() -> None: + """Guard against accidentally vendoring the 'Server Unavailable' stub.""" + data = resources.files(_PKG).joinpath(_SKILL_FILE).read_text(encoding="utf-8") + assert "Server Unavailable" not in data, ( + "vendored body must be the REAL graph-query skill, not the stub" + ) + assert "# Context Intelligence Graph Query" in data + # The watched-skill name the swap logic resolves must match this file's stem. + from amplifier_module_tool_context_intelligence_query.skill_fetcher import WATCHED_SKILLS + + assert _SKILL_FILE[: -len(".md")] in WATCHED_SKILLS diff --git a/modules/tool-context-intelligence-query/tests/test_skill_fetcher.py b/modules/tool-context-intelligence-query/tests/test_skill_fetcher.py new file mode 100644 index 0000000..b9b0b89 --- /dev/null +++ b/modules/tool-context-intelligence-query/tests/test_skill_fetcher.py @@ -0,0 +1,353 @@ +"""Tests for SkillFetcher (relocated into tool-context-intelligence-query) — conditional HTTP GET. + +Ported from spike branch modules/tool-graph-query/tests/test_skill_fetcher.py. +Package retargeted: amplifier_module_tool_graph_query + → amplifier_module_tool_context_intelligence_query +No structural fixes needed — all symbols exist verbatim on main. +""" + +from __future__ import annotations + +import hashlib +import logging +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + + +def _make_http_mock(status_code: int, text: str, etag: str) -> MagicMock: + """Patch-ready mock for httpx.AsyncClient used as an async context manager.""" + response = MagicMock() + response.status_code = status_code + response.text = text + response.headers = {"etag": etag} if etag else {} + + client = AsyncMock() + client.get = AsyncMock(return_value=response) + client.__aenter__ = AsyncMock(return_value=client) + client.__aexit__ = AsyncMock(return_value=None) + return MagicMock(return_value=client) + + +def _make_error_mock(exc: Exception) -> MagicMock: + """Patch-ready mock for httpx.AsyncClient that raises exc on get().""" + client = AsyncMock() + client.get = AsyncMock(side_effect=exc) + client.__aenter__ = AsyncMock(return_value=client) + client.__aexit__ = AsyncMock(return_value=None) + return MagicMock(return_value=client) + + +def _make_version_http_mock(status_code: int, body: dict) -> MagicMock: + """Mock for check_server_version() — calls AsyncClient().get() directly (no async with).""" + response = MagicMock() + response.status_code = status_code + response.json = MagicMock(return_value=body) + + client = AsyncMock() + client.get = AsyncMock(return_value=response) + return MagicMock(return_value=client) + + +class TestConstants: + def test_watched_skills_contains_only_graph_query(self) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import WATCHED_SKILLS + + assert WATCHED_SKILLS == frozenset({"context-intelligence-graph-query"}) + + +class TestSkillFetcher200: + async def test_returns_true_on_200(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + fetcher = SkillFetcher("http://localhost:8000") + with patch("httpx.AsyncClient", _make_http_mock(200, "skill content", 'W/"abc123"')): + result = await fetcher.fetch("my-skill", skill_path) + assert result is True + + async def test_writes_content_to_skill_path(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + fetcher = SkillFetcher("http://localhost:8000") + with patch("httpx.AsyncClient", _make_http_mock(200, "skill content here", 'W/"abc123"')): + await fetcher.fetch("my-skill", skill_path) + assert skill_path.read_text() == "skill content here" + + async def test_writes_etag_sidecar(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + fetcher = SkillFetcher("http://localhost:8000") + with patch("httpx.AsyncClient", _make_http_mock(200, "skill content", 'W/"etag-value"')): + await fetcher.fetch("my-skill", skill_path) + etag_path = tmp_path / ".etag" + assert etag_path.exists() + assert etag_path.read_text() == 'W/"etag-value"' + + async def test_writes_content_hash_sidecar(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + fetcher = SkillFetcher("http://localhost:8000") + with patch("httpx.AsyncClient", _make_http_mock(200, "abc", 'W/"e"')): + await fetcher.fetch("my-skill", skill_path) + content_hash_path = tmp_path / ".content_hash" + assert content_hash_path.exists() + assert content_hash_path.read_text() == hashlib.sha256(b"abc").hexdigest() + + +class TestSkillFetcher304: + async def test_returns_false_on_304(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + skill_path.write_text("# Existing Content") + (tmp_path / ".etag").write_text('W/"abc123"') + fetcher = SkillFetcher("http://localhost:8000") + with patch("httpx.AsyncClient", _make_http_mock(304, "", "")): + result = await fetcher.fetch("my-skill", skill_path) + assert result is False + + async def test_does_not_overwrite_skill_on_304(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + skill_path.write_text("# Existing Content") + (tmp_path / ".etag").write_text('W/"abc123"') + fetcher = SkillFetcher("http://localhost:8000") + with patch("httpx.AsyncClient", _make_http_mock(304, "", "")): + await fetcher.fetch("my-skill", skill_path) + assert skill_path.read_text() == "# Existing Content" + + +class TestSkillFetcherUnexpectedStatus: + async def test_returns_false_on_404(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + fetcher = SkillFetcher("http://localhost:8000") + with patch("httpx.AsyncClient", _make_http_mock(404, "not found", "")): + result = await fetcher.fetch("my-skill", skill_path) + assert result is False + assert not skill_path.exists() + + async def test_logs_warning_on_unexpected_status( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + fetcher = SkillFetcher("http://localhost:8000") + with caplog.at_level(logging.WARNING): + with patch("httpx.AsyncClient", _make_http_mock(500, "server error", "")): + await fetcher.fetch("my-skill", skill_path) + assert any("skill_fetch_failed" in r.getMessage() for r in caplog.records) + + +class TestSkillFetcherErrors: + async def test_returns_false_on_connect_error(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + fetcher = SkillFetcher("http://localhost:8000") + with patch("httpx.AsyncClient", _make_error_mock(httpx.ConnectError("refused"))): + result = await fetcher.fetch("my-skill", skill_path) + assert result is False + assert not skill_path.exists() + + async def test_returns_false_on_timeout(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + fetcher = SkillFetcher("http://localhost:8000") + with patch( + "httpx.AsyncClient", + _make_error_mock(httpx.TimeoutException("timed out", request=None)), + ): + result = await fetcher.fetch("my-skill", skill_path) + assert result is False + assert not skill_path.exists() + + +class TestSkillFetcherETagSidecar: + async def test_no_etag_sidecar_sends_unconditional_get(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + fetcher = SkillFetcher("http://localhost:8000") + mock_cls = _make_http_mock(200, "skill content", "") + with patch("httpx.AsyncClient", mock_cls): + await fetcher.fetch("my-skill", skill_path) + sent_headers = mock_cls.return_value.get.call_args.kwargs.get("headers", {}) + assert "If-None-Match" not in sent_headers + + async def test_existing_etag_sidecar_sends_if_none_match_when_hash_matches( + self, tmp_path: Path + ) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + skill_path.write_text("# Existing skill content") + (tmp_path / ".content_hash").write_text(hashlib.sha256(skill_path.read_bytes()).hexdigest()) + (tmp_path / ".etag").write_text("stored-etag-value") + fetcher = SkillFetcher("http://localhost:8000") + mock_cls = _make_http_mock(304, "", "") + with patch("httpx.AsyncClient", mock_cls): + await fetcher.fetch("my-skill", skill_path) + sent_headers = mock_cls.return_value.get.call_args.kwargs.get("headers", {}) + assert sent_headers.get("If-None-Match") == "stored-etag-value" + + async def test_drift_skips_if_none_match_for_unconditional_get(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + skill_path.write_text("# drifted local content") + # Stored hash deliberately does NOT match the current file -> drift. + (tmp_path / ".content_hash").write_text("0" * 64) + (tmp_path / ".etag").write_text("stored-etag-value") + fetcher = SkillFetcher("http://localhost:8000") + mock_cls = _make_http_mock(200, "new server content", 'W/"new"') + with patch("httpx.AsyncClient", mock_cls): + await fetcher.fetch("my-skill", skill_path) + sent_headers = mock_cls.return_value.get.call_args.kwargs.get("headers", {}) + assert "If-None-Match" not in sent_headers + + async def test_no_etag_sidecar_written_when_response_omits_etag(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + etag_path = tmp_path / ".etag" + fetcher = SkillFetcher("http://localhost:8000") + with patch("httpx.AsyncClient", _make_http_mock(200, "skill content", "")): + result = await fetcher.fetch("my-skill", skill_path) + assert result is True + assert skill_path.read_text() == "skill content" + assert not etag_path.exists() + + async def test_etag_sidecar_updated_on_200(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + (tmp_path / ".etag").write_text("old-etag") + fetcher = SkillFetcher("http://localhost:8000") + with patch("httpx.AsyncClient", _make_http_mock(200, "new content", "new-etag")): + await fetcher.fetch("my-skill", skill_path) + assert (tmp_path / ".etag").read_text() == "new-etag" + + +class TestVersionCapability: + def test_is_skills_capable_none_returns_false(self) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import ( + _is_skills_capable, + ) + + assert _is_skills_capable(None) is False + + def test_is_skills_capable_old_version_returns_false(self) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import ( + _is_skills_capable, + ) + + assert _is_skills_capable("1.9.0") is False + + def test_is_skills_capable_min_version_returns_true(self) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import ( + _is_skills_capable, + ) + + assert _is_skills_capable("2.0.0") is True + + def test_is_skills_capable_unparseable_returns_false(self) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import ( + _is_skills_capable, + ) + + assert _is_skills_capable("invalid") is False + + def test_version_check_result_namedtuple(self) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import ( + VersionCheckResult, + ) + + result = VersionCheckResult(reachable=True, version="2.0.0") + assert result.reachable is True + assert result.version == "2.0.0" + + +class TestCheckServerVersion: + async def test_connect_error_returns_unreachable(self) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import ( + SkillFetcher, + VersionCheckResult, + ) + + fetcher = SkillFetcher("http://localhost:8000") + with patch("httpx.AsyncClient", _make_error_mock(httpx.ConnectError("refused"))): + result = await fetcher.check_server_version() + assert result == VersionCheckResult(reachable=False, version=None) + + async def test_404_returns_reachable_with_none_version(self) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import ( + SkillFetcher, + VersionCheckResult, + ) + + fetcher = SkillFetcher("http://localhost:8000") + with patch("httpx.AsyncClient", _make_version_http_mock(404, {})): + result = await fetcher.check_server_version() + assert result == VersionCheckResult(reachable=True, version=None) + + async def test_200_with_version_returns_reachable_with_version(self) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import ( + SkillFetcher, + VersionCheckResult, + ) + + fetcher = SkillFetcher("http://localhost:8000") + with patch("httpx.AsyncClient", _make_version_http_mock(200, {"version": "2.0.0"})): + result = await fetcher.check_server_version() + assert result == VersionCheckResult(reachable=True, version="2.0.0") + + +class TestSkillFetcherAuth: + async def test_bearer_header_present_when_api_key_set(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + fetcher = SkillFetcher("http://localhost:8000", api_key="secret-token") + mock_cls = _make_http_mock(200, "skill content", "") + with patch("httpx.AsyncClient", mock_cls): + await fetcher.fetch("my-skill", skill_path) + sent_headers = mock_cls.return_value.get.call_args.kwargs.get("headers", {}) + assert sent_headers.get("Authorization") == "Bearer secret-token" + + async def test_no_auth_header_when_api_key_absent(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + fetcher = SkillFetcher("http://localhost:8000") + mock_cls = _make_http_mock(200, "skill content", "") + with patch("httpx.AsyncClient", mock_cls): + await fetcher.fetch("my-skill", skill_path) + sent_headers = mock_cls.return_value.get.call_args.kwargs.get("headers", {}) + assert "Authorization" not in sent_headers + + async def test_auth_and_if_none_match_coexist(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import SkillFetcher + + skill_path = tmp_path / "SKILL.md" + content = b"# Existing skill content" + skill_path.write_bytes(content) + (tmp_path / ".content_hash").write_text(hashlib.sha256(content).hexdigest()) + (tmp_path / ".etag").write_text("stored-etag-value") + fetcher = SkillFetcher("http://localhost:8000", api_key="secret-token") + mock_cls = _make_http_mock(304, "", "") + with patch("httpx.AsyncClient", mock_cls): + await fetcher.fetch("my-skill", skill_path) + sent_headers = mock_cls.return_value.get.call_args.kwargs.get("headers", {}) + assert sent_headers.get("Authorization") == "Bearer secret-token" + assert sent_headers.get("If-None-Match") == "stored-etag-value" diff --git a/modules/tool-context-intelligence-query/tests/test_skill_sync.py b/modules/tool-context-intelligence-query/tests/test_skill_sync.py new file mode 100644 index 0000000..fbfac17 --- /dev/null +++ b/modules/tool-context-intelligence-query/tests/test_skill_sync.py @@ -0,0 +1,505 @@ +"""Tests for skill_sync — offline integrity + per-skill sync helpers. + +Ported from spike branch modules/tool-graph-query/tests/test_skill_sync.py. +Package retargeted: amplifier_module_tool_graph_query + → amplifier_module_tool_context_intelligence_query + +Reference fixes applied (noted inline): + [FIX-1] All patch targets updated: amplifier_module_tool_graph_query.skill_sync.* + → amplifier_module_tool_context_intelligence_query.skill_sync.* + [FIX-2] All imports updated to the new package name. + [FIX-3] on_session_ready now delegates to _resync_all_watched (refactor on main) + rather than dispatching directly; tests that patch _sync_skill are + unaffected because _resync_all_watched still calls _sync_skill with + identical arguments. + [FIX-4] Disabled-path tests use _apply_offline_skill_bodies internally on main + (instead of a direct _install_vendored_body call); SkillFetcher is still + never instantiated, so mock_fetcher.assert_not_called() still holds. +""" + +from __future__ import annotations + +import hashlib +import logging +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + + +class TestInvalidateIfDrift: + def test_drift_deletes_both_sidecars_keeps_content(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_sync import _invalidate_if_drift + + skill = tmp_path / "SKILL.md" + skill.write_text("# drifted content") + etag = tmp_path / ".etag" + etag.write_text("etag-value") + chash = tmp_path / ".content_hash" + chash.write_text("0" * 64) # Does NOT match actual content -> drift + + _invalidate_if_drift("my-skill", skill, etag, chash) + + assert skill.exists(), "Content file must be retained" + assert not etag.exists(), ".etag sidecar must be deleted" + assert not chash.exists(), ".content_hash sidecar must be deleted" + + def test_match_is_noop(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_sync import _invalidate_if_drift + + skill = tmp_path / "SKILL.md" + skill.write_text("# matching content") + etag = tmp_path / ".etag" + etag.write_text("etag-value") + chash = tmp_path / ".content_hash" + chash.write_text(hashlib.sha256(skill.read_bytes()).hexdigest()) # Matches -> in sync + + _invalidate_if_drift("my-skill", skill, etag, chash) + + assert skill.exists() + assert etag.exists(), ".etag must remain when hash matches" + assert chash.exists(), ".content_hash must remain when hash matches" + + def test_no_content_hash_sidecar_is_noop(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_sync import _invalidate_if_drift + + skill = tmp_path / "SKILL.md" + skill.write_text("# some content") + etag = tmp_path / ".etag" + etag.write_text("etag-value") + # No .content_hash created — _invalidate_if_drift should return early + + _invalidate_if_drift("my-skill", skill, etag, tmp_path / ".content_hash") + + assert etag.exists(), ".etag must be untouched when no .content_hash present" + assert etag.read_text() == "etag-value" + + +class TestSyncSkill: + async def test_no_server_url_runs_offline_integrity_no_fetch(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_sync import _sync_skill + + skill = tmp_path / "SKILL.md" + skill.write_text("# drifted content") + etag = tmp_path / ".etag" + etag.write_text("etag-value") + chash = tmp_path / ".content_hash" + chash.write_text("0" * 64) # Drift state — hash does not match + + with patch( + "amplifier_module_tool_context_intelligence_query.skill_sync.SkillFetcher" + ) as mock_fetcher: # [FIX-1] + await _sync_skill("my-skill", skill, server_url=None, api_key=None) + + mock_fetcher.assert_not_called() + assert not etag.exists(), ".etag must be deleted (offline drift detected)" + assert not chash.exists(), ".content_hash must be deleted (offline drift detected)" + + async def test_unreachable_server_runs_offline_integrity_no_fetch(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import ( + VersionCheckResult, + ) + from amplifier_module_tool_context_intelligence_query.skill_sync import _sync_skill + + skill = tmp_path / "SKILL.md" + skill.write_text("# drifted content") + etag = tmp_path / ".etag" + etag.write_text("etag-value") + chash = tmp_path / ".content_hash" + chash.write_text("0" * 64) # Drift state + + instance = MagicMock() + instance.check_server_version = AsyncMock( + return_value=VersionCheckResult(reachable=False, version=None) + ) + instance.fetch = AsyncMock() + + with patch( + "amplifier_module_tool_context_intelligence_query.skill_sync.SkillFetcher", # [FIX-1] + return_value=instance, + ): + await _sync_skill("my-skill", skill, server_url="http://down:9000", api_key=None) + + instance.fetch.assert_not_awaited() + assert not etag.exists(), ".etag must be deleted (unreachable server + drift)" + assert not chash.exists(), ".content_hash must be deleted (unreachable server + drift)" + + async def test_reachable_server_calls_fetch(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_fetcher import ( + VersionCheckResult, + ) + from amplifier_module_tool_context_intelligence_query.skill_sync import _sync_skill + + skill = tmp_path / "SKILL.md" + skill.write_text("# content") + + instance = MagicMock() + instance.check_server_version = AsyncMock( + return_value=VersionCheckResult(reachable=True, version="2.0.0") + ) + instance.fetch = AsyncMock(return_value=True) + + with patch( + "amplifier_module_tool_context_intelligence_query.skill_sync.SkillFetcher", # [FIX-1] + return_value=instance, + ) as mock_fetcher_cls: + await _sync_skill("my-skill", skill, server_url="http://up:9000", api_key="k") + + mock_fetcher_cls.assert_called_once_with("http://up:9000", api_key="k") + instance.fetch.assert_awaited_once_with("my-skill", skill) + + +# ====================================================================== +# Helpers for on_session_ready tests +# ====================================================================== + + +def _make_tool(server_url: str, api_key: str = "k", workspace: str = "ws") -> MagicMock: + tool = MagicMock() + tool._resolve_server_config = MagicMock(return_value=(server_url, api_key, workspace)) + return tool + + +def _make_ready_coordinator( + skill_path: Path, + tool: MagicMock | None, + *, + discovery_present: bool = True, + find_returns_meta: bool = True, +) -> MagicMock: + discovery: MagicMock | None = None + if discovery_present: + discovery = MagicMock() + meta = MagicMock() + meta.path = skill_path + discovery.find = MagicMock(return_value=meta if find_returns_meta else None) + + caps: dict[str, object] = { + "skills_discovery": discovery, + "context_intelligence._graph_query_tool": tool, + } + + coord = MagicMock() + coord.get_capability = MagicMock(side_effect=lambda name: caps.get(name)) + coord.hooks = MagicMock() + coord.hooks.register = MagicMock(return_value=MagicMock()) + return coord + + +class TestOnSessionReadyHardGuards: + async def test_missing_discovery_capability_is_loud_noop(self, tmp_path: Path, caplog) -> None: + from amplifier_module_tool_context_intelligence_query.skill_sync import ( + on_session_ready, + ) + + tool = _make_tool("http://up:9000") + coord = _make_ready_coordinator(tmp_path / "SKILL.md", tool, discovery_present=False) + + with patch( + "amplifier_module_tool_context_intelligence_query.skill_sync._sync_skill", # [FIX-1] + new_callable=AsyncMock, + ) as mock_sync: + with caplog.at_level(logging.WARNING): + await on_session_ready(coord) + + mock_sync.assert_not_awaited() + assert any("skill_sync" in record.message for record in caplog.records) + + async def test_find_returns_none_is_loud_noop(self, tmp_path: Path, caplog) -> None: + from amplifier_module_tool_context_intelligence_query.skill_sync import ( + on_session_ready, + ) + + tool = _make_tool("http://up:9000") + coord = _make_ready_coordinator(tmp_path / "SKILL.md", tool, find_returns_meta=False) + + with patch( + "amplifier_module_tool_context_intelligence_query.skill_sync._sync_skill", # [FIX-1] + new_callable=AsyncMock, + ) as mock_sync: + with caplog.at_level(logging.WARNING): + await on_session_ready(coord) + + mock_sync.assert_not_awaited() + assert any("skill_sync" in record.message for record in caplog.records) + + +class TestOnSessionReadyOrchestration: + async def test_dispatches_sync_with_resolved_config(self, tmp_path: Path) -> None: + # [FIX-3] on_session_ready → _resync_all_watched → _sync_skill; patch still intercepts. + from amplifier_module_tool_context_intelligence_query.skill_sync import ( + on_session_ready, + ) + + skill_path = tmp_path / "SKILL.md" + tool = _make_tool("http://up:9000", api_key="key-1", workspace="ws-1") + coord = _make_ready_coordinator(skill_path, tool) + + with patch( + "amplifier_module_tool_context_intelligence_query.skill_sync._sync_skill", # [FIX-1] + new_callable=AsyncMock, + ) as mock_sync: + await on_session_ready(coord) + + mock_sync.assert_awaited_once_with( + "context-intelligence-graph-query", skill_path, "http://up:9000", "key-1" + ) + + async def test_registers_skill_unloaded_handler(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_sync import ( + on_session_ready, + ) + + skill_path = tmp_path / "SKILL.md" + tool = _make_tool("http://up:9000") + coord = _make_ready_coordinator(skill_path, tool) + + with patch( + "amplifier_module_tool_context_intelligence_query.skill_sync._sync_skill", # [FIX-1] + new_callable=AsyncMock, + ): + await on_session_ready(coord) + + assert "skill:unloaded" in [c.args[0] for c in coord.hooks.register.call_args_list] + + +_STUB_BODY = ( + "---\nname: context-intelligence-graph-query\nversion: 2.0.0\n---\n\n" + "# Context Intelligence Graph Query — Server Unavailable\n\n" + "The context intelligence server is not reachable.\n" + "Delegate immediately to `session-navigator`. Do not attempt Cypher queries.\n" +) +_ETAG = ".etag" +_CHASH = ".content_hash" + + +def _write_stub(skill_path: Path) -> str: + skill_path.write_text(_STUB_BODY) + return hashlib.sha256(skill_path.read_bytes()).hexdigest() + + +class TestOnSessionReadySkillSyncDisabled: + """skill_sync_enabled=false gate at the top of on_session_ready. + + Disabled performs ZERO per-turn network and does NOT register the + skill:unloaded handler. But it must NOT strand a working graph-analyst on the + pessimistic "Server Unavailable" stub: + - server configured -> swap stub for the vendored real body (local copy) + - no server -> retain the stub (graph genuinely absent) + """ + + async def test_disabled_server_configured_swaps_in_vendored_body(self, tmp_path: Path) -> None: + # [FIX-4] Disabled path uses _apply_offline_skill_bodies which calls + # _install_vendored_body; SkillFetcher is still never instantiated. + from amplifier_module_tool_context_intelligence_query.bundled_skill import ( + EXPECTED_BUNDLED_SKILL_SHA256, + ) + from amplifier_module_tool_context_intelligence_query.skill_sync import ( + on_session_ready, + ) + + skill_path = tmp_path / "SKILL.md" + _write_stub(skill_path) + tool = _make_tool("http://up:9000") + tool.skill_sync_enabled = False + coord = _make_ready_coordinator(skill_path, tool) + + # ZERO network: SkillFetcher must never be constructed, _sync_skill never awaited. + with ( + patch( + "amplifier_module_tool_context_intelligence_query.skill_sync.SkillFetcher" # [FIX-1] + ) as mock_fetcher, + patch( + "amplifier_module_tool_context_intelligence_query.skill_sync._sync_skill", # [FIX-1] + new_callable=AsyncMock, + ) as mock_sync, + ): + await on_session_ready(coord) + + mock_fetcher.assert_not_called() + mock_sync.assert_not_awaited() + # The pessimistic stub has been replaced by the vendored real body. + got = hashlib.sha256(skill_path.read_bytes()).hexdigest() + assert got == EXPECTED_BUNDLED_SKILL_SHA256 + assert "Server Unavailable" not in skill_path.read_text() + # No per-turn reload handler. + assert "skill:unloaded" not in [c.args[0] for c in coord.hooks.register.call_args_list] + + async def test_disabled_server_configured_removes_stale_etag_and_sets_hash( + self, tmp_path: Path + ) -> None: + from amplifier_module_tool_context_intelligence_query.bundled_skill import ( + EXPECTED_BUNDLED_SKILL_SHA256, + ) + from amplifier_module_tool_context_intelligence_query.skill_sync import ( + on_session_ready, + ) + + skill_path = tmp_path / "SKILL.md" + _write_stub(skill_path) + # Seed a STALE etag + content_hash (left over from a prior server fetch). + (tmp_path / _ETAG).write_text('W/"stale-etag"') + (tmp_path / _CHASH).write_text("0" * 64) + tool = _make_tool("http://up:9000") + tool.skill_sync_enabled = False + coord = _make_ready_coordinator(skill_path, tool) + + await on_session_ready(coord) + + # Stale etag removed (so a later re-enabled sync does a clean unconditional GET). + assert not (tmp_path / _ETAG).exists(), "stale .etag must be removed on vendored swap" + # content_hash now matches the vendored body. + assert (tmp_path / _CHASH).read_text().strip() == EXPECTED_BUNDLED_SKILL_SHA256 + + async def test_disabled_server_configured_idempotent_second_turn_no_rewrite( + self, tmp_path: Path + ) -> None: + from amplifier_module_tool_context_intelligence_query.skill_sync import ( + on_session_ready, + ) + + skill_path = tmp_path / "SKILL.md" + _write_stub(skill_path) + tool = _make_tool("http://up:9000") + tool.skill_sync_enabled = False + coord = _make_ready_coordinator(skill_path, tool) + + await on_session_ready(coord) # turn 1 — writes vendored body + first_mtime = skill_path.stat().st_mtime_ns + await on_session_ready(coord) # turn 2 — content already correct + second_mtime = skill_path.stat().st_mtime_ns + + assert first_mtime == second_mtime, "idempotent: SKILL.md must not be rewritten on turn 2" + + async def test_disabled_rewrites_when_content_differs_by_trailing_newline( + self, tmp_path: Path + ) -> None: + # tester-breaker: idempotency must compare by sha256, not eyeballing. A + # one-byte difference (extra trailing newline) is NOT the vendored body + # and must be normalized back to it. + from amplifier_module_tool_context_intelligence_query.bundled_skill import ( + EXPECTED_BUNDLED_SKILL_SHA256, + ) + from amplifier_module_tool_context_intelligence_query.skill_sync import ( + _vendored_body, + on_session_ready, + ) + + skill_path = tmp_path / "SKILL.md" + body = _vendored_body("context-intelligence-graph-query") + assert body is not None + skill_path.write_text(body + "\n") # differs by one trailing newline + tool = _make_tool("http://up:9000") + tool.skill_sync_enabled = False + coord = _make_ready_coordinator(skill_path, tool) + + await on_session_ready(coord) + + got = hashlib.sha256(skill_path.read_bytes()).hexdigest() + assert got == EXPECTED_BUNDLED_SKILL_SHA256 + + async def test_disabled_no_server_retains_stub(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_sync import ( + on_session_ready, + ) + + skill_path = tmp_path / "SKILL.md" + stub_hash = _write_stub(skill_path) + tool = _make_tool("") # no server configured + tool.skill_sync_enabled = False + coord = _make_ready_coordinator(skill_path, tool) + + with patch( + "amplifier_module_tool_context_intelligence_query.skill_sync.SkillFetcher" # [FIX-1] + ) as mock_fetcher: + await on_session_ready(coord) + + mock_fetcher.assert_not_called() + assert hashlib.sha256(skill_path.read_bytes()).hexdigest() == stub_hash, ( + "no server -> the 'Server Unavailable' stub must be retained untouched" + ) + assert "skill:unloaded" not in [c.args[0] for c in coord.hooks.register.call_args_list] + + async def test_disabled_missing_vendored_body_fails_loud_and_leaves_file( + self, tmp_path: Path, caplog + ) -> None: + from amplifier_module_tool_context_intelligence_query.skill_sync import ( + on_session_ready, + ) + + skill_path = tmp_path / "SKILL.md" + stub_hash = _write_stub(skill_path) + tool = _make_tool("http://up:9000") + tool.skill_sync_enabled = False + coord = _make_ready_coordinator(skill_path, tool) + + with patch( + "amplifier_module_tool_context_intelligence_query.skill_sync._vendored_body", # [FIX-1] + return_value=None, + ): + with caplog.at_level(logging.ERROR): + await on_session_ready(coord) + + # Fail loud + leave the on-disk file untouched (never a silent wrong result). + assert any("skill_swap_unavailable" in r.message for r in caplog.records) + assert hashlib.sha256(skill_path.read_bytes()).hexdigest() == stub_hash + + async def test_disabled_emits_legible_info_signal(self, tmp_path: Path, caplog) -> None: + from amplifier_module_tool_context_intelligence_query.skill_sync import ( + on_session_ready, + ) + + skill_path = tmp_path / "SKILL.md" + _write_stub(skill_path) + tool = _make_tool("http://up:9000") + tool.skill_sync_enabled = False + coord = _make_ready_coordinator(skill_path, tool) + + with caplog.at_level(logging.INFO): + await on_session_ready(coord) + + assert any("skill_sync_disabled" in record.message for record in caplog.records), ( + "disabled gate must log a legible INFO signal" + ) + + async def test_enabled_explicit_true_still_syncs(self, tmp_path: Path) -> None: + from amplifier_module_tool_context_intelligence_query.skill_sync import ( + on_session_ready, + ) + + skill_path = tmp_path / "SKILL.md" + tool = _make_tool("http://up:9000", api_key="key-1", workspace="ws-1") + tool.skill_sync_enabled = True + coord = _make_ready_coordinator(skill_path, tool) + + with patch( + "amplifier_module_tool_context_intelligence_query.skill_sync._sync_skill", # [FIX-1] + new_callable=AsyncMock, + ) as mock_sync: + await on_session_ready(coord) + + mock_sync.assert_awaited_once_with( + "context-intelligence-graph-query", skill_path, "http://up:9000", "key-1" + ) + # Enabled path registers the reload handler. + assert "skill:unloaded" in [c.args[0] for c in coord.hooks.register.call_args_list] + + async def test_tool_absent_falls_through_to_offline_path(self, tmp_path: Path) -> None: + # Gate only fires when tool is present AND disabled. With no tool the + # existing offline-integrity path must run unchanged (server_url None). + from amplifier_module_tool_context_intelligence_query.skill_sync import ( + on_session_ready, + ) + + skill_path = tmp_path / "SKILL.md" + coord = _make_ready_coordinator(skill_path, tool=None) + + with patch( + "amplifier_module_tool_context_intelligence_query.skill_sync._sync_skill", # [FIX-1] + new_callable=AsyncMock, + ) as mock_sync: + await on_session_ready(coord) + + mock_sync.assert_awaited_once_with( + "context-intelligence-graph-query", skill_path, None, None + ) + registered_events = [c.args[0] for c in coord.hooks.register.call_args_list] + assert "skill:unloaded" in registered_events diff --git a/modules/tool-context-intelligence-query/tests/test_skill_sync_edges.py b/modules/tool-context-intelligence-query/tests/test_skill_sync_edges.py new file mode 100644 index 0000000..5664f0d --- /dev/null +++ b/modules/tool-context-intelligence-query/tests/test_skill_sync_edges.py @@ -0,0 +1,586 @@ +"""Adversarial edge tests for skill-sync — tester-breaker list. + +These tests cover cases not included in the ported spike suite. Every test +asserts the REAL behaviour; where a FINDING is surfaced it is marked clearly. +""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Shared stubs (mirrored from test_skill_sync.py to keep this file standalone) +# --------------------------------------------------------------------------- + +_STUB_BODY = ( + "---\nname: context-intelligence-graph-query\nversion: 2.0.0\n---\n\n" + "# Context Intelligence Graph Query — Server Unavailable\n\n" + "The context intelligence server is not reachable.\n" + "Delegate immediately to `session-navigator`. Do not attempt Cypher queries.\n" +) + + +def _write_stub(skill_path: Path) -> str: + skill_path.write_text(_STUB_BODY) + return hashlib.sha256(skill_path.read_bytes()).hexdigest() + + +def _make_tool(server_url: str, api_key: str = "k", workspace: str = "ws") -> MagicMock: + tool = MagicMock() + tool._resolve_server_config = MagicMock(return_value=(server_url, api_key, workspace)) + return tool + + +def _make_ready_coordinator( + skill_path: Path, + tool: MagicMock | None, + *, + discovery_present: bool = True, + find_returns_meta: bool = True, +) -> MagicMock: + discovery: MagicMock | None = None + if discovery_present: + discovery = MagicMock() + meta = MagicMock() + meta.path = skill_path + discovery.find = MagicMock(return_value=meta if find_returns_meta else None) + + caps: dict[str, object] = { + "skills_discovery": discovery, + "context_intelligence._graph_query_tool": tool, + } + + coord = MagicMock() + coord.get_capability = MagicMock(side_effect=lambda name: caps.get(name)) + coord.hooks = MagicMock() + coord.hooks.register = MagicMock(return_value=MagicMock()) + return coord + + +# =========================================================================== +# 1. _coerce_bool tier fall-through +# Unrecognized / empty / whitespace / non-standard values must be treated +# as ABSENT (None), never as True or False, so they fall through to the +# next resolution tier. The final default is FALSE (opt-in). +# =========================================================================== + + +class TestCoerceBoolTierFallThrough: + """Direct unit coverage of _coerce_bool — the gatekeeper for boolean config knobs.""" + + def _coerce(self, value: object) -> bool | None: + from context_intelligence.tool_resolver import _coerce_bool + + return _coerce_bool(value) + + # --- Unrecognised / ambiguous values → None (absent, fall through) --- + + def test_unrecognised_string_maybe_returns_none(self) -> None: + assert self._coerce("maybe") is None, "'maybe' is not a boolean token — must be None" + + def test_unrecognised_string_none_returns_none(self) -> None: + """'none' is a common YAML accident; it is not a boolean token.""" + assert self._coerce("none") is None + + def test_integer_2_returns_none(self) -> None: + """Only the string '1' and bool True are affirmative; int 2 is unrecognised.""" + assert self._coerce(2) is None + + def test_whitespace_only_returns_none(self) -> None: + """Unexpanded YAML placeholder collapses to whitespace → absent.""" + assert self._coerce(" ") is None + + def test_empty_string_returns_none(self) -> None: + """Empty string from an unexpanded ${VAR:} placeholder → absent.""" + assert self._coerce("") is None + + def test_none_python_returns_none(self) -> None: + """Python None (absent key in config dict) → absent.""" + assert self._coerce(None) is None + + # --- Recognised TRUE tokens --- + + def test_string_true_lowercase_returns_true(self) -> None: + assert self._coerce("true") is True + + def test_string_true_mixed_case_returns_true(self) -> None: + assert self._coerce("True") is True + + def test_string_1_returns_true(self) -> None: + assert self._coerce("1") is True + + def test_string_yes_returns_true(self) -> None: + assert self._coerce("yes") is True + + def test_string_on_returns_true(self) -> None: + assert self._coerce("on") is True + + def test_bool_true_returns_true(self) -> None: + assert self._coerce(True) is True + + # --- Recognised FALSE tokens --- + + def test_string_false_lowercase_returns_false(self) -> None: + assert self._coerce("false") is False + + def test_string_False_mixed_case_returns_false(self) -> None: + """'False' (capital F) from YAML must resolve to False, not None.""" + assert self._coerce("False") is False + + def test_string_0_returns_false(self) -> None: + assert self._coerce("0") is False + + def test_string_no_returns_false(self) -> None: + assert self._coerce("no") is False + + def test_string_off_returns_false(self) -> None: + assert self._coerce("off") is False + + def test_bool_false_returns_false(self) -> None: + assert self._coerce(False) is False + + # --- Whitespace normalisation --- + + def test_string_true_with_surrounding_whitespace_returns_true(self) -> None: + """' true ' (extra spaces) must be coerced to True, not None.""" + assert self._coerce(" true ") is True + + def test_string_false_with_surrounding_whitespace_returns_false(self) -> None: + assert self._coerce(" false ") is False + + +class TestSkillSyncEnabledTierFallThrough: + """Integration: ToolConfigResolver.skill_sync_enabled resolution-tier fall-through.""" + + def _resolver(self, config: dict, coord_config: dict | None = None) -> object: + from context_intelligence.tool_resolver import ToolConfigResolver + + coord = MagicMock() + coord.config = coord_config or {} + return ToolConfigResolver(config, coord) + + def test_unrecognised_mount_config_falls_through_to_coord_config_false(self) -> None: + """mount config 'maybe' (unrecognised) → absent → coord.config False → returns False.""" + resolver = self._resolver( + config={"skill_sync_enabled": "maybe"}, + coord_config={"skill_sync_enabled": False}, + ) + assert resolver.skill_sync_enabled is False + + def test_unrecognised_mount_config_falls_through_to_coord_config_true(self) -> None: + resolver = self._resolver( + config={"skill_sync_enabled": "maybe"}, + coord_config={"skill_sync_enabled": True}, + ) + assert resolver.skill_sync_enabled is True + + def test_absent_at_all_tiers_returns_default_false(self) -> None: + """No config at any tier → default is FALSE (opt-in).""" + env_clean = {k: v for k, v in os.environ.items() if "SKILL_SYNC_ENABLED" not in k} + with patch.dict(os.environ, env_clean, clear=True): + resolver = self._resolver(config={}, coord_config={}) + assert resolver.skill_sync_enabled is False + + def test_empty_string_mount_config_falls_through_to_default_false(self) -> None: + """An unexpanded YAML placeholder ('') at tier 1 falls through to the default.""" + env_clean = {k: v for k, v in os.environ.items() if "SKILL_SYNC_ENABLED" not in k} + with patch.dict(os.environ, env_clean, clear=True): + resolver = self._resolver(config={"skill_sync_enabled": ""}, coord_config={}) + assert resolver.skill_sync_enabled is False + + def test_integer_2_mount_config_falls_through_to_default_false(self) -> None: + """int(2) at tier 1 is unrecognised → falls through to default (False).""" + env_clean = {k: v for k, v in os.environ.items() if "SKILL_SYNC_ENABLED" not in k} + with patch.dict(os.environ, env_clean, clear=True): + resolver = self._resolver(config={"skill_sync_enabled": 2}, coord_config={}) + assert resolver.skill_sync_enabled is False + + def test_none_mount_config_falls_through_to_coord_config(self) -> None: + """None at tier 1 is absent → falls through to tier 2.""" + resolver = self._resolver( + config={"skill_sync_enabled": None}, + coord_config={"skill_sync_enabled": True}, + ) + assert resolver.skill_sync_enabled is True + + +# =========================================================================== +# 2. SHA-mismatch loud-fail +# The pin in EXPECTED_BUNDLED_SKILL_SHA256 is the only thing that prevents +# a silently-wrong vendored body from reaching production. These tests +# verify the detection mechanism is real. +# =========================================================================== + + +class TestBundledSkillSHAMismatch: + def test_mismatch_is_detectable(self) -> None: + """Any body that is not the canonical skill produces a different SHA-256. + + This is the property the test_bundled_skill.py pin assertion relies on: + if the file on disk drifts, the SHA changes and the assertion catches it. + """ + from amplifier_module_tool_context_intelligence_query.bundled_skill import ( + EXPECTED_BUNDLED_SKILL_SHA256, + ) + + tampered = "tampered — not the real skill body" + assert ( + hashlib.sha256(tampered.encode("utf-8")).hexdigest() != EXPECTED_BUNDLED_SKILL_SHA256 + ), "SHA-256 of tampered content must differ from the pin (mismatch detection works)" + + def test_pinned_sha_matches_the_real_vendored_file(self) -> None: + """Re-prove the pin end-to-end: read file → hash → compare to constant. + + This is a redundant cross-check on top of test_bundled_skill.py to + ensure the mismatch path is exercised explicitly. + """ + from importlib import resources + + from amplifier_module_tool_context_intelligence_query.bundled_skill import ( + EXPECTED_BUNDLED_SKILL_SHA256, + ) + + pkg = "amplifier_module_tool_context_intelligence_query.bundled_skill" + data = ( + resources.files(pkg) + .joinpath("context-intelligence-graph-query.md") + .read_text(encoding="utf-8") + ) + actual = hashlib.sha256(data.encode("utf-8")).hexdigest() + assert actual == EXPECTED_BUNDLED_SKILL_SHA256, ( + f"Vendored body SHA ({actual[:8]}…) != pin ({EXPECTED_BUNDLED_SKILL_SHA256[:8]}…). " + "This is the loud-fail the mismatch guard produces." + ) + + def test_install_vendored_body_raises_on_sha_mismatch(self, tmp_path: Path) -> None: + """_install_vendored_body RAISES and writes NOTHING when the vendored body + does not match the pinned SHA-256. + + A tampered or corrupted wheel body must never reach disk silently. + The function must raise (ValueError) naming the skill and both hashes, + and the skill file must remain unchanged (the pessimistic stub content). + """ + from amplifier_module_tool_context_intelligence_query.bundled_skill import ( + EXPECTED_BUNDLED_SKILL_SHA256, + ) + from amplifier_module_tool_context_intelligence_query.skill_sync import ( + _install_vendored_body, + ) + + skill_path = tmp_path / "SKILL.md" + _write_stub(skill_path) # start with the pessimistic stub + original_content = skill_path.read_text(encoding="utf-8") + tampered_body = "tampered — not the real skill" + + with patch( + "amplifier_module_tool_context_intelligence_query.skill_sync._vendored_body", + return_value=tampered_body, + ): + with pytest.raises(ValueError, match="skill_install_sha_mismatch"): + _install_vendored_body("context-intelligence-graph-query", skill_path) + + # File must be untouched — no partial write on mismatch. + assert skill_path.read_text(encoding="utf-8") == original_content, ( + "skill_path must remain unchanged after a SHA mismatch — " + "no corrupted or tampered body must reach disk" + ) + # The installed SHA must NOT match the pin (it was never written). + installed_sha = hashlib.sha256( + skill_path.read_text(encoding="utf-8").encode("utf-8") + ).hexdigest() + assert ( + installed_sha != EXPECTED_BUNDLED_SKILL_SHA256 or original_content == tampered_body + ), "Sanity: the stub content is not the vendored body, so its SHA must differ from the pin" + + +# =========================================================================== +# 3. Vendored-body install torn-state +# Simulate os.replace raising AFTER the .etag deletion to verify that: +# a) the exception surfaces (not swallowed), +# b) the .etag is already gone (ETag-first property), +# c) skill_path is unchanged (atomic swap failed before rename). +# =========================================================================== + + +class TestInstallVendoredBodyTornState: + def test_os_replace_raises_surfaces_exception(self, tmp_path: Path) -> None: + """os.replace raising propagates out of _install_vendored_body — fail loud.""" + from amplifier_module_tool_context_intelligence_query.skill_sync import ( + _install_vendored_body, + ) + + skill_path = tmp_path / "SKILL.md" + skill_path.write_text("# original content") + etag_path = tmp_path / ".etag" + etag_path.write_text("stale-etag") + + with patch("os.replace", side_effect=OSError("simulated atomic rename failure")): + with pytest.raises(OSError, match="simulated atomic rename failure"): + _install_vendored_body("context-intelligence-graph-query", skill_path) + + def test_etag_deleted_before_os_replace_attempt(self, tmp_path: Path) -> None: + """ETag-first property: .etag is removed BEFORE os.replace is attempted. + + After a torn-state crash the .etag is gone, so a later re-enabled sync + issues a clean unconditional GET (never a stale-ETag→304 freeze). + """ + from amplifier_module_tool_context_intelligence_query.skill_sync import ( + _install_vendored_body, + ) + + skill_path = tmp_path / "SKILL.md" + skill_path.write_text("# original content") + etag_path = tmp_path / ".etag" + etag_path.write_text("stale-etag") + + with patch("os.replace", side_effect=OSError("simulated crash")): + with pytest.raises(OSError): + _install_vendored_body("context-intelligence-graph-query", skill_path) + + assert not etag_path.exists(), ( + ".etag must be deleted BEFORE os.replace so a later sync does " + "an unconditional GET rather than freezing on the stale ETag" + ) + + def test_skill_path_unchanged_after_torn_state(self, tmp_path: Path) -> None: + """skill_path retains its original content when os.replace fails. + + The atomic rename never completed, so the old content is still intact. + """ + from amplifier_module_tool_context_intelligence_query.skill_sync import ( + _install_vendored_body, + ) + + skill_path = tmp_path / "SKILL.md" + original_content = "# original content" + skill_path.write_text(original_content) + (tmp_path / ".etag").write_text("stale-etag") + + with patch("os.replace", side_effect=OSError("simulated crash")): + with pytest.raises(OSError): + _install_vendored_body("context-intelligence-graph-query", skill_path) + + assert skill_path.read_text() == original_content, ( + "skill_path must be unchanged after a failed os.replace — " + "never a half-written state masquerading as success" + ) + + +# =========================================================================== +# 4. Disabled-path zero-network +# With skill_sync_enabled=False, on_session_ready must never construct +# SkillFetcher or call check_server_version / fetch. +# We use side_effect=AssertionError so the test fails if called. +# =========================================================================== + + +class TestDisabledPathZeroNetwork: + async def test_check_server_version_never_called_when_disabled(self, tmp_path: Path) -> None: + """SkillFetcher is never even instantiated on the disabled path.""" + from amplifier_module_tool_context_intelligence_query.skill_sync import on_session_ready + + skill_path = tmp_path / "SKILL.md" + _write_stub(skill_path) + tool = _make_tool("http://up:9000") + tool.skill_sync_enabled = False + coord = _make_ready_coordinator(skill_path, tool) + + def _fail_if_instantiated(*args: object, **kwargs: object) -> None: + raise AssertionError( + "SkillFetcher must NOT be instantiated when skill_sync_enabled=False" + ) + + with patch( + "amplifier_module_tool_context_intelligence_query.skill_sync.SkillFetcher", + side_effect=_fail_if_instantiated, + ): + # Must not raise — SkillFetcher is never constructed. + await on_session_ready(coord) + + async def test_fetch_never_called_when_disabled(self, tmp_path: Path) -> None: + """Patching SkillFetcher instance methods to fail if called confirms zero-network.""" + from amplifier_module_tool_context_intelligence_query.skill_sync import on_session_ready + + skill_path = tmp_path / "SKILL.md" + _write_stub(skill_path) + tool = _make_tool("http://up:9000") + tool.skill_sync_enabled = False + coord = _make_ready_coordinator(skill_path, tool) + + mock_instance = AsyncMock() + mock_instance.check_server_version = AsyncMock( + side_effect=AssertionError("check_server_version called on disabled path") + ) + mock_instance.fetch = AsyncMock(side_effect=AssertionError("fetch called on disabled path")) + mock_fetcher_cls = MagicMock(return_value=mock_instance) + + with patch( + "amplifier_module_tool_context_intelligence_query.skill_sync.SkillFetcher", + mock_fetcher_cls, + ): + await on_session_ready(coord) # must complete without raising + + mock_fetcher_cls.assert_not_called() + + async def test_disabled_no_server_no_network_call(self, tmp_path: Path) -> None: + """Even when no server is configured, SkillFetcher is never constructed.""" + from amplifier_module_tool_context_intelligence_query.skill_sync import on_session_ready + + skill_path = tmp_path / "SKILL.md" + _write_stub(skill_path) + tool = _make_tool("") # empty server_url + tool.skill_sync_enabled = False + coord = _make_ready_coordinator(skill_path, tool) + + with patch( + "amplifier_module_tool_context_intelligence_query.skill_sync.SkillFetcher" + ) as mock_fetcher_cls: + await on_session_ready(coord) + + mock_fetcher_cls.assert_not_called() + + async def test_no_skill_unloaded_handler_registered_when_disabled(self, tmp_path: Path) -> None: + """The skill:unloaded reload handler must NOT be registered on the disabled path.""" + from amplifier_module_tool_context_intelligence_query.skill_sync import on_session_ready + + skill_path = tmp_path / "SKILL.md" + _write_stub(skill_path) + tool = _make_tool("http://up:9000") + tool.skill_sync_enabled = False + coord = _make_ready_coordinator(skill_path, tool) + + await on_session_ready(coord) + + registered = [c.args[0] for c in coord.hooks.register.call_args_list] + assert "skill:unloaded" not in registered, ( + "disabled path must not register the per-turn skill:unloaded reload handler" + ) + + +# =========================================================================== +# 5. build_payload coupling guard +# The hook strip (Brick B) must not have broken the build_payload import. +# =========================================================================== + + +class TestBuildPayloadCouplingGuard: + """Guard that the hook strip (Brick B) did not accidentally remove build_payload. + + These tests require the hook module on sys.path. Run from the repo root with: + PYTHONPATH=modules/hook-context-intelligence uv run pytest \ + modules/tool-context-intelligence-query/tests/test_skill_sync_edges.py \ + -k build_payload + + When the hook is not on sys.path the tests are SKIPPED (not failed) — a skip + means "unverified in this venv", not "the code is broken". Install the hook + module or use the PYTHONPATH above for a TRUE pass. + """ + + @staticmethod + def _require_hook() -> None: + """Skip the test if the hook module is not on sys.path.""" + import importlib.util + + if importlib.util.find_spec("amplifier_module_hook_context_intelligence") is None: + pytest.skip( + "amplifier_module_hook_context_intelligence not on sys.path — " + "run with PYTHONPATH=modules/hook-context-intelligence for a full coupling check" + ) + + def test_build_payload_import_intact(self) -> None: + """hook-context-intelligence.upload.build_payload must still be importable. + + The hook strip removed skill_fetcher.py and legacy_content/ from the + hook module. This guard ensures build_payload — consumed by other + integrations — was NOT accidentally deleted as part of that strip. + """ + self._require_hook() + + import importlib + + hook_upload = importlib.import_module("amplifier_module_hook_context_intelligence.upload") + build_payload = getattr(hook_upload, "build_payload", None) + assert callable(build_payload), ( + "build_payload must be a callable function; the hook strip must not have removed it" + ) + + def test_build_payload_accepts_expected_signature(self) -> None: + """build_payload(event, workspace, data) must accept its documented signature.""" + import importlib + import inspect + + self._require_hook() + + hook_upload = importlib.import_module("amplifier_module_hook_context_intelligence.upload") + build_payload = getattr(hook_upload, "build_payload") + sig = inspect.signature(build_payload) + param_names = list(sig.parameters.keys()) + assert "event" in param_names + assert "workspace" in param_names + assert "data" in param_names + + +# =========================================================================== +# 6. Bonus: GraphQueryTool.skill_sync_enabled pass-through +# Verifies that the property correctly reflects the resolver's value so +# on_session_ready's get_capability → .skill_sync_enabled chain works. +# =========================================================================== + + +class TestGraphQueryToolSkillSyncPassThrough: + def _make_tool_with_config(self, config: dict) -> object: + from context_intelligence.tool_resolver import ToolConfigResolver + + from amplifier_module_tool_context_intelligence_query.graph_query_tool import ( + GraphQueryTool, + ) + + coord = MagicMock() + coord.config = {} + resolver = ToolConfigResolver(config, coord) + return GraphQueryTool(coord, resolver) + + def test_default_is_false(self) -> None: + """With no config, skill_sync_enabled defaults to False (opt-in).""" + env_clean = {k: v for k, v in os.environ.items() if "SKILL_SYNC_ENABLED" not in k} + with patch.dict(os.environ, env_clean, clear=True): + tool = self._make_tool_with_config({}) + assert tool.skill_sync_enabled is False # type: ignore[union-attr] + + def test_explicit_true_in_config_returns_true(self) -> None: + tool = self._make_tool_with_config({"skill_sync_enabled": True}) + assert tool.skill_sync_enabled is True # type: ignore[union-attr] + + def test_explicit_false_in_config_returns_false(self) -> None: + tool = self._make_tool_with_config({"skill_sync_enabled": False}) + assert tool.skill_sync_enabled is False # type: ignore[union-attr] + + def test_string_true_in_config_returns_true(self) -> None: + tool = self._make_tool_with_config({"skill_sync_enabled": "true"}) + assert tool.skill_sync_enabled is True # type: ignore[union-attr] + + def test_unrecognised_string_in_config_falls_through_to_default_false(self) -> None: + env_clean = {k: v for k, v in os.environ.items() if "SKILL_SYNC_ENABLED" not in k} + with patch.dict(os.environ, env_clean, clear=True): + tool = self._make_tool_with_config({"skill_sync_enabled": "maybe"}) + assert tool.skill_sync_enabled is False # type: ignore[union-attr] + + def test_getattr_default_semantics_for_on_session_ready_gate(self) -> None: + """on_session_ready uses getattr(tool, 'skill_sync_enabled', True). + + When the resolver returns False (default), getattr returns False, + so 'not False = True' and the disabled branch IS taken. This is the + correct opt-in semantics. + """ + env_clean = {k: v for k, v in os.environ.items() if "SKILL_SYNC_ENABLED" not in k} + with patch.dict(os.environ, env_clean, clear=True): + tool = self._make_tool_with_config({}) + result = getattr(tool, "skill_sync_enabled", True) + assert result is False, "default must be False so disabled-path is taken by default" + assert result is not True, "not False == True → disabled path executes" diff --git a/scripts/validate-full.sh b/scripts/validate-full.sh new file mode 100755 index 0000000..d0af718 --- /dev/null +++ b/scripts/validate-full.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# validate-full.sh — run `validate-bundle-repo` against THIS bundle in FULL mode. +# +# WHY THIS EXISTS +# -------------- +# The validator runs its Python checks through a bash `python3` heredoc. In a +# default Amplifier environment that `python3` is a minimal interpreter with no +# `pip` and no `amplifier_foundation` / `hatchling`, so the recipe self-downgrades +# to `validation_mode: structural_only` — it SKIPS the two checks that matter most +# for a behaviour split: BundleRegistry resolution of the layered includes, and the +# package build check. +# +# This script builds a throwaway uv venv that HAS those deps, puts its `bin` first +# on PATH, and runs the recipe — so the recipe's `python3` resolves to an +# interpreter that can `import amplifier_foundation` and `import hatchling`, which +# flips the run to `validation_mode: full`. +# +# (This is the uv-based equivalent of the recipe's own documented +# `uvx --with hatchling --with amplifier-foundation amplifier tool invoke ...` +# one-liner; the venv form is used because the recipe shells out to `python3`, +# so the deps must live on the PATH `python3`, not just in a uvx tool env.) +# +# USAGE +# ----- +# scripts/validate-full.sh [REPO_PATH] +# REPO_PATH defaults to this bundle's repo root. +# +# ENV +# CI_VALIDATE_VENV override the venv location (default: $TMPDIR/ci-validate-venv) +# +# Requires: uv, and the `amplifier` CLI on PATH, with the amplifier-foundation +# bundle present in ~/.amplifier/cache (it ships the recipe). +# +set -euo pipefail + +REPO_PATH="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +VENV="${CI_VALIDATE_VENV:-${TMPDIR:-/tmp}/ci-validate-venv}" + +echo ">> building deps venv: $VENV" +uv venv --python 3.11 "$VENV" >/dev/null +uv pip install --python "$VENV/bin/python" --quiet \ + hatchling pyyaml \ + "amplifier-core @ git+https://github.com/microsoft/amplifier-core@main" \ + "amplifier-foundation @ git+https://github.com/microsoft/amplifier-foundation@main" + +# Locate the foundation validate-bundle-repo recipe in the Amplifier cache. +# (The bare `amplifier tool invoke` CLI does not resolve the `foundation:` recipe +# namespace, so we pass the cached recipe by absolute path.) +RECIPE="$(ls -1 "${HOME}/.amplifier/cache/"amplifier-foundation-*/recipes/validate-bundle-repo.yaml 2>/dev/null | head -1 || true)" +if [[ -z "$RECIPE" ]]; then + echo "!! validate-bundle-repo.yaml not found under ~/.amplifier/cache/amplifier-foundation-*/recipes/" >&2 + echo " Ensure the amplifier-foundation bundle is installed/cached, then retry." >&2 + exit 1 +fi + +echo ">> recipe: $RECIPE" +echo ">> repo: $REPO_PATH" +echo ">> running validate-bundle-repo in FULL mode ..." +PATH="$VENV/bin:$PATH" amplifier tool invoke recipes operation=execute \ + recipe_path="$RECIPE" \ + context="{\"repo_path\":\"$REPO_PATH\"}"