diff --git a/.amplifier/digital-twin-universe/profiles/ci-bundle-smoke-test.yaml b/.amplifier/digital-twin-universe/profiles/ci-bundle-smoke-test.yaml deleted file mode 100644 index e218c77e..00000000 --- a/.amplifier/digital-twin-universe/profiles/ci-bundle-smoke-test.yaml +++ /dev/null @@ -1,144 +0,0 @@ -# Smoke-test profile for amplifier-bundle-context-intelligence -# -# Tests: library imports, CLI tools, hook ConfigResolver, and Amplifier -# bundle loading. Does NOT require an external CI server. -# -# Usage: -# export GH_TOKEN=$(gh auth token) -# amplifier-digital-twin launch .amplifier/digital-twin-universe/profiles/ci-bundle-smoke-test.yaml \ -# --name ci-bundle-smoke-test -name: ci-bundle-smoke-test -description: > - Smoke test for the context-intelligence bundle. Validates library imports, - upload CLI, hook additional_events config, and Amplifier bundle loading. - -base: - image: ubuntu:24.04 - -passthrough: - allow_external: true - services: - - name: anthropic - key_env: ANTHROPIC_API_KEY - - name: github - key_env: GH_TOKEN - -provision: - setup_cmds: - # System dependencies - - apt-get update && apt-get install -y git curl python3 python3-venv - - # Install uv - - curl -LsSf https://astral.sh/uv/install.sh | sh - - # Configure git credentials for private GitHub repos - - | - if [ -n "$GH_TOKEN" ]; then - echo "machine github.com login x-token-auth password $GH_TOKEN" > /root/.netrc - chmod 600 /root/.netrc - git config --global credential.helper 'store' - fi - - # Clone the bundle (HEAD of main) - - | - export PATH="/root/.local/bin:$PATH" - git clone --depth 1 https://github.com/microsoft/amplifier-bundle-context-intelligence.git /opt/ci-bundle - - # Create a venv and install packages in dependency order. - # Ubuntu 24.04 marks system Python as externally managed (PEP 668), - # so we use a dedicated virtualenv for the smoke-test packages. - - | - export PATH="/root/.local/bin:$PATH" - uv venv /opt/venv --python python3 - export VIRTUAL_ENV=/opt/venv - export PATH="/opt/venv/bin:$PATH" - uv pip install /opt/ci-bundle/ - uv pip install "httpx>=0.28.1" - uv pip install --no-deps /opt/ci-bundle/modules/hook-context-intelligence/ - uv pip install --no-deps /opt/ci-bundle/modules/tool-context-intelligence-upload/ - - # Quick sanity — library importable? - - /opt/venv/bin/python3 -c "from context_intelligence.config import resolve_config; print('context_intelligence importable')" - - # Install Amplifier itself (uv tool uses its own isolated venv) - - | - export PATH="/root/.local/bin:$PATH" - uv tool install git+https://github.com/microsoft/amplifier@main - - # Configure Amplifier provider - - | - mkdir -p /root/.amplifier - cat > /root/.amplifier/settings.yaml << 'EOF' - config: - providers: - - module: provider-anthropic - source: git+https://github.com/microsoft/amplifier-module-provider-anthropic@main - config: - api_key_env: ANTHROPIC_API_KEY - EOF - - # Add the context-intelligence bundle to Amplifier - - | - export PATH="/root/.local/bin:$PATH" - amplifier bundle add git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main --app - - # Verify Amplifier is working - - | - export PATH="/root/.local/bin:$PATH" - amplifier --version - - # Persist PATH for interactive shells and exec commands - - | - echo 'export PATH="/opt/venv/bin:/root/.local/bin:$PATH"' >> /root/.bashrc - echo 'export VIRTUAL_ENV=/opt/venv' >> /root/.bashrc - echo 'PATH=/opt/venv/bin:/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' >> /etc/environment - echo 'VIRTUAL_ENV=/opt/venv' >> /etc/environment - - # Workspace for the user - - mkdir -p /home/user/project - -readiness: - - name: library-check - command: "/opt/venv/bin/python3 -c \"from context_intelligence.client import AsyncCIClient; print('ready')\"" - -validation_cmds: - # Install the stub event contributor fixture module from the local bundle - - name: install-stub-module - command: | - export PATH="/opt/venv/bin:/root/.local/bin:$PATH" - export VIRTUAL_ENV=/opt/venv - uv pip install --no-deps /opt/ci-bundle/modules/hook-context-intelligence/tests/dtu/fixtures/stub-event-contributor/ - - # Run a short session that exercises the on_session_ready path. - # The stub-event-contributor module contributes its event AFTER the hook - # mounts (because tool modules mount before hook modules in Amplifier). - # on_session_ready() must pick it up and write it to events.jsonl. - - name: run-session-with-stub-module - command: | - export PATH="/root/.local/bin:$PATH" - mkdir -p /tmp/ci-validation-project - cd /tmp/ci-validation-project - amplifier run \ - --module stub-event-contributor \ - "Say hello in one word." \ - --max-turns 1 \ - 2>&1 | tee /tmp/session-output.log - echo "Session completed" - - # Assert the stub event appears in events.jsonl - - name: verify-stub-event-in-jsonl - command: | - set -e - EVENTS_FILE=$(find /root/.amplifier/projects -name "events.jsonl" -newer /tmp/session-output.log | head -1) - if [ -z "$EVENTS_FILE" ]; then - echo "FAIL: No events.jsonl found after session" - exit 1 - fi - if grep -q "stub-event-contributor:test-event" "$EVENTS_FILE"; then - echo "PASS: stub-event-contributor:test-event found in $EVENTS_FILE" - else - echo "FAIL: stub-event-contributor:test-event NOT found in $EVENTS_FILE" - echo "Events found:" - cat "$EVENTS_FILE" | python3 -c "import sys,json; [print(json.loads(l).get('event','?')) for l in sys.stdin]" - exit 1 - fi diff --git a/.amplifier/digital-twin-universe/profiles/context-intelligence-analyst-behavioral-test.yaml b/.amplifier/digital-twin-universe/profiles/context-intelligence-analyst-behavioral-test.yaml new file mode 100644 index 00000000..560077dd --- /dev/null +++ b/.amplifier/digital-twin-universe/profiles/context-intelligence-analyst-behavioral-test.yaml @@ -0,0 +1,451 @@ +# ============================================================================ +# BEHAVIORAL TEST PROFILE 2 of 2 -- ANALYST MODE +# ============================================================================ +# +# GOAL +# Deep, evidence-based proof that a FRESH Amplifier session, with the +# context-intelligence bundle installed in its ANALYTICS-ONLY behavior, +# provides graph/session analysis capabilities WITHOUT instrumenting the +# session (NO event-capture hook), and that the graph-analyst correctly +# falls back to local JSONL extraction when no CI server is reachable. +# +# WHAT "WORKS AS EXPECTED" MEANS HERE +# The read/query-only DESIGN behavior (behaviors/context-intelligence-design.yaml) +# composes the graph-analyst + session-navigator agents, the +# context-intelligence design MODE, tool-delegate, and tool-skills (5 general +# skills) -- but NOT the hook-context-intelligence module. Installed via +# `amplifier bundle add @main#subdirectory=behaviors/context-intelligence-design.yaml --app`. +# Therefore: +# * No new session writes a context-intelligence/events.jsonl (no hook). +# * graph-analyst is the mandatory entry point; it checks server +# availability and, when unreachable / 0 sessions, falls back to +# session-navigator which reads local JSONL via bash/jq. +# * Design-mode agents (context-intelligence-design-facilitator, +# context-intelligence-tool-designer) are MODE-GATED and must NOT be +# present while the context-intelligence mode is inactive. +# +# A seeded FIXTURE session (containing a recognizable "fixture_marker_tool" +# event) gives the analyst path real local data to read, proving the +# server-unreachable -> session-navigator -> local-extraction fallback works. +# +# ASSERTIONS (each validation_cmd prints PASS/FAIL and exits non-zero on FAIL) +# A1 Hook is ABSENT: after running an analytics session, NO new +# context-intelligence/events.jsonl is created for that new session +# (only the seeded fixture dir exists). Proof analytics-only has no hook. +# A2 Agents available: `amplifier agents list` shows graph-analyst AND +# session-navigator. +# A3 Mode-gating: design-mode agents context-intelligence-design-facilitator +# and context-intelligence-tool-designer are NOT listed while the +# context-intelligence mode is inactive. +# A4 End-to-end analyst behavior with no server: graph-analyst detects the +# server is unreachable, FALLS BACK to session-navigator, reads the +# seeded fixture JSONL, and the output references the fixture (mentions +# fixture_marker_tool or the correct session count). +# +# LOCAL-BRANCH UNDER TEST +# Source of truth is the local working branch of the +# amplifier-bundle-context-intelligence submodule, mirrored to Gitea (see +# the MANDATORY url_rewrites block below). NOT GitHub @main. +# +# USAGE +# amplifier-digital-twin launch \ +# .amplifier/digital-twin-universe/profiles/context-intelligence-analyst-behavioral-test.yaml \ +# --var GITEA_URL=http://: \ +# --var GITEA_TOKEN= \ +# --name context-intelligence-analyst-behavioral-test +# ============================================================================ + +name: context-intelligence-analyst-behavioral-test +description: > + ANALYST MODE behavioral test for the context-intelligence bundle (local branch + via Gitea). Installs the ANALYTICS-ONLY behavior (no hook), seeds a fixture + session, and asserts: no hook instrumentation, graph-analyst + session-navigator + present, design-mode agents gated out, and graph-analyst falls back to local + JSONL extraction when no CI server is reachable. + +base: + image: ubuntu:24.04 + +# MANDATORY: Gitea URL rewrite for local branch skill resolution. +# The bundle ships skills with a git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills source in behaviors/context-intelligence.yaml. +# When running in DTU, local working branch is source of truth -- NOT GitHub @main. Without rewrite, skill registration inside DTU fetches from GitHub @main and silently runs stale code, producing test results that do not reflect local changes. +# The digital-twin-universe:dtu-profile-builder agent mirrors the local amplifier-bundle-context-intelligence submodule to Gitea on current branch. These rewrites point every reference at that mirror. +# url_rewrites: +# - from: "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main" +# to: "git+http://{gitea_host}/microsoft/amplifier-bundle-context-intelligence@main" +# +# Implemented below using the functional DTU url_rewrites schema (auth + rules +# with boundary match). The mitmproxy rewrite redirects EVERY reference to +# github.com/microsoft/amplifier-bundle-context-intelligence (the analytics +# behavior subdirectory AND the 5 #subdirectory=skills/* skill sources declared +# in behaviors/context-intelligence-design.yaml) to the Gitea mirror +# admin/amplifier-bundle-context-intelligence, whose `main` branch holds the +# local working-tree snapshot. {gitea_host} is supplied at launch via +# --var GITEA_URL (resolved to the Gitea mirror reachable from inside the DTU). +url_rewrites: + auth: + username: admin + token_var: GITEA_TOKEN + default_match_mode: boundary + rules: + - match: github.com/microsoft/amplifier-bundle-context-intelligence + target: ${GITEA_URL}/admin/amplifier-bundle-context-intelligence + +passthrough: + allow_external: true + services: + - name: anthropic + key_env: ANTHROPIC_API_KEY + - name: github + key_env: GH_TOKEN + +provision: + setup_cmds: + # System dependencies (jq + python3 used by the assertions below) + - apt-get update && apt-get install -y git curl python3 jq + + # Install uv + - curl -LsSf https://astral.sh/uv/install.sh | sh + + # Configure git credentials for GitHub (transitive public deps; harmless if unset) + - | + if [ -n "$GH_TOKEN" ]; then + echo "machine github.com login x-token-auth password $GH_TOKEN" > /root/.netrc + chmod 600 /root/.netrc + git config --global credential.helper 'store' + fi + + # Install Amplifier itself + - uv tool install git+https://github.com/microsoft/amplifier@main + + # Configure Amplifier provider (anthropic only -- minimal, matches smoke test) + - | + mkdir -p /root/.amplifier + cat > /root/.amplifier/settings.yaml << 'EOF' + config: + providers: + - module: provider-anthropic + source: git+https://github.com/microsoft/amplifier-module-provider-anthropic@main + config: + api_key_env: ANTHROPIC_API_KEY + EOF + + # Install the read/query-only DESIGN behavior (NO hook). + # url_rewrites redirects this @main reference to the Gitea mirror (local branch). + - amplifier bundle add 'git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=behaviors/context-intelligence-design.yaml' --app + + # Sanity: Amplifier is working and the analytics behavior is registered + - amplifier --version + - amplifier bundle list + + # ------------------------------------------------------------------ + # SEED a fixture local session so the analyst path has real data to read. + # Layout: ~/.amplifier/projects//sessions//context-intelligence/ + # Contains valid envelope lines incl. a recognizable "fixture_marker_tool" + # tool event, plus a matching metadata.json with all required fields. + # This dir is created at PROVISION time (before any validation marker), so + # A1's "no NEW events.jsonl" check correctly ignores it. + # ------------------------------------------------------------------ + - | + FIX=/root/.amplifier/projects/fixture-proj/sessions/fixture-0001/context-intelligence + mkdir -p "$FIX" + cat > "$FIX/events.jsonl" << 'EOF' + {"event":"session.started","workspace":"fixture-proj","timestamp":"2026-01-01T00:00:00Z","data":{"session_id":"fixture-0001"}} + {"event":"provider.request","workspace":"fixture-proj","timestamp":"2026-01-01T00:00:01Z","data":{"model":"claude-sonnet"}} + {"event":"tool.called","workspace":"fixture-proj","timestamp":"2026-01-01T00:00:02Z","data":{"tool":"fixture_marker_tool","args":{"probe":"deep-evidence"}}} + {"event":"provider.response","workspace":"fixture-proj","timestamp":"2026-01-01T00:00:03Z","data":{"tokens":42}} + {"event":"session.completed","workspace":"fixture-proj","timestamp":"2026-01-01T00:00:04Z","data":{"status":"ok"}} + EOF + cat > "$FIX/metadata.json" << 'EOF' + { + "format": "context-intelligence-events", + "version": "1", + "session_id": "fixture-0001", + "workspace": "fixture-proj", + "started_at": "2026-01-01T00:00:00Z", + "last_event_at": "2026-01-01T00:00:04Z", + "status": "completed", + "working_dir": "/tmp/fixture-proj" + } + EOF + echo "Seeded fixture session at $FIX" + ls -la "$FIX" + + # Snapshot the set of events.jsonl present AFTER seeding, BEFORE any test + # session. A1 compares against this baseline to prove no hook fired. + - find /root/.amplifier/projects -path '*context-intelligence/events.jsonl' | sort > /tmp/jsonl-baseline.txt + - mkdir -p /tmp/analyst-proj + +readiness: + - name: amplifier-installed + command: amplifier --version + +validation_cmds: + # ---------------------------------------------------------------------- + # A2: graph-analyst AND session-navigator are COMPOSED into the session. + # + # METHODOLOGY: `amplifier agents show` reports the GLOBAL agent catalog (every + # agent .md in any cached bundle), not what was composed -- so it cannot prove + # composition. Analytics-only ships NO context-intelligence hook, so there is + # no context-intelligence/events.jsonl to read. We instead read the FOUNDATION + # generic event logger (hooks-logging) at sessions//events.jsonl, which + # captures the same session:config event, and assert data.raw.agents contains + # both analytics agents AND that the context-intelligence hook is ABSENT. + # (A4 below provides the stronger runtime proof via actual delegation.) + # ---------------------------------------------------------------------- + - name: A2-agents-composed + command: | + set -e + mkdir -p /tmp/a2-proj && cd /tmp/a2-proj + SID=$(amplifier run "Say hello in one word." --mode single 2>&1 | tee /tmp/a2.log | grep -oE 'Session ID: [0-9a-f-]+' | awk '{print $3}') + echo "session: $SID" + GEN=$(find /root/.amplifier/projects -path "*/sessions/$SID/events.jsonl" 2>/dev/null | head -1) + if [ -z "$GEN" ]; then echo "FAIL[A2]: no generic session log found for $SID"; exit 1; fi + echo "generic session log: $GEN" + python3 - "$GEN" << 'PY' + import json, sys + cfg = None + for l in open(sys.argv[1]): + l = l.strip() + if not l: + continue + try: + o = json.loads(l) + except Exception: + continue + if o.get("event") == "session:config": + cfg = o["data"]["raw"] + if not cfg: + print("FAIL[A2]: no session:config captured"); sys.exit(1) + agents = list((cfg.get("agents") or {}).keys()) + hook_mods = [h.get("module") for h in (cfg.get("hooks") or [])] + ga = [a for a in agents if "graph-analyst" in a] + sn = [a for a in agents if "session-navigator" in a] + context_intelligence_hook = [m for m in hook_mods if m and "context-intelligence" in m] + if not ga or not sn: + print("FAIL[A2]: analytics agents missing -- graph-analyst:", ga or "ABSENT", "session-navigator:", sn or "ABSENT"); sys.exit(1) + if context_intelligence_hook: + print("FAIL[A2]: context-intelligence hook present in analytics-only mode:", context_intelligence_hook); sys.exit(1) + print("PASS[A2]: analytics agents composed -- graph-analyst", ga, "AND session-navigator", sn, "; context-intelligence hook ABSENT (correct)") + PY + + # ---------------------------------------------------------------------- + # A3: design-mode agents are GATED behind the context-intelligence MODE, + # proven by COMPOSED SESSION inspection (not by grepping cached YAML). + # + # In a default (mode-inactive) session composed from the design behavior, + # session:config.raw.agents MUST contain graph-analyst + session-navigator + # (reachable transitively via the analysis -> navigation layers) and MUST + # NOT contain the design-mode agents (context-intelligence-design-facilitator, + # context-intelligence-tool-designer) -- those are contributed ONLY by the + # context-intelligence mode and appear only after the mode is activated. + # ---------------------------------------------------------------------- + - name: A3-mode-gating-composed + command: | + set -e + mkdir -p /tmp/a3-proj && cd /tmp/a3-proj + SID=$(amplifier run "Say hello in one word." --mode single 2>&1 | tee /tmp/a3.log | grep -oE 'Session ID: [0-9a-f-]+' | awk '{print $3}') + echo "session: $SID" + GEN=$(find /root/.amplifier/projects -path "*/sessions/$SID/events.jsonl" 2>/dev/null | head -1) + if [ -z "$GEN" ]; then echo "FAIL[A3]: no generic session log for $SID"; exit 1; fi + python3 - "$GEN" << 'PY' + import json, sys + cfg = None + for line in open(sys.argv[1]): + line = line.strip() + if not line: + continue + try: + o = json.loads(line) + except Exception: + continue + if o.get("event") == "session:config": + cfg = o["data"]["raw"] + if not cfg: + print("FAIL[A3]: no session:config captured"); sys.exit(1) + agents = list((cfg.get("agents") or {}).keys()) + analyst = [a for a in agents if "graph-analyst" in a or "session-navigator" in a] + design = [a for a in agents if "design-facilitator" in a or "tool-designer" in a] + if not analyst: + print("FAIL[A3]: analyst agents missing from composed session:", agents); sys.exit(1) + if design: + print("FAIL[A3]: design-mode agents present while mode INACTIVE (gating broken):", design); sys.exit(1) + print("PASS[A3]: composed session has analyst agents", analyst, "and design-mode agents are ABSENT (mode-gated) ->", design or "none") + PY + + # ---------------------------------------------------------------------- + # A1: hook ABSENT -- run an analytics session, assert NO new events.jsonl + # ---------------------------------------------------------------------- + - name: A1-hook-absent + command: | + set -e + mkdir -p /tmp/analyst-proj + cd /tmp/analyst-proj + touch /tmp/analyst-marker + sleep 1 + amplifier run "Say hello in one word." --mode single 2>&1 | tee /tmp/analyst-trivial.log + # Any events.jsonl newer than the marker would mean a hook fired -> FAIL + NEW=$(find /root/.amplifier/projects -path '*context-intelligence/events.jsonl' -newer /tmp/analyst-marker 2>/dev/null) + if [ -n "$NEW" ]; then + echo "FAIL[A1]: a hook fired -- new events.jsonl created in analytics-only mode:" + echo "$NEW" + exit 1 + fi + # Also confirm the events.jsonl set is unchanged from the seeded baseline + find /root/.amplifier/projects -path '*context-intelligence/events.jsonl' | sort > /tmp/jsonl-after.txt + if ! diff -q /tmp/jsonl-baseline.txt /tmp/jsonl-after.txt >/dev/null; then + echo "FAIL[A1]: events.jsonl set changed after analytics session" + diff /tmp/jsonl-baseline.txt /tmp/jsonl-after.txt || true + exit 1 + fi + echo "PASS[A1]: no hook -- analytics session created NO new events.jsonl (only seeded fixture present)" + + # ---------------------------------------------------------------------- + # A4: graph-analyst -> session-navigator local-extraction fallback (no server) + # ---------------------------------------------------------------------- + - name: A4-analyst-fallback-local-extraction + command: | + set -e + mkdir -p /tmp/analyst-proj + cd /tmp/analyst-proj + amplifier run "Use the graph-analyst to tell me how many context-intelligence sessions exist on disk and name any tool that was used in them. Report the exact tool name." \ + --mode single 2>&1 | tee /tmp/analyst-query.log + echo "----- checking output for fixture evidence -----" + if grep -qi "fixture_marker_tool" /tmp/analyst-query.log; then + echo "PASS[A4]: graph-analyst fell back to local extraction and surfaced the fixture marker tool 'fixture_marker_tool'" + exit 0 + fi + # Secondary acceptance: correct session count (exactly 1 fixture session) + if grep -qiE '\b(1|one)\b[^.]*session' /tmp/analyst-query.log; then + echo "PASS[A4]: graph-analyst fell back to local extraction and reported the correct session count (1)" + exit 0 + fi + echo "FAIL[A4]: analyst output did not reference the fixture (no 'fixture_marker_tool' and no correct session count)" + echo "--- analyst output tail ---" + tail -40 /tmp/analyst-query.log + exit 1 + + # ---------------------------------------------------------------------- + # A5: ZERO always-on awareness. The composed default session must not load + # any context-intelligence awareness file into always-on context. We inspect + # session:config.raw.context and assert none of the (now deleted) awareness + # filenames appear. + # ---------------------------------------------------------------------- + - name: A5-zero-always-on-awareness + command: | + set -e + mkdir -p /tmp/a5-proj && cd /tmp/a5-proj + SID=$(amplifier run "Say hello in one word." --mode single 2>&1 | tee /tmp/a5.log | grep -oE 'Session ID: [0-9a-f-]+' | awk '{print $3}') + GEN=$(find /root/.amplifier/projects -path "*/sessions/$SID/events.jsonl" 2>/dev/null | head -1) + if [ -z "$GEN" ]; then echo "FAIL[A5]: no generic session log for $SID"; exit 1; fi + python3 - "$GEN" << 'PY' + import json, sys + cfg = None + for line in open(sys.argv[1]): + line = line.strip() + if not line: + continue + try: + o = json.loads(line) + except Exception: + continue + if o.get("event") == "session:config": + cfg = o["data"]["raw"] + if not cfg: + print("FAIL[A5]: no session:config captured"); sys.exit(1) + blob = json.dumps(cfg.get("context") or cfg) + banned = [ + "context-intelligence-awareness.md", + "context-intelligence-navigation-awareness.md", + "context-intelligence-graph-awareness.md", + ] + hits = [b for b in banned if b in blob] + if hits: + print("FAIL[A5]: always-on awareness file(s) present in composed context:", hits); sys.exit(1) + print("PASS[A5]: zero always-on awareness -- none of", banned, "are in the composed session context") + PY + + # ---------------------------------------------------------------------- + # A6: mode registration. The design behavior registers the context-intelligence + # mode via hooks-mode search_paths. Prove it with a live `amplifier mode list`. + # ---------------------------------------------------------------------- + - name: A6-mode-registered + command: | + set -e + mkdir -p /tmp/a6 && cd /tmp/a6 + SID=$(amplifier run "Call the mode tool exactly once with operation=set and name=context-intelligence. Report the raw result." \ + --mode single 2>&1 | tee /tmp/a6.log | grep -oE 'Session ID: [0-9a-f-]+' | awk '{print $3}') + echo "session: $SID" + GEN=$(find /root/.amplifier/projects -path "*/sessions/$SID/events.jsonl" 2>/dev/null | head -1) + if [ -z "$GEN" ]; then echo "FAIL[A6]: no session log for $SID"; exit 1; fi + python3 - "$GEN" << 'PY' + import json, sys + recognized = False; unknown = False; detail = None + for l in open(sys.argv[1]): + l = l.strip() + if not l: continue + try: o = json.loads(l) + except: continue + d = o.get("data", {}) + if o.get("event") == "tool:post" and d.get("tool_name") == "mode": + res = d.get("result", {}) or {} + out = res.get("output", {}) if isinstance(res.get("output", {}), dict) else {} + blob = json.dumps(res) + if out.get("active_mode") == "context-intelligence" or out.get("status") == "activated" \ + or out.get("denied_mode") == "context-intelligence" or out.get("status") == "denied": + recognized = True; detail = out.get("status") or out.get("active_mode") + if ("not found" in blob.lower() or "unknown mode" in blob.lower()) and "context-intelligence" in blob: + unknown = True + if unknown and not recognized: + print("FAIL[A6]: mode tool reports context-intelligence as unknown/not-found (NOT registered)"); sys.exit(1) + if not recognized: + print("FAIL[A6]: no mode(set) result referencing context-intelligence found"); sys.exit(1) + print(f"PASS[A6]: context-intelligence mode is REGISTERED/activatable -- mode(set) recognized it (result status: {detail})") + PY + + # ---------------------------------------------------------------------- + # A7: mode-only strategy context + eval skill RESOLVES via load_skill. + # Run a session that activates the context-intelligence mode and instructs the + # agent to load the context-intelligence-evaluation-methodology skill. Assert + # the generic event log shows a SUCCESSFUL load_skill tool call for that skill + # (proves it resolves, not just appears), and that the strategy context file is + # referenced once the mode is active. + # NOTE: replace `--mode context-intelligence` below with the exact activation + # flag confirmed via `amplifier run --help` if it differs. + # ---------------------------------------------------------------------- + - name: A7-mode-active-eval-skill-resolves + command: | + set -e + mkdir -p /tmp/a7 && cd /tmp/a7 + SID=$(amplifier run "You must activate a mode and then load a skill. Step 1: call mode(operation=set, name=context-intelligence). Step 2: if the result status is 'denied' or asks to confirm, call mode(operation=set, name=context-intelligence) a SECOND time to confirm. Step 3: once active, call load_skill(skill_name=context-intelligence-evaluation-methodology). Step 4: report the skill's first heading. Do all four steps yourself; do not ask me anything." \ + --mode single 2>&1 | tee /tmp/a7.log | grep -oE 'Session ID: [0-9a-f-]+' | awk '{print $3}') + echo "session: $SID" + GEN=$(find /root/.amplifier/projects -path "*/sessions/$SID/events.jsonl" 2>/dev/null | head -1) + if [ -z "$GEN" ]; then echo "FAIL[A7]: no session log for $SID"; exit 1; fi + python3 - "$GEN" << 'PY' + import json, sys + mode_status = []; activated = False; skill_ok = False; skill_err = None + for l in open(sys.argv[1]): + l = l.strip() + if not l: continue + try: o = json.loads(l) + except: continue + d = o.get("data", {}) + if o.get("event") == "tool:post" and d.get("tool_name") == "mode": + out = d.get("result", {}).get("output", {}) if isinstance(d.get("result", {}).get("output", {}), dict) else {} + mode_status.append(out.get("status") or out.get("active_mode")) + if out.get("status") == "activated" or out.get("active_mode") == "context-intelligence": + activated = True + if o.get("event") == "tool:post" and d.get("tool_name") == "load_skill" \ + and "context-intelligence-evaluation-methodology" in json.dumps(d.get("tool_input", {})): + res = d.get("result", {}) or {} + if res.get("error"): skill_err = res["error"].get("message", "")[:140] + else: skill_ok = True + print("mode set attempts (status):", mode_status) + print("eval skill load error:", skill_err) + if not activated: + print("FAIL[A7]: context-intelligence mode never activated"); sys.exit(1) + if not skill_ok: + print(f"FAIL[A7]: eval skill did NOT resolve via load_skill (err: {skill_err})"); sys.exit(1) + print("PASS[A7]: mode activated via retry-to-confirm AND context-intelligence-evaluation-methodology resolved via load_skill (error null) while mode active") + PY diff --git a/.amplifier/digital-twin-universe/profiles/context-intelligence-bundle-smoke-test.yaml b/.amplifier/digital-twin-universe/profiles/context-intelligence-bundle-smoke-test.yaml new file mode 100644 index 00000000..f562698f --- /dev/null +++ b/.amplifier/digital-twin-universe/profiles/context-intelligence-bundle-smoke-test.yaml @@ -0,0 +1,169 @@ +# Smoke-test profile for amplifier-bundle-context-intelligence +# +# Tests: library imports, CLI tools, hook HookConfigResolver, and Amplifier +# bundle loading. Does NOT require an external CI server. +# +# Usage: +# export GH_TOKEN=$(gh auth token) +# amplifier-digital-twin launch .amplifier/digital-twin-universe/profiles/context-intelligence-bundle-smoke-test.yaml \ +# --name context-intelligence-bundle-smoke-test +name: context-intelligence-bundle-smoke-test +description: > + Smoke test for the context-intelligence bundle. Validates library imports, + upload CLI, hook additional_events config, and Amplifier bundle loading. + +url_rewrites: + auth: + username: admin + token_var: GITEA_TOKEN + default_match_mode: boundary + rules: + - match: github.com/microsoft/amplifier-bundle-context-intelligence + target: ${GITEA_URL}/admin/amplifier-bundle-context-intelligence + +base: + image: ubuntu:24.04 + +passthrough: + allow_external: true + services: + - name: anthropic + key_env: ANTHROPIC_API_KEY + - name: github + key_env: GH_TOKEN + +provision: + setup_cmds: + # System dependencies + - apt-get update && apt-get install -y git curl python3 python3-venv + + # Install uv + - curl -LsSf https://astral.sh/uv/install.sh | sh + + # Configure git credentials for private GitHub repos + - | + if [ -n "$GH_TOKEN" ]; then + echo "machine github.com login x-token-auth password $GH_TOKEN" > /root/.netrc + chmod 600 /root/.netrc + git config --global credential.helper 'store' + fi + + # Clone the bundle (HEAD of main) + - | + export PATH="/root/.local/bin:$PATH" + git clone --depth 1 https://github.com/microsoft/amplifier-bundle-context-intelligence.git /opt/context-intelligence-bundle + + # Create a venv and install packages in dependency order. + # Ubuntu 24.04 marks system Python as externally managed (PEP 668), + # so we use a dedicated virtualenv for the smoke-test packages. + - | + export PATH="/root/.local/bin:$PATH" + uv venv /opt/venv --python python3 + export VIRTUAL_ENV=/opt/venv + export PATH="/opt/venv/bin:$PATH" + uv pip install /opt/context-intelligence-bundle/ + uv pip install "httpx>=0.28.1" + uv pip install --no-deps /opt/context-intelligence-bundle/modules/hook-context-intelligence/ + uv pip install --no-deps /opt/context-intelligence-bundle/modules/tool-context-intelligence-upload/ + + # Quick sanity — library importable? + - /opt/venv/bin/python3 -c "from context_intelligence.config import resolve_config; print('context_intelligence importable')" + + # Install Amplifier itself (uv tool uses its own isolated venv) + - | + export PATH="/root/.local/bin:$PATH" + uv tool install git+https://github.com/microsoft/amplifier@main + + # Configure Amplifier provider + - | + mkdir -p /root/.amplifier + cat > /root/.amplifier/settings.yaml << 'EOF' + config: + providers: + - module: provider-anthropic + source: git+https://github.com/microsoft/amplifier-module-provider-anthropic@main + config: + api_key_env: ANTHROPIC_API_KEY + EOF + + # Add the context-intelligence bundle to Amplifier + - | + export PATH="/root/.local/bin:$PATH" + amplifier bundle add git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main --app + + # Verify Amplifier is working + - | + export PATH="/root/.local/bin:$PATH" + amplifier --version + + # Persist PATH for interactive shells and exec commands + - | + echo 'export PATH="/opt/venv/bin:/root/.local/bin:$PATH"' >> /root/.bashrc + echo 'export VIRTUAL_ENV=/opt/venv' >> /root/.bashrc + echo 'PATH=/opt/venv/bin:/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' >> /etc/environment + echo 'VIRTUAL_ENV=/opt/venv' >> /etc/environment + + # Workspace for the user + - mkdir -p /home/user/project + + # Write a smoke wrapper bundle that composes the context-intelligence umbrella + # bundle plus the stub-event-contributor fixture module, then register it. + - | + cat > /root/context-intelligence-smoke-stub-bundle.yaml <<'YAML' + bundle: + name: context-intelligence-smoke-stub + version: 0.1.0 + description: Smoke wrapper - context-intelligence umbrella bundle + stub-event-contributor module. + includes: + - bundle: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main + tools: + - module: stub-event-contributor + source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/hook-context-intelligence/tests/dtu/fixtures/stub-event-contributor + YAML + amplifier bundle add /root/context-intelligence-smoke-stub-bundle.yaml + +readiness: + - name: library-check + command: "/opt/venv/bin/python3 -c \"from context_intelligence.client import AsyncCIClient; print('ready')\"" + +validation_cmds: + - name: smoke-compose-and-on-session-ready + command: | + set -e + mkdir -p /tmp/smoke-final && cd /tmp/smoke-final + amplifier run --bundle context-intelligence-smoke-stub "Say hello in one word." --mode single \ + 2>&1 | tee /tmp/smoke-final.log >/dev/null + SID=$(grep -oE 'Session ID: [0-9a-f-]+' /tmp/smoke-final.log | awk '{print $3}') + echo "SID=$SID" + SDIR=$(find /root/.amplifier/projects -type d -path "*/sessions/$SID" 2>/dev/null | head -1) + GEN="$SDIR/events.jsonl" + CIJ="$SDIR/context-intelligence/events.jsonl" + python3 - "$GEN" "$CIJ" << 'PY' + import json, os, sys + gen, cij = sys.argv[1], sys.argv[2] + composed = False + for l in open(gen): + l = l.strip() + if not l: continue + try: o = json.loads(l) + except: continue + if o.get("event") == "session:config": + tools = (o.get("data", {}).get("raw", {}).get("tools") or []) + if any(isinstance(t, dict) and t.get("module") == "stub-event-contributor" for t in tools): + composed = True + if not composed: + print("FAIL[SMOKE]: stub-event-contributor NOT in session:config mount plan"); sys.exit(1) + if not os.path.exists(cij) or os.path.getsize(cij) == 0: + print("FAIL[SMOKE]: CI hook events.jsonl missing/empty -> on_session_ready did not register the handler"); sys.exit(1) + evset = set() + for l in open(cij): + l = l.strip() + if not l: continue + try: evset.add(json.loads(l).get("event")) + except: pass + need = {"session:start", "session:end"} + if not need.issubset(evset): + print(f"FAIL[SMOKE]: CI events.jsonl missing lifecycle {need - evset}"); sys.exit(1) + print("PASS[SMOKE]: stub-event-contributor composed into mount plan AND on_session_ready ran " + "(CI events.jsonl populated with session:start/session:end via the late-registered LoggingHandler)") + PY diff --git a/.amplifier/digital-twin-universe/profiles/context-intelligence-hook-behavioral-test.yaml b/.amplifier/digital-twin-universe/profiles/context-intelligence-hook-behavioral-test.yaml new file mode 100644 index 00000000..146a2f5e --- /dev/null +++ b/.amplifier/digital-twin-universe/profiles/context-intelligence-hook-behavioral-test.yaml @@ -0,0 +1,391 @@ +# ============================================================================ +# BEHAVIORAL TEST PROFILE 1 of 2 -- HOOK MODE +# ============================================================================ +# +# GOAL +# Deep, evidence-based proof that a FRESH Amplifier session, with the +# context-intelligence bundle installed in its FULL behavior, instruments +# itself correctly via the event-capture hook (hook-context-intelligence) +# running in LOCAL-ONLY mode (no CI server). +# +# WHAT "WORKS AS EXPECTED" MEANS HERE +# The FULL behavior (behaviors/context-intelligence.yaml) is the ONLY +# behavior that ships the event-capture hook. Installing it via +# `amplifier bundle add @main --app` mounts the hook onto every +# session. With NO server env vars set the hook degrades gracefully to +# LOCAL-ONLY mode: it still writes a per-session events.jsonl + metadata.json +# under ~/.amplifier/projects//sessions//context-intelligence/, +# capturing canonical amplifier_core lifecycle events. Server dispatch is +# OFF unless BOTH AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL and +# AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY are set -- which they are NOT here. +# +# ASSERTIONS (each validation_cmd prints PASS/FAIL and exits non-zero on FAIL) +# H1 events.jsonl is created under ~/.amplifier/projects/**/context-intelligence/ +# after a real 1-turn session, and is non-empty. +# H2 metadata.json exists alongside it with required fields: +# format, version, session_id, workspace, started_at, status, working_dir. +# H3 Every line of events.jsonl is valid JSON with the envelope keys +# event, workspace, timestamp, data. +# H4 At least one CANONICAL lifecycle event is present (event name matching +# session/provider/tool) -- proves ALL_EVENTS wiring, not a single custom event. +# H5 Local-only graceful degradation: with NO server env vars, the session +# still completes AND the JSONL is written (no crash, no dispatch required). +# +# LOCAL-BRANCH UNDER TEST +# Source of truth is the local working branch of the +# amplifier-bundle-context-intelligence submodule, mirrored to Gitea (see +# the MANDATORY url_rewrites block below). NOT GitHub @main. +# +# USAGE +# amplifier-digital-twin launch \ +# .amplifier/digital-twin-universe/profiles/context-intelligence-hook-behavioral-test.yaml \ +# --var GITEA_URL=http://: \ +# --var GITEA_TOKEN= \ +# --name context-intelligence-hook-behavioral-test +# ============================================================================ + +name: context-intelligence-hook-behavioral-test +description: > + HOOK MODE behavioral test for the context-intelligence bundle (local branch + via Gitea). Installs the FULL behavior, runs a real 1-turn session in + local-only mode (no CI server), and asserts the event-capture hook writes a + valid per-session events.jsonl + metadata.json with canonical lifecycle events. + +base: + image: ubuntu:24.04 + +# MANDATORY: Gitea URL rewrite for local branch skill resolution. +# The bundle ships skills with a git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills source in behaviors/context-intelligence.yaml. +# When running in DTU, local working branch is source of truth -- NOT GitHub @main. Without rewrite, skill registration inside DTU fetches from GitHub @main and silently runs stale code, producing test results that do not reflect local changes. +# The digital-twin-universe:dtu-profile-builder agent mirrors the local amplifier-bundle-context-intelligence submodule to Gitea on current branch. These rewrites point every reference at that mirror. +# url_rewrites: +# - from: "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main" +# to: "git+http://{gitea_host}/microsoft/amplifier-bundle-context-intelligence@main" +# +# Implemented below using the functional DTU url_rewrites schema (auth + rules +# with boundary match). The mitmproxy rewrite redirects EVERY reference to +# github.com/microsoft/amplifier-bundle-context-intelligence (the bundle add, +# the analytics subdirectory, and #subdirectory=skills/* skill sources) to the +# Gitea mirror admin/amplifier-bundle-context-intelligence, whose `main` branch +# holds the local working-tree snapshot. {gitea_host} is supplied at launch via +# --var GITEA_URL (resolved to the Gitea mirror reachable from inside the DTU). +url_rewrites: + auth: + username: admin + token_var: GITEA_TOKEN + default_match_mode: boundary + rules: + - match: github.com/microsoft/amplifier-bundle-context-intelligence + target: ${GITEA_URL}/admin/amplifier-bundle-context-intelligence + +passthrough: + allow_external: true + services: + - name: anthropic + key_env: ANTHROPIC_API_KEY + - name: github + key_env: GH_TOKEN + +provision: + setup_cmds: + # System dependencies (jq + python3 used by the assertions below) + - apt-get update && apt-get install -y git curl python3 jq + + # Install uv + - curl -LsSf https://astral.sh/uv/install.sh | sh + + # Configure git credentials for GitHub (transitive public deps; harmless if unset) + - | + if [ -n "$GH_TOKEN" ]; then + echo "machine github.com login x-token-auth password $GH_TOKEN" > /root/.netrc + chmod 600 /root/.netrc + git config --global credential.helper 'store' + fi + + # Install Amplifier itself + - uv tool install git+https://github.com/microsoft/amplifier@main + + # Configure Amplifier provider (anthropic only -- minimal, matches smoke test) + - | + mkdir -p /root/.amplifier + cat > /root/.amplifier/settings.yaml << 'EOF' + config: + providers: + - module: provider-anthropic + source: git+https://github.com/microsoft/amplifier-module-provider-anthropic@main + config: + api_key_env: ANTHROPIC_API_KEY + EOF + + # Install the FULL context-intelligence behavior (analytics + HOOK). + # url_rewrites redirects this @main reference to the Gitea mirror (local branch). + # The hook ships ONLY in this behavior. + - amplifier bundle add git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main --app + + # Sanity: Amplifier is working and the bundle is registered + - amplifier --version + - amplifier bundle list + + # Workspace for the test session + - mkdir -p /tmp/hook-proj + +readiness: + - name: amplifier-installed + command: amplifier --version + +validation_cmds: + # ---------------------------------------------------------------------- + # Run a REAL 1-turn session in LOCAL-ONLY mode (no CI server env vars). + # The FULL behavior's hook mounts and must write events.jsonl + metadata.json. + # ---------------------------------------------------------------------- + - name: run-hook-session + command: | + set -e + # Prove local-only mode: no server env vars set + echo "AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL='${AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL:-}'" + echo "AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY='${AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY:-}'" + mkdir -p /tmp/hook-proj + cd /tmp/hook-proj + touch /tmp/hook-pre-marker + sleep 1 + amplifier run "Say hello in one word." --mode single 2>&1 | tee /tmp/hook-session.log + echo "Session completed" + + # ---------------------------------------------------------------------- + # H1: events.jsonl created and non-empty + # ---------------------------------------------------------------------- + - name: H1-events-jsonl-created + command: | + set -e + EVENTS_FILE=$(find /root/.amplifier/projects -path '*context-intelligence/events.jsonl' -newer /tmp/hook-pre-marker 2>/dev/null | head -1) + if [ -z "$EVENTS_FILE" ]; then + echo "FAIL[H1]: No events.jsonl created under ~/.amplifier/projects/**/context-intelligence/ after session" + find /root/.amplifier/projects -type f 2>/dev/null | head -50 + exit 1 + fi + if [ ! -s "$EVENTS_FILE" ]; then + echo "FAIL[H1]: events.jsonl exists but is EMPTY: $EVENTS_FILE" + exit 1 + fi + LINES=$(wc -l < "$EVENTS_FILE") + echo "$EVENTS_FILE" > /tmp/hook-events-path + echo "PASS[H1]: events.jsonl created and non-empty ($LINES lines): $EVENTS_FILE" + + # ---------------------------------------------------------------------- + # H2: metadata.json exists with required fields + # ---------------------------------------------------------------------- + - name: H2-metadata-required-fields + command: | + set -e + EVENTS_FILE=$(cat /tmp/hook-events-path) + META="$(dirname "$EVENTS_FILE")/metadata.json" + if [ ! -f "$META" ]; then + echo "FAIL[H2]: metadata.json not found at $META" + exit 1 + fi + # Required fields per the hook's metadata contract. The criterion is + # PRESENCE of the keys (the hook may legitimately leave a value empty in + # some run contexts). Empty values are surfaced as a NOTE, not a failure. + MISSING=$(python3 - "$META" << 'PY' + import json, sys + required = ["format","version","session_id","workspace","started_at","status","working_dir"] + d = json.load(open(sys.argv[1])) + print(",".join([k for k in required if k not in d])) + PY + ) + if [ -n "$MISSING" ]; then + echo "FAIL[H2]: metadata.json missing required field(s): $MISSING" + echo "--- metadata.json ---"; cat "$META" + exit 1 + fi + EMPTY=$(python3 - "$META" << 'PY' + import json, sys + required = ["format","version","session_id","workspace","started_at","status","working_dir"] + d = json.load(open(sys.argv[1])) + print(",".join([k for k in required if d.get(k) in (None, "")])) + PY + ) + echo "PASS[H2]: metadata.json contains all required fields (format,version,session_id,workspace,started_at,status,working_dir): $META" + if [ -n "$EMPTY" ]; then + echo "NOTE[H2]: the following required field(s) are present but EMPTY: $EMPTY" + fi + cat "$META" + + # ---------------------------------------------------------------------- + # H3: every line is valid JSON with envelope keys event/workspace/timestamp/data + # ---------------------------------------------------------------------- + - name: H3-envelope-structure + command: | + set -e + EVENTS_FILE=$(cat /tmp/hook-events-path) + python3 - "$EVENTS_FILE" << 'PY' + import json, sys + required = {"event","workspace","timestamp","data"} + bad = 0 + total = 0 + with open(sys.argv[1]) as f: + for i, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + total += 1 + try: + obj = json.loads(line) + except Exception as e: + print(f"FAIL[H3]: line {i} is not valid JSON: {e}") + bad += 1 + continue + missing = required - set(obj.keys()) + if missing: + print(f"FAIL[H3]: line {i} missing envelope keys: {sorted(missing)}") + bad += 1 + if bad or total == 0: + print(f"FAIL[H3]: {bad} bad line(s) of {total}") + sys.exit(1) + print(f"PASS[H3]: all {total} lines valid JSON with envelope keys event/workspace/timestamp/data") + PY + + # ---------------------------------------------------------------------- + # H4: at least one canonical lifecycle event (session/provider/tool) + # ---------------------------------------------------------------------- + - name: H4-canonical-lifecycle-event + command: | + set -e + EVENTS_FILE=$(cat /tmp/hook-events-path) + python3 - "$EVENTS_FILE" << 'PY' + import json, sys, re + pat = re.compile(r'(session|provider|tool)', re.I) + names = [] + with open(sys.argv[1]) as f: + for line in f: + line = line.strip() + if not line: + continue + try: + names.append(json.loads(line).get("event","")) + except Exception: + pass + matches = sorted({n for n in names if pat.search(n or "")}) + if not matches: + print("FAIL[H4]: no canonical session/provider/tool lifecycle event found") + print("Events present:", sorted(set(names))) + sys.exit(1) + print("PASS[H4]: canonical lifecycle event(s) present:", matches) + print("All distinct events:", sorted(set(names))) + PY + + # ---------------------------------------------------------------------- + # H5: local-only graceful degradation -- no server env, session completed, JSONL written + # ---------------------------------------------------------------------- + - name: H5-local-only-degradation + command: | + set -e + FAILED=0 + if [ -n "${AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL:-}" ] || [ -n "${AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY:-}" ]; then + echo "FAIL[H5]: server env vars are set -- this test must run local-only" + FAILED=1 + fi + EVENTS_FILE=$(cat /tmp/hook-events-path 2>/dev/null || true) + if [ -z "$EVENTS_FILE" ] || [ ! -s "$EVENTS_FILE" ]; then + echo "FAIL[H5]: no non-empty events.jsonl written in local-only mode" + FAILED=1 + fi + # Session must have completed without a crash/traceback + if grep -qiE 'Traceback \(most recent call last\)|Fatal error' /tmp/hook-session.log; then + echo "FAIL[H5]: session log contains a crash/traceback" + grep -iE 'Traceback|Fatal error' /tmp/hook-session.log | head + FAILED=1 + fi + if [ "$FAILED" -ne 0 ]; then + exit 1 + fi + echo "PASS[H5]: local-only mode -- no server env vars, session completed cleanly, JSONL written ($EVENTS_FILE)" + + # ---------------------------------------------------------------------- + # F2: analytics surface PRESENT in FULL behavior (regression for the split). + # The FULL behavior now `includes:` analytics + logging, so graph-analyst AND + # session-navigator must both be composed and available/[on] -- proving FULL + # still carries the read/query surface after the refactor. + # ---------------------------------------------------------------------- + # NOTE: `amplifier agents show` reports the GLOBAL agent catalog (every agent + # .md in any cached bundle), so it cannot distinguish composed-vs-catalogued. + # We assert against the captured session:config (data.raw.agents) -- the + # authoritative record of what was actually composed into THIS session. + - name: F2-analytics-present + command: | + set -e + EVENTS_FILE=$(cat /tmp/hook-events-path) + python3 - "$EVENTS_FILE" << 'PY' + import json, sys + cfg = None + for l in open(sys.argv[1]): + l = l.strip() + if not l: + continue + o = json.loads(l) + if o.get("event") == "session:config": + cfg = o["data"]["raw"] + if not cfg: + print("FAIL[F2]: no session:config captured"); sys.exit(1) + agents = list((cfg.get("agents") or {}).keys()) + ga = [a for a in agents if "graph-analyst" in a] + sn = [a for a in agents if "session-navigator" in a] + if not ga or not sn: + print("FAIL[F2]: FULL session missing analytics agents -- graph-analyst:", ga or "ABSENT", "session-navigator:", sn or "ABSENT"); sys.exit(1) + print("PASS[F2]: FULL behavior composes analytics -- graph-analyst", ga, "AND session-navigator", sn, "present in session:config") + PY + + # ---------------------------------------------------------------------- + # F3: hook registered EXACTLY ONCE despite FULL including BOTH behaviors. + # The critical regression check: FULL `includes:` analytics + logging, and the + # hook ships in logging. There must be NO double registration: + # (a) exactly ONE context-intelligence/events.jsonl was created for the session; + # (b) exactly ONE session-start lifecycle event in that file (a second mount + # would duplicate lifecycle capture); + # (c) no duplicate-registration warning/error in the session log. + # ---------------------------------------------------------------------- + - name: F3-hook-registered-once + command: | + set -e + FAILED=0 + # (a) exactly one events.jsonl newer than the pre-session marker + mapfile -t NEWFILES < <(find /root/.amplifier/projects -path '*context-intelligence/events.jsonl' -newer /tmp/hook-pre-marker 2>/dev/null) + COUNT=${#NEWFILES[@]} + echo "New events.jsonl files for this session: $COUNT" + printf '%s\n' "${NEWFILES[@]}" + if [ "$COUNT" -ne 1 ]; then + echo "FAIL[F3a]: expected exactly 1 context-intelligence events.jsonl for the session, found $COUNT (possible double hook registration)" + FAILED=1 + fi + EVENTS_FILE=$(cat /tmp/hook-events-path) + # (b) exactly one session-start lifecycle event (no duplicated capture) + STARTS=$(python3 - "$EVENTS_FILE" << 'PY' + import json, sys, re + pat = re.compile(r'session[:._-](start|started|begin)', re.I) + n = 0 + for line in open(sys.argv[1]): + line = line.strip() + if not line: + continue + try: + ev = json.loads(line).get("event","") + except Exception: + continue + if pat.search(ev or ""): + n += 1 + print(n) + PY + ) + echo "session-start lifecycle events in file: $STARTS" + if [ "$STARTS" -gt 1 ]; then + echo "FAIL[F3b]: $STARTS session-start events -- hook lifecycle captured more than once (double registration)" + FAILED=1 + fi + # (c) no duplicate-registration warning/error in the session log + if grep -qiE 'already registered|duplicate (hook|handler|registration)|registered twice|registered more than once' /tmp/hook-session.log; then + echo "FAIL[F3c]: session log shows a duplicate-registration warning/error" + grep -niE 'already registered|duplicate (hook|handler|registration)|registered twice|registered more than once' /tmp/hook-session.log | head + FAILED=1 + fi + [ "$FAILED" -ne 0 ] && exit 1 + echo "PASS[F3]: hook registered EXACTLY ONCE despite FULL including both behaviors (one events.jsonl, one session-start event, no duplicate-registration warnings)" diff --git a/.amplifier/digital-twin-universe/profiles/context-intelligence-logging-behavioral-test.yaml b/.amplifier/digital-twin-universe/profiles/context-intelligence-logging-behavioral-test.yaml new file mode 100644 index 00000000..17d4221c --- /dev/null +++ b/.amplifier/digital-twin-universe/profiles/context-intelligence-logging-behavioral-test.yaml @@ -0,0 +1,342 @@ +# ============================================================================ +# BEHAVIORAL TEST PROFILE 3 of 3 -- LOGGING-ONLY MODE (the NEW granular split) +# ============================================================================ +# +# GOAL +# Deep, evidence-based proof that a FRESH Amplifier session, with the +# context-intelligence bundle installed in its NEW LOGGING-ONLY behavior +# (behaviors/context-intelligence-logging.yaml), instruments itself via the +# event-capture hook (hook-context-intelligence) running in LOCAL-ONLY mode +# (no CI server) -- AND carries NONE of the analytics read/query surface. +# +# THE NEW THREE-WAY SPLIT (consumers pick granularity) +# * LOGGING-ONLY (this profile) -> behaviors/context-intelligence-logging.yaml +# hook-context-intelligence ONLY (event capture / JSONL). NO agents, +# NO tools, NO skills, NO mode. Pure producer side. +# * READ/QUERY -> behaviors/context-intelligence-design.yaml +# graph-analyst + session-navigator + tools + skills + mode, NO hook. +# * FULL -> behaviors/context-intelligence.yaml +# `includes:` BOTH design and logging (hook registered EXACTLY once). +# +# ASSERTIONS (each validation_cmd prints PASS/FAIL and exits non-zero on FAIL) +# L1 events.jsonl created & non-empty after a real 1-turn session AND +# metadata.json has all required fields: session_id, workspace, +# started_at, status, working_dir, format, version. +# L2 Every events.jsonl line is a valid envelope {event,workspace,timestamp, +# data} AND canonical lifecycle events are present (session/provider/ +# llm/tool). +# L3 Analytics surface is ABSENT (this is the KEY new check): +# (a) `amplifier agents show graph-analyst` / `session-navigator` +# report NEITHER as available/[on]; +# (b) the cached logging behavior YAML has a hooks: section but NO +# agents:/tools:/context: sections (structural proof of pure +# producer side); +# (c) graph_query / blob_read tools are NOT referenced anywhere in the +# composed session telemetry. Proves logging-only carries no +# read/query surface. +# L4 Local-only graceful degradation: with NO server env vars, the session +# still completes cleanly AND the JSONL is written (no crash, no dispatch). +# +# LOCAL-BRANCH UNDER TEST +# Source of truth is the local working branch of the +# amplifier-bundle-context-intelligence submodule, mirrored to Gitea (see the +# MANDATORY url_rewrites block below). NOT GitHub @main. +# +# USAGE +# amplifier-digital-twin launch \ +# .amplifier/digital-twin-universe/profiles/context-intelligence-logging-behavioral-test.yaml \ +# --var GITEA_URL=http://: \ +# --var GITEA_TOKEN= \ +# --name context-intelligence-logging-behavioral-test +# ============================================================================ + +name: context-intelligence-logging-behavioral-test +description: > + LOGGING-ONLY behavioral test for the context-intelligence bundle (local branch + via Gitea). Installs the NEW logging-only behavior (hook only, no analytics), + runs a real 1-turn session in local-only mode (no CI server), and asserts the + event-capture hook writes a valid per-session events.jsonl + metadata.json with + canonical lifecycle events, while NONE of the analytics agents/tools/skills are + composed (proving the granular producer-only split). + +base: + image: ubuntu:24.04 + +# MANDATORY: Gitea URL rewrite for local branch skill resolution. +# The bundle ships skills with a git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills source in behaviors/context-intelligence.yaml. +# When running in DTU, local working branch is source of truth -- NOT GitHub @main. Without rewrite, skill registration inside DTU fetches from GitHub @main and silently runs stale code, producing test results that do not reflect local changes. +# The digital-twin-universe:dtu-profile-builder agent mirrors the local amplifier-bundle-context-intelligence submodule to Gitea on current branch. These rewrites point every reference at that mirror. +# url_rewrites: +# - from: "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main" +# to: "git+http://{gitea_host}/microsoft/amplifier-bundle-context-intelligence@main" +# +# Implemented below using the functional DTU url_rewrites schema (auth + rules +# with boundary match). The mitmproxy rewrite redirects EVERY reference to +# github.com/microsoft/amplifier-bundle-context-intelligence (the logging +# behavior subdirectory AND the hook module #subdirectory=modules/... source +# declared in behaviors/context-intelligence-logging.yaml) to the Gitea mirror +# admin/amplifier-bundle-context-intelligence, whose `main` branch holds the +# local working-tree snapshot. {gitea_host} is supplied at launch via +# --var GITEA_URL (resolved to the Gitea mirror reachable from inside the DTU). +url_rewrites: + auth: + username: admin + token_var: GITEA_TOKEN + default_match_mode: boundary + rules: + - match: github.com/microsoft/amplifier-bundle-context-intelligence + target: ${GITEA_URL}/admin/amplifier-bundle-context-intelligence + +passthrough: + allow_external: true + services: + - name: anthropic + key_env: ANTHROPIC_API_KEY + - name: github + key_env: GH_TOKEN + +provision: + setup_cmds: + # System dependencies (jq + python3 used by the assertions below) + - apt-get update && apt-get install -y git curl python3 jq + + # Install uv + - curl -LsSf https://astral.sh/uv/install.sh | sh + + # Configure git credentials for GitHub (transitive public deps; harmless if unset) + - | + if [ -n "$GH_TOKEN" ]; then + echo "machine github.com login x-token-auth password $GH_TOKEN" > /root/.netrc + chmod 600 /root/.netrc + git config --global credential.helper 'store' + fi + + # Install Amplifier itself + - uv tool install git+https://github.com/microsoft/amplifier@main + + # Configure Amplifier provider (anthropic only -- minimal, matches smoke test) + - | + mkdir -p /root/.amplifier + cat > /root/.amplifier/settings.yaml << 'EOF' + config: + providers: + - module: provider-anthropic + source: git+https://github.com/microsoft/amplifier-module-provider-anthropic@main + config: + api_key_env: ANTHROPIC_API_KEY + EOF + + # Install the LOGGING-ONLY behavior (hook ONLY -- no analytics agents/tools/skills). + # url_rewrites redirects this @main reference to the Gitea mirror (local branch). + - amplifier bundle add 'git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=behaviors/context-intelligence-logging.yaml' --app + + # Sanity: Amplifier is working and the logging behavior is registered + - amplifier --version + - amplifier bundle list + + # Workspace for the test session + - mkdir -p /tmp/logging-proj + +readiness: + - name: amplifier-installed + command: amplifier --version + +validation_cmds: + # ---------------------------------------------------------------------- + # Run a REAL 1-turn session in LOCAL-ONLY mode (no CI server env vars). + # The logging-only behavior's hook mounts and must write events.jsonl + metadata.json. + # ---------------------------------------------------------------------- + - name: run-logging-session + command: | + set -e + echo "AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL='${AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL:-}'" + echo "AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY='${AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY:-}'" + mkdir -p /tmp/logging-proj + cd /tmp/logging-proj + touch /tmp/logging-pre-marker + sleep 1 + amplifier run "Say hello in one word." --mode single 2>&1 | tee /tmp/logging-session.log + echo "Session completed" + + # ---------------------------------------------------------------------- + # L1: events.jsonl created & non-empty + metadata.json required fields + # ---------------------------------------------------------------------- + - name: L1-events-and-metadata + command: | + set -e + EVENTS_FILE=$(find /root/.amplifier/projects -path '*context-intelligence/events.jsonl' -newer /tmp/logging-pre-marker 2>/dev/null | head -1) + if [ -z "$EVENTS_FILE" ]; then + echo "FAIL[L1]: No events.jsonl created under ~/.amplifier/projects/**/context-intelligence/ after session" + find /root/.amplifier/projects -type f 2>/dev/null | head -50 + exit 1 + fi + if [ ! -s "$EVENTS_FILE" ]; then + echo "FAIL[L1]: events.jsonl exists but is EMPTY: $EVENTS_FILE" + exit 1 + fi + LINES=$(wc -l < "$EVENTS_FILE") + echo "$EVENTS_FILE" > /tmp/logging-events-path + echo "events.jsonl: $EVENTS_FILE ($LINES lines)" + META="$(dirname "$EVENTS_FILE")/metadata.json" + if [ ! -f "$META" ]; then + echo "FAIL[L1]: metadata.json not found at $META" + exit 1 + fi + MISSING=$(python3 - "$META" << 'PY' + import json, sys + required = ["session_id","workspace","started_at","status","working_dir","format","version"] + d = json.load(open(sys.argv[1])) + print(",".join([k for k in required if k not in d])) + PY + ) + if [ -n "$MISSING" ]; then + echo "FAIL[L1]: metadata.json missing required field(s): $MISSING" + echo "--- metadata.json ---"; cat "$META" + exit 1 + fi + EMPTY=$(python3 - "$META" << 'PY' + import json, sys + required = ["session_id","workspace","started_at","status","working_dir","format","version"] + d = json.load(open(sys.argv[1])) + print(",".join([k for k in required if d.get(k) in (None, "")])) + PY + ) + echo "PASS[L1]: events.jsonl non-empty ($LINES lines) AND metadata.json has all required fields (session_id,workspace,started_at,status,working_dir,format,version)" + if [ -n "$EMPTY" ]; then + echo "NOTE[L1]: present-but-EMPTY required field(s): $EMPTY" + fi + cat "$META" + + # ---------------------------------------------------------------------- + # L2: envelope structure + canonical lifecycle events + # ---------------------------------------------------------------------- + - name: L2-envelope-and-lifecycle + command: | + set -e + EVENTS_FILE=$(cat /tmp/logging-events-path) + python3 - "$EVENTS_FILE" << 'PY' + import json, sys, re + required = {"event","workspace","timestamp","data"} + bad = 0; total = 0; names = [] + with open(sys.argv[1]) as f: + for i, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + total += 1 + try: + obj = json.loads(line) + except Exception as e: + print(f"FAIL[L2]: line {i} not valid JSON: {e}"); bad += 1; continue + missing = required - set(obj.keys()) + if missing: + print(f"FAIL[L2]: line {i} missing envelope keys: {sorted(missing)}"); bad += 1 + names.append(obj.get("event","")) + if bad or total == 0: + print(f"FAIL[L2]: {bad} bad line(s) of {total}"); sys.exit(1) + pat = re.compile(r'(session|provider|llm|tool)', re.I) + lifecycle = sorted({n for n in names if pat.search(n or "")}) + if not lifecycle: + print("FAIL[L2]: no canonical session/provider/llm/tool lifecycle event found") + print("Events present:", sorted(set(names))); sys.exit(1) + print(f"PASS[L2]: all {total} lines valid envelopes; canonical lifecycle events present: {lifecycle}") + print("All distinct events:", sorted(set(names))) + PY + + # ---------------------------------------------------------------------- + # L3: analytics surface ABSENT (the KEY new check) + # + # METHODOLOGY NOTE: `amplifier agents show ` reports the GLOBAL agent + # CATALOG (every agent .md present in any cached bundle repo). Because the + # url_rewrite mirrors the WHOLE amplifier-bundle-context-intelligence repo to + # Gitea, that catalog DOES contain graph-analyst/session-navigator even in + # logging-only mode -- so `agents show` is NOT a valid composition probe. + # The authoritative source of what was actually COMPOSED into the session is + # the `session:config` event the hook captured (data.raw.agents / .tools / + # .hooks). We assert against that. + # ---------------------------------------------------------------------- + - name: L3-analytics-absent + command: | + set -e + FAILED=0 + EVENTS_FILE=$(cat /tmp/logging-events-path) + # (a)/(c) authoritative composition probe via captured session:config + python3 - "$EVENTS_FILE" << 'PY' || FAILED=1 + import json, sys + cfg = None + for l in open(sys.argv[1]): + l = l.strip() + if not l: + continue + o = json.loads(l) + if o.get("event") == "session:config": + cfg = o["data"]["raw"] + if not cfg: + print("FAIL[L3]: no session:config event captured -- cannot prove composition"); sys.exit(1) + agents = list((cfg.get("agents") or {}).keys()) + tk = cfg.get("tools") + tools = list(tk.keys()) if isinstance(tk, dict) else ([t.get("module") if isinstance(t, dict) else t for t in tk] if isinstance(tk, list) else (tk or [])) + hook_mods = [h.get("module") for h in (cfg.get("hooks") or [])] + context_intelligence_agents = [a for a in agents if "graph-analyst" in a or "session-navigator" in a] + context_intelligence_tools = [t for t in tools if t and ("graph_query" in str(t) or "blob_read" in str(t))] + context_intelligence_hook = [m for m in hook_mods if m and "context-intelligence" in m] + bad = False + if context_intelligence_agents: + print("FAIL[L3a]: analytics agents composed into logging-only session:", context_intelligence_agents); bad = True + else: + print("PASS[L3a]: NO context-intelligence graph-analyst/session-navigator composed (analytics agents ABSENT)") + if context_intelligence_tools: + print("FAIL[L3c]: analytics tools composed into logging-only session:", context_intelligence_tools); bad = True + else: + print("PASS[L3c]: NO graph_query/blob_read tools composed (analytics tools ABSENT)") + if context_intelligence_hook != ["hook-context-intelligence"]: + print("FAIL[L3]: expected exactly the logging hook composed, got:", context_intelligence_hook); bad = True + else: + print("PASS[L3]: hook-context-intelligence IS composed (producer side present)") + if bad: + sys.exit(1) + PY + + # (b) structural: cached logging behavior YAML has hooks: and NO agents/tools/context/modes + BEH=$(find /root/.amplifier/cache -path '*behaviors/context-intelligence-logging.yaml' 2>/dev/null | head -1) + if [ -z "$BEH" ]; then + echo "FAIL[L3b]: could not locate cached logging behavior YAML in DTU (rewrite/resolution problem)"; FAILED=1 + else + echo "Cached logging behavior: $BEH" + python3 - "$BEH" << 'PY' || FAILED=1 + import sys, yaml + d = yaml.safe_load(open(sys.argv[1])) + bad = [] + if "hooks" not in d: bad.append("missing hooks:") + for k in ("agents","tools","context","modes"): + if k in d: bad.append(f"unexpected {k}: section present") + if bad: + print("FAIL[L3b]:", "; ".join(bad)); sys.exit(1) + mods = [h.get("module") for h in d.get("hooks", [])] + print("PASS[L3b]: logging behavior has hooks:", mods, "and NO agents/tools/context/modes sections (pure producer side)") + PY + fi + + [ "$FAILED" -ne 0 ] && exit 1 + echo "PASS[L3]: analytics read/query surface is ABSENT in logging-only mode (no analytics agents/tools composed; structural producer-only; logging hook present)" + + # ---------------------------------------------------------------------- + # L4: local-only graceful degradation + # ---------------------------------------------------------------------- + - name: L4-local-only-degradation + command: | + set -e + FAILED=0 + if [ -n "${AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL:-}" ] || [ -n "${AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY:-}" ]; then + echo "FAIL[L4]: server env vars are set -- this test must run local-only"; FAILED=1 + fi + EVENTS_FILE=$(cat /tmp/logging-events-path 2>/dev/null || true) + if [ -z "$EVENTS_FILE" ] || [ ! -s "$EVENTS_FILE" ]; then + echo "FAIL[L4]: no non-empty events.jsonl written in local-only mode"; FAILED=1 + fi + if grep -qiE 'Traceback \(most recent call last\)|Fatal error' /tmp/logging-session.log; then + echo "FAIL[L4]: session log contains a crash/traceback" + grep -iE 'Traceback|Fatal error' /tmp/logging-session.log | head; FAILED=1 + fi + [ "$FAILED" -ne 0 ] && exit 1 + echo "PASS[L4]: local-only mode -- no server env vars, session completed cleanly, JSONL written ($EVENTS_FILE)" diff --git a/.amplifier/digital-twin-universe/profiles/ci-signals-validation.yaml b/.amplifier/digital-twin-universe/profiles/context-intelligence-signals-validation.yaml similarity index 91% rename from .amplifier/digital-twin-universe/profiles/ci-signals-validation.yaml rename to .amplifier/digital-twin-universe/profiles/context-intelligence-signals-validation.yaml index 86811bfa..69fe6892 100644 --- a/.amplifier/digital-twin-universe/profiles/ci-signals-validation.yaml +++ b/.amplifier/digital-twin-universe/profiles/context-intelligence-signals-validation.yaml @@ -1,4 +1,4 @@ -# ci-signals-validation.yaml +# context-intelligence-signals-validation.yaml # # End-to-end integration test for context_intelligence.signals library, # CLI commands, render-findings output, and recipe YAML structural validity. @@ -9,10 +9,10 @@ # Usage: # export GH_TOKEN=$(gh auth token) # amplifier-digital-twin launch \ -# .amplifier/digital-twin-universe/profiles/ci-signals-validation.yaml \ -# --name ci-signals-validation +# .amplifier/digital-twin-universe/profiles/context-intelligence-signals-validation.yaml \ +# --name context-intelligence-signals-validation # -name: ci-signals-validation +name: context-intelligence-signals-validation description: > Integration test for context_intelligence.signals library and workflow-pattern-analysis recipe. Validates signal scoring, @@ -50,7 +50,7 @@ provision: git clone \ --depth 1 \ https://github.com/microsoft/amplifier-bundle-context-intelligence.git \ - /opt/ci-bundle + /opt/context-intelligence-bundle # Create a venv and install the signals library + pyyaml (for validation E) # Note: pyyaml is a dev dependency — not bundled in the wheel, must be explicit. @@ -59,16 +59,16 @@ provision: uv venv /opt/venv --python python3 export VIRTUAL_ENV=/opt/venv export PATH="/opt/venv/bin:$PATH" - uv pip install /opt/ci-bundle/ + uv pip install /opt/context-intelligence-bundle/ uv pip install pyyaml # Quick sanity — signals module importable? - /opt/venv/bin/python3 -c "from context_intelligence.signals import score_session; print('signals importable')" - # Set up fixture session directories under /root/.amplifier/projects/ci-test-workspace/ + # Set up fixture session directories under /root/.amplifier/projects/context-intelligence-test-workspace/ - | mkdir -p /root/.amplifier/projects - bash /opt/ci-bundle/tests/dtu/setup_fixtures.sh /root/.amplifier/projects + bash /opt/context-intelligence-bundle/tests/dtu/setup_fixtures.sh /root/.amplifier/projects # Persist PATH and VIRTUAL_ENV for exec commands - | @@ -108,7 +108,7 @@ validation_cmds: import json, sys, subprocess result = subprocess.run( ["/opt/venv/bin/python3", "-m", "context_intelligence.signals", - "score-session", "/opt/ci-bundle/tests/fixtures/s1_session.jsonl"], + "score-session", "/opt/context-intelligence-bundle/tests/fixtures/s1_session.jsonl"], capture_output=True, text=True ) if result.returncode != 0: @@ -136,7 +136,7 @@ validation_cmds: import json, sys, subprocess result = subprocess.run( ["/opt/venv/bin/python3", "-m", "context_intelligence.signals", - "score-session", "/opt/ci-bundle/tests/fixtures/minimal_session.jsonl"], + "score-session", "/opt/context-intelligence-bundle/tests/fixtures/minimal_session.jsonl"], capture_output=True, text=True ) if result.returncode != 0: @@ -227,7 +227,7 @@ validation_cmds: set -e /opt/venv/bin/python3 -c " import yaml, pathlib, sys - text = pathlib.Path('/opt/ci-bundle/recipes/workflow-pattern-analysis.yaml').read_text() + text = pathlib.Path('/opt/context-intelligence-bundle/recipes/workflow-pattern-analysis.yaml').read_text() data = yaml.safe_load(text) stages = data.get('stages', []) stage_names = [s['name'] for s in stages] @@ -253,7 +253,7 @@ validation_cmds: command: | set -e export PATH="/opt/venv/bin:$PATH" - FIXTURE_DIR="/opt/ci-bundle/tests/fixtures" + FIXTURE_DIR="/opt/context-intelligence-bundle/tests/fixtures" PASS_COUNT=0 FAIL_COUNT=0 diff --git a/.amplifier/digital-twin-universe/profiles/context-intelligence-skill-sync-disabled-behavioral-test.yaml b/.amplifier/digital-twin-universe/profiles/context-intelligence-skill-sync-disabled-behavioral-test.yaml new file mode 100644 index 00000000..84c86cd3 --- /dev/null +++ b/.amplifier/digital-twin-universe/profiles/context-intelligence-skill-sync-disabled-behavioral-test.yaml @@ -0,0 +1,246 @@ +# ============================================================================ +# SKILL-SYNC OPT-OUT (skill_sync_enabled) BEHAVIORAL TEST PROFILE +# ============================================================================ +# GOAL +# End-to-end, evidence-based proof of the `skill_sync_enabled` opt-out added +# for issue #283, INCLUDING the corrected vendored-body swap. Installs +# tool-graph-query STANDALONE from the local-branch Gitea mirror and drives +# the REAL on_session_ready() against a live local HTTP capture server that +# records every request, proving the full 4-cell matrix: +# +# Cell 1 disabled + server configured -> stub is SWAPPED for the vendored +# real body; ZERO network (capture server saw no request); NO +# skill:unloaded handler registered. +# Cell 2 disabled + NO server -> "Server Unavailable" stub is +# RETAINED untouched; ZERO network. +# Cell 3 enabled + server up -> GET /version AND +# GET /skills/context-intelligence-graph-query DID fire; SKILL.md +# updated from the server body; skill:unloaded handler registered. +# Cell 4 enabled + server DOWN -> graceful offline path, no crash, +# stub retained. +# +# This is deterministic (no LLM, no provider key): it imports and runs the +# shipped module code, so it also proves the vendored body actually ships in +# the wheel (a missing file would fail Cell 1). +# +# LOCAL-BRANCH UNDER TEST +# tool-graph-query's bundle self-dependency points at +# github.com/microsoft/amplifier-bundle-context-intelligence@main. The +# url_rewrites block redirects that to the Gitea mirror of the LOCAL working +# branch, so the install resolves local code (incl. bundled_skill/), not +# GitHub @main. +# +# USAGE +# amplifier-digital-twin launch \ +# .amplifier/digital-twin-universe/profiles/context-intelligence-skill-sync-disabled-behavioral-test.yaml \ +# --var GITEA_URL=http://: \ +# --var GITEA_TOKEN= \ +# --name context-intelligence-skill-sync-disabled-behavioral-test +# ============================================================================ + +name: context-intelligence-skill-sync-disabled-behavioral-test +description: > + End-to-end proof of the skill_sync_enabled opt-out + vendored-body swap + (issue #283) for the context-intelligence bundle (local branch via Gitea). + Installs tool-graph-query standalone and drives the real on_session_ready + against a live HTTP capture server, asserting the full 4-cell matrix: + disabled+server -> vendored swap + zero network; disabled+no-server -> stub + retained; enabled+up -> server fetch fires; enabled+down -> graceful offline. + +base: + image: ubuntu:24.04 + +# MANDATORY: redirect every github.com/microsoft/amplifier-bundle-context-intelligence +# reference (the module's @main bundle self-dependency, which carries the shared +# context_intelligence package AND the vendored bundled_skill) to the Gitea mirror +# whose `main` branch holds the local working-tree snapshot. +url_rewrites: + auth: + username: admin + token_var: GITEA_TOKEN + default_match_mode: boundary + rules: + - match: github.com/microsoft/amplifier-bundle-context-intelligence + target: ${GITEA_URL}/admin/amplifier-bundle-context-intelligence + +passthrough: + allow_external: true + services: + - name: github + key_env: GH_TOKEN + +provision: + setup_cmds: + - apt-get update && apt-get install -y git curl python3 + - curl -LsSf https://astral.sh/uv/install.sh | sh + - | + if [ -n "$GH_TOKEN" ]; then + echo "machine github.com login x-token-auth password $GH_TOKEN" > /root/.netrc + chmod 600 /root/.netrc + git config --global credential.helper 'store' + fi + # Standalone install of tool-graph-query from the local-branch mirror. + # --no-sources reproduces foundation's module-activation policy; the bundle + # self-dependency (@main, github) is rewritten to the Gitea mirror. + - | + export PATH="$HOME/.local/bin:$PATH" + uv venv /tmp/ss-venv + VIRTUAL_ENV=/tmp/ss-venv uv pip install --no-sources \ + "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/tool-graph-query" + # graph_query_tool.py imports amplifier_core.models at top level. The kernel + # is always present in a real deployment, so the module declares it dev-only, + # not as a hard runtime dep — install it explicitly for this standalone harness. + VIRTUAL_ENV=/tmp/ss-venv uv pip install "amplifier-core>=1.6.0" + +readiness: + - name: module-imports + command: /tmp/ss-venv/bin/python -c "import amplifier_module_tool_graph_query, context_intelligence; print('ok')" + +validation_cmds: + # -------------------------------------------------------------------------- + # SS1: Full 4-cell matrix against a live HTTP capture server. + # Deterministic — drives the real on_session_ready(), no LLM involved. + # -------------------------------------------------------------------------- + - name: SS1-skill-sync-matrix-end-to-end + command: | + set -e + /tmp/ss-venv/bin/python - << 'PY' + import asyncio, hashlib, json, os, threading + from http.server import BaseHTTPRequestHandler, HTTPServer + from pathlib import Path + import tempfile + + # Neutralize ambient CI config so each cell's server posture is exactly + # what its config dict declares (a developer host may export these — the + # clean DTU container does not). Without this, the "no server" cell could + # silently inherit a real server_url and stop testing what it claims to. + for _v in ( + "AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", + "AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY", + "AMPLIFIER_CONTEXT_INTELLIGENCE_SKILL_SYNC_ENABLED", + ): + os.environ.pop(_v, None) + + from amplifier_module_tool_graph_query.graph_query_tool import GraphQueryTool + from amplifier_module_tool_graph_query.skill_sync import ( + on_session_ready, + _GRAPH_QUERY_TOOL_CAPABILITY, + TOOL_SKILLS_DISCOVERY_CAPABILITY, + ) + from amplifier_module_tool_graph_query.bundled_skill import EXPECTED_BUNDLED_SKILL_SHA256 + + SKILL = "context-intelligence-graph-query" + STUB = ( + "---\nname: context-intelligence-graph-query\nversion: 2.0.0\n---\n\n" + "# Context Intelligence Graph Query \u2014 Server Unavailable\n\n" + "The context intelligence server is not reachable. Do not attempt Cypher queries.\n" + ) + SERVER_BODY = ( + "---\nname: context-intelligence-graph-query\nversion: 2.0.0\n---\n\n" + "# Context Intelligence Graph Query\n\nFRESH-FROM-SERVER-MARKER\n" + ) + SERVER_ETAG = '"server-etag-v2"' + + def _sha(p): return hashlib.sha256(Path(p).read_bytes()).hexdigest() + def _sha_text(s): return hashlib.sha256(s.encode()).hexdigest() + + # ---- live HTTP capture server ----------------------------------------- + class Handler(BaseHTTPRequestHandler): + requests = [] + def log_message(self, *a): pass + def do_GET(self): + Handler.requests.append(self.path) + if self.path == "/version": + body = json.dumps({"version": "2.0.0"}).encode() + self.send_response(200); self.send_header("Content-Type","application/json") + self.send_header("Content-Length", str(len(body))); self.end_headers() + self.wfile.write(body); return + if self.path == f"/skills/{SKILL}": + body = SERVER_BODY.encode() + self.send_response(200); self.send_header("Content-Type","text/markdown") + self.send_header("ETag", SERVER_ETAG) + self.send_header("Content-Length", str(len(body))); self.end_headers() + self.wfile.write(body); return + self.send_response(404); self.end_headers() + + srv = HTTPServer(("127.0.0.1", 0), Handler) + port = srv.server_address[1] + threading.Thread(target=srv.serve_forever, daemon=True).start() + UP = f"http://127.0.0.1:{port}" + DOWN = "http://127.0.0.1:1" # nothing listening + + # ---- fakes ------------------------------------------------------------- + class Meta: + def __init__(self, path): self.path = str(path) + class Discovery: + def __init__(self, path): self._m = Meta(path) + def find(self, name): return self._m if name == SKILL else None + class Hooks: + def __init__(self): self.events = [] + def register(self, event, *a, **k): self.events.append(event) + class Coord: + def __init__(self, skill_path): + self.config = {} + self.hooks = Hooks() + self._caps = {TOOL_SKILLS_DISCOVERY_CAPABILITY: Discovery(skill_path)} + def get_capability(self, name): return self._caps.get(name) + + def make(cell_dir, config, seed=STUB): + d = Path(cell_dir); d.mkdir(parents=True, exist_ok=True) + skill_path = d / "SKILL.md"; skill_path.write_text(seed) + coord = Coord(skill_path) + tool = GraphQueryTool(coordinator=coord, config=config) + coord._caps[_GRAPH_QUERY_TOOL_CAPABILITY] = tool + return coord, skill_path + + tmp = Path(tempfile.mkdtemp()) + failures = [] + def check(cond, msg): + print(("PASS: " if cond else "FAIL: ") + msg) + if not cond: failures.append(msg) + + # ---- Cell 1: disabled + server configured -> vendored swap, zero net --- + Handler.requests.clear() + coord, sp = make(tmp/"c1", {"context_intelligence_server_url": UP, "skill_sync_enabled": False}) + asyncio.run(on_session_ready(coord)) + check(Handler.requests == [], "Cell1 disabled+server: ZERO network (no requests) -> %r" % Handler.requests) + check(_sha(sp) == EXPECTED_BUNDLED_SKILL_SHA256, "Cell1 disabled+server: stub SWAPPED for vendored real body") + check("Server Unavailable" not in sp.read_text(), "Cell1 disabled+server: 'Server Unavailable' stub is gone") + check("skill:unloaded" not in coord.hooks.events, "Cell1 disabled+server: NO skill:unloaded handler registered") + + # ---- Cell 2: disabled + no server -> stub retained, zero net ----------- + Handler.requests.clear() + coord, sp = make(tmp/"c2", {"skill_sync_enabled": False}) + asyncio.run(on_session_ready(coord)) + check(Handler.requests == [], "Cell2 disabled+no-server: ZERO network") + check(_sha(sp) == _sha_text(STUB), "Cell2 disabled+no-server: 'Server Unavailable' stub RETAINED untouched") + check("skill:unloaded" not in coord.hooks.events, "Cell2 disabled+no-server: NO skill:unloaded handler") + + # ---- Cell 3: enabled + server up -> fetch fires, body updated ---------- + Handler.requests.clear() + coord, sp = make(tmp/"c3", {"context_intelligence_server_url": UP}) # default skill_sync_enabled=True + asyncio.run(on_session_ready(coord)) + check("/version" in Handler.requests, "Cell3 enabled+up: GET /version fired -> %r" % Handler.requests) + check(f"/skills/{SKILL}" in Handler.requests, "Cell3 enabled+up: GET /skills/%s fired" % SKILL) + check(_sha(sp) == _sha_text(SERVER_BODY), "Cell3 enabled+up: SKILL.md updated from SERVER body") + check("skill:unloaded" in coord.hooks.events, "Cell3 enabled+up: skill:unloaded handler registered") + + # ---- Cell 4: enabled + server down -> graceful offline, no crash ------- + Handler.requests.clear() + coord, sp = make(tmp/"c4", {"context_intelligence_server_url": DOWN}) + try: + asyncio.run(on_session_ready(coord)) + crashed = False + except Exception as e: # noqa: BLE001 + crashed = True; print("unexpected exception:", e) + check(not crashed, "Cell4 enabled+down: graceful offline path, no crash") + check(_sha(sp) == _sha_text(STUB), "Cell4 enabled+down: stub retained (offline, content preserved)") + + srv.shutdown() + if failures: + print("\nFAIL[SS1]: %d assertion(s) failed" % len(failures)) + for f in failures: print(" - " + f) + raise SystemExit(1) + print("\nPASS[SS1]: all 4 cells proven end-to-end (vendored body shipped in wheel; " + "disabled=zero network + correct body; enabled=server fetch).") + PY diff --git a/.amplifier/digital-twin-universe/profiles/context-intelligence-standalone-install-test.yaml b/.amplifier/digital-twin-universe/profiles/context-intelligence-standalone-install-test.yaml new file mode 100644 index 00000000..8ae4273d --- /dev/null +++ b/.amplifier/digital-twin-universe/profiles/context-intelligence-standalone-install-test.yaml @@ -0,0 +1,94 @@ +# ============================================================================ +# STANDALONE-INSTALL TEST PROFILE +# ============================================================================ +# GOAL +# Prove the hook module installs and imports STANDALONE (outside the monorepo, +# with NO [tool.uv.sources] path override) via its PEP 508 direct git +# reference to the bundle. Reproduces foundation's `uv pip install --no-sources` +# module-activation policy, which strips [tool.uv.sources] -- so this only +# passes if the bundle dependency is a surviving `name @ git+https://...` +# direct reference that carries the shared `context_intelligence` package. +# +# LOCAL-BRANCH UNDER TEST +# The hook's bundle dependency points at +# git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main. +# The url_rewrites block below redirects that to the Gitea mirror of the LOCAL +# working branch, so the install resolves local code, not GitHub @main. +# +# USAGE +# amplifier-digital-twin launch \ +# .amplifier/digital-twin-universe/profiles/context-intelligence-standalone-install-test.yaml \ +# --var GITEA_URL=http://: \ +# --var GITEA_TOKEN= \ +# --name context-intelligence-standalone-install-test +# ============================================================================ + +name: context-intelligence-standalone-install-test +description: > + Standalone-install proof for the context-intelligence hook module. Installs the + hook straight from git with `uv pip install --no-sources` (no path override), with + the bundle self-reference redirected to the local-branch Gitea mirror, then imports + context_intelligence and the hook mount entry point. + +base: + image: ubuntu:24.04 + +# MANDATORY: redirect every github.com/microsoft/amplifier-bundle-context-intelligence +# reference (the hook's @main bundle self-dependency) to the Gitea mirror whose `main` +# branch holds the local working-tree snapshot. {GITEA_URL} is supplied at launch. +url_rewrites: + auth: + username: admin + token_var: GITEA_TOKEN + default_match_mode: boundary + rules: + - match: github.com/microsoft/amplifier-bundle-context-intelligence + target: ${GITEA_URL}/admin/amplifier-bundle-context-intelligence + +passthrough: + allow_external: true + services: + - name: github + key_env: GH_TOKEN + +provision: + setup_cmds: + - apt-get update && apt-get install -y git curl python3 + - curl -LsSf https://astral.sh/uv/install.sh | sh + - | + if [ -n "$GH_TOKEN" ]; then + echo "machine github.com login x-token-auth password $GH_TOKEN" > /root/.netrc + chmod 600 /root/.netrc + git config --global credential.helper 'store' + fi + +readiness: + - name: uv-installed + command: bash -lc 'uv --version' + +validation_cmds: + # -------------------------------------------------------------------------- + # S1: Standalone install from git with --no-sources, then import the shared + # library and the hook mount entry point. + # -------------------------------------------------------------------------- + - name: S1-standalone-install-and-import + command: | + set -e + export PATH="$HOME/.local/bin:$PATH" + # Fresh, isolated venv -- nothing from the monorepo on the path. + uv venv /tmp/standalone-venv + # Install the hook module straight from git, reproducing foundation's + # module-activation policy (--no-sources strips any [tool.uv.sources]). + # The bundle self-dependency (@main, github) is rewritten to the Gitea + # local-branch mirror by the url_rewrites block above. + VIRTUAL_ENV=/tmp/standalone-venv uv pip install --no-sources \ + "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/hook-context-intelligence" + # Prove the shared library AND the hook entry point import in the clean venv. + /tmp/standalone-venv/bin/python - << 'PY' + import importlib + ci = importlib.import_module("context_intelligence") + mod = importlib.import_module("amplifier_module_hook_context_intelligence") + assert hasattr(mod, "mount") and callable(mod.mount), "hook module has no callable mount()" + print("PASS[S1]: standalone install OK -- imported context_intelligence from", ci.__file__, + "and amplifier_module_hook_context_intelligence.mount is callable") + PY diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2420a3ec..ea63532d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,10 +90,19 @@ jobs: - name: Install dependencies working-directory: modules/${{ matrix.module }} # --frozen: respect the module's own lockfile exactly as committed. - # The module's path dependency on `../..` is resolved from the - # checked-out root, so no special setup is needed. + # The module declares the shared bundle as a @main git ref (NOT a path + # source — enforced by test_bundle_is_not_a_uv_path_source for standalone + # install). So uv installs the *published* context_intelligence; the Run + # tests step shadows it with the in-repo source (below). run: uv sync --frozen - name: Run tests working-directory: modules/${{ matrix.module }} + # Test the module against the IN-REPO shared library, which may carry + # not-yet-published changes (e.g. context_intelligence/tool_resolver.py) + # that @main only gains when this PR merges. Put the checked-out root on + # PYTHONPATH to shadow the @main-installed copy — the same convention + # AGENTS.md prescribes for local runs. + env: + PYTHONPATH: ${{ github.workspace }} run: uv run pytest tests/ -q --tb=short --ignore=tests/dtu diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..ab62c2b4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,137 @@ +# AGENTS.md — amplifier-bundle-context-intelligence + +## What this repo is + +Composable behaviors for session observability + context intelligence, organized as a **layered +"onion"** plus an independent telemetry hook. Each layer adds exactly one capability and +`includes:` the layer beneath it: + +- **`context-intelligence-navigation`** — innermost; `session-navigator` agent + local-JSONL nav skill. +- **`context-intelligence-analysis`** — includes navigation; adds `graph-analyst` + graph skills. +- **`context-intelligence-design`** — includes analysis; adds the `/context-intelligence` design **mode** + (registered via `hooks-mode` `search_paths: ["@context-intelligence:modes"]`; mode is `advertised: false`, activated on demand). +- **`context-intelligence-logging`** — independent; the `hook-context-intelligence` telemetry hook only. +- **`context-intelligence`** (full umbrella) — composes `design` + `logging`. + +Ships as a bundle with behaviors in `behaviors/`. + +--- + +## Key directories + +| Path | Contents | +|------|----------| +| `behaviors/` | Layered behavior YAMLs: `-navigation` → `-analysis` → `-design` (nested onion) + independent `-logging` + the full `context-intelligence` umbrella | +| `modules/tool-graph-query/` | Graph query tool + `SkillFetcher` + `skill_sync` (`on_session_ready`) — owns dynamic skill-content sync from the server | +| `modules/tool-blob-read/` | `blob_read` tool for resolving `ci-blob://` URIs | +| `modules/hook-context-intelligence/` | Telemetry hook (logging behavior) — **pure telemetry**, no skill sync code | +| `context_intelligence/` | Shared library (config + `HookConfigResolver` + `ToolConfigResolver`) used by all three modules | +| `agents/` | `graph-analyst` + `session-navigator` agent definitions | +| `docs/` | Product docs + diagrams (`bundle.dot` and `bundle.png` are at the **repo root**, not here) | +| `.amplifier/digital-twin-universe/profiles/` | DTU profiles for end-to-end behavioral testing | + +--- + +## Setup + +Each module is an independent `uv` package — set up separately: + +```bash +cd modules/tool-graph-query && uv sync +cd modules/tool-blob-read && uv sync +cd modules/hook-context-intelligence && uv sync +``` + +--- + +## Test commands + +Run before claiming done — reviewer expects evidence: + +```bash +cd modules/tool-graph-query && PYTHONPATH="$(git rev-parse --show-toplevel)" uv run pytest -q # 90 tests +cd modules/tool-blob-read && PYTHONPATH="$(git rev-parse --show-toplevel)" uv run pytest -q # 35 tests +cd modules/hook-context-intelligence && PYTHONPATH="$(git rev-parse --show-toplevel)" uv run pytest -q # 300 tests +``` + +Lint + types (run from each module directory): + +```bash +uv run ruff check . && uv run ruff format --check . && uv run pyright +``` + +--- + +## DTU end-to-end tests (REQUIRED for server/sync changes) + +Changes touching `skill_sync.py`, `SkillFetcher`, `on_session_ready`, or `ToolConfigResolver` +**must** be validated against a live context-intelligence server via all four DTU scenarios: + +| Scenario | What it covers | +|----------|----------------| +| **S1** | Analysis-layer sync — skill fetched and discoverable after `on_session_ready` | +| **S2** | Offline-drift invalidation — ETag/hash sidecars removed when body drifts; content retained | +| **S3** | Logging-only — zero skill activity (hook has no sync code) | +| **S4** | Full behavior — telemetry hook + analysis-layer sync both active | + +DTU profiles live in `.amplifier/digital-twin-universe/profiles/` (including +`context-intelligence-analyst-behavioral-test`, `context-intelligence-logging-behavioral-test`, `context-intelligence-hook-behavioral-test`, +`example-dtu-external-server`). Load the `digital-twin-universe` skill or use the +`amplifier-tester` bundle to run them. + +> **Mandatory:** the DTU mirrors your **local branch** to Gitea (`url_rewrite` in the profile) +> so it runs your uncommitted code — not a published version. Confirm `url_rewrite` is set +> before trusting DTU results. + +--- + +## Verification gradient + +| Change area | Required verification | +|-------------|----------------------| +| `skill_sync` / `SkillFetcher` / `on_session_ready` | Unit tests + all 4 DTU scenarios | +| `ToolConfigResolver` / config resolution | Unit tests + placeholder-expansion regression tests | +| `tool-graph-query` / `tool-blob-read` tool logic | Unit tests | +| `hook-context-intelligence` | Unit tests | +| Bundle YAML / behaviors / agents | Regenerate `bundle.dot` via the `generate-bundle-docs` recipe | + +--- + +## Common pitfalls + +Each of these burned real debugging time: + +- **Shared lib is a `@main` git self-reference, not a path source** — each module's `pyproject` + declares `amplifier-bundle-context-intelligence @ git+...@main` (with `[tool.hatch.metadata]` + `allow-direct-references = true`); there is no `[tool.uv.sources]` `path = "../.."` override. + This makes modules install identically in the monorepo and standalone (PR #36's intent). For + LOCAL unit runs, the installed copy is the git `@main` snapshot, so tests import the LOCAL shared + library by shadowing it with the repo root on `PYTHONPATH`: + `PYTHONPATH="$(git rev-parse --show-toplevel)" uv run pytest -q`. Do NOT reintroduce a + `[tool.uv.sources]` `path = "../.."` override to fix imports. + +- **`skills find()` returns `None` — SKILL.md must start with `---`** — tool-skills' catalog + builder silently drops any `SKILL.md` lacking a leading `---` YAML frontmatter delimiter. + When drifting skill content in a test, change the **body only** and keep the `---` header. + Frontmatter-destroying drift makes the skill vanish from discovery before sync runs — that's a + test-methodology bug, not a product bug. + +- **`amplifier-core` is the PyPI wheel (>=1.6.0), NOT a git/Rust source build** — all three + modules pin `amplifier-core>=1.6.0` from PyPI (prebuilt wheel). Do not switch to a git source + or downgrade to v1.2.x — that forces a maturin/Rust build that hangs the test run. + +- **Analysis-layer config placeholders** — `${AMPLIFIER_CONTEXT_INTELLIGENCE_*}` placeholders in + behavior config are expanded by `ToolConfigResolver._expand`. In the analysis layer without the + hook (e.g. `context-intelligence-analysis`/`-design` composed without `-logging`), + the tool resolver — not the hook resolver — supplies `server_url`/`api_key`. If placeholder + expansion produces raw `${...}` strings at runtime, check whether you edited the tool resolver + path, not just the hook resolver. + +--- + +## Done means + +- Module unit tests green (90 + 35 + 300). +- For sync/server changes: all 4 DTU scenarios pass. +- `bundle.dot` regenerated (via `generate-bundle-docs` recipe) if bundle structure changed. +- `.github/PULL_REQUEST_TEMPLATE.md` (if present) is honored. diff --git a/README.md b/README.md index d937748e..fd255c37 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,66 @@ 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. +### Composable layers (the "onion") + +The bundle's behaviors are organized as a **layered onion** — each layer adds **exactly one capability** and `includes:` the layer beneath it. Compose the smallest layer that covers your need; everything below it comes along automatically. + +``` +context-intelligence ← FULL umbrella: design + logging +├── context-intelligence-design + /context-intelligence design MODE +│ └── context-intelligence-analysis + graph-analyst agent & graph skills +│ └── context-intelligence-navigation session-navigator (local JSONL only) +└── context-intelligence-logging telemetry hook only (independent layer) +``` + +| Layer | Adds | Builds on | Use when | +|-------|------|-----------|----------| +| **`context-intelligence-navigation`** | `session-navigator` agent + local-JSONL navigation skill | — (innermost) | You only need offline/local session navigation, no graph server. | +| **`context-intelligence-analysis`** | `graph-analyst` agent + graph-query / blob-read / reconstruction / pattern skills | navigation | You need graph-powered query & exploration (still no design mode). | +| **`context-intelligence-design`** | the `/context-intelligence` design **mode** (`advertised: false`, activate on demand) | analysis | You also want the goal-driven tooling-design workflow. | +| **`context-intelligence-logging`** | `hook-context-intelligence` telemetry hook only | — (independent) | Always-on, team-wide session telemetry. Natural `--app` fit. | +| **`context-intelligence`** (full) | composes **design + logging** | both | Telemetry **and** full analysis/design in one install. | + +> The design mode ships `advertised: false` — it never clutters `/modes`; users activate it explicitly with `/mode context-intelligence` when they want the design workspace. + +### Skill sync and per-session overhead + +The `graph-analyst` agent relies on the `context-intelligence-graph-query` skill, whose content is kept current by `tool-graph-query`. On every session start — when a server URL is configured — the analytics path performs a lightweight sync: a `GET /version` reachability ping plus a conditional, ETag-cached `GET` of the skill. For one long-lived interactive session this is negligible. But workflows that drive Amplifier as a **series of one-shot commands** run the session-start lifecycle on *every* invocation, so the sync fires **per turn** and compounds over a long run. + +**Pick the layer that matches your traffic profile** — only the analysis/full layers carry any skill-sync cost at all: + +| If you… | Use | Skill-sync cost | +|---------|-----|-----------------| +| Forward telemetry only (headless / pipeline / event-forwarding) | `context-intelligence-logging` | **Zero** — no `tool-graph-query`, no version ping | +| Navigate local JSONL only | `context-intelligence-navigation` | **Zero** | +| Need graph-powered analysis | `context-intelligence-analysis` / `context-intelligence` (full) | Per-session sync (ETag-conditioned) | + +**Already on the full behavior but running a single-command series?** Set `skill_sync_enabled: false` on `tool-graph-query` to eliminate the per-turn skill traffic — **no `GET /version`, no `GET /skills/`, no `skill:unloaded` reload handler** — *without* downgrading your behavior. + +Disabling sync does **not** strand the graph-analyst, though. The bundle ships `context-intelligence-graph-query`'s `SKILL.md` as a pessimistic *"Server Unavailable"* stub (so a fresh install with no server never invites Cypher queries against a graph that isn't there). On the disabled path: + +| Disabled + … | What happens on disk | Network | +|--------------|----------------------|---------| +| **a server URL is configured** | The stub is **swapped** for the real graph-query body **vendored in the `tool-graph-query` package** (a byte-for-byte copy of the canonical server skill), so the graph-analyst stays fully usable | **Zero** — local file copy only | +| **no server configured** | The *"Server Unavailable"* stub is **retained** (the graph genuinely isn't there) | **Zero** | + +The swap is idempotent (rewrites only on content change) and crash-safe. The trade-off to understand: with sync disabled you get the **bundled** body, which is refreshed from the canonical [`microsoft/amplifier-context-intelligence`](https://github.com/microsoft/amplifier-context-intelligence) skill at bundle-authoring time — it will not auto-refresh from your live server until you re-enable sync. And if the server is configured but actually **down** while sync is disabled, the agent receives the optimistic "graph available" body; its `graph_query` calls then error and the graph-analyst's built-in fallback delegates to `session-navigator` (local JSONL) — degraded but never broken. + +| Key | Env var | Default | Effect when `false` | +|-----|---------|---------|---------------------| +| `skill_sync_enabled` | `AMPLIFIER_CONTEXT_INTELLIGENCE_SKILL_SYNC_ENABLED` | `true` | Zero per-turn skill traffic; server-configured sessions get the vendored offline body, no-server sessions keep the stub | + +Configure it through the `tool-graph-query` module config (agent frontmatter or a `settings.yaml` override), or set the env var to disable per-turn sync host-wide: + +```bash +# ~/.amplifier/keys.env or your shell — disable per-turn skill sync host-wide +AMPLIFIER_CONTEXT_INTELLIGENCE_SKILL_SYNC_ENABLED=false +``` + +Accepted values are case-insensitive: `true`/`1`/`yes`/`on` and `false`/`0`/`no`/`off`. An empty or unset value resolves to the default (`true`) — an unexpanded `${VAR:}` placeholder can never silently disable sync. + +> **Want cheaper sync rather than no sync?** A per-process version-check cache with a short TTL (skip `GET /version` while a recent check is still fresh) is a tracked follow-up that would reduce per-turn cost for users who still want sync. `skill_sync_enabled` is the opt-out available today; the TTL cache is a separate, deferred enhancement. + --- ## Understanding workspace @@ -62,10 +122,36 @@ The hook resolves `workspace` using the same `config → coordinator → default ### 1. Install -**Add to an existing app** (recommended) — layers the behavior on top of your active bundle without pulling in foundation as a dependency: +**Add to an existing app** — the `--app` flag layers a behavior onto **every** session, regardless of which primary bundle is active. Pick the layer from the onion above that fits your needs (each one includes everything beneath it): + +**Full** (everything — analysis agents + design mode + telemetry hook): ```bash -amplifier bundle add git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=behaviors/context-intelligence.yaml --app +amplifier bundle add "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=behaviors/context-intelligence.yaml" --app +``` + +**Design** (analysis + the `/context-intelligence` design mode, no telemetry hook): + +```bash +amplifier bundle add "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=behaviors/context-intelligence-design.yaml" --app +``` + +**Analysis** (graph-analyst + graph skills, no design mode, no telemetry hook): + +```bash +amplifier bundle add "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=behaviors/context-intelligence-analysis.yaml" --app +``` + +**Navigation** (innermost — `session-navigator` for local-JSONL navigation only): + +```bash +amplifier bundle add "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=behaviors/context-intelligence-navigation.yaml" --app +``` + +**Logging only** (telemetry hook only — ideal as an always-on app bundle): + +```bash +amplifier bundle add "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=behaviors/context-intelligence-logging.yaml" --app ``` **Standalone** — creates a dedicated session configuration using the full root bundle (includes foundation): @@ -77,6 +163,13 @@ amplifier bundle use context-intelligence Every Amplifier session will now write events to local JSONL files automatically — no server required. +> **Composing in your own bundle?** Use the bare `namespace:path` include form, e.g. +> `- bundle: context-intelligence:behaviors/context-intelligence-analysis`. Because the layers +> nest via `includes:`, picking one layer pulls in every layer beneath it — and the +> tool-skills config merges additively, so each layer contributes exactly its own skills. + +**`--app` vs standalone:** `--app` composes the behavior onto every session regardless of which primary bundle is active — it never becomes the primary bundle. The standalone form (`bundle add` + `bundle use`) makes context-intelligence the primary bundle for explicitly selected sessions. + ### 2. (Optional) Enable server forwarding To push events to the [Context Intelligence Server](https://github.com/microsoft/amplifier-context-intelligence) for graph storage and querying, you need a running server instance and its API key. See the [server repository](https://github.com/microsoft/amplifier-context-intelligence) for setup instructions. @@ -157,7 +250,7 @@ overrides: context_intelligence_api_key: "${CONTEXT_INTELLIGENCE_TEAM_SERVER_API_KEY}" ``` -The `${...}` placeholder is resolved by the app-cli before the value reaches the hook, so `ConfigResolver` receives the secret value through its config dict (highest resolution priority). The custom key name in `keys.env` is invisible to the bundle itself. +The `${...}` placeholder is resolved by the app-cli before the value reaches the hook, so `HookConfigResolver` receives the secret value through its config dict (highest resolution priority). The custom key name in `keys.env` is invisible to the bundle itself. --- @@ -230,10 +323,10 @@ cleanup = await mount(coordinator, config={ ### Accessing resolved values -`mount()` registers a `ConfigResolver` as the `context_intelligence.config_resolver` capability: +`mount()` registers a `HookConfigResolver` as the `context_intelligence.hook_config_resolver` capability: ```python -resolver = coordinator.get_capability("context_intelligence.config_resolver") +resolver = coordinator.get_capability("context_intelligence.hook_config_resolver") resolver.workspace # resolved workspace string resolver.base_path # resolved Path object resolver.session_dir("abc-123") # Path to a session's CI directory @@ -409,7 +502,7 @@ amplifier-bundle-context-intelligence/ │ ├── event-schema.md ← all 51+ Amplifier events │ ├── graph-model-reference.md ← Neo4j graph model for Cypher queries │ ├── safe-extraction-patterns.md ← JSONL navigation patterns -│ ├── config-resolution.dot ← ConfigResolver fallback chain diagram +│ ├── config-resolution.dot ← HookConfigResolver fallback chain diagram │ ├── session-disk-layout.dot ← on-disk session directory structure │ ├── delegation-strategy.dot ← graph-analyst → session-navigator delegation logic │ ├── agents/ @@ -434,12 +527,28 @@ amplifier-bundle-context-intelligence/ ## Development +Each module is an independent `uv` package. Set up and test them separately: + ```bash -# Module tests -cd modules/hook-context-intelligence -uv sync -uv run pytest tests/ -q +# Setup +cd modules/tool-graph-query && uv sync +cd modules/tool-blob-read && uv sync +cd modules/hook-context-intelligence && uv sync + +# Tests (run from the respective module directory) +cd modules/tool-graph-query && uv run pytest -q # 97 tests +cd modules/tool-blob-read && uv run pytest -q # 35 tests +cd modules/hook-context-intelligence && uv run pytest -q # 312 tests + +# Lint + types (run from the respective module directory) +uv run ruff check . && uv run ruff format --check . && uv run pyright +``` + +> **Built-copy caveat — mandatory before any red-green cycle on shared code:** modules install the shared `context_intelligence` package as a **built (non-editable) copy** in their venv. After editing shared code under `context_intelligence/`, run `uv sync --reinstall --refresh` in the affected module before testing — otherwise the stale built copy silently shadows your change and tests falsely pass even with the fix reverted. +End-to-end behavior is validated in Digital Twin Universe (DTU) scenarios against a live context-intelligence server. See [AGENTS.md](AGENTS.md) for the DTU gate and the `.amplifier/digital-twin-universe/profiles/` profiles. + +```bash # Bundle-level tests uv run pytest ../../tests/ -q diff --git a/agents/graph-analyst.md b/agents/graph-analyst.md index 780cc052..32161f12 100644 --- a/agents/graph-analyst.md +++ b/agents/graph-analyst.md @@ -40,8 +40,18 @@ tools: source: git+https://github.com/microsoft/amplifier-foundation@main#subdirectory=modules/tool-delegate - module: tool-graph-query source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/tool-graph-query + config: + # Used as fallback when hook-context-intelligence is not mounted (analytics-only mode). + # When the hook is present its config_resolver capability takes priority over these values. + context_intelligence_server_url: "${AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL:}" + context_intelligence_api_key: "${AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY:}" + workspace: "${AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE:}" - module: tool-blob-read source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/tool-blob-read + config: + # Same fallback semantics as tool-graph-query above. + context_intelligence_server_url: "${AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL:}" + context_intelligence_api_key: "${AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY:}" - module: tool-filesystem source: git+https://github.com/microsoft/amplifier-module-tool-filesystem@main config: @@ -291,7 +301,7 @@ reconstructs session summaries that can then be uploaded to the graph server for ## Section 4: Context File References @context-intelligence:context/config-resolution.dot - + @context-intelligence:context/delegation-strategy.dot diff --git a/agents/session-navigator.md b/agents/session-navigator.md index 19a4ba64..fca2ff6d 100644 --- a/agents/session-navigator.md +++ b/agents/session-navigator.md @@ -45,6 +45,20 @@ tools: > **IDENTITY NOTICE**: You ARE the session-navigator agent. When you receive a task involving local JSONL session navigation, event search, or session discovery — YOU perform it directly using YOUR tools. Do NOT delegate to "session-navigator" — that would be delegating to yourself, causing an infinite loop. You have all the capabilities needed: filesystem access, search, bash, and skills. Execute the requested operations directly. +## Base Path Resolution + +**At the start of every investigation**, resolve the base path with: + +```bash +export BASE_PATH="${AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH:-$HOME/.amplifier/projects}" +echo "Base path: $BASE_PATH" +``` + +Use `$BASE_PATH` (not the literal `~/.amplifier/projects`) in every subsequent bash command. +This allows operators to override the storage root without changing agent instructions. + +Default: `~/.amplifier/projects`. Override: set `AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH`. + --- ## ⛔ CRITICAL: events.jsonl Will Kill Your Session @@ -113,11 +127,11 @@ You are `session-navigator` — the local JSONL fallback navigation agent for th **No server tools:** You do NOT have `graph_query` or `blob_read` tools. You operate entirely on local filesystem files using bash/jq/grep safe extraction patterns. Never attempt to use server tools — they are not available in your tool set. -**Storage path convention:** All session data lives at: +**Storage path convention:** All session data lives under `$BASE_PATH` (resolve it first — see top of document): ``` -~/.amplifier/projects/{project-slug}/sessions/{session_id}/context-intelligence/events.jsonl -~/.amplifier/projects/{project-slug}/sessions/{session_id}/context-intelligence/metadata.json +$BASE_PATH/{project-slug}/sessions/{session_id}/context-intelligence/events.jsonl +$BASE_PATH/{project-slug}/sessions/{session_id}/context-intelligence/metadata.json ``` Every `events.jsonl` line and every `metadata.json` file contains a `workspace` field. The graph-analyst will pass the active workspace when it delegates to you. **Always scope your search to that workspace.** @@ -129,7 +143,7 @@ When a workspace is provided by the caller, apply it immediately before any othe **Step 1 — Try directory-first lookup** (fast, covers the common case where workspace equals the project slug): ```bash -ls ~/.amplifier/projects/{WORKSPACE}/sessions/ 2>/dev/null +ls "$BASE_PATH/{WORKSPACE}/sessions/" 2>/dev/null ``` If this directory exists and contains sessions, work within it exclusively. @@ -137,7 +151,7 @@ If this directory exists and contains sessions, work within it exclusively. **Step 2 — If that directory is empty or missing**, the workspace was set explicitly and differs from the project slug. Scan across all project directories and filter by the `workspace` field in `metadata.json`: ```bash -for f in ~/.amplifier/projects/*/sessions/*/context-intelligence/metadata.json; do +for f in "$BASE_PATH"/*/sessions/*/context-intelligence/metadata.json; do jq -r 'select(.workspace == "{WORKSPACE}") | input_filename' "$f" 2>/dev/null done ``` @@ -156,25 +170,25 @@ Find sessions by ID, project slug, date, or agent name, always scoped to the pro ```bash # List sessions in a workspace (directory-first path) -for f in ~/.amplifier/projects/my-project/sessions/*/context-intelligence/metadata.json; do +for f in "$BASE_PATH/my-project/sessions"/*/context-intelligence/metadata.json; do jq -r '[.session_id, .workspace, .status, .started_at, .agent_name // "(root)"] | join("\t")' "$f" 2>/dev/null done | sort -t$'\t' -k4 # List sessions scoped by workspace field (cross-project scan) -for f in ~/.amplifier/projects/*/sessions/*/context-intelligence/metadata.json; do +for f in "$BASE_PATH"/*/sessions/*/context-intelligence/metadata.json; do jq -r 'select(.workspace == "my-project") | [.session_id, .status, .started_at, .agent_name // "(root)"] | join("\t")' "$f" 2>/dev/null done | sort -t$'\t' -k3 # Find a session by partial ID (within a workspace) -find ~/.amplifier/projects/my-project/sessions -maxdepth 1 -name "*PARTIAL_ID*" -type d +find "$BASE_PATH/my-project/sessions" -maxdepth 1 -name "*PARTIAL_ID*" -type d # Find sessions by agent name within a workspace -for f in ~/.amplifier/projects/my-project/sessions/*/context-intelligence/metadata.json; do +for f in "$BASE_PATH/my-project/sessions"/*/context-intelligence/metadata.json; do jq -r 'select(.agent_name == "TARGET_AGENT") | .session_id' "$f" 2>/dev/null done # Confirm the workspace of a specific session -jq -r '.workspace' ~/.amplifier/projects/my-project/sessions/SESSION_ID/context-intelligence/metadata.json +jq -r '.workspace' "$BASE_PATH/my-project/sessions/SESSION_ID/context-intelligence/metadata.json" ``` ### Event Search @@ -212,7 +226,7 @@ jq -r '{parent_id, workspace, status}' metadata.json # Find child sessions within a workspace PARENT_ID="YOUR_SESSION_ID_HERE" -for f in ~/.amplifier/projects/my-project/sessions/*/context-intelligence/metadata.json; do +for f in "$BASE_PATH/my-project/sessions"/*/context-intelligence/metadata.json; do jq -r "select(.parent_id == \"$PARENT_ID\") | [.session_id, .agent_name // \"(root)\", .status, .workspace] | join(\"\t\")" "$f" 2>/dev/null done @@ -238,7 +252,7 @@ Since session-navigator is active when no server is configured, you must locate ```bash context-intelligence-upload \ - --path ~/.amplifier/projects/my-project \ + --path "$BASE_PATH/my-project" \ --server-url "https://your-server.example.com" \ --api-key "your-api-key" ``` diff --git a/behaviors/context-intelligence-analysis.yaml b/behaviors/context-intelligence-analysis.yaml new file mode 100644 index 00000000..4161ad8d --- /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 00000000..d8eff604 --- /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 00000000..44b0e61b --- /dev/null +++ b/behaviors/context-intelligence-logging.yaml @@ -0,0 +1,34 @@ +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: + 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 → env var fallback + 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 where session files are written. Defaults to ~/.amplifier/projects. + # Override via env var or set explicitly here. + base_path: "${AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH:}" + # project_slug: (auto-resolved from working directory; uncomment to override) + # exclude_events: [] (optional fnmatch patterns; uncomment and list events to suppress) diff --git a/behaviors/context-intelligence-navigation.yaml b/behaviors/context-intelligence-navigation.yaml new file mode 100644 index 00000000..65592b5d --- /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 9cfe94be..985c7f07 100644 --- a/behaviors/context-intelligence.yaml +++ b/behaviors/context-intelligence.yaml @@ -2,63 +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: - 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 → env var fallback - 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) + - bundle: context-intelligence:behaviors/context-intelligence-design + - bundle: context-intelligence:behaviors/context-intelligence-logging diff --git a/bundle.dot b/bundle.dot index c7d8b5b5..89109aa1 100644 --- a/bundle.dot +++ b/bundle.dot @@ -8,7 +8,7 @@ digraph context_intelligence { nodesep=0.6 ranksep=0.7 bgcolor="white" - source_hash="dba7ae999a5fd095337bcd9ede4c54b866044c0b31d63d20c4b0ef4483abaf6c" + source_hash="b8319fbcaf08bc56718b69d03a5cf0015567db9891a1b58f4be73f6412e8ebf8" node [fontname="Helvetica", fontsize=11, style="filled,rounded"] edge [fontname="Helvetica", fontsize=9] @@ -21,7 +21,11 @@ digraph context_intelligence { fillcolor="#f9f9f9" color="#999999" - beh_context_intelligence_behavior [label="context-intelligence-behavior\n4 tools\n~1832 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~497 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 { @@ -73,7 +77,7 @@ digraph context_intelligence { 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 b0b34e1a..a6e08683 100644 Binary files a/bundle.png and b/bundle.png differ diff --git a/context/config-resolution.dot b/context/config-resolution.dot index 249df50c..5358858a 100644 --- a/context/config-resolution.dot +++ b/context/config-resolution.dot @@ -1,4 +1,4 @@ -// ConfigResolver — lazy fallback chain for hook configuration values. +// HookConfigResolver — lazy fallback chain for hook configuration values. // // Each property is resolved on first access and cached. // Empty strings in config are treated as absent and fall through @@ -6,14 +6,14 @@ // // Render: dot -Tsvg config-resolution.dot -o config-resolution.svg -digraph ConfigResolver { +digraph HookConfigResolver { rankdir=TB; fontname="Helvetica"; node [fontname="Helvetica", shape=box, style="rounded,filled", fillcolor="#f0f0f0"]; edge [fontname="Helvetica", fontsize=10]; // --- Central node --- - resolver [label="ConfigResolver", shape=component, fillcolor="#d4e6f1", style="filled"]; + resolver [label="HookConfigResolver", shape=component, fillcolor="#d4e6f1", style="filled"]; // --- Input sources --- subgraph cluster_inputs { diff --git a/context/dual-path-library-template.md b/context/dual-path-library-template.md index da99d454..70deb189 100644 --- a/context/dual-path-library-template.md +++ b/context/dual-path-library-template.md @@ -164,7 +164,7 @@ def _via_jsonl(*args: Any, session_dir: Path) -> list[dict]: # Wrapper examples (each is a thin shell over get_X): # # Agent tool (in-session): -# register a tool whose handler reads ConfigResolver for server_url and +# register a tool whose handler reads HookConfigResolver for server_url and # session_dir, then calls get_X(..., mode="auto", ...). # # CLI (out-of-session): diff --git a/context_intelligence/config.py b/context_intelligence/config.py index df45f032..bfac942d 100644 --- a/context_intelligence/config.py +++ b/context_intelligence/config.py @@ -16,6 +16,7 @@ import logging import os +import re from pathlib import Path log = logging.getLogger("context_intelligence.config") @@ -87,6 +88,57 @@ def _parse_settings_yaml(path: Path) -> dict: return result +# --------------------------------------------------------------------------- +# Shared env-var helpers (used by HookConfigResolver and ToolConfigResolver) +# --------------------------------------------------------------------------- + +#: Environment variable prefix shared by all CI configuration. +#: ``AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE`` → workspace +#: ``AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL`` → context_intelligence_server_url +#: etc. +_ENV_PREFIX = "AMPLIFIER_CONTEXT_INTELLIGENCE_" + + +def _env(suffix: str) -> str | None: + """Read ``AMPLIFIER_CONTEXT_INTELLIGENCE_`` from the environment. + + Returns the value as a string if set and non-empty, otherwise ``None``. + """ + value = os.environ.get(_ENV_PREFIX + suffix) + return value if value else None + + +# --------------------------------------------------------------------------- +# Shell-style placeholder expander (used by ToolConfigResolver) +# --------------------------------------------------------------------------- + +_PLACEHOLDER_RE = re.compile(r"\$\{([^}:]+)(?::([^}]*))?\}") + + +def _expand_env_placeholders(value: str) -> str: + """Expand shell-style ``${VAR}``, ``${VAR:}``, ``${VAR:default}`` placeholders. + + - ``${VAR}`` — replaced with ``os.environ[VAR]`` if set, else ``""``. + - ``${VAR:}`` — same as ``${VAR}`` (empty default when var is unset). + - ``${VAR:default}`` — replaced with ``os.environ[VAR]`` if set, else ``"default"``. + - Non-placeholder strings pass through unchanged. + + Note: ``os.path.expandvars`` does **not** support the ``${VAR:default}`` + colon syntax used by the agent behavior YAML files shipped with this bundle, + hence this small regex-based helper. + + Note: every ``${...}`` token is treated as an expandable placeholder. + There is NO escape syntax — literal ``${...}`` sequences are not preserved. + """ + + def _replace(m: re.Match[str]) -> str: + var_name = m.group(1) + default = m.group(2) if m.group(2) is not None else "" + return os.environ.get(var_name, default) + + return _PLACEHOLDER_RE.sub(_replace, value) + + # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- diff --git a/context_intelligence/tool_resolver.py b/context_intelligence/tool_resolver.py new file mode 100644 index 00000000..0759d55e --- /dev/null +++ b/context_intelligence/tool_resolver.py @@ -0,0 +1,234 @@ +"""ToolConfigResolver — lazy config resolver for CI tools in analytics-only mode. + +Used by tool-graph-query and tool-blob-read when the hook-context-intelligence +module is NOT mounted. Constructed **eagerly** inside the tool's ``__init__`` +(both tools always create a ``ToolConfigResolver`` at construction time). Its +properties are evaluated lazily on each access. The hook resolver +(``context_intelligence.hook_config_resolver`` coordinator capability), when +present, takes priority over ``ToolConfigResolver`` at call time. + +Resolution priority for every property (mirrors HookConfigResolver for the +shared keys): + + 1. mount() config dict — highest, from agent frontmatter + 2. coordinator.config — app-level programmatic override + 3. AMPLIFIER_CONTEXT_INTELLIGENCE_* env var + 4. ~/.amplifier/settings.yaml — lowest-priority fallback + 5. default — built-in last resort + +workspace resolution differs from HookConfigResolver by design: + + HookConfigResolver.workspace falls back to ``project_slug`` which is + auto-derived from ``session.working_dir`` — a coordinator capability + that only exists in an active capture session managed by the hook. + + ToolConfigResolver.workspace falls back to the env var then ``"default"`` + because in analytics-only mode there is no live capture session to derive + a project slug from. Set ``AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE`` + explicitly, or pass ``workspace`` in the tool's mount() config dict. + +Properties: ``context_intelligence_server_url``, ``context_intelligence_api_key``, +``workspace``. ``workspace`` is cached after first access; ``server_url`` and +``api_key`` are recomputed on each call (mirrors HookConfigResolver behaviour). +""" + +from __future__ import annotations + +from typing import Any + +from context_intelligence.config import ( # type: ignore[attr-defined] + SETTINGS_PATH, + _env, + _expand_env_placeholders, + _parse_settings_yaml, +) + +_DEFAULT_WORKSPACE = "default" + +#: 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) + + +class ToolConfigResolver: + """Config resolver for CI tools — analytics-only mode (no hook mounted). + + Constructed eagerly in the tool's ``__init__`` (both tools always create a + ``ToolConfigResolver`` at construction time, alongside ``_hook_resolver = None``). + Its properties are evaluated lazily on each access. At call time, the hook + resolver (when present via the coordinator capability) takes priority; this + class is only consulted when the hook capability is absent. Reads + ``server_url``, ``api_key``, and ``workspace`` using the same four-level + priority chain as ``HookConfigResolver`` for those keys. + See module docstring for the workspace asymmetry rationale. + """ + + def __init__(self, config: dict[str, Any], coordinator: Any) -> None: + self._config = config + self._coordinator = coordinator + self._workspace: str | None = None # cached after first access + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _coordinator_config_get(self, key: str) -> Any: + """Safely read *key* from coordinator.config. + + Returns ``None`` if the coordinator has no ``.config`` attribute or + if the key is absent from it. Mirrors HookConfigResolver._coordinator_config_get. + """ + coord_config = getattr(self._coordinator, "config", None) + if not isinstance(coord_config, dict): + return None + return coord_config.get(key) + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def context_intelligence_server_url(self) -> str | None: + """Server URL. + + Resolution order (first truthy value wins): + 1. config['context_intelligence_server_url'] — mount() config dict + 2. coordinator.config['context_intelligence_server_url'] + 3. AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL env var + 4. ~/.amplifier/settings.yaml + + Shell-style placeholders (``${VAR:}``) in steps 1–2 are expanded + against the environment before the truthiness check so that an + unexpanded placeholder does not short-circuit the chain. + """ + value = ( + _expand(self._config.get("context_intelligence_server_url")) + or _expand(self._coordinator_config_get("context_intelligence_server_url")) + or _env("SERVER_URL") + or _parse_settings_yaml(SETTINGS_PATH).get("server_url") + ) + return str(value) if value else None + + @property + def context_intelligence_api_key(self) -> str | None: + """API key. + + Resolution order (first truthy value wins): + 1. config['context_intelligence_api_key'] — mount() config dict + 2. coordinator.config['context_intelligence_api_key'] + 3. AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY env var + 4. ~/.amplifier/settings.yaml + + Shell-style placeholders (``${VAR:}``) in steps 1–2 are expanded + against the environment before the truthiness check so that an + unexpanded placeholder does not short-circuit the chain. + """ + value = ( + _expand(self._config.get("context_intelligence_api_key")) + or _expand(self._coordinator_config_get("context_intelligence_api_key")) + or _env("API_KEY") + or _parse_settings_yaml(SETTINGS_PATH).get("api_key") + ) + return str(value) if value else None + + @property + def workspace(self) -> str: + """Workspace identifier for scoping queries. + + Resolution order (first truthy value wins): + 1. config['workspace'] — mount() config dict + 2. coordinator.config['workspace'] + 3. AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE env var + 4. 'default' — built-in fallback + + Note: does NOT fall back to project_slug / session.working_dir. + That auto-derivation belongs to HookConfigResolver, which runs inside + an active capture session. In analytics-only mode set the env var or + pass workspace explicitly via the tool's config. + + Shell-style placeholders (``${VAR:}``) in steps 1–2 are expanded + against the environment before the truthiness check. + + Cached after first access. + """ + if self._workspace is None: + self._workspace = str( + _expand(self._config.get("workspace")) + or _expand(self._coordinator_config_get("workspace")) + or _env("WORKSPACE") + or _DEFAULT_WORKSPACE + ) + return self._workspace + + @property + def skill_sync_enabled(self) -> bool: + """Whether the analytics path syncs watched skills on session start. + + Default ``True`` — preserves the existing behaviour for every consumer + who does not set the knob. Set to ``false`` for headless / pipeline / + single-command-series workflows that compose the full behaviour but + never invoke the graph-analyst sub-session: when disabled, + ``skill_sync.on_session_ready`` becomes a complete no-op (no + ``GET /version`` ping, no skill fetch, no ``skill:unloaded`` reload + handler registration), so those workflows pay **zero** skill traffic + per turn. + + 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. True — default + + 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 (``True``), never ``False`` — it + cannot silently disable 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 True diff --git a/docs/context-intelligence-skill-sync-flow.dot b/docs/context-intelligence-skill-sync-flow.dot new file mode 100644 index 00000000..8c897038 --- /dev/null +++ b/docs/context-intelligence-skill-sync-flow.dot @@ -0,0 +1,616 @@ +// context-intelligence-skill-sync-flow.dot +// Context-Intelligence Skill-Sync Flow (with the skill_sync_enabled opt-out) +// Owned by tool-graph-query (graph-analyst sub-session). +// The logging hook is now PURE TELEMETRY — skill-content sync was relocated here. +// +// 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_graph_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-graph-query/amplifier_module_tool_graph_query/skill_sync.py +// modules/tool-graph-query/amplifier_module_tool_graph_query/skill_fetcher.py +// modules/tool-graph-query/amplifier_module_tool_graph_query/graph_query_tool.py +// modules/tool-graph-query/amplifier_module_tool_graph_query/bundled_skill/__init__.py +// context_intelligence/tool_resolver.py (skill_sync_enabled property) +// modules/tool-graph-query/amplifier_module_tool_graph_query/__init__.py +// +// Generated: 2026-06-21 +// 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-graph-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-graph-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 TRUE)", + fillcolor="#FFEBEE", + color="#C62828", + fontcolor="#C62828" + ]; + } + + // ════════════════════════════════════════════════════════════════════════ + // CLUSTER 2b — DISABLED path: zero-network offline-body swap + // (the opt-out for headless / single-command-series workflows — issue #283) + // ════════════════════════════════════════════════════════════════════════ + + 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• 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 (full) ", color="#6A1B9A", fontcolor="#6A1B9A", penwidth=2 + ]; + cfg_hook_check -> cfg_tool [ + label=" NO (analytics-only) ", 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: reachability gate + // ═══════════════════════════════════════════════════════════════ + + subgraph cluster_dispatch { + label="4. _sync_skill — Server Reachability Gate"; + 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: ENABLED (default) → existing resync flow + gate_enabled -> osr_cap_check [ + label=" TRUE (default) — sync from server ", + color="#2E7D32", fontcolor="#2E7D32", penwidth=2, + ltail=cluster_gate, lhead=cluster_osr + ]; + + // Gate: DISABLED → zero-network offline-body swap (issue #283 opt-out) + gate_enabled -> dis_server_check [ + label=" FALSE — zero per-turn network ", + color="#37474F", fontcolor="#37474F", penwidth=2, + ltail=cluster_gate, lhead=cluster_disabled + ]; + + // 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 00000000..fd344287 Binary files /dev/null and b/docs/context-intelligence-skill-sync-flow.png differ diff --git a/docs/logging-handler-flow.dot b/docs/logging-handler-flow.dot index 7a1c09a1..540478a3 100644 --- a/docs/logging-handler-flow.dot +++ b/docs/logging-handler-flow.dot @@ -41,7 +41,7 @@ digraph LoggingHandlerFlow { ]; mount_resolver [ - label="ConfigResolver(config, coordinator)\n───────────────────\nLazy fallback chains for:\nproject_slug, base_path,\nworkspace, server_url, log_level", + label="HookConfigResolver(config, coordinator)\n───────────────────\nLazy fallback chains for:\nproject_slug, base_path,\nworkspace, server_url, log_level", fillcolor="#E8F5E9", color="#2E7D32" ]; @@ -295,7 +295,7 @@ digraph LoggingHandlerFlow { deterministic key supports server dedup ─────────────────────────── httpx.AsyncClient(connect=0.5s, write=10s, read=3s) - bounded queue: failure/full => disable dispatch + bounded queue: failure/full => disable dispatch >, fillcolor="#E0F7FA", color="#00838F" diff --git a/docs/logging-handler-flow.png b/docs/logging-handler-flow.png new file mode 100644 index 00000000..ae0fc226 Binary files /dev/null and b/docs/logging-handler-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 59b7b3bd..623a3a9d 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 @@ -13,7 +13,7 @@ workspace : str, optional Workspace identifier used to scope graph data on the server. Resolved automatically from the coordinator when not set - (see ConfigResolver.workspace). + (see HookConfigResolver.workspace). log_level : str, optional Logging level. Default ``"WARNING"``. base_path : str, optional @@ -36,97 +36,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.""" @@ -153,84 +68,20 @@ async def mount( """Mount the context-intelligence hook. Always: - - Registers ConfigResolver as ``context_intelligence.config_resolver`` capability + - Registers HookConfigResolver as ``context_intelligence.hook_config_resolver`` capability - LoggingHandler — writes events.jsonl + dispatches to CI server """ - from .config_resolver import ConfigResolver + 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 = ConfigResolver(config, coordinator) + resolver = HookConfigResolver(config, coordinator) log.setLevel(resolver.log_level) - coordinator.register_capability("context_intelligence.config_resolver", resolver) + coordinator.register_capability("context_intelligence.hook_config_resolver", resolver) unregister_fns: list[Callable[[], None]] = [] logging_handler = LoggingHandler(resolver) - # Skill fetch phase — deferred to skills:discovered event - server_url = resolver.context_intelligence_server_url - fetcher: SkillFetcher | None = None - skills_capable: bool = False - - if not server_url: - log.info("skill_fetch_skipped: no server_url in config") - else: - _tentative_fetcher = SkillFetcher(server_url) - result = await _tentative_fetcher.check_server_version() - log.info( - "skill_version_check: server=%s reachable=%s version=%s", - server_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) - - 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. @@ -252,7 +103,7 @@ async def cleanup() -> None: except Exception: pass try: - coordinator.register_capability("context_intelligence.config_resolver", None) + coordinator.register_capability("context_intelligence.hook_config_resolver", None) except Exception: pass try: diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py index aa2b6f2a..f530d66e 100644 --- a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py +++ b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py @@ -1,23 +1,16 @@ -"""ConfigResolver — lazy fallback chain for hook configuration values.""" +"""HookConfigResolver — lazy fallback chain for hook configuration values.""" from __future__ import annotations -import os from pathlib import Path from typing import Any -from context_intelligence.config import SETTINGS_PATH, _parse_settings_yaml +from context_intelligence.config import SETTINGS_PATH, _env, _parse_settings_yaml # type: ignore[attr-defined] from context_intelligence.reconstruct.discover import workspace_slug _DEFAULT_BASE_PATH = "~/.amplifier/projects" _DEFAULT_PROJECT_SLUG = "default" -# Environment variable prefix for all hook configuration. -# AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE → workspace -# AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL → context_intelligence_server_url -# etc. -_ENV_PREFIX = "AMPLIFIER_CONTEXT_INTELLIGENCE_" - # Default event-name patterns (fnmatch) excluded from local JSONL logging and graph dispatch. # # The pattern ``llm:stream_*delta`` expresses the transient-streaming-delta *category*: it @@ -35,15 +28,6 @@ _DEFAULT_EXCLUDE_EVENTS: list[str] = ["llm:stream_*delta"] -def _env(suffix: str) -> str | None: - """Read ``AMPLIFIER_CONTEXT_INTELLIGENCE_`` from the environment. - - Returns the value as a string if set and non-empty, otherwise ``None``. - """ - value = os.environ.get(_ENV_PREFIX + suffix) - return value if value else None - - def _slugify_path(path_str: str) -> str: """Convert an absolute path to the CLI's project slug format. @@ -64,13 +48,13 @@ def _slugify_path(path_str: str) -> str: return slug or _DEFAULT_PROJECT_SLUG -class ConfigResolver: - """Resolve configuration values with lazy fallback chains. +class HookConfigResolver: + """Resolve configuration values with lazy fallback chains for the CI hook. Resolution order per property: - project_slug: config → coordinator.config → session.working_dir capability → 'default' - - base_path: config → coordinator.config → default + - base_path: config → coordinator.config → AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH env var → default - workspace: config['workspace'] → coordinator.config['workspace'] → project_slug Resolved values are cached after first access. @@ -151,13 +135,17 @@ def project_slug(self) -> str: def base_path(self) -> Path: """Resolved base path for project storage. - Chain: config['base_path'] → coordinator.config['base_path'] → default. + Chain: config['base_path'] + → coordinator.config['base_path'] + → AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH env var + → default (~/.amplifier/projects). Tilde is expanded. Result is cached after first access. """ if self._base_path is None: raw = ( self._config.get("base_path") or self._coordinator_config_get("base_path") + or _env("BASE_PATH") or _DEFAULT_BASE_PATH ) self._base_path = Path(raw).expanduser() 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 e69de29b..00000000 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 221aced2..00000000 --- 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/pyproject.toml b/modules/hook-context-intelligence/pyproject.toml index d8954adc..fb75fb8b 100644 --- a/modules/hook-context-intelligence/pyproject.toml +++ b/modules/hook-context-intelligence/pyproject.toml @@ -8,7 +8,7 @@ license = "MIT" dependencies = [ "httpx>=0.28.1", "idna>=3.15", - "amplifier-bundle-context-intelligence @ git+https://github.com/microsoft/amplifier-bundle-context-intelligence@v0.1.1", + "amplifier-bundle-context-intelligence @ git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main", ] [project.entry-points."amplifier.modules"] @@ -30,7 +30,7 @@ allow-direct-references = true [dependency-groups] dev = [ - "amplifier-core>=1.4.1", + "amplifier-core>=1.6.0", "pytest>=9.0.3", "pytest-asyncio>=0.24", "pyyaml>=6.0", @@ -38,13 +38,6 @@ dev = [ "ruff>=0.4", ] -[tool.uv.sources] -amplifier-core = { git = "https://github.com/microsoft/amplifier-core", rev = "v1.4.1" } -# Note: the bundle dependency is declared as a PEP 508 direct git reference in -# [project.dependencies] above (survives `uv pip install --no-sources`). It is -# intentionally NOT given a `path = "../.."` source here, so the module installs -# identically inside the monorepo and standalone. - [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" diff --git a/modules/hook-context-intelligence/tests/helpers.py b/modules/hook-context-intelligence/tests/helpers.py index ccce71ec..8cd7e348 100644 --- a/modules/hook-context-intelligence/tests/helpers.py +++ b/modules/hook-context-intelligence/tests/helpers.py @@ -7,9 +7,8 @@ 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. +The ``config_resolver``-focused tests in ``test_config_resolver.py`` use a +different coordinator shape and should keep their own local helpers. """ from __future__ import annotations diff --git a/modules/hook-context-intelligence/tests/test_bundle.py b/modules/hook-context-intelligence/tests/test_bundle.py index 55564439..a75cdca6 100644 --- a/modules/hook-context-intelligence/tests/test_bundle.py +++ b/modules/hook-context-intelligence/tests/test_bundle.py @@ -9,21 +9,26 @@ def _load_behavior() -> dict: - """Load and parse the behavior YAML file.""" + """Load and parse the FULL umbrella behavior YAML file (composes design + logging).""" path = REPO_ROOT / "behaviors" / "context-intelligence.yaml" return yaml.safe_load(path.read_text()) -def _ci_hook(data: dict) -> dict: - """Return the hook-context-intelligence spec, located by module name. +def _load_logging_behavior() -> dict: + """Load and parse the LOGGING behavior YAML file (hook-only).""" + path = REPO_ROOT / "behaviors" / "context-intelligence-logging.yaml" + return yaml.safe_load(path.read_text()) + + +def _load_named_behavior(name: str) -> dict: + """Load and parse a behavior YAML file by behavior name (no .yaml suffix).""" + path = REPO_ROOT / "behaviors" / f"{name}.yaml" + return yaml.safe_load(path.read_text()) + - 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. - """ - 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] +def _bundle_refs(data: dict) -> list[str]: + """Return the list of `includes[].bundle` reference strings for a behavior.""" + return [i["bundle"] for i in data.get("includes", []) if "bundle" in i] class TestBundleRoot: @@ -77,29 +82,64 @@ def test_root_pyproject_toml_is_for_library(self): assert "context_intelligence" in packages -class TestBehaviorYaml: - """Validate behavior YAML structure.""" +class TestFullBehaviorYaml: + """Validate the FULL umbrella behavior composes design + logging (no inline hook).""" def test_behavior_yaml_exists(self): assert (REPO_ROOT / "behaviors" / "context-intelligence.yaml").is_file() - def test_behavior_has_hooks_section(self): + def test_full_behavior_composes_design_and_logging(self): + """Full umbrella must include BOTH the design (top analysis layer) and logging behaviors.""" + bundle_refs = _bundle_refs(_load_behavior()) + assert any("context-intelligence-design" in ref for ref in bundle_refs), ( + f"Full behavior must include the design behavior, got: {bundle_refs!r}" + ) + assert any("context-intelligence-logging" in ref for ref in bundle_refs), ( + f"Full behavior must include logging behavior, got: {bundle_refs!r}" + ) + + def test_full_behavior_has_no_inline_hook(self): + """The hook now lives in the logging behavior, not inline in the full behavior. + + This keeps the hook registered exactly once across the include graph. + """ data = _load_behavior() - assert "hooks" in data, "Behavior YAML must have a hooks: section" + hook_modules = [h["module"] for h in data.get("hooks", [])] + assert "hook-context-intelligence" not in hook_modules, ( + "Full behavior must NOT inline the hook; it is composed via the logging behavior" + ) + + +class TestLoggingBehaviorYaml: + """Validate the LOGGING (hook-only) behavior YAML structure.""" + + def test_logging_behavior_exists(self): + assert (REPO_ROOT / "behaviors" / "context-intelligence-logging.yaml").is_file() + + def test_behavior_has_hooks_section(self): + data = _load_logging_behavior() + assert "hooks" in data, "Logging behavior YAML must have a hooks: section" + + def test_logging_behavior_has_no_agents_or_tools(self): + """Logging behavior is hook-ONLY — no analysis surface.""" + data = _load_logging_behavior() + assert "agents" not in data, "Logging behavior must not declare agents" + assert "tools" not in data, "Logging behavior must not declare tools" def test_behavior_hook_module_name(self): - data = _load_behavior() - # Located by module name, not position: the behavior also wires hooks-mode. - assert _ci_hook(data)["module"] == "hook-context-intelligence" + data = _load_logging_behavior() + hook_specs = data.get("hooks", []) + assert len(hook_specs) >= 1 + assert hook_specs[0]["module"] == "hook-context-intelligence" def test_behavior_hook_has_source(self): - data = _load_behavior() - hook_spec = _ci_hook(data) + data = _load_logging_behavior() + hook_spec = data["hooks"][0] assert "source" in hook_spec, "Hook spec must have a source field" def test_behavior_hook_has_config(self): - data = _load_behavior() - hook_spec = _ci_hook(data) + data = _load_logging_behavior() + hook_spec = data["hooks"][0] assert "config" in hook_spec, "Hook spec must have a config field" config = hook_spec["config"] # Thin forwarder config keys @@ -107,7 +147,7 @@ def test_behavior_hook_has_config(self): assert "log_level" in config def test_behavior_hook_is_in_hooks_section_not_tools(self): - data = _load_behavior() + data = _load_logging_behavior() hook_modules = [h["module"] for h in data.get("hooks", [])] assert "hook-context-intelligence" in hook_modules tool_modules = [t["module"] for t in data.get("tools", [])] @@ -115,15 +155,15 @@ def test_behavior_hook_is_in_hooks_section_not_tools(self): def test_behavior_source_points_to_main(self): """Source must point to the main branch (post-merge).""" - data = _load_behavior() - source = _ci_hook(data).get("source", "") + data = _load_logging_behavior() + source = data["hooks"][0].get("source", "") # Source may have a #subdirectory= fragment after @main assert "@main" in source, f"Source must reference @main branch after merge, got: {source!r}" def test_no_graph_store_in_config(self): """Thin forwarder has no graph_store config (moved to server).""" - data = _load_behavior() - config = _ci_hook(data).get("config", {}) + data = _load_logging_behavior() + config = data["hooks"][0].get("config", {}) assert "graph_store" not in config, "graph_store must be removed from thin-forwarder config" assert "enable_graph" not in config, ( "enable_graph must be removed from thin-forwarder config" diff --git a/modules/hook-context-intelligence/tests/test_config_resolver.py b/modules/hook-context-intelligence/tests/test_config_resolver.py index ee9c47cf..324059f6 100644 --- a/modules/hook-context-intelligence/tests/test_config_resolver.py +++ b/modules/hook-context-intelligence/tests/test_config_resolver.py @@ -1,11 +1,12 @@ -"""Tests for ConfigResolver resolution chains.""" +"""Tests for HookConfigResolver resolution chains.""" +import fnmatch from pathlib import Path from unittest.mock import MagicMock -import fnmatch - -from amplifier_module_hook_context_intelligence.config_resolver import ConfigResolver +from amplifier_module_hook_context_intelligence.config_resolver import ( # type: ignore[attr-defined] + HookConfigResolver, +) from amplifier_module_hook_context_intelligence.config_resolver import _slugify_path @@ -35,35 +36,39 @@ class TestBasePathResolution: def test_config_value_wins(self) -> None: """Explicit hook config base_path wins over coordinator config.""" coordinator = _make_coordinator(config={"base_path": "/coordinator/path"}) - resolver = ConfigResolver(config={"base_path": "/explicit/path"}, coordinator=coordinator) + resolver = HookConfigResolver( + config={"base_path": "/explicit/path"}, coordinator=coordinator + ) assert resolver.base_path == Path("/explicit/path") def test_coordinator_fallback_when_config_absent(self) -> None: """When config has no base_path, falls back to coordinator.config.""" coordinator = _make_coordinator(config={"base_path": "/coordinator/path"}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert resolver.base_path == Path("/coordinator/path") def test_default_when_both_absent(self) -> None: """When both config and coordinator lack base_path, uses default.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert resolver.base_path == Path("~/.amplifier/projects").expanduser() def test_tilde_expanded(self) -> None: """Tilde in base_path is expanded (no '~' in string result).""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={"base_path": "~/custom/path"}, coordinator=coordinator) + resolver = HookConfigResolver( + config={"base_path": "~/custom/path"}, coordinator=coordinator + ) assert "~" not in str(resolver.base_path) def test_cached_after_first_access(self) -> None: """base_path returns the same object on repeated access (cached).""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) first = resolver.base_path second = resolver.base_path @@ -73,44 +78,73 @@ def test_cached_after_first_access(self) -> None: def test_coordinator_without_config_attr_falls_back_to_default(self) -> None: """Coordinator without .config attribute safely falls back to default.""" bare = _make_bare_coordinator() - resolver = ConfigResolver(config={}, coordinator=bare) + resolver = HookConfigResolver(config={}, coordinator=bare) assert resolver.base_path == Path("~/.amplifier/projects").expanduser() def test_returns_path_type(self) -> None: """base_path always returns a Path instance.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={"base_path": "/some/path"}, coordinator=coordinator) + resolver = HookConfigResolver(config={"base_path": "/some/path"}, coordinator=coordinator) assert isinstance(resolver.base_path, Path) + def test_env_var_used_when_config_and_coordinator_absent(self, monkeypatch) -> None: + """AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH env var is used when config and coordinator both lack base_path.""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH", "/from/env") + resolver = HookConfigResolver(config={}, coordinator=_make_coordinator(config={})) + + assert resolver.base_path == Path("/from/env") + + def test_env_var_does_not_override_config_dict(self, monkeypatch) -> None: + """Config dict base_path wins over AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH env var.""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH", "/from/env") + resolver = HookConfigResolver( + config={"base_path": "/from/config"}, + coordinator=_make_coordinator(config={}), + ) + + assert resolver.base_path == Path("/from/config") + + def test_env_var_does_not_override_coordinator_config(self, monkeypatch) -> None: + """Coordinator config base_path wins over AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH env var.""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH", "/from/env") + resolver = HookConfigResolver( + config={}, + coordinator=_make_coordinator(config={"base_path": "/from/coordinator"}), + ) + + assert resolver.base_path == Path("/from/coordinator") + class TestProjectSlugResolution: def test_config_value_wins(self) -> None: """Explicit hook config project_slug wins over coordinator config.""" coordinator = _make_coordinator(config={"project_slug": "from-coordinator"}) - resolver = ConfigResolver(config={"project_slug": "from-config"}, coordinator=coordinator) + resolver = HookConfigResolver( + config={"project_slug": "from-config"}, coordinator=coordinator + ) assert resolver.project_slug == "from-config" def test_coordinator_fallback_when_config_absent(self) -> None: """When config has no project_slug, falls back to coordinator.config.""" coordinator = _make_coordinator(config={"project_slug": "from-coordinator"}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert resolver.project_slug == "from-coordinator" def test_default_when_both_absent(self) -> None: """When both config and coordinator lack project_slug, uses 'default'.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert resolver.project_slug == "default" def test_cached_after_first_access(self) -> None: """project_slug returns the same object on repeated access (cached).""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) first = resolver.project_slug second = resolver.project_slug @@ -120,14 +154,16 @@ def test_cached_after_first_access(self) -> None: def test_coordinator_without_config_attr_falls_back_to_default(self) -> None: """Coordinator without .config attribute safely falls back to 'default'.""" bare = _make_bare_coordinator() - resolver = ConfigResolver(config={}, coordinator=bare) + resolver = HookConfigResolver(config={}, coordinator=bare) assert resolver.project_slug == "default" def test_returns_str_type(self) -> None: """project_slug always returns a str instance.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={"project_slug": "my-project"}, coordinator=coordinator) + resolver = HookConfigResolver( + config={"project_slug": "my-project"}, coordinator=coordinator + ) assert isinstance(resolver.project_slug, str) @@ -143,48 +179,48 @@ class TestWorkspaceResolution: def test_hook_config_wins_over_coordinator_config(self) -> None: """config['workspace'] has highest priority — overrides coordinator.config.""" coordinator = _make_coordinator(config={"workspace": "from-coordinator"}) - resolver = ConfigResolver(config={"workspace": "from-hook"}, coordinator=coordinator) + resolver = HookConfigResolver(config={"workspace": "from-hook"}, coordinator=coordinator) assert resolver.workspace == "from-hook" def test_coordinator_config_fallback_when_hook_config_absent(self) -> None: """coordinator.config['workspace'] is used when config has no workspace.""" coordinator = _make_coordinator(config={"workspace": "from-coordinator"}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert resolver.workspace == "from-coordinator" def test_hook_config_wins_over_project_slug(self) -> None: """config['workspace'] wins when coordinator has no workspace.""" coordinator = _make_coordinator(config={"project_slug": "proj-slug"}) - resolver = ConfigResolver(config={"workspace": "from-hook"}, coordinator=coordinator) + resolver = HookConfigResolver(config={"workspace": "from-hook"}, coordinator=coordinator) assert resolver.workspace == "from-hook" def test_falls_back_to_project_slug(self) -> None: """When both coordinator.config and config lack workspace, falls back to project_slug.""" coordinator = _make_coordinator(config={"project_slug": "slug-fallback"}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert resolver.workspace == "slug-fallback" def test_defaults_to_default_when_all_absent(self) -> None: """When all workspace sources are absent, resolves to 'default'.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert resolver.workspace == "default" def test_returns_str_type(self) -> None: """workspace always returns a str.""" coordinator = _make_coordinator(config={"workspace": "my-ws"}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert isinstance(resolver.workspace, str) def test_coordinator_none_falls_back_to_config(self) -> None: """When coordinator is None, falls back to config['workspace'].""" - resolver = ConfigResolver(config={"workspace": "from-config"}, coordinator=None) + resolver = HookConfigResolver(config={"workspace": "from-config"}, coordinator=None) assert resolver.workspace == "from-config" @@ -194,19 +230,20 @@ class TestContextIntelligenceServerUrl: def test_returns_none_when_absent(self, monkeypatch, tmp_path) -> None: """Returns None when context_intelligence_server_url not in config.""" + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", raising=False) monkeypatch.setattr( "amplifier_module_hook_context_intelligence.config_resolver.SETTINGS_PATH", tmp_path / "nonexistent.yaml", ) coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert resolver.context_intelligence_server_url is None def test_returns_string_when_set(self) -> None: """Returns the URL string when configured.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver( + resolver = HookConfigResolver( config={"context_intelligence_server_url": "http://localhost:8000"}, coordinator=coordinator, ) @@ -215,12 +252,13 @@ def test_returns_string_when_set(self) -> None: def test_returns_none_for_empty_string(self, monkeypatch, tmp_path) -> None: """Returns None when value is an empty string (falsy).""" + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", raising=False) monkeypatch.setattr( "amplifier_module_hook_context_intelligence.config_resolver.SETTINGS_PATH", tmp_path / "nonexistent.yaml", ) coordinator = _make_coordinator(config={}) - resolver = ConfigResolver( + resolver = HookConfigResolver( config={"context_intelligence_server_url": ""}, coordinator=coordinator, ) @@ -238,7 +276,7 @@ def test_defaults_to_stream_delta_glob(self) -> None: by shared code or import — the two hooks must remain decoupled. """ coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert resolver.exclude_events == {"llm:stream_*delta"} @@ -250,7 +288,7 @@ def test_explicit_empty_list_disables_filter(self) -> None: is intentional: unset uses the default; [] means the operator wants everything. """ coordinator = _make_coordinator(config={}) - resolver = ConfigResolver( + resolver = HookConfigResolver( config={"exclude_events": []}, coordinator=coordinator, ) @@ -260,14 +298,14 @@ def test_explicit_empty_list_disables_filter(self) -> None: def test_stream_block_delta_excluded_by_default(self) -> None: """llm:stream_block_delta is matched by the default glob — treated as excluded.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert any(fnmatch.fnmatch("llm:stream_block_delta", p) for p in resolver.exclude_events) def test_stream_block_start_not_excluded_by_default(self) -> None: """llm:stream_block_start is NOT matched by the default glob — structural event spared.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert not any( fnmatch.fnmatch("llm:stream_block_start", p) for p in resolver.exclude_events @@ -276,21 +314,21 @@ def test_stream_block_start_not_excluded_by_default(self) -> None: def test_stream_block_end_not_excluded_by_default(self) -> None: """llm:stream_block_end is NOT matched by the default glob — structural event spared.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert not any(fnmatch.fnmatch("llm:stream_block_end", p) for p in resolver.exclude_events) def test_stream_aborted_not_excluded_by_default(self) -> None: """llm:stream_aborted is NOT matched by the default glob — structural event spared.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert not any(fnmatch.fnmatch("llm:stream_aborted", p) for p in resolver.exclude_events) def test_ordinary_event_not_excluded_by_default(self) -> None: """Ordinary events like llm:response are NOT matched by the default glob.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert not any(fnmatch.fnmatch("llm:response", p) for p in resolver.exclude_events) @@ -303,7 +341,7 @@ def test_glob_spares_structural_streaming_events(self) -> None: llm:stream_aborted -> no match (passes through) """ coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) patterns = resolver.exclude_events def is_excluded(event: str) -> bool: @@ -317,7 +355,7 @@ def is_excluded(event: str) -> bool: def test_returns_set_from_list(self) -> None: """exclude_events converts a list from config to a set.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver( + resolver = HookConfigResolver( config={"exclude_events": ["event_a", "event_b"]}, coordinator=coordinator, ) @@ -327,7 +365,7 @@ def test_returns_set_from_list(self) -> None: def test_returns_frozenset_type(self) -> None: """exclude_events always returns a frozenset instance (cached, immutable).""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver( + resolver = HookConfigResolver( config={"exclude_events": ["event_a"]}, coordinator=coordinator, ) @@ -337,7 +375,7 @@ def test_returns_frozenset_type(self) -> None: def test_cached_after_first_access(self) -> None: """exclude_events returns the same object on repeated access (cached).""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver( + resolver = HookConfigResolver( config={"exclude_events": ["event_a", "event_b"]}, coordinator=coordinator, ) @@ -352,14 +390,14 @@ class TestLogLevel: def test_defaults_to_warning(self) -> None: """log_level returns 'WARNING' when not set in config.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert resolver.log_level == "WARNING" def test_explicit_value_works(self) -> None: """log_level returns the explicitly configured value.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={"log_level": "DEBUG"}, coordinator=coordinator) + resolver = HookConfigResolver(config={"log_level": "DEBUG"}, coordinator=coordinator) assert resolver.log_level == "DEBUG" @@ -368,7 +406,7 @@ class TestSessionDir: def test_composes_correct_path_from_explicit_values(self) -> None: """session_dir composes base_path / project_slug / sessions / session_id / context-intelligence.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver( + resolver = HookConfigResolver( config={"base_path": "/my/base", "project_slug": "my-project"}, coordinator=coordinator, ) @@ -380,7 +418,7 @@ def test_composes_correct_path_from_explicit_values(self) -> None: def test_uses_resolved_defaults(self) -> None: """session_dir uses default base_path and project_slug when not configured.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) result = resolver.session_dir("xyz") expected = ( @@ -396,7 +434,7 @@ def test_uses_resolved_defaults(self) -> None: def test_returns_path_type(self) -> None: """session_dir returns a Path instance.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver( + resolver = HookConfigResolver( config={"base_path": "/base", "project_slug": "proj"}, coordinator=coordinator, ) @@ -408,7 +446,7 @@ def test_uses_coordinator_values_in_path_composition(self) -> None: coordinator = _make_coordinator( config={"base_path": "/coord/base", "project_slug": "coord-project"} ) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) result = resolver.session_dir("sess-42") @@ -421,7 +459,7 @@ class TestBlobStoreRoot: def test_blob_store_root_returns_path(self) -> None: """blob_store_root is base_path / project_slug / 'sessions'.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver( + resolver = HookConfigResolver( config={"base_path": "/tmp/test-projects", "project_slug": "my-project"}, coordinator=coordinator, ) @@ -431,7 +469,7 @@ def test_blob_store_root_returns_path(self) -> None: def test_blob_store_root_uses_default_base_path(self) -> None: """blob_store_root works with default base_path.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={"project_slug": "default"}, coordinator=coordinator) + resolver = HookConfigResolver(config={"project_slug": "default"}, coordinator=coordinator) result = resolver.blob_store_root expected = Path("~/.amplifier/projects").expanduser() / "default" / "sessions" assert result == expected @@ -442,17 +480,18 @@ class TestContextIntelligenceApiKey: def test_returns_none_when_not_configured(self, monkeypatch, tmp_path) -> None: """Returns None when context_intelligence_api_key not in config.""" + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY", raising=False) monkeypatch.setattr( "amplifier_module_hook_context_intelligence.config_resolver.SETTINGS_PATH", tmp_path / "nonexistent.yaml", ) - resolver = ConfigResolver(config={}, coordinator=_make_coordinator(config={})) + resolver = HookConfigResolver(config={}, coordinator=_make_coordinator(config={})) assert resolver.context_intelligence_api_key is None def test_returns_string_when_configured(self) -> None: """Returns the API key string when configured.""" - resolver = ConfigResolver( + resolver = HookConfigResolver( config={"context_intelligence_api_key": "my-secret-key"}, coordinator=_make_coordinator(config={}), ) @@ -461,11 +500,12 @@ def test_returns_string_when_configured(self) -> None: def test_returns_none_for_empty_string(self, monkeypatch, tmp_path) -> None: """Returns None when value is an empty string (falsy).""" + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY", raising=False) monkeypatch.setattr( "amplifier_module_hook_context_intelligence.config_resolver.SETTINGS_PATH", tmp_path / "nonexistent.yaml", ) - resolver = ConfigResolver( + resolver = HookConfigResolver( config={"context_intelligence_api_key": ""}, coordinator=_make_coordinator(config={}), ) @@ -474,7 +514,7 @@ def test_returns_none_for_empty_string(self, monkeypatch, tmp_path) -> None: def test_coerces_non_string_to_string(self) -> None: """Coerces non-string values to str.""" - resolver = ConfigResolver( + resolver = HookConfigResolver( config={"context_intelligence_api_key": 12345}, coordinator=_make_coordinator(config={}), ) @@ -487,21 +527,21 @@ class TestDispatchTimeout: def test_defaults_to_10(self) -> None: """dispatch_timeout returns 10.0 when not configured.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert resolver.dispatch_timeout == 10.0 def test_reads_from_config(self) -> None: """dispatch_timeout returns the configured value as a float.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={"dispatch_timeout": 10}, coordinator=coordinator) + resolver = HookConfigResolver(config={"dispatch_timeout": 10}, coordinator=coordinator) assert resolver.dispatch_timeout == 10.0 def test_returns_float_type(self) -> None: """dispatch_timeout always returns a float even when config value is a string.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={"dispatch_timeout": "45"}, coordinator=coordinator) + resolver = HookConfigResolver(config={"dispatch_timeout": "45"}, coordinator=coordinator) assert isinstance(resolver.dispatch_timeout, float) assert resolver.dispatch_timeout == 45.0 @@ -511,21 +551,23 @@ class TestDispatchFailureThreshold: def test_defaults_to_3(self) -> None: """dispatch_failure_threshold returns 3 when not configured.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert resolver.dispatch_failure_threshold == 3 def test_reads_from_config(self) -> None: """dispatch_failure_threshold returns the configured value as an int.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={"dispatch_failure_threshold": 5}, coordinator=coordinator) + resolver = HookConfigResolver( + config={"dispatch_failure_threshold": 5}, coordinator=coordinator + ) assert resolver.dispatch_failure_threshold == 5 def test_returns_int_type(self) -> None: """dispatch_failure_threshold always returns an int even when config value is a string.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver( + resolver = HookConfigResolver( config={"dispatch_failure_threshold": "7"}, coordinator=coordinator ) @@ -537,14 +579,16 @@ class TestDispatchQueueCapacity: def test_defaults_to_256(self) -> None: """dispatch_queue_capacity returns 256 when not configured.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert resolver.dispatch_queue_capacity == 256 def test_reads_from_config(self) -> None: """dispatch_queue_capacity returns the configured value as an int.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={"dispatch_queue_capacity": 64}, coordinator=coordinator) + resolver = HookConfigResolver( + config={"dispatch_queue_capacity": 64}, coordinator=coordinator + ) assert resolver.dispatch_queue_capacity == 64 @@ -553,14 +597,16 @@ class TestCloseDrainTimeout: def test_defaults_to_half_second(self) -> None: """close_drain_timeout returns 0.5 when not configured.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert resolver.close_drain_timeout == 0.5 def test_reads_from_config(self) -> None: """close_drain_timeout returns the configured value as a float.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={"close_drain_timeout": "1.25"}, coordinator=coordinator) + resolver = HookConfigResolver( + config={"close_drain_timeout": "1.25"}, coordinator=coordinator + ) assert resolver.close_drain_timeout == 1.25 @@ -569,14 +615,14 @@ class TestAdditionalEvents: def test_defaults_to_empty_set(self) -> None: """additional_events returns an empty frozenset when not set in config.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver(config={}, coordinator=coordinator) + resolver = HookConfigResolver(config={}, coordinator=coordinator) assert resolver.additional_events == frozenset() def test_returns_frozenset_from_list(self) -> None: """additional_events converts a list from config to a frozenset.""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver( + resolver = HookConfigResolver( config={"additional_events": ["delegate:agent_spawned", "delegate:agent_completed"]}, coordinator=coordinator, ) @@ -589,7 +635,7 @@ def test_returns_frozenset_from_list(self) -> None: def test_cached_after_first_access(self) -> None: """additional_events returns the same object on repeated access (cached).""" coordinator = _make_coordinator(config={}) - resolver = ConfigResolver( + resolver = HookConfigResolver( config={"additional_events": ["delegate:agent_spawned"]}, coordinator=coordinator, ) @@ -619,7 +665,7 @@ def test_server_url_falls_back_to_settings_yaml(self, monkeypatch, tmp_path): settings_file, ) - resolver = ConfigResolver(config={}, coordinator=_make_coordinator(config={})) + resolver = HookConfigResolver(config={}, coordinator=_make_coordinator(config={})) assert resolver.context_intelligence_server_url == "http://from-settings-yaml" @@ -640,7 +686,7 @@ def test_env_var_wins_over_settings_yaml(self, monkeypatch, tmp_path): settings_file, ) - resolver = ConfigResolver(config={}, coordinator=_make_coordinator(config={})) + resolver = HookConfigResolver(config={}, coordinator=_make_coordinator(config={})) assert resolver.context_intelligence_server_url == "http://from-env" @@ -661,7 +707,7 @@ def test_api_key_falls_back_to_settings_yaml(self, monkeypatch, tmp_path): settings_file, ) - resolver = ConfigResolver(config={}, coordinator=_make_coordinator(config={})) + resolver = HookConfigResolver(config={}, coordinator=_make_coordinator(config={})) assert resolver.context_intelligence_api_key == "sk-from-settings-yaml" @@ -672,7 +718,7 @@ def test_settings_yaml_returns_none_when_file_missing(self, monkeypatch, tmp_pat "amplifier_module_hook_context_intelligence.config_resolver.SETTINGS_PATH", tmp_path / "nonexistent.yaml", ) - resolver = ConfigResolver(config={}, coordinator=_make_coordinator(config={})) + resolver = HookConfigResolver(config={}, coordinator=_make_coordinator(config={})) assert resolver.context_intelligence_server_url is None @@ -686,33 +732,33 @@ class TestParentId: """ def test_parent_id_from_config(self) -> None: - """ConfigResolver.parent_id reads from hook config['parent_id'].""" - cr = ConfigResolver({"parent_id": "parent-abc-123"}, _make_coordinator()) + """HookConfigResolver.parent_id reads from hook config['parent_id'].""" + cr = HookConfigResolver({"parent_id": "parent-abc-123"}, _make_coordinator()) assert cr.parent_id == "parent-abc-123" def test_parent_id_empty_when_absent(self) -> None: - """ConfigResolver.parent_id returns empty string when config has no parent_id key.""" - cr = ConfigResolver({}, _make_coordinator()) + """HookConfigResolver.parent_id returns empty string when config has no parent_id key.""" + cr = HookConfigResolver({}, _make_coordinator()) assert cr.parent_id == "" def test_parent_id_empty_when_none(self) -> None: - """ConfigResolver.parent_id returns empty string when config has parent_id=None.""" - cr = ConfigResolver({"parent_id": None}, _make_coordinator()) + """HookConfigResolver.parent_id returns empty string when config has parent_id=None.""" + cr = HookConfigResolver({"parent_id": None}, _make_coordinator()) assert cr.parent_id == "" def test_parent_id_returns_str_type(self) -> None: - """ConfigResolver.parent_id always returns a str.""" - cr = ConfigResolver({"parent_id": "abc"}, _make_coordinator()) + """HookConfigResolver.parent_id always returns a str.""" + cr = HookConfigResolver({"parent_id": "abc"}, _make_coordinator()) assert isinstance(cr.parent_id, str) def test_no_coordinator_fallback(self) -> None: - """ConfigResolver.parent_id does NOT fall back to coordinator.config. + """HookConfigResolver.parent_id does NOT fall back to coordinator.config. parent_id is a per-session value stamped by the resolver; it must not bleed from a coordinator-level config that spans multiple sessions. """ coordinator = _make_coordinator(config={"parent_id": "from-coordinator"}) - cr = ConfigResolver({}, coordinator) + cr = HookConfigResolver({}, coordinator) assert cr.parent_id == "" @@ -723,23 +769,23 @@ class TestResolveInstanceId: """ def test_resolve_instance_id_from_config(self) -> None: - """ConfigResolver.resolve_instance_id reads from hook config['resolve_instance_id'].""" - cr = ConfigResolver({"resolve_instance_id": "abc-def-123"}, _make_coordinator()) + """HookConfigResolver.resolve_instance_id reads from hook config['resolve_instance_id'].""" + cr = HookConfigResolver({"resolve_instance_id": "abc-def-123"}, _make_coordinator()) assert cr.resolve_instance_id == "abc-def-123" def test_resolve_instance_id_empty_when_absent(self) -> None: - """ConfigResolver.resolve_instance_id returns empty string when absent.""" - cr = ConfigResolver({}, _make_coordinator()) + """HookConfigResolver.resolve_instance_id returns empty string when absent.""" + cr = HookConfigResolver({}, _make_coordinator()) assert cr.resolve_instance_id == "" def test_resolve_instance_id_empty_when_none(self) -> None: - """ConfigResolver.resolve_instance_id returns empty string when config has None.""" - cr = ConfigResolver({"resolve_instance_id": None}, _make_coordinator()) + """HookConfigResolver.resolve_instance_id returns empty string when config has None.""" + cr = HookConfigResolver({"resolve_instance_id": None}, _make_coordinator()) assert cr.resolve_instance_id == "" def test_resolve_instance_id_returns_str_type(self) -> None: - """ConfigResolver.resolve_instance_id always returns a str.""" - cr = ConfigResolver({"resolve_instance_id": "xyz"}, _make_coordinator()) + """HookConfigResolver.resolve_instance_id always returns a str.""" + cr = HookConfigResolver({"resolve_instance_id": "xyz"}, _make_coordinator()) assert isinstance(cr.resolve_instance_id, str) diff --git a/modules/hook-context-intelligence/tests/test_module_loading.py b/modules/hook-context-intelligence/tests/test_module_loading.py index 18619a80..81d348c1 100644 --- a/modules/hook-context-intelligence/tests/test_module_loading.py +++ b/modules/hook-context-intelligence/tests/test_module_loading.py @@ -62,7 +62,9 @@ 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" + # The hook now lives in the dedicated logging behavior (composed by the + # full context-intelligence behavior). Hook-shape assertions read it here. + path = REPO_ROOT / "behaviors" / "context-intelligence-logging.yaml" return yaml.safe_load(path.read_text()) def test_behavior_yaml_module_matches_entry_point(self): @@ -134,7 +136,9 @@ 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" + # The hook now lives in the dedicated logging behavior (composed by the + # full context-intelligence behavior). Hook-shape assertions read it here. + path = REPO_ROOT / "behaviors" / "context-intelligence-logging.yaml" return yaml.safe_load(path.read_text()) def _ci_hook(self, data: dict) -> dict: diff --git a/modules/hook-context-intelligence/tests/test_mount.py b/modules/hook-context-intelligence/tests/test_mount.py index 5f53e177..96f9c795 100644 --- a/modules/hook-context-intelligence/tests/test_mount.py +++ b/modules/hook-context-intelligence/tests/test_mount.py @@ -56,17 +56,28 @@ async def test_mount_returns_cleanup_callable(): async def test_mount_registers_hook_state_capability(): """mount() must register _hook_state containing logging_handler, unregister_fns, resolver.""" + import os from unittest.mock import patch from amplifier_module_hook_context_intelligence import mount from amplifier_module_hook_context_intelligence.handlers.logging_handler import LoggingHandler coordinator = _make_coordinator() - # Isolate from local ~/.amplifier/settings.yaml — prevents live server detection from - # populating unregister_fns with skill-fetcher handlers before on_session_ready(). - with patch( - "amplifier_module_hook_context_intelligence.config_resolver._parse_settings_yaml", - return_value={}, + # Isolate from local ~/.amplifier/settings.yaml AND from CI env vars — both prevent live + # server detection from populating unregister_fns with skill-fetcher handlers before + # on_session_ready(). + with ( + patch( + "amplifier_module_hook_context_intelligence.config_resolver._parse_settings_yaml", + return_value={}, + ), + patch.dict( + os.environ, + { + "AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL": "", + "AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY": "", + }, + ), ): await mount(coordinator, config={}) diff --git a/modules/hook-context-intelligence/tests/test_mount_dispatcher.py b/modules/hook-context-intelligence/tests/test_mount_dispatcher.py index af765bf5..d601b2dc 100644 --- a/modules/hook-context-intelligence/tests/test_mount_dispatcher.py +++ b/modules/hook-context-intelligence/tests/test_mount_dispatcher.py @@ -326,22 +326,24 @@ def test_mount_signature_has_coordinator_and_config(self) -> None: # TestCapabilityRegistration # --------------------------------------------------------------------------- class TestCapabilityRegistration: - """Hook registers ConfigResolver as a coordinator capability.""" + """Hook registers HookConfigResolver as a coordinator capability.""" async def test_config_resolver_capability_registered_on_mount(self) -> None: - """mount() registers the config_resolver capability with a ConfigResolver instance.""" + """mount() registers the config_resolver capability with a HookConfigResolver instance.""" from amplifier_module_hook_context_intelligence import mount - from amplifier_module_hook_context_intelligence.config_resolver import ConfigResolver + from amplifier_module_hook_context_intelligence.config_resolver import HookConfigResolver coordinator = _make_coordinator() await mount(coordinator, config={}) reg_calls = coordinator.register_capability.call_args_list - cap_calls = [c for c in reg_calls if c.args[0] == "context_intelligence.config_resolver"] + cap_calls = [ + c for c in reg_calls if c.args[0] == "context_intelligence.hook_config_resolver" + ] assert len(cap_calls) == 1, ( - "register_capability should be called once with 'context_intelligence.config_resolver'" + "register_capability should be called once with 'context_intelligence.hook_config_resolver'" ) - assert isinstance(cap_calls[0].args[1], ConfigResolver) + assert isinstance(cap_calls[0].args[1], HookConfigResolver) async def test_hook_state_capability_registered_on_mount(self) -> None: """mount() registers the _hook_state capability as a dict with required keys.""" @@ -375,5 +377,5 @@ async def test_cleanup_vacates_both_capabilities(self) -> None: null_calls: dict[str, Any] = { c.args[0]: c.args[1] for c in coordinator.register_capability.call_args_list } - assert null_calls["context_intelligence.config_resolver"] is None + assert null_calls["context_intelligence.hook_config_resolver"] is None assert null_calls["context_intelligence._hook_state"] is None 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 6d9a30be..00000000 --- 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_cleanup.py b/modules/hook-context-intelligence/tests/test_skill_cleanup.py new file mode 100644 index 00000000..ce63ec27 --- /dev/null +++ b/modules/hook-context-intelligence/tests/test_skill_cleanup.py @@ -0,0 +1,70 @@ +"""Assertions that skill-fetch code has been fully stripped from the hook module. + +Tests verify: +- The skill_fetcher sub-module is no longer importable. +- mount() registers no skill-related event handlers (skills:discovered, skill:unloaded). +- mount() still registers the context_intelligence.hook_config_resolver capability. +""" + +from __future__ import annotations + +import importlib +from unittest.mock import AsyncMock, MagicMock + +import pytest + +_HookCalls = list[tuple[str, object, dict[str, object]]] + + +def _capture_register() -> tuple[MagicMock, _HookCalls]: + """Return a (register_mock, calls) pair. + + The mock appends (event, handler, dict(kwargs)) to *calls* on each + invocation and returns a fresh MagicMock() as the unregister handle. + """ + calls: _HookCalls = [] + + def _side_effect(event: str, handler: object, **kwargs: object) -> MagicMock: + calls.append((event, handler, dict(kwargs))) + return MagicMock() + + return MagicMock(side_effect=_side_effect), calls + + +class TestSkillFetcherModuleGone: + def test_skill_fetcher_module_is_unimportable(self) -> None: + with pytest.raises(ModuleNotFoundError): + importlib.import_module("amplifier_module_hook_context_intelligence.skill_fetcher") + + +class TestMountRegistersNoSkillHandlers: + async def test_mount_registers_no_skill_event_handlers(self) -> None: + from amplifier_module_hook_context_intelligence import mount + + coordinator = MagicMock() + coordinator.config = {} + coordinator.collect_contributions = AsyncMock(return_value=[]) + coordinator.register_capability = MagicMock() + register, calls = _capture_register() + coordinator.hooks.register = register + + await mount(coordinator, config={}) + + registered_events = {evt for evt, _h, _k in calls} + assert "skills:discovered" not in registered_events + assert "skill:unloaded" not in registered_events + + async def test_mount_still_registers_hook_config_resolver_capability(self) -> None: + from amplifier_module_hook_context_intelligence import mount + + coordinator = MagicMock() + coordinator.config = {} + coordinator.collect_contributions = AsyncMock(return_value=[]) + coordinator.register_capability = MagicMock() + register, calls = _capture_register() + coordinator.hooks.register = register + + await mount(coordinator, config={}) + + cap_names = [c.args[0] for c in coordinator.register_capability.call_args_list] + assert "context_intelligence.hook_config_resolver" in cap_names 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 eb8771ca..00000000 --- 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 7d48b202..00000000 --- a/modules/hook-context-intelligence/tests/test_skill_fetcher_mount.py +++ /dev/null @@ -1,785 +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"}, - ) - - # 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"}, - ) - - # 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.config_resolver.SETTINGS_PATH", - tmp_path / "no-settings.yaml", - ), - 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"}, - ) - - # 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.config_resolver.SETTINGS_PATH", - tmp_path / "no-settings.yaml", - ), - 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"}, - ) - - # 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"}, - ) - - # 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"}, - ) - - # 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) - with patch( - "amplifier_module_hook_context_intelligence.config_resolver.SETTINGS_PATH", - tmp_path / "no-settings.yaml", - ): - 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) - - with patch( - "amplifier_module_hook_context_intelligence.config_resolver.SETTINGS_PATH", - tmp_path / "no-settings.yaml", - ): - 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"}, - ) - - 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"}, - ) - - # 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"}, - ) - - # 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"}, - ) - - # 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"}, - ) - - # 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"}, - ) - - # 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"}, - ) - - 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_tool_resolver.py b/modules/hook-context-intelligence/tests/test_tool_resolver.py new file mode 100644 index 00000000..2a60336c --- /dev/null +++ b/modules/hook-context-intelligence/tests/test_tool_resolver.py @@ -0,0 +1,521 @@ +"""Tests for ToolConfigResolver resolution chains. + +ToolConfigResolver is the analytics-only resolver for CI tools when the hook is +NOT mounted. It shares the same priority chain as HookConfigResolver for the +three keys tools actually use (server_url, api_key, workspace), with one +deliberate difference: workspace does NOT fall back to project_slug because +there is no live capture session to derive it from. +""" + +from unittest.mock import MagicMock + +from context_intelligence.tool_resolver import ToolConfigResolver + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_coordinator(config: dict | None = None) -> MagicMock: + """Build a MagicMock coordinator with a .config dict attribute.""" + coordinator = MagicMock() + coordinator.config = config if config is not None else {} + return coordinator + + +def _make_bare_coordinator() -> object: + """Return a plain object without a .config attribute.""" + return object() + + +# --------------------------------------------------------------------------- +# TestToolConfigResolverServerUrl +# --------------------------------------------------------------------------- + + +class TestToolConfigResolverServerUrl: + """context_intelligence_server_url: config dict → coordinator → env var → settings.yaml.""" + + def test_config_dict_wins_over_coordinator(self) -> None: + """Mount-time config dict has highest priority over coordinator.config.""" + coordinator = _make_coordinator( + config={"context_intelligence_server_url": "http://from-coordinator"} + ) + resolver = ToolConfigResolver( + config={"context_intelligence_server_url": "http://from-config"}, + coordinator=coordinator, + ) + assert resolver.context_intelligence_server_url == "http://from-config" + + def test_coordinator_fallback_when_config_absent(self) -> None: + """Falls back to coordinator.config when config dict has no server URL.""" + coordinator = _make_coordinator( + config={"context_intelligence_server_url": "http://from-coordinator"} + ) + resolver = ToolConfigResolver(config={}, coordinator=coordinator) + assert resolver.context_intelligence_server_url == "http://from-coordinator" + + def test_env_var_fallback_when_config_and_coordinator_absent(self, monkeypatch) -> None: + """AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL env var is the third priority.""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", "http://from-env") + resolver = ToolConfigResolver(config={}, coordinator=_make_coordinator(config={})) + assert resolver.context_intelligence_server_url == "http://from-env" + + def test_config_wins_over_env_var(self, monkeypatch) -> None: + """Config dict wins over env var.""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", "http://from-env") + resolver = ToolConfigResolver( + config={"context_intelligence_server_url": "http://from-config"}, + coordinator=_make_coordinator(config={}), + ) + assert resolver.context_intelligence_server_url == "http://from-config" + + def test_coordinator_wins_over_env_var(self, monkeypatch) -> None: + """Coordinator config wins over env var.""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", "http://from-env") + resolver = ToolConfigResolver( + config={}, + coordinator=_make_coordinator( + config={"context_intelligence_server_url": "http://from-coordinator"} + ), + ) + assert resolver.context_intelligence_server_url == "http://from-coordinator" + + def test_settings_yaml_fallback(self, monkeypatch, tmp_path) -> None: + """~/.amplifier/settings.yaml is the lowest-priority fallback.""" + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", raising=False) + settings_file = tmp_path / "settings.yaml" + settings_file.write_text( + "overrides:\n" + " hook-context-intelligence:\n" + " config:\n" + " context_intelligence_server_url: http://from-settings-yaml\n" + ) + monkeypatch.setattr("context_intelligence.tool_resolver.SETTINGS_PATH", settings_file) + resolver = ToolConfigResolver(config={}, coordinator=_make_coordinator(config={})) + assert resolver.context_intelligence_server_url == "http://from-settings-yaml" + + def test_env_var_wins_over_settings_yaml(self, monkeypatch, tmp_path) -> None: + """Env var beats settings.yaml.""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", "http://from-env") + settings_file = tmp_path / "settings.yaml" + settings_file.write_text( + "overrides:\n" + " hook-context-intelligence:\n" + " config:\n" + " context_intelligence_server_url: http://from-settings-yaml\n" + ) + monkeypatch.setattr("context_intelligence.tool_resolver.SETTINGS_PATH", settings_file) + resolver = ToolConfigResolver(config={}, coordinator=_make_coordinator(config={})) + assert resolver.context_intelligence_server_url == "http://from-env" + + def test_returns_none_when_all_absent(self, monkeypatch, tmp_path) -> None: + """Returns None when no source has a server URL.""" + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", raising=False) + monkeypatch.setattr( + "context_intelligence.tool_resolver.SETTINGS_PATH", + tmp_path / "nonexistent.yaml", + ) + resolver = ToolConfigResolver(config={}, coordinator=_make_coordinator(config={})) + assert resolver.context_intelligence_server_url is None + + def test_returns_none_for_empty_string(self, monkeypatch, tmp_path) -> None: + """Empty string config value is treated as absent.""" + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", raising=False) + monkeypatch.setattr( + "context_intelligence.tool_resolver.SETTINGS_PATH", + tmp_path / "nonexistent.yaml", + ) + resolver = ToolConfigResolver( + config={"context_intelligence_server_url": ""}, + coordinator=_make_coordinator(config={}), + ) + assert resolver.context_intelligence_server_url is None + + def test_returns_string_type(self) -> None: + """Returns a str when value is present.""" + resolver = ToolConfigResolver( + config={"context_intelligence_server_url": "http://localhost:8000"}, + coordinator=_make_coordinator(config={}), + ) + result = resolver.context_intelligence_server_url + assert isinstance(result, str) + + def test_bare_coordinator_falls_back_to_config(self, monkeypatch, tmp_path) -> None: + """Coordinator without .config attribute safely skips to config dict.""" + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", raising=False) + monkeypatch.setattr( + "context_intelligence.tool_resolver.SETTINGS_PATH", + tmp_path / "nonexistent.yaml", + ) + bare = _make_bare_coordinator() + resolver = ToolConfigResolver( + config={"context_intelligence_server_url": "http://from-config"}, + coordinator=bare, + ) + assert resolver.context_intelligence_server_url == "http://from-config" + + +# --------------------------------------------------------------------------- +# TestToolConfigResolverApiKey +# --------------------------------------------------------------------------- + + +class TestToolConfigResolverApiKey: + """context_intelligence_api_key: config dict → coordinator → env var → settings.yaml.""" + + def test_config_dict_wins_over_coordinator(self) -> None: + """Mount-time config dict has highest priority.""" + coordinator = _make_coordinator( + config={"context_intelligence_api_key": "key-from-coordinator"} + ) + resolver = ToolConfigResolver( + config={"context_intelligence_api_key": "key-from-config"}, + coordinator=coordinator, + ) + assert resolver.context_intelligence_api_key == "key-from-config" + + def test_coordinator_fallback_when_config_absent(self) -> None: + """Falls back to coordinator.config when config dict has no API key.""" + coordinator = _make_coordinator( + config={"context_intelligence_api_key": "key-from-coordinator"} + ) + resolver = ToolConfigResolver(config={}, coordinator=coordinator) + assert resolver.context_intelligence_api_key == "key-from-coordinator" + + def test_env_var_fallback(self, monkeypatch) -> None: + """AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY env var is the third priority.""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY", "key-from-env") + resolver = ToolConfigResolver(config={}, coordinator=_make_coordinator(config={})) + assert resolver.context_intelligence_api_key == "key-from-env" + + def test_settings_yaml_fallback(self, monkeypatch, tmp_path) -> None: + """settings.yaml is lowest-priority fallback for api_key.""" + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY", raising=False) + settings_file = tmp_path / "settings.yaml" + settings_file.write_text( + "overrides:\n" + " hook-context-intelligence:\n" + " config:\n" + " context_intelligence_api_key: sk-from-settings-yaml\n" + ) + monkeypatch.setattr("context_intelligence.tool_resolver.SETTINGS_PATH", settings_file) + resolver = ToolConfigResolver(config={}, coordinator=_make_coordinator(config={})) + assert resolver.context_intelligence_api_key == "sk-from-settings-yaml" + + def test_returns_none_when_all_absent(self, monkeypatch, tmp_path) -> None: + """Returns None when no source has an API key.""" + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY", raising=False) + monkeypatch.setattr( + "context_intelligence.tool_resolver.SETTINGS_PATH", + tmp_path / "nonexistent.yaml", + ) + resolver = ToolConfigResolver(config={}, coordinator=_make_coordinator(config={})) + assert resolver.context_intelligence_api_key is None + + def test_returns_none_for_empty_string(self, monkeypatch, tmp_path) -> None: + """Empty string config value is treated as absent.""" + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY", raising=False) + monkeypatch.setattr( + "context_intelligence.tool_resolver.SETTINGS_PATH", + tmp_path / "nonexistent.yaml", + ) + resolver = ToolConfigResolver( + config={"context_intelligence_api_key": ""}, + coordinator=_make_coordinator(config={}), + ) + assert resolver.context_intelligence_api_key is None + + +# --------------------------------------------------------------------------- +# TestToolConfigResolverWorkspace +# --------------------------------------------------------------------------- + + +class TestToolConfigResolverWorkspace: + """workspace: config dict → coordinator → env var → 'default'. + + Key difference from HookConfigResolver: does NOT fall back to project_slug + (auto-derived from session.working_dir). In analytics-only mode there is + no live capture session to derive it from. + """ + + def test_config_dict_wins_over_coordinator(self) -> None: + """Mount-time config dict has highest priority.""" + coordinator = _make_coordinator(config={"workspace": "from-coordinator"}) + resolver = ToolConfigResolver(config={"workspace": "from-config"}, coordinator=coordinator) + assert resolver.workspace == "from-config" + + def test_coordinator_fallback_when_config_absent(self) -> None: + """Falls back to coordinator.config when config dict has no workspace.""" + coordinator = _make_coordinator(config={"workspace": "from-coordinator"}) + resolver = ToolConfigResolver(config={}, coordinator=coordinator) + assert resolver.workspace == "from-coordinator" + + def test_env_var_fallback(self, monkeypatch) -> None: + """AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE env var is the third priority.""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE", "from-env") + resolver = ToolConfigResolver(config={}, coordinator=_make_coordinator(config={})) + assert resolver.workspace == "from-env" + + def test_config_wins_over_env_var(self, monkeypatch) -> None: + """Config dict wins over env var.""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE", "from-env") + resolver = ToolConfigResolver( + config={"workspace": "from-config"}, coordinator=_make_coordinator(config={}) + ) + assert resolver.workspace == "from-config" + + def test_coordinator_wins_over_env_var(self, monkeypatch) -> None: + """Coordinator config wins over env var.""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE", "from-env") + resolver = ToolConfigResolver( + config={}, + coordinator=_make_coordinator(config={"workspace": "from-coordinator"}), + ) + assert resolver.workspace == "from-coordinator" + + def test_defaults_to_default_when_all_absent(self, monkeypatch) -> None: + """Falls back to 'default' string when no source provides workspace.""" + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE", raising=False) + resolver = ToolConfigResolver(config={}, coordinator=_make_coordinator(config={})) + assert resolver.workspace == "default" + + def test_does_not_use_project_slug_derivation(self, monkeypatch) -> None: + """workspace does NOT auto-derive from session.working_dir in analytics-only mode. + + HookConfigResolver falls back to project_slug (slugified from session.working_dir) + when no workspace is configured. ToolConfigResolver MUST NOT do this — it is + designed for use without an active capture session. The fallback is 'default'. + """ + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE", raising=False) + coordinator = _make_coordinator(config={}) + # Simulate a coordinator that has a working_dir capability (as hook would see) + coordinator.get_capability = MagicMock(return_value="/home/user/myproject") + resolver = ToolConfigResolver(config={}, coordinator=coordinator) + # ToolConfigResolver must ignore get_capability and return 'default' + assert resolver.workspace == "default" + + def test_returns_str_type(self, monkeypatch) -> None: + """workspace always returns a str.""" + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE", raising=False) + resolver = ToolConfigResolver(config={}, coordinator=_make_coordinator(config={})) + assert isinstance(resolver.workspace, str) + + def test_bare_coordinator_falls_back_to_default(self, monkeypatch) -> None: + """Coordinator without .config attribute safely falls back to 'default'.""" + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE", raising=False) + bare = _make_bare_coordinator() + resolver = ToolConfigResolver(config={}, coordinator=bare) + assert resolver.workspace == "default" + + +# --------------------------------------------------------------------------- +# TestToolConfigResolverWorkspaceCaching +# --------------------------------------------------------------------------- + + +class TestToolConfigResolverWorkspaceCaching: + """workspace value is cached after first access.""" + + def test_workspace_cached_after_first_access(self, monkeypatch) -> None: + """workspace returns the same object on repeated access (cached).""" + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE", raising=False) + resolver = ToolConfigResolver( + config={"workspace": "my-workspace"}, coordinator=_make_coordinator(config={}) + ) + first = resolver.workspace + second = resolver.workspace + assert first is second + + def test_workspace_cache_does_not_read_env_twice(self, monkeypatch) -> None: + """Env var is read once; subsequent accesses return the cached value.""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE", "from-env-first") + resolver = ToolConfigResolver(config={}, coordinator=_make_coordinator(config={})) + + first = resolver.workspace + assert first == "from-env-first" + + # Change the env var — cached value must NOT change + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE", "from-env-second") + second = resolver.workspace + assert second == "from-env-first" + + +# --------------------------------------------------------------------------- +# TestToolConfigResolverDuckTyping +# --------------------------------------------------------------------------- + + +class TestToolConfigResolverDuckTyping: + """ToolConfigResolver exposes the same interface the tools depend on. + + Tools access exactly three attributes: context_intelligence_server_url, + context_intelligence_api_key, and workspace. This class verifies they + exist and return the expected types — enabling duck-type compatibility + with HookConfigResolver for the tool-facing contract. + """ + + def test_has_server_url_property(self) -> None: + """context_intelligence_server_url attribute exists and returns str or None.""" + resolver = ToolConfigResolver( + config={"context_intelligence_server_url": "http://x"}, + coordinator=_make_coordinator(), + ) + result = resolver.context_intelligence_server_url + assert result is None or isinstance(result, str) + + def test_has_api_key_property(self) -> None: + """context_intelligence_api_key attribute exists and returns str or None.""" + resolver = ToolConfigResolver( + config={"context_intelligence_api_key": "key"}, + coordinator=_make_coordinator(), + ) + result = resolver.context_intelligence_api_key + assert result is None or isinstance(result, str) + + def test_has_workspace_property(self, monkeypatch) -> None: + """workspace attribute exists and always returns a str.""" + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE", raising=False) + resolver = ToolConfigResolver(config={}, coordinator=_make_coordinator()) + result = resolver.workspace + assert isinstance(result, str) + + def test_interface_is_property_based_not_method_based(self) -> None: + """All three attributes are properties (accessed without calling them).""" + resolver = ToolConfigResolver( + config={"context_intelligence_server_url": "http://x"}, + coordinator=_make_coordinator(), + ) + # Properties: access via attribute, not via call + assert not callable(resolver.context_intelligence_server_url) + # workspace is always a str (not a callable) + assert isinstance(resolver.workspace, str) + + def test_same_keys_as_hook_resolver(self) -> None: + """ToolConfigResolver exposes the three keys that tools read from HookConfigResolver. + + Verifies the duck-type contract: a tool that does + ``resolver.context_intelligence_server_url`` + ``resolver.context_intelligence_api_key`` + ``resolver.workspace`` + will work with EITHER HookConfigResolver or ToolConfigResolver. + """ + from amplifier_module_hook_context_intelligence.config_resolver import HookConfigResolver + + hook_resolver = HookConfigResolver( + config={"context_intelligence_server_url": "http://h"}, + coordinator=_make_coordinator(), + ) + tool_resolver = ToolConfigResolver( + config={"context_intelligence_server_url": "http://t"}, + coordinator=_make_coordinator(), + ) + + for resolver in (hook_resolver, tool_resolver): + assert hasattr(resolver, "context_intelligence_server_url") + assert hasattr(resolver, "context_intelligence_api_key") + assert hasattr(resolver, "workspace") + + +# --------------------------------------------------------------------------- +# TestToolConfigResolverSkillSyncEnabled +# --------------------------------------------------------------------------- + +_SKILL_SYNC_ENV = "AMPLIFIER_CONTEXT_INTELLIGENCE_SKILL_SYNC_ENABLED" + + +class TestToolConfigResolverSkillSyncEnabled: + """skill_sync_enabled: three-state resolution, default True. + + Resolution order (first DEFINITE value wins; empty / placeholder / + unrecognized => absent => fall through): config dict -> coordinator.config + -> AMPLIFIER_CONTEXT_INTELLIGENCE_SKILL_SYNC_ENABLED env -> True. + """ + + def test_default_is_true_when_nothing_set(self, monkeypatch) -> None: + monkeypatch.delenv(_SKILL_SYNC_ENV, raising=False) + resolver = ToolConfigResolver(config={}, coordinator=_make_coordinator()) + assert resolver.skill_sync_enabled is True + + def test_config_real_bool_false_is_not_eaten(self, monkeypatch) -> None: + # Regression guard: a truthy ``or``-chain would silently drop False. + monkeypatch.delenv(_SKILL_SYNC_ENV, raising=False) + resolver = ToolConfigResolver( + config={"skill_sync_enabled": False}, coordinator=_make_coordinator() + ) + assert resolver.skill_sync_enabled is False + + def test_config_real_bool_true(self, monkeypatch) -> None: + monkeypatch.delenv(_SKILL_SYNC_ENV, raising=False) + resolver = ToolConfigResolver( + config={"skill_sync_enabled": True}, coordinator=_make_coordinator() + ) + assert resolver.skill_sync_enabled is True + + def test_config_string_false_forms(self, monkeypatch) -> None: + monkeypatch.delenv(_SKILL_SYNC_ENV, raising=False) + for token in ("false", "False", "FALSE", "0", "no", "off", " off "): + resolver = ToolConfigResolver( + config={"skill_sync_enabled": token}, coordinator=_make_coordinator() + ) + assert resolver.skill_sync_enabled is False, token + + def test_config_string_true_forms(self, monkeypatch) -> None: + monkeypatch.delenv(_SKILL_SYNC_ENV, raising=False) + for token in ("true", "True", "1", "yes", "on", " ON "): + resolver = ToolConfigResolver( + config={"skill_sync_enabled": token}, coordinator=_make_coordinator() + ) + assert resolver.skill_sync_enabled is True, token + + def test_empty_string_resolves_to_default_true_not_false(self, monkeypatch) -> None: + # The critical trap: an unexpanded YAML placeholder that resolves to "" + # must be treated as ABSENT (default True), never as False. + monkeypatch.delenv(_SKILL_SYNC_ENV, raising=False) + for blank in ("", " ", "\t"): + resolver = ToolConfigResolver( + config={"skill_sync_enabled": blank}, coordinator=_make_coordinator() + ) + assert resolver.skill_sync_enabled is True, repr(blank) + + def test_unexpanded_placeholder_with_unset_env_is_default_true(self, monkeypatch) -> None: + monkeypatch.delenv(_SKILL_SYNC_ENV, raising=False) + resolver = ToolConfigResolver( + config={"skill_sync_enabled": "${" + _SKILL_SYNC_ENV + ":}"}, + coordinator=_make_coordinator(), + ) + assert resolver.skill_sync_enabled is True + + def test_placeholder_expands_from_env_to_false(self, monkeypatch) -> None: + monkeypatch.setenv(_SKILL_SYNC_ENV, "false") + resolver = ToolConfigResolver( + config={"skill_sync_enabled": "${" + _SKILL_SYNC_ENV + ":}"}, + coordinator=_make_coordinator(), + ) + assert resolver.skill_sync_enabled is False + + def test_coordinator_fallback_when_config_absent(self, monkeypatch) -> None: + monkeypatch.delenv(_SKILL_SYNC_ENV, raising=False) + coordinator = _make_coordinator(config={"skill_sync_enabled": False}) + resolver = ToolConfigResolver(config={}, coordinator=coordinator) + assert resolver.skill_sync_enabled is False + + def test_env_fallback_when_config_and_coordinator_absent(self, monkeypatch) -> None: + monkeypatch.setenv(_SKILL_SYNC_ENV, "off") + resolver = ToolConfigResolver(config={}, coordinator=_make_coordinator()) + assert resolver.skill_sync_enabled is False + + def test_config_dict_wins_over_coordinator_and_env(self, monkeypatch) -> None: + monkeypatch.setenv(_SKILL_SYNC_ENV, "true") + coordinator = _make_coordinator(config={"skill_sync_enabled": True}) + resolver = ToolConfigResolver(config={"skill_sync_enabled": False}, coordinator=coordinator) + assert resolver.skill_sync_enabled is False + + def test_unrecognized_string_falls_through_to_default_true(self, monkeypatch) -> None: + monkeypatch.delenv(_SKILL_SYNC_ENV, raising=False) + resolver = ToolConfigResolver( + config={"skill_sync_enabled": "maybe"}, coordinator=_make_coordinator() + ) + assert resolver.skill_sync_enabled is True diff --git a/modules/hook-context-intelligence/uv.lock b/modules/hook-context-intelligence/uv.lock index d5ae18ac..845a63b8 100644 --- a/modules/hook-context-intelligence/uv.lock +++ b/modules/hook-context-intelligence/uv.lock @@ -5,12 +5,12 @@ requires-python = ">=3.11" [[package]] name = "amplifier-bundle-context-intelligence" version = "0.1.1" -source = { git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=v0.1.1#b722074f17a354816ebf5adcf0881b1562a2cbc5" } +source = { git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main#5509b397eb61054039ba2e62a1e898be4b1d5519" } [[package]] name = "amplifier-core" -version = "1.4.1" -source = { git = "https://github.com/microsoft/amplifier-core?rev=v1.4.1#12f3d383de723993c9d15cf61d8f0f77e5e874d4" } +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "pydantic" }, @@ -18,6 +18,14 @@ dependencies = [ { name = "tomli" }, { name = "typing-extensions" }, ] +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/90/d520390cd91aae3d02db53653f828046089c79203dbb142e9bda346fa1d6/amplifier_core-1.6.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d35130e4262cf0db2d6c5f7e65e244a9ef2c7397bfe2a9853bc9b0d9fd05be64", size = 8113151, upload-time = "2026-05-18T16:13:46.825Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/3ab3126ba5a6f2fc6051a4d08e42364899e4c9ac4daa9d0a60947bf8acd1/amplifier_core-1.6.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:387a2c58fcf4caefdb45c52ec228307bc225e73606897f242154782bc3e123da", size = 7268223, upload-time = "2026-05-18T16:13:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/21/22/5a36160b3487170bcba0cbc61535101ff624e8314ed38fd35e561cb711a1/amplifier_core-1.6.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8344fccdedd725a51c018de17867cdf1c35abb571dabc0bbccdb5c1242324a47", size = 7532259, upload-time = "2026-05-18T16:13:50.614Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d7/3874c2308523209411367cf3b8b690e14e869f5f6bfb64cb1b1971e06a96/amplifier_core-1.6.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a8e0103242a2e2a975c880b1de0e5a02501e0421c1e5386dadae3f111e1d2b5", size = 8507642, upload-time = "2026-05-18T16:13:52.977Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/3646a89537b4556274183519f6db9c354fb3d183f52ef4a2179af12dd386/amplifier_core-1.6.0-cp311-abi3-win_amd64.whl", hash = "sha256:5113aa2d88038776eb257af9e7d9de7af13b3cd9097d2ac67aef5730fa0678e3", size = 8910313, upload-time = "2026-05-18T16:13:55.249Z" }, + { url = "https://files.pythonhosted.org/packages/9f/9e/58b141115e5eea65703f0b01459eefed36b561e9642ba96d48542345cd8f/amplifier_core-1.6.0-cp311-abi3-win_arm64.whl", hash = "sha256:e1b2731dc09d1cbc668b411007e7f9a2c7edbd75b2525407cae1e6b4a4de0b83", size = 7661416, upload-time = "2026-05-18T16:13:57.513Z" }, +] [[package]] name = "amplifier-module-hook-context-intelligence" @@ -41,14 +49,14 @@ dev = [ [package.metadata] requires-dist = [ - { name = "amplifier-bundle-context-intelligence", git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=v0.1.1" }, + { name = "amplifier-bundle-context-intelligence", git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "idna", specifier = ">=3.15" }, ] [package.metadata.requires-dev] dev = [ - { name = "amplifier-core", git = "https://github.com/microsoft/amplifier-core?rev=v1.4.1" }, + { name = "amplifier-core", specifier = ">=1.6.0" }, { name = "pyright", specifier = ">=1.1" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=0.24" }, diff --git a/modules/tool-blob-read/amplifier_module_tool_blob_read/__init__.py b/modules/tool-blob-read/amplifier_module_tool_blob_read/__init__.py index 9e15876f..95492e3e 100644 --- a/modules/tool-blob-read/amplifier_module_tool_blob_read/__init__.py +++ b/modules/tool-blob-read/amplifier_module_tool_blob_read/__init__.py @@ -1,7 +1,7 @@ """Blob read tool module — reads binary/text blobs from the context-intelligence server. Implements the Amplifier Tool protocol. Configuration is resolved lazily -via the ``context_intelligence.config_resolver`` coordinator capability +via the ``context_intelligence.hook_config_resolver`` coordinator capability registered by the hook-context-intelligence module. """ @@ -12,9 +12,16 @@ __amplifier_module_type__ = "tool" -async def mount(coordinator: Any, config: Any) -> dict[str, Any]: # noqa: ARG001 +async def mount(coordinator: Any, config: dict[str, Any]) -> dict[str, Any]: + """Mount the blob_read tool. + + Passes ``config`` into BlobReadTool so it can resolve server_url and + api_key directly when hook-context-intelligence is not mounted + (analytics-only mode). When the hook IS mounted its + ``context_intelligence.hook_config_resolver`` capability takes priority. + """ from amplifier_module_tool_blob_read.blob_read_tool import BlobReadTool - tool = BlobReadTool(coordinator) + tool = BlobReadTool(coordinator=coordinator, config=config) await coordinator.mount("tools", tool, name=tool.name) return {"tool": tool.name, "status": "mounted"} diff --git a/modules/tool-blob-read/amplifier_module_tool_blob_read/blob_read_tool.py b/modules/tool-blob-read/amplifier_module_tool_blob_read/blob_read_tool.py index d6fb9d1f..b4e5fd62 100644 --- a/modules/tool-blob-read/amplifier_module_tool_blob_read/blob_read_tool.py +++ b/modules/tool-blob-read/amplifier_module_tool_blob_read/blob_read_tool.py @@ -9,6 +9,7 @@ from amplifier_core import ToolResult from context_intelligence.client import AsyncCIClient +from context_intelligence.tool_resolver import ToolConfigResolver _URI_SCHEME = "ci-blob://" _BLOB_DIR = Path("/tmp/ci-blobs") @@ -20,11 +21,21 @@ def _sanitize_path_component(s: str) -> str: class BlobReadTool: - """Tool that fetches a ci-blob:// URI from the server and writes it to disk.""" + """Tool that fetches a ci-blob:// URI from the server and writes it to disk. - def __init__(self, coordinator: Any) -> None: + Configuration priority at execute() time: + + 1. ``context_intelligence.hook_config_resolver`` coordinator capability + (registered by hook-context-intelligence when the full behavior is used). + 2. ``config`` dict passed to mount() — used when the analytics-only behavior + is composed without the hook. + """ + + def __init__(self, coordinator: Any, config: dict[str, Any] | None = None) -> None: self._coordinator = coordinator - self._resolver: Any = None + self._config: dict[str, Any] = config or {} + self._hook_resolver: Any | None = None + self._tool_resolver = ToolConfigResolver(self._config, coordinator) @property def name(self) -> str: @@ -52,21 +63,26 @@ def input_schema(self) -> dict[str, Any]: async def execute(self, input: dict[str, Any]) -> ToolResult: # noqa: A002 # (1) Lazy capability resolution - if self._resolver is None: - self._resolver = self._coordinator.get_capability( - "context_intelligence.config_resolver" - ) - if self._resolver is None: - return ToolResult( - success=False, - error={ - "message": "context-intelligence hook not configured", - "type": "configuration_error", - }, + if self._hook_resolver is None: + self._hook_resolver = self._coordinator.get_capability( + "context_intelligence.hook_config_resolver" ) - # (2) Get server_url from resolver - server_url: str | None = self._resolver.context_intelligence_server_url + # (2) Resolve server_url and api_key — from hook capability when + # available, otherwise from ToolConfigResolver (full env/settings + # fallback chain). + if self._hook_resolver is not None: + server_url: str | None = self._hook_resolver.context_intelligence_server_url + api_key: str | None = self._hook_resolver.context_intelligence_api_key + else: + # Analytics-only mode: hook not mounted. Delegate to + # ToolConfigResolver which applies the full four-level priority + # chain: config dict → coordinator.config → + # AMPLIFIER_CONTEXT_INTELLIGENCE_* env vars → + # ~/.amplifier/settings.yaml. + server_url = self._tool_resolver.context_intelligence_server_url + api_key = self._tool_resolver.context_intelligence_api_key + if not server_url: return ToolResult( success=False, @@ -105,7 +121,6 @@ async def execute(self, input: dict[str, Any]) -> ToolResult: # noqa: A002 safe_key = _sanitize_path_component(key) # (5) Construct AsyncCIClient - api_key: str | None = self._resolver.context_intelligence_api_key async_client = AsyncCIClient(server_url=server_url, api_key=api_key or "") # (6) Fetch blob using original unsanitized values for the server request diff --git a/modules/tool-blob-read/pyproject.toml b/modules/tool-blob-read/pyproject.toml index d5c92195..1ef7d5c3 100644 --- a/modules/tool-blob-read/pyproject.toml +++ b/modules/tool-blob-read/pyproject.toml @@ -6,7 +6,7 @@ requires-python = ">=3.11" license = "MIT" dependencies = [ - "amplifier-bundle-context-intelligence", + "amplifier-bundle-context-intelligence @ git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main", "httpx>=0.28.1", "idna>=3.15", ] @@ -24,19 +24,18 @@ package = true [tool.hatch.build.targets.wheel] packages = ["amplifier_module_tool_blob_read"] +[tool.hatch.metadata] +allow-direct-references = true + [dependency-groups] dev = [ - "amplifier-core", + "amplifier-core>=1.6.0", "pytest>=9.0.3", "pytest-asyncio>=0.24", "pyright>=1.1", "ruff>=0.4", ] -[tool.uv.sources] -amplifier-bundle-context-intelligence = { path = "../.." } -amplifier-core = { git = "https://github.com/microsoft/amplifier-core", branch = "main" } - [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" diff --git a/modules/tool-blob-read/tests/test_blob_read_tool.py b/modules/tool-blob-read/tests/test_blob_read_tool.py index 856ecd7d..9ff1faeb 100644 --- a/modules/tool-blob-read/tests/test_blob_read_tool.py +++ b/modules/tool-blob-read/tests/test_blob_read_tool.py @@ -136,11 +136,20 @@ async def test_execute_returns_tool_result(self) -> None: class TestLazyCapabilityResolution: """execute() must resolve the config capability lazily and cache it.""" - async def test_capability_not_found_returns_configuration_error(self) -> None: + async def test_capability_not_found_returns_configuration_error(self, monkeypatch) -> None: from amplifier_module_tool_blob_read.blob_read_tool import BlobReadTool + # Isolate from live AMPLIFIER_CONTEXT_INTELLIGENCE_* env vars so that + # ToolConfigResolver cannot find a server URL anywhere and the guard + # in execute() returns configuration_error. + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", raising=False) + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY", raising=False) + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE", raising=False) + + coordinator = _make_coordinator(None) + coordinator.config = {} # get_capability returns None → capability not registered - tool = BlobReadTool(_make_coordinator(None)) + tool = BlobReadTool(coordinator) result = await tool.execute({"uri": "ci-blob://session/key"}) assert result.success is False @@ -405,3 +414,100 @@ async def test_none_api_key_passes_empty_string(self) -> None: await tool.execute({"uri": "ci-blob://my-session/my-key"}) mock_cls.assert_called_once_with(server_url="http://localhost:8080", api_key="") + + +# --------------------------------------------------------------------------- +# (8) Analytics-only mode +# --------------------------------------------------------------------------- + + +class TestAnalyticsOnlyMode: + """Analytics-only mode: config dict is used when the hook capability is absent.""" + + async def test_analytics_only_success(self) -> None: + """Tool succeeds using config values when no hook capability is registered.""" + from amplifier_module_tool_blob_read.blob_read_tool import BlobReadTool + + coordinator = _make_coordinator(None) + config = { + "context_intelligence_server_url": "http://ci:4200", + "context_intelligence_api_key": "key123", + } + tool = BlobReadTool(coordinator, config=config) + + with _patch_async_client(fetch_blob_return={"data": "test"}) as (mock_cls, _): + result = await tool.execute({"uri": "ci-blob://session-123/some-key"}) + + assert result.success is True + mock_cls.assert_called_once_with(server_url="http://ci:4200", api_key="key123") + + async def test_analytics_only_no_server_url_returns_error(self, monkeypatch) -> None: + """Missing server URL in config returns a configuration_error — not 'hook not configured'.""" + from amplifier_module_tool_blob_read.blob_read_tool import BlobReadTool + + # Isolate from live AMPLIFIER_CONTEXT_INTELLIGENCE_* env vars so + # ToolConfigResolver has nowhere to find a server URL. + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", raising=False) + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY", raising=False) + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE", raising=False) + + coordinator = _make_coordinator(None) + coordinator.config = {} + config = { + "context_intelligence_api_key": "key123", + # no "context_intelligence_server_url" + } + tool = BlobReadTool(coordinator, config=config) + + result = await tool.execute({"uri": "ci-blob://session-123/some-key"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "configuration_error" + assert "server URL not configured" in result.error["message"] + + async def test_analytics_only_env_var_fallback_resolves_server_url(self, monkeypatch) -> None: + """ToolConfigResolver env-var fallback must be used when config has no server_url. + + Regression: commits 584efb9/be6451e removed ToolConfigResolver, dropping the + AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL env-var fallback path in analytics-only + mode. This test ensures the fallback is restored. + """ + from amplifier_module_tool_blob_read.blob_read_tool import BlobReadTool + + # Stamp a specific URL into the env var (overrides whatever live value exists) + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", "http://env-server:8000") + + coordinator = _make_coordinator(None) + coordinator.config = {} # prevent coordinator.config from providing a URL + config = { + "context_intelligence_api_key": "key123", + # deliberately NO "context_intelligence_server_url" in config + } + tool = BlobReadTool(coordinator, config=config) + + with _patch_async_client(fetch_blob_return={"data": "test"}) as (mock_cls, _): + result = await tool.execute({"uri": "ci-blob://session-123/some-key"}) + + # ToolConfigResolver must have resolved the URL from the env var + assert result.success is True, f"Expected success=True (env-var fallback), got: {result}" + mock_cls.assert_called_once() + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs.get("server_url") == "http://env-server:8000" + + +# --------------------------------------------------------------------------- +# (9) Attribute naming +# --------------------------------------------------------------------------- + + +class TestResolverAttributeName: + """BlobReadTool must use _hook_resolver (not _resolver) for the cached capability.""" + + def test_init_has_hook_resolver_attribute(self) -> None: + from amplifier_module_tool_blob_read.blob_read_tool import BlobReadTool + + tool = BlobReadTool(coordinator=_make_coordinator(resolver=None)) + assert hasattr(tool, "_hook_resolver"), "Expected _hook_resolver attribute" + assert tool._hook_resolver is None + assert not hasattr(tool, "_resolver"), "Found old _resolver attribute — rename incomplete" diff --git a/modules/tool-blob-read/tests/test_mount.py b/modules/tool-blob-read/tests/test_mount.py index 738be388..3d862e7b 100644 --- a/modules/tool-blob-read/tests/test_mount.py +++ b/modules/tool-blob-read/tests/test_mount.py @@ -71,3 +71,16 @@ async def test_mount_returns_metadata_dict(self) -> None: assert isinstance(result, dict) assert "tool" in result assert "status" in result + + async def test_config_dict_passed_to_tool_constructor(self) -> None: + """Config dict is forwarded to the tool so it can resolve server_url without the hook.""" + from amplifier_module_tool_blob_read import mount + + coordinator = MagicMock() + coordinator.mount = AsyncMock() + await mount( + coordinator, + config={"context_intelligence_server_url": "http://test"}, + ) + tool = coordinator.mount.call_args.args[1] + assert tool._config["context_intelligence_server_url"] == "http://test" diff --git a/modules/tool-blob-read/uv.lock b/modules/tool-blob-read/uv.lock index b385fab4..18eeb03f 100644 --- a/modules/tool-blob-read/uv.lock +++ b/modules/tool-blob-read/uv.lock @@ -5,25 +5,12 @@ requires-python = ">=3.11" [[package]] name = "amplifier-bundle-context-intelligence" version = "0.1.1" -source = { editable = "../../" } - -[package.metadata] - -[package.metadata.requires-dev] -dev = [ - { name = "httpx", specifier = ">=0.25" }, - { name = "idna", specifier = ">=3.15" }, - { name = "pyright", specifier = ">=1.1" }, - { name = "pytest", specifier = ">=9.0.3" }, - { name = "pytest-asyncio", specifier = ">=0.24" }, - { name = "pyyaml", specifier = ">=6.0" }, - { name = "ruff", specifier = ">=0.4" }, -] +source = { git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main#5509b397eb61054039ba2e62a1e898be4b1d5519" } [[package]] name = "amplifier-core" -version = "1.2.5" -source = { git = "https://github.com/microsoft/amplifier-core?branch=main#308b2455728378be896266cf620c00da2a408b65" } +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "pydantic" }, @@ -31,6 +18,14 @@ dependencies = [ { name = "tomli" }, { name = "typing-extensions" }, ] +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/90/d520390cd91aae3d02db53653f828046089c79203dbb142e9bda346fa1d6/amplifier_core-1.6.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d35130e4262cf0db2d6c5f7e65e244a9ef2c7397bfe2a9853bc9b0d9fd05be64", size = 8113151, upload-time = "2026-05-18T16:13:46.825Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/3ab3126ba5a6f2fc6051a4d08e42364899e4c9ac4daa9d0a60947bf8acd1/amplifier_core-1.6.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:387a2c58fcf4caefdb45c52ec228307bc225e73606897f242154782bc3e123da", size = 7268223, upload-time = "2026-05-18T16:13:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/21/22/5a36160b3487170bcba0cbc61535101ff624e8314ed38fd35e561cb711a1/amplifier_core-1.6.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8344fccdedd725a51c018de17867cdf1c35abb571dabc0bbccdb5c1242324a47", size = 7532259, upload-time = "2026-05-18T16:13:50.614Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d7/3874c2308523209411367cf3b8b690e14e869f5f6bfb64cb1b1971e06a96/amplifier_core-1.6.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a8e0103242a2e2a975c880b1de0e5a02501e0421c1e5386dadae3f111e1d2b5", size = 8507642, upload-time = "2026-05-18T16:13:52.977Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/3646a89537b4556274183519f6db9c354fb3d183f52ef4a2179af12dd386/amplifier_core-1.6.0-cp311-abi3-win_amd64.whl", hash = "sha256:5113aa2d88038776eb257af9e7d9de7af13b3cd9097d2ac67aef5730fa0678e3", size = 8910313, upload-time = "2026-05-18T16:13:55.249Z" }, + { url = "https://files.pythonhosted.org/packages/9f/9e/58b141115e5eea65703f0b01459eefed36b561e9642ba96d48542345cd8f/amplifier_core-1.6.0-cp311-abi3-win_arm64.whl", hash = "sha256:e1b2731dc09d1cbc668b411007e7f9a2c7edbd75b2525407cae1e6b4a4de0b83", size = 7661416, upload-time = "2026-05-18T16:13:57.513Z" }, +] [[package]] name = "amplifier-module-tool-blob-read" @@ -53,14 +48,14 @@ dev = [ [package.metadata] requires-dist = [ - { name = "amplifier-bundle-context-intelligence", editable = "../../" }, + { name = "amplifier-bundle-context-intelligence", git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "idna", specifier = ">=3.15" }, ] [package.metadata.requires-dev] dev = [ - { name = "amplifier-core", git = "https://github.com/microsoft/amplifier-core?branch=main" }, + { name = "amplifier-core", specifier = ">=1.6.0" }, { name = "pyright", specifier = ">=1.1" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=0.24" }, diff --git a/modules/tool-context-intelligence-upload/amplifier_module_tool_context_intelligence_upload/uploader.py b/modules/tool-context-intelligence-upload/amplifier_module_tool_context_intelligence_upload/uploader.py index b037df55..43e3fa74 100644 --- a/modules/tool-context-intelligence-upload/amplifier_module_tool_context_intelligence_upload/uploader.py +++ b/modules/tool-context-intelligence-upload/amplifier_module_tool_context_intelligence_upload/uploader.py @@ -71,7 +71,7 @@ def _workspace_from_path(session_dir: Path) -> str: Used as a fallback when an ``events.jsonl`` record does not carry a ``workspace`` field. Sessions captured before workspace was added to the on-disk format lack the field entirely; the project slug is the value - ``ConfigResolver`` would have resolved at live-capture time. + ``HookConfigResolver`` would have resolved at live-capture time. Path structure: .../.amplifier/projects/{project_slug}/sessions/{id}/context-intelligence/ diff --git a/modules/tool-context-intelligence-upload/pyproject.toml b/modules/tool-context-intelligence-upload/pyproject.toml index 6e3cf3a7..baf3ece7 100644 --- a/modules/tool-context-intelligence-upload/pyproject.toml +++ b/modules/tool-context-intelligence-upload/pyproject.toml @@ -5,7 +5,7 @@ requires-python = ">=3.11" license = "MIT" dependencies = [ - "amplifier-bundle-context-intelligence @ git+https://github.com/microsoft/amplifier-bundle-context-intelligence@v0.1.1", + "amplifier-bundle-context-intelligence @ git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main", "httpx>=0.28.1", "idna>=3.15", "amplifier-module-hook-context-intelligence", diff --git a/modules/tool-context-intelligence-upload/uv.lock b/modules/tool-context-intelligence-upload/uv.lock index 60978200..f1563e93 100644 --- a/modules/tool-context-intelligence-upload/uv.lock +++ b/modules/tool-context-intelligence-upload/uv.lock @@ -5,7 +5,7 @@ requires-python = ">=3.11" [[package]] name = "amplifier-bundle-context-intelligence" version = "0.1.1" -source = { git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=v0.1.1#b722074f17a354816ebf5adcf0881b1562a2cbc5" } +source = { git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main#5509b397eb61054039ba2e62a1e898be4b1d5519" } [[package]] name = "amplifier-module-hook-context-intelligence" @@ -19,14 +19,14 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "amplifier-bundle-context-intelligence", git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=v0.1.1" }, + { name = "amplifier-bundle-context-intelligence", git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "idna", specifier = ">=3.15" }, ] [package.metadata.requires-dev] dev = [ - { name = "amplifier-core", git = "https://github.com/microsoft/amplifier-core?rev=v1.4.1" }, + { name = "amplifier-core", specifier = ">=1.6.0" }, { name = "pyright", specifier = ">=1.1" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=0.24" }, @@ -55,7 +55,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "amplifier-bundle-context-intelligence", git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=v0.1.1" }, + { name = "amplifier-bundle-context-intelligence", git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main" }, { name = "amplifier-module-hook-context-intelligence", editable = "../hook-context-intelligence" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "idna", specifier = ">=3.15" }, diff --git a/modules/tool-graph-query/amplifier_module_tool_graph_query/__init__.py b/modules/tool-graph-query/amplifier_module_tool_graph_query/__init__.py index baa50c3e..761c1af5 100644 --- a/modules/tool-graph-query/amplifier_module_tool_graph_query/__init__.py +++ b/modules/tool-graph-query/amplifier_module_tool_graph_query/__init__.py @@ -1,26 +1,37 @@ """Graph query tool module — Cypher queries against the context-intelligence server. Implements the Amplifier Tool protocol. Configuration is resolved lazily -via the ``context_intelligence.config_resolver`` coordinator capability +via the ``context_intelligence.hook_config_resolver`` coordinator capability registered by the hook-context-intelligence module. + +This module also owns analytics-path skill-content sync: it exposes a +module-level ``on_session_ready`` (see ``skill_sync``) that the kernel runs +after all modules mount, syncing the context-intelligence-graph-query skill +in the graph-analyst sub-session where it is consumed. """ from __future__ import annotations 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: dict[str, Any]) -> dict[str, Any]: # noqa: ARG001 +async def mount(coordinator: Any, config: dict[str, Any]) -> dict[str, Any]: """Mount the graph_query tool. - Captures a coordinator reference for lazy capability resolution. - The tool reads the config resolver at execute() time, not mount() time, - because hooks mount after tools. + Passes ``config`` into GraphQueryTool so it can resolve server_url, + api_key and workspace directly when hook-context-intelligence is not + mounted (analytics-only mode). When the hook IS mounted its + ``context_intelligence.hook_config_resolver`` capability takes priority. """ from .graph_query_tool import GraphQueryTool - tool = GraphQueryTool(coordinator=coordinator) + tool = GraphQueryTool(coordinator=coordinator, config=config) await coordinator.mount("tools", tool, name=tool.name) + coordinator.register_capability(_GRAPH_QUERY_TOOL_CAPABILITY, tool) return {"tool": tool.name, "status": "mounted"} diff --git a/modules/tool-graph-query/amplifier_module_tool_graph_query/bundled_skill/__init__.py b/modules/tool-graph-query/amplifier_module_tool_graph_query/bundled_skill/__init__.py new file mode 100644 index 00000000..7063ff36 --- /dev/null +++ b/modules/tool-graph-query/amplifier_module_tool_graph_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-graph-query/amplifier_module_tool_graph_query/bundled_skill/context-intelligence-graph-query.md b/modules/tool-graph-query/amplifier_module_tool_graph_query/bundled_skill/context-intelligence-graph-query.md new file mode 100644 index 00000000..1d43a596 --- /dev/null +++ b/modules/tool-graph-query/amplifier_module_tool_graph_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-graph-query/amplifier_module_tool_graph_query/graph_query_tool.py b/modules/tool-graph-query/amplifier_module_tool_graph_query/graph_query_tool.py index 120f7581..6d826b95 100644 --- a/modules/tool-graph-query/amplifier_module_tool_graph_query/graph_query_tool.py +++ b/modules/tool-graph-query/amplifier_module_tool_graph_query/graph_query_tool.py @@ -1,8 +1,10 @@ """GraphQueryTool — agent-facing tool for executing Cypher queries. -Implements the Amplifier Tool protocol. Resolves configuration lazily -via the ``context_intelligence.config_resolver`` coordinator capability -registered by the hook-context-intelligence module. +Implements the Amplifier Tool protocol. Configuration is resolved lazily at +execute() time, preferring the ``context_intelligence.hook_config_resolver`` +coordinator capability registered by hook-context-intelligence. When the hook +is not mounted (analytics-only mode) the tool falls back to the ``config`` dict +passed via mount() — the standard Amplifier tool configuration mechanism. """ from __future__ import annotations @@ -10,6 +12,7 @@ from typing import Any from context_intelligence.client import AsyncCIClient +from context_intelligence.tool_resolver import ToolConfigResolver from amplifier_core.models import ToolResult @@ -18,13 +21,19 @@ class GraphQueryTool: """Execute Cypher queries against the context-intelligence server. Implements the Amplifier Tool protocol (name, description, input_schema, - execute). Configuration is resolved lazily at execute() time via the - coordinator's ``context_intelligence.config_resolver`` capability. + execute). Configuration priority at execute() time: + + 1. ``context_intelligence.hook_config_resolver`` coordinator capability + (registered by hook-context-intelligence when the full behavior is used). + 2. ``config`` dict passed to mount() — used when the analytics-only behavior + is composed without the hook. """ - def __init__(self, coordinator: Any) -> None: + def __init__(self, coordinator: Any, config: dict[str, Any] | None = None) -> None: self._coordinator = coordinator - self._resolver: Any | None = None + self._config: dict[str, Any] = config or {} + self._hook_resolver: Any | None = None + self._tool_resolver = ToolConfigResolver(self._config, coordinator) @property def name(self) -> str: @@ -74,22 +83,57 @@ def input_schema(self) -> dict[str, Any]: "required": ["query"], } - async def execute(self, input: dict[str, Any]) -> ToolResult: # noqa: A002 - if self._resolver is None: - self._resolver = self._coordinator.get_capability( - "context_intelligence.config_resolver" + @property + def skill_sync_enabled(self) -> bool: + """Whether the analytics-path skill sync runs on session start. + + Defaults to ``True`` (existing behaviour preserved). Resolved via the + tool's ``ToolConfigResolver`` from the ``skill_sync_enabled`` key + (mount config dict -> coordinator.config -> + ``AMPLIFIER_CONTEXT_INTELLIGENCE_SKILL_SYNC_ENABLED`` env var -> + default). When ``False``, ``skill_sync.on_session_ready`` is a complete + no-op, so headless / pipeline / single-command-series workflows pay zero + skill traffic per turn. Read by ``skill_sync.on_session_ready`` via the + ``context_intelligence._graph_query_tool`` coordinator capability. + """ + return self._tool_resolver.skill_sync_enabled + + def _resolve_server_config(self, coordinator: Any) -> tuple[str | None, str | None, str]: + """Return (server_url, api_key, workspace) from hook resolver or ToolConfigResolver. + + While ``_hook_resolver`` is ``None``, calls ``get_capability`` on every + invocation so that a hook mounted after tool construction is picked up on + the very next ``execute()`` call (late-mount upgrade path). Once set, + ``_hook_resolver`` is cached and ``get_capability`` is no longer called. + + In analytics-only mode (no hook), ``_tool_resolver`` (a + ``ToolConfigResolver``) is used. It applies the full four-level + priority chain: config dict → coordinator.config → + AMPLIFIER_CONTEXT_INTELLIGENCE_* env vars → ~/.amplifier/settings.yaml. + If no source provides a server URL, ``server_url`` is ``None`` and + ``execute()`` will return a ``configuration_error``. + """ + if self._hook_resolver is None: + self._hook_resolver = coordinator.get_capability( + "context_intelligence.hook_config_resolver" ) - - if self._resolver is None: - return ToolResult( - success=False, - error={ - "message": "context-intelligence hook not configured", - "type": "configuration_error", - }, + if self._hook_resolver is not None: + return ( + self._hook_resolver.context_intelligence_server_url, + self._hook_resolver.context_intelligence_api_key, + self._hook_resolver.workspace, ) + # Analytics-only mode: delegate to ToolConfigResolver for full + # env-var / settings.yaml fallback chain. + return ( + self._tool_resolver.context_intelligence_server_url, + self._tool_resolver.context_intelligence_api_key, + self._tool_resolver.workspace, + ) + + async def execute(self, input: dict[str, Any]) -> ToolResult: # noqa: A002 + server_url, api_key, workspace = self._resolve_server_config(self._coordinator) - server_url = self._resolver.context_intelligence_server_url if not server_url: return ToolResult( success=False, @@ -99,7 +143,6 @@ async def execute(self, input: dict[str, Any]) -> ToolResult: # noqa: A002 }, ) - workspace = self._resolver.workspace query: str = input["query"] ws_override = input.get("workspace") effective_workspace = ws_override if ws_override is not None else workspace @@ -118,7 +161,6 @@ async def execute(self, input: dict[str, Any]) -> ToolResult: # noqa: A002 else: params = raw_params - api_key = self._resolver.context_intelligence_api_key async_client = AsyncCIClient(server_url=server_url, api_key=api_key or "") result = await async_client.cypher(query, effective_workspace, params=params) return ToolResult(success=True, output=result) diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/skill_fetcher.py b/modules/tool-graph-query/amplifier_module_tool_graph_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-graph-query/amplifier_module_tool_graph_query/skill_fetcher.py index 15f865d0..7048a83b 100644 --- a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/skill_fetcher.py +++ b/modules/tool-graph-query/amplifier_module_tool_graph_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-graph-query/amplifier_module_tool_graph_query/skill_sync.py b/modules/tool-graph-query/amplifier_module_tool_graph_query/skill_sync.py new file mode 100644 index 00000000..2446659e --- /dev/null +++ b/modules/tool-graph-query/amplifier_module_tool_graph_query/skill_sync.py @@ -0,0 +1,338 @@ +"""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 .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_graph_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 + + new_hash = _sha256_text(body) + 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-graph-query/pyproject.toml b/modules/tool-graph-query/pyproject.toml index 346bc500..dc02c6ee 100644 --- a/modules/tool-graph-query/pyproject.toml +++ b/modules/tool-graph-query/pyproject.toml @@ -6,7 +6,7 @@ requires-python = ">=3.11" license = "MIT" dependencies = [ - "amplifier-bundle-context-intelligence", + "amplifier-bundle-context-intelligence @ git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main", "httpx>=0.28.1", "idna>=3.15", ] @@ -24,19 +24,26 @@ package = true [tool.hatch.build.targets.wheel] packages = ["amplifier_module_tool_graph_query"] +# Guarantee the vendored offline skill body ships in the wheel. It is a non-.py +# data file consumed at runtime (skill_sync._vendored_body) on the +# skill_sync_enabled=false path; a missing copy would silently strand the +# graph-analyst on the "Server Unavailable" stub. tests/test_bundled_skill.py +# asserts its presence + pinned hash so a faulty build fails loud. +[tool.hatch.build.targets.wheel.force-include] +"amplifier_module_tool_graph_query/bundled_skill/context-intelligence-graph-query.md" = "amplifier_module_tool_graph_query/bundled_skill/context-intelligence-graph-query.md" + +[tool.hatch.metadata] +allow-direct-references = true + [dependency-groups] dev = [ - "amplifier-core", + "amplifier-core>=1.6.0", "pytest>=9.0.3", "pytest-asyncio>=0.24", "pyright>=1.1", "ruff>=0.4", ] -[tool.uv.sources] -amplifier-bundle-context-intelligence = { path = "../.." } -amplifier-core = { git = "https://github.com/microsoft/amplifier-core", branch = "main" } - [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" diff --git a/modules/tool-graph-query/tests/test_bundled_skill.py b/modules/tool-graph-query/tests/test_bundled_skill.py new file mode 100644 index 00000000..09d8743b --- /dev/null +++ b/modules/tool-graph-query/tests/test_bundled_skill.py @@ -0,0 +1,52 @@ +"""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. +""" + +from __future__ import annotations + +import hashlib +from importlib import resources + +_PKG = "amplifier_module_tool_graph_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_graph_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_graph_query.skill_fetcher import WATCHED_SKILLS + + assert _SKILL_FILE[: -len(".md")] in WATCHED_SKILLS diff --git a/modules/tool-graph-query/tests/test_graph_query_tool.py b/modules/tool-graph-query/tests/test_graph_query_tool.py index ad626145..d78ccb08 100644 --- a/modules/tool-graph-query/tests/test_graph_query_tool.py +++ b/modules/tool-graph-query/tests/test_graph_query_tool.py @@ -110,10 +110,18 @@ async def test_execute_returns_tool_result(self) -> None: class TestLazyCapabilityResolution: """Lazy resolver lookup and caching behaviour.""" - async def test_capability_not_found_returns_configuration_error(self) -> None: + async def test_capability_not_found_returns_configuration_error(self, monkeypatch) -> None: from amplifier_module_tool_graph_query.graph_query_tool import GraphQueryTool + # Isolate from live AMPLIFIER_CONTEXT_INTELLIGENCE_* env vars so that + # ToolConfigResolver cannot find a server URL anywhere and the guard + # in execute() returns configuration_error. + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", raising=False) + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY", raising=False) + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE", raising=False) + coordinator = _make_coordinator(resolver=None) + coordinator.config = {} tool = GraphQueryTool(coordinator=coordinator) result = await tool.execute({"query": "MATCH (n) RETURN n"}) @@ -146,7 +154,9 @@ async def test_resolver_cached_after_first_lookup(self) -> None: await tool.execute({"query": "MATCH (n) RETURN n LIMIT 2"}) # get_capability should only be called once (on first execute) - coordinator.get_capability.assert_called_once_with("context_intelligence.config_resolver") + coordinator.get_capability.assert_called_once_with( + "context_intelligence.hook_config_resolver" + ) async def test_configured_resolver_succeeds(self) -> None: from amplifier_module_tool_graph_query.graph_query_tool import GraphQueryTool @@ -376,3 +386,409 @@ async def test_non_dict_params_returns_validation_error(self) -> None: assert result.success is False assert result.error is not None assert result.error["type"] == "validation_error" + + +# --------------------------------------------------------------------------- +# TestAnalyticsOnlyMode +# --------------------------------------------------------------------------- + + +class TestAnalyticsOnlyMode: + """Analytics-only mode: config dict is used when the hook capability is absent.""" + + async def test_analytics_only_success(self) -> None: + """Tool succeeds using config values when no hook capability is registered.""" + from amplifier_module_tool_graph_query.graph_query_tool import GraphQueryTool + + coordinator = _make_coordinator(resolver=None) + config = { + "context_intelligence_server_url": "http://ci:4200", + "context_intelligence_api_key": "key123", + "workspace": "my-ws", + } + tool = GraphQueryTool(coordinator=coordinator, config=config) + + mock_instance, mock_cls = _make_mock_async_ci_client() + with patch("amplifier_module_tool_graph_query.graph_query_tool.AsyncCIClient", mock_cls): + result = await tool.execute({"query": "MATCH (n) RETURN n"}) + + assert result.success is True + mock_cls.assert_called_once() + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs.get("server_url") == "http://ci:4200" + assert call_kwargs.get("api_key") == "key123" + # workspace must be forwarded as the 2nd positional arg to cypher() + cypher_args = mock_instance.cypher.call_args + all_args = list(cypher_args.args) + list(cypher_args.kwargs.values()) + assert "my-ws" in all_args + + async def test_analytics_only_workspace_defaults_to_default(self) -> None: + """When config has no 'workspace' key the cypher call receives 'default'.""" + from amplifier_module_tool_graph_query.graph_query_tool import GraphQueryTool + + coordinator = _make_coordinator(resolver=None) + config = { + "context_intelligence_server_url": "http://ci:4200", + "context_intelligence_api_key": "key123", + # no "workspace" key + } + tool = GraphQueryTool(coordinator=coordinator, config=config) + + mock_instance, mock_cls = _make_mock_async_ci_client() + with patch("amplifier_module_tool_graph_query.graph_query_tool.AsyncCIClient", mock_cls): + await tool.execute({"query": "MATCH (n) RETURN n"}) + + cypher_args = mock_instance.cypher.call_args + # workspace is the 2nd positional arg: cypher(query, workspace, params=...) + assert cypher_args.args[1] == "default" + + async def test_analytics_only_no_server_url_returns_error(self, monkeypatch) -> None: + """Missing server URL in config returns a configuration_error — not 'hook not configured'.""" + from amplifier_module_tool_graph_query.graph_query_tool import GraphQueryTool + + # Isolate from live AMPLIFIER_CONTEXT_INTELLIGENCE_* env vars so + # ToolConfigResolver has nowhere to find a server URL. + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", raising=False) + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY", raising=False) + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE", raising=False) + + coordinator = _make_coordinator(resolver=None) + coordinator.config = {} + config = { + "context_intelligence_api_key": "key123", + # no "context_intelligence_server_url" + } + tool = GraphQueryTool(coordinator=coordinator, config=config) + + result = await tool.execute({"query": "MATCH (n) RETURN n"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "configuration_error" + assert "server URL not configured" in result.error["message"] + + async def test_analytics_only_env_var_fallback_resolves_server_url(self, monkeypatch) -> None: + """ToolConfigResolver env-var fallback must be used when config has no server_url. + + Regression: commits 584efb9/be6451e removed ToolConfigResolver, dropping the + AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL env-var fallback path in analytics-only + mode. This test ensures the fallback is restored. + """ + from amplifier_module_tool_graph_query.graph_query_tool import GraphQueryTool + + # Stamp a specific URL into the env var (overrides whatever live value exists) + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", "http://env-server:8000") + + coordinator = _make_coordinator(resolver=None) + coordinator.config = {} # prevent coordinator.config from providing a URL + config = { + "context_intelligence_api_key": "key123", + # deliberately NO "context_intelligence_server_url" in config + } + tool = GraphQueryTool(coordinator=coordinator, config=config) + + mock_instance, mock_cls = _make_mock_async_ci_client() + with patch("amplifier_module_tool_graph_query.graph_query_tool.AsyncCIClient", mock_cls): + result = await tool.execute({"query": "MATCH (n) RETURN n"}) + + # ToolConfigResolver must have resolved the URL from the env var + assert result.success is True, f"Expected success=True (env-var fallback), got: {result}" + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs.get("server_url") == "http://env-server:8000" + + +# --------------------------------------------------------------------------- +# TestLateMountUpgrade +# --------------------------------------------------------------------------- + + +class TestLateMountUpgrade: + """Late-mount upgrade path: hook resolver supersedes ToolConfigResolver after mount. + + The lazy re-check design: while ``_resolver`` is ``None``, + ``get_capability`` is called on every ``execute()``. A coordinator that + mounts the hook between two calls therefore causes the tool to switch from + the ToolConfigResolver fallback to the hook resolver — changing + ``server_url`` — on the very next call. + """ + + async def test_late_mount_switches_from_tool_resolver_to_hook_resolver( + self, + ) -> None: + """First execute() uses ToolConfigResolver (hook absent); second uses hook resolver. + + Asserts that ``AsyncCIClient`` is constructed with the tool-config + ``server_url`` on call 1 and the hook ``server_url`` on call 2. + """ + from amplifier_module_tool_graph_query.graph_query_tool import GraphQueryTool + + hook_resolver = _make_resolver( + server_url="http://hook-server:9000", + workspace="hook-workspace", + api_key="hook-key", + ) + # First get_capability call returns None (hook not yet mounted). + # Second get_capability call returns hook_resolver (hook mounted between calls). + coordinator = MagicMock() + coordinator.get_capability = MagicMock(side_effect=[None, hook_resolver]) + coordinator.config = {} + + config = { + "context_intelligence_server_url": "http://tool-config-server:8000", + "context_intelligence_api_key": "tool-key", + "workspace": "tool-workspace", + } + tool = GraphQueryTool(coordinator=coordinator, config=config) + + mock_instance, mock_cls = _make_mock_async_ci_client() + with patch("amplifier_module_tool_graph_query.graph_query_tool.AsyncCIClient", mock_cls): + result1 = await tool.execute({"query": "MATCH (n) RETURN n LIMIT 1"}) + result2 = await tool.execute({"query": "MATCH (n) RETURN n LIMIT 2"}) + + # Both calls must succeed + assert result1.success is True + assert result2.success is True + + # AsyncCIClient must have been constructed once per execute() call + assert mock_cls.call_count == 2 + + first_url = mock_cls.call_args_list[0].kwargs.get("server_url") + second_url = mock_cls.call_args_list[1].kwargs.get("server_url") + + # Call 1: hook absent → ToolConfigResolver drives server_url from config dict + assert first_url == "http://tool-config-server:8000", ( + f"Expected tool-config URL on first call, got {first_url!r}" + ) + # Call 2: hook now present → hook resolver drives server_url + assert second_url == "http://hook-server:9000", ( + f"Expected hook URL on second call, got {second_url!r}" + ) + + +# --------------------------------------------------------------------------- +# TestResolveServerConfigHelper +# --------------------------------------------------------------------------- + + +class TestResolveServerConfigHelper: + """Tests for the _resolve_server_config(coordinator) helper method.""" + + def test_uses_hook_resolver_when_present(self) -> None: + """When a hook resolver is registered, it drives server_url/api_key/workspace.""" + from amplifier_module_tool_graph_query.graph_query_tool import GraphQueryTool + + hook = _make_resolver( + server_url="http://hook:9000", + api_key="hook-key", + workspace="hook-ws", + ) + coordinator = _make_coordinator(resolver=hook) + tool = GraphQueryTool(coordinator=coordinator) + + server_url, api_key, workspace = tool._resolve_server_config(coordinator) + + assert server_url == "http://hook:9000" + assert api_key == "hook-key" + assert workspace == "hook-ws" + + def test_falls_back_to_config_dict_when_hook_absent(self) -> None: + """When no hook resolver is registered, config dict values are returned.""" + from amplifier_module_tool_graph_query.graph_query_tool import GraphQueryTool + + coordinator = _make_coordinator(resolver=None) + coordinator.config = {} + config = { + "context_intelligence_server_url": "http://tool:8000", + "context_intelligence_api_key": "tool-key", + "workspace": "tool-ws", + } + tool = GraphQueryTool(coordinator=coordinator, config=config) + + server_url, api_key, workspace = tool._resolve_server_config(coordinator) + + assert server_url == "http://tool:8000" + assert api_key == "tool-key" + assert workspace == "tool-ws" + + def test_caches_hook_resolver_on_attribute(self) -> None: + """After resolution, _hook_resolver is set to the hook resolver object.""" + from amplifier_module_tool_graph_query.graph_query_tool import GraphQueryTool + + hook = _make_resolver( + server_url="http://hook:9000", + api_key="hook-key", + workspace="hook-ws", + ) + coordinator = _make_coordinator(resolver=hook) + tool = GraphQueryTool(coordinator=coordinator) + + tool._resolve_server_config(coordinator) + + assert tool._hook_resolver is hook + + +# --------------------------------------------------------------------------- +# TestExpandEnvPlaceholders — unit tests for the helper (Case 4) +# --------------------------------------------------------------------------- + + +class TestExpandEnvPlaceholders: + """Unit tests for _expand_env_placeholders in context_intelligence.config.""" + + def test_var_syntax_env_set(self, monkeypatch) -> None: + """${VAR} with env set → env var value.""" + from context_intelligence.config import _expand_env_placeholders + + monkeypatch.setenv("_CI_TEST_PLACEHOLDER_VAR", "hello") + assert _expand_env_placeholders("${_CI_TEST_PLACEHOLDER_VAR}") == "hello" + + def test_var_syntax_env_unset(self, monkeypatch) -> None: + """${VAR} with env unset → empty string.""" + from context_intelligence.config import _expand_env_placeholders + + monkeypatch.delenv("_CI_TEST_PLACEHOLDER_VAR", raising=False) + assert _expand_env_placeholders("${_CI_TEST_PLACEHOLDER_VAR}") == "" + + def test_var_colon_empty_default_env_set(self, monkeypatch) -> None: + """${VAR:} with env set → env var value.""" + from context_intelligence.config import _expand_env_placeholders + + monkeypatch.setenv("_CI_TEST_PLACEHOLDER_VAR", "world") + assert _expand_env_placeholders("${_CI_TEST_PLACEHOLDER_VAR:}") == "world" + + def test_var_colon_empty_default_env_unset(self, monkeypatch) -> None: + """${VAR:} with env unset → empty string.""" + from context_intelligence.config import _expand_env_placeholders + + monkeypatch.delenv("_CI_TEST_PLACEHOLDER_VAR", raising=False) + assert _expand_env_placeholders("${_CI_TEST_PLACEHOLDER_VAR:}") == "" + + def test_var_with_default_env_unset(self, monkeypatch) -> None: + """${VAR:default} with env unset → 'default'.""" + from context_intelligence.config import _expand_env_placeholders + + monkeypatch.delenv("_CI_TEST_PLACEHOLDER_VAR", raising=False) + assert _expand_env_placeholders("${_CI_TEST_PLACEHOLDER_VAR:my_default}") == "my_default" + + def test_var_with_default_env_set(self, monkeypatch) -> None: + """${VAR:default} with env set → env var value (default is ignored).""" + from context_intelligence.config import _expand_env_placeholders + + monkeypatch.setenv("_CI_TEST_PLACEHOLDER_VAR", "from_env") + assert _expand_env_placeholders("${_CI_TEST_PLACEHOLDER_VAR:my_default}") == "from_env" + + def test_plain_string_passes_through_unchanged(self) -> None: + """A non-placeholder string passes through unchanged.""" + from context_intelligence.config import _expand_env_placeholders + + assert _expand_env_placeholders("http://plain:8000") == "http://plain:8000" + + def test_multiple_placeholders_in_one_string(self, monkeypatch) -> None: + """Multiple ${VAR} placeholders in a single string — one re.sub pass expands all.""" + from context_intelligence.config import _expand_env_placeholders + + monkeypatch.setenv("_CI_TEST_PLACEHOLDER_VAR_A", "valA") + monkeypatch.setenv("_CI_TEST_PLACEHOLDER_VAR_B", "valB") + assert ( + _expand_env_placeholders("${_CI_TEST_PLACEHOLDER_VAR_A}:${_CI_TEST_PLACEHOLDER_VAR_B}") + == "valA:valB" + ) + + +# --------------------------------------------------------------------------- +# TestToolConfigResolverPlaceholderExpansion — Cases 1-3 +# --------------------------------------------------------------------------- + + +class TestToolConfigResolverPlaceholderExpansion: + """ToolConfigResolver must expand ${VAR} placeholders from the config dict. + + In analytics-only mode, agent behaviors ship config values like: + context_intelligence_server_url: "${AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL:}" + These arrive as literal strings in the mount() config dict. Without + expansion they are truthy and short-circuit the env-var fallback chain. + """ + + def test_server_url_placeholder_with_env_set(self, monkeypatch) -> None: + """Case 1: placeholder config + env set → real URL and api_key are both returned.""" + from context_intelligence.tool_resolver import ToolConfigResolver + + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", "http://real:8000") + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY", "real-key") + # Isolate the settings.yaml fallback so only env var or placeholder matters. + import context_intelligence.tool_resolver as _tr + + monkeypatch.setattr(_tr, "_parse_settings_yaml", lambda _: {}) + + config = { + "context_intelligence_server_url": "${AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL:}", + "context_intelligence_api_key": "${AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY:}", + } + resolver = ToolConfigResolver(config=config, coordinator=MagicMock()) + + assert resolver.context_intelligence_server_url == "http://real:8000" + assert resolver.context_intelligence_api_key == "real-key" + + def test_server_url_placeholder_with_env_unset_returns_none(self, monkeypatch) -> None: + """Case 2: placeholder config + env unset → None (falls through entire chain).""" + from context_intelligence.tool_resolver import ToolConfigResolver + + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", raising=False) + import context_intelligence.tool_resolver as _tr + + monkeypatch.setattr(_tr, "_parse_settings_yaml", lambda _: {}) + + config = { + "context_intelligence_server_url": "${AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL:}" + } + coordinator = MagicMock() + coordinator.config = {} # ensure _coordinator_config_get returns None + resolver = ToolConfigResolver(config=config, coordinator=coordinator) + + assert resolver.context_intelligence_server_url is None + + def test_api_key_placeholder_with_env_set(self, monkeypatch) -> None: + """Case 3: api_key placeholder config + env set → 'secret' is returned.""" + from context_intelligence.tool_resolver import ToolConfigResolver + + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY", "secret") + import context_intelligence.tool_resolver as _tr + + monkeypatch.setattr(_tr, "_parse_settings_yaml", lambda _: {}) + + config = {"context_intelligence_api_key": "${AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY:}"} + resolver = ToolConfigResolver(config=config, coordinator=MagicMock()) + + assert resolver.context_intelligence_api_key == "secret" + + def test_workspace_placeholder_with_env_set(self, monkeypatch) -> None: + """workspace placeholder config + env set → env value is returned.""" + from context_intelligence.tool_resolver import ToolConfigResolver + + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE", "_CI_TEST_my-ws") + + config = {"workspace": "${AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE:}"} + resolver = ToolConfigResolver(config=config, coordinator=MagicMock()) + + assert resolver.workspace == "_CI_TEST_my-ws" + + def test_server_url_placeholder_via_coordinator_config(self, monkeypatch) -> None: + """coordinator.config placeholder + env set → env URL returned (step 2 of chain).""" + from context_intelligence.tool_resolver import ToolConfigResolver + + monkeypatch.setenv( + "AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL", "http://_ci_test_coord:7000" + ) + import context_intelligence.tool_resolver as _tr + + monkeypatch.setattr(_tr, "_parse_settings_yaml", lambda _: {}) + + # No key in mount config dict (step 1 absent) — placeholder lives in coordinator.config + config: dict = {} + coordinator = MagicMock() + coordinator.config = { + "context_intelligence_server_url": ("${AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL:}") + } + resolver = ToolConfigResolver(config=config, coordinator=coordinator) + + assert resolver.context_intelligence_server_url == "http://_ci_test_coord:7000" diff --git a/modules/tool-graph-query/tests/test_mount.py b/modules/tool-graph-query/tests/test_mount.py index d97b8437..f6d55876 100644 --- a/modules/tool-graph-query/tests/test_mount.py +++ b/modules/tool-graph-query/tests/test_mount.py @@ -71,3 +71,74 @@ async def test_mount_returns_metadata_dict(self) -> None: assert isinstance(result, dict) assert result["tool"] == "graph_query" assert result["status"] == "mounted" + + async def test_config_dict_passed_to_tool_constructor(self) -> None: + """Config dict is forwarded to the tool so it can resolve server_url and workspace.""" + from amplifier_module_tool_graph_query import mount + + coordinator = MagicMock() + coordinator.mount = AsyncMock() + await mount( + coordinator, + config={"context_intelligence_server_url": "http://test", "workspace": "ws1"}, + ) + tool = coordinator.mount.call_args.args[1] + assert tool._config["context_intelligence_server_url"] == "http://test" + assert tool._config["workspace"] == "ws1" + + +class TestOnSessionReadyWiring: + """on_session_ready is exposed at module level and mount() registers the tool capability.""" + + def test_module_exposes_on_session_ready(self) -> None: + import amplifier_module_tool_graph_query as mod + + fn = getattr(mod, "on_session_ready", None) + assert fn is not None + assert inspect.iscoroutinefunction(fn) + sig = inspect.signature(fn) + first_param = list(sig.parameters.keys())[0] + assert first_param == "coordinator" + + async def test_mount_registers_graph_query_tool_capability(self) -> None: + from amplifier_module_tool_graph_query import mount + + coordinator = MagicMock() + coordinator.mount = AsyncMock() + coordinator.register_capability = MagicMock() + await mount(coordinator, config={}) + names = [c.args[0] for c in coordinator.register_capability.call_args_list] + assert "context_intelligence._graph_query_tool" in names + + +class TestSkillSyncEnabledConfig: + """The skill_sync_enabled knob is forwarded to the tool and resolves.""" + + async def test_config_skill_sync_enabled_false_forwarded_and_resolves( + self, monkeypatch + ) -> None: + from amplifier_module_tool_graph_query import mount + + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SKILL_SYNC_ENABLED", raising=False) + coordinator = MagicMock() + coordinator.config = {} # real dict so the resolver coordinator-level read is clean + coordinator.mount = AsyncMock() + + await mount(coordinator, config={"skill_sync_enabled": False}) + + tool = coordinator.mount.call_args.args[1] + assert tool._config["skill_sync_enabled"] is False + assert tool.skill_sync_enabled is False + + async def test_default_skill_sync_enabled_is_true(self, monkeypatch) -> None: + from amplifier_module_tool_graph_query import mount + + monkeypatch.delenv("AMPLIFIER_CONTEXT_INTELLIGENCE_SKILL_SYNC_ENABLED", raising=False) + coordinator = MagicMock() + coordinator.config = {} + coordinator.mount = AsyncMock() + + await mount(coordinator, config={}) + + tool = coordinator.mount.call_args.args[1] + assert tool.skill_sync_enabled is True diff --git a/modules/tool-graph-query/tests/test_skill_fetcher.py b/modules/tool-graph-query/tests/test_skill_fetcher.py new file mode 100644 index 00000000..18f36906 --- /dev/null +++ b/modules/tool-graph-query/tests/test_skill_fetcher.py @@ -0,0 +1,337 @@ +"""Tests for SkillFetcher (relocated into tool-graph-query) — conditional HTTP GET.""" + +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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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_graph_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-graph-query/tests/test_skill_sync.py b/modules/tool-graph-query/tests/test_skill_sync.py new file mode 100644 index 00000000..78d6faef --- /dev/null +++ b/modules/tool-graph-query/tests/test_skill_sync.py @@ -0,0 +1,442 @@ +"""Tests for skill_sync — offline integrity + per-skill sync helpers.""" + +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_graph_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_graph_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_graph_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_graph_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_graph_query.skill_sync.SkillFetcher") as mock_fetcher: + 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_graph_query.skill_fetcher import VersionCheckResult + from amplifier_module_tool_graph_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_graph_query.skill_sync.SkillFetcher", + 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_graph_query.skill_fetcher import VersionCheckResult + from amplifier_module_tool_graph_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_graph_query.skill_sync.SkillFetcher", + 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_graph_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_graph_query.skill_sync._sync_skill", + 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_graph_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_graph_query.skill_sync._sync_skill", + 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: + from amplifier_module_tool_graph_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_graph_query.skill_sync._sync_skill", + 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_graph_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_graph_query.skill_sync._sync_skill", + 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: + from amplifier_module_tool_graph_query.bundled_skill import EXPECTED_BUNDLED_SKILL_SHA256 + from amplifier_module_tool_graph_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_graph_query.skill_sync.SkillFetcher") as mock_fetcher, + patch( + "amplifier_module_tool_graph_query.skill_sync._sync_skill", + 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_graph_query.bundled_skill import EXPECTED_BUNDLED_SKILL_SHA256 + from amplifier_module_tool_graph_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_graph_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_graph_query.bundled_skill import EXPECTED_BUNDLED_SKILL_SHA256 + from amplifier_module_tool_graph_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_graph_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_graph_query.skill_sync.SkillFetcher") 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_graph_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_graph_query.skill_sync._vendored_body", + 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_graph_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_graph_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_graph_query.skill_sync._sync_skill", + 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_graph_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_graph_query.skill_sync._sync_skill", + 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-graph-query/uv.lock b/modules/tool-graph-query/uv.lock index 8d2a6e23..b7a8387a 100644 --- a/modules/tool-graph-query/uv.lock +++ b/modules/tool-graph-query/uv.lock @@ -5,25 +5,12 @@ requires-python = ">=3.11" [[package]] name = "amplifier-bundle-context-intelligence" version = "0.1.1" -source = { editable = "../../" } - -[package.metadata] - -[package.metadata.requires-dev] -dev = [ - { name = "httpx", specifier = ">=0.25" }, - { name = "idna", specifier = ">=3.15" }, - { name = "pyright", specifier = ">=1.1" }, - { name = "pytest", specifier = ">=9.0.3" }, - { name = "pytest-asyncio", specifier = ">=0.24" }, - { name = "pyyaml", specifier = ">=6.0" }, - { name = "ruff", specifier = ">=0.4" }, -] +source = { git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main#5509b397eb61054039ba2e62a1e898be4b1d5519" } [[package]] name = "amplifier-core" -version = "1.2.4" -source = { git = "https://github.com/microsoft/amplifier-core?branch=main#7a99cbbcb6b191872e9ffd5cf4beed18a59511c2" } +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "pydantic" }, @@ -31,6 +18,14 @@ dependencies = [ { name = "tomli" }, { name = "typing-extensions" }, ] +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/90/d520390cd91aae3d02db53653f828046089c79203dbb142e9bda346fa1d6/amplifier_core-1.6.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d35130e4262cf0db2d6c5f7e65e244a9ef2c7397bfe2a9853bc9b0d9fd05be64", size = 8113151, upload-time = "2026-05-18T16:13:46.825Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/3ab3126ba5a6f2fc6051a4d08e42364899e4c9ac4daa9d0a60947bf8acd1/amplifier_core-1.6.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:387a2c58fcf4caefdb45c52ec228307bc225e73606897f242154782bc3e123da", size = 7268223, upload-time = "2026-05-18T16:13:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/21/22/5a36160b3487170bcba0cbc61535101ff624e8314ed38fd35e561cb711a1/amplifier_core-1.6.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8344fccdedd725a51c018de17867cdf1c35abb571dabc0bbccdb5c1242324a47", size = 7532259, upload-time = "2026-05-18T16:13:50.614Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d7/3874c2308523209411367cf3b8b690e14e869f5f6bfb64cb1b1971e06a96/amplifier_core-1.6.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a8e0103242a2e2a975c880b1de0e5a02501e0421c1e5386dadae3f111e1d2b5", size = 8507642, upload-time = "2026-05-18T16:13:52.977Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/3646a89537b4556274183519f6db9c354fb3d183f52ef4a2179af12dd386/amplifier_core-1.6.0-cp311-abi3-win_amd64.whl", hash = "sha256:5113aa2d88038776eb257af9e7d9de7af13b3cd9097d2ac67aef5730fa0678e3", size = 8910313, upload-time = "2026-05-18T16:13:55.249Z" }, + { url = "https://files.pythonhosted.org/packages/9f/9e/58b141115e5eea65703f0b01459eefed36b561e9642ba96d48542345cd8f/amplifier_core-1.6.0-cp311-abi3-win_arm64.whl", hash = "sha256:e1b2731dc09d1cbc668b411007e7f9a2c7edbd75b2525407cae1e6b4a4de0b83", size = 7661416, upload-time = "2026-05-18T16:13:57.513Z" }, +] [[package]] name = "amplifier-module-tool-graph-query" @@ -53,14 +48,14 @@ dev = [ [package.metadata] requires-dist = [ - { name = "amplifier-bundle-context-intelligence", editable = "../../" }, + { name = "amplifier-bundle-context-intelligence", git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "idna", specifier = ">=3.15" }, ] [package.metadata.requires-dev] dev = [ - { name = "amplifier-core", git = "https://github.com/microsoft/amplifier-core?branch=main" }, + { name = "amplifier-core", specifier = ">=1.6.0" }, { name = "pyright", specifier = ">=1.1" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=0.24" }, diff --git a/skills/context-intelligence-session-navigation/SKILL.md b/skills/context-intelligence-session-navigation/SKILL.md index 15ebe2af..e240e48a 100644 --- a/skills/context-intelligence-session-navigation/SKILL.md +++ b/skills/context-intelligence-session-navigation/SKILL.md @@ -53,7 +53,7 @@ Each line in `events.jsonl` is a single JSON object with exactly **four** fields | `timestamp` | string | ISO 8601 timestamp of when the event was recorded | | `data` | object | Raw event payload, exactly as the kernel emitted it | -**Key principle:** No field promotion, no level classification, no payload mutation. What the kernel emits is exactly what gets stored in `data`. The `workspace` field is the only addition the hook makes — it is injected at write time from `ConfigResolver.workspace`. +**Key principle:** No field promotion, no level classification, no payload mutation. What the kernel emits is exactly what gets stored in `data`. The `workspace` field is the only addition the hook makes — it is injected at write time from `HookConfigResolver.workspace`. ---