From 7a52dff5000a73f0e8e803d46d04d7133e502fdd Mon Sep 17 00:00:00 2001 From: dgenio Date: Mon, 9 Mar 2026 14:44:16 +0000 Subject: [PATCH 1/6] feat: add Python logging integration with structured events (#111) Add stdlib logging across all four subsystems (context, routing, store, adapters) with structured log messages: - context pipeline: DEBUG for each stage (candidates, scoring, dedup, selection, firewall, sensitivity), INFO for build completion - routing: INFO for route completion with top-k scores - store: DEBUG for append/put/add/search operations - adapters: DEBUG for MCP and A2A conversions Safety: sensitivity filter never logs text content (sensitivity guard). Performance: lazy %s formatting zero cost at default WARNING level. Also updates: - CHANGELOG.md with [Unreleased] entry - AGENTS.md and .claude/CLAUDE.md debugging tips - New test file: tests/test_logging.py (16 tests) --- .claude/CLAUDE.md | 2 + AGENTS.md | 2 + CHANGELOG.md | 7 + src/contextweaver/adapters/a2a.py | 10 + src/contextweaver/adapters/mcp.py | 11 ++ src/contextweaver/context/candidates.py | 14 +- src/contextweaver/context/dedup.py | 5 + src/contextweaver/context/firewall.py | 9 + src/contextweaver/context/manager.py | 22 +++ src/contextweaver/context/scoring.py | 11 ++ src/contextweaver/context/selection.py | 12 ++ src/contextweaver/context/sensitivity.py | 10 + src/contextweaver/routing/router.py | 9 + src/contextweaver/store/artifacts.py | 4 + src/contextweaver/store/episodic.py | 8 +- src/contextweaver/store/event_log.py | 4 + src/contextweaver/store/facts.py | 4 + tests/test_logging.py | 242 +++++++++++++++++++++++ 18 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 tests/test_logging.py diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 3826778..fbd3b54 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -80,6 +80,8 @@ 3. `make test` — test suite. 4. Check `BuildStats` fields to understand what the context engine dropped and why. 5. Use `ContextManager.artifact_store.list_refs()` to inspect intercepted tool outputs. +6. Enable `logging.DEBUG` on `contextweaver.context` to trace pipeline stages. +7. Enable `logging.DEBUG` on `contextweaver.routing` to trace beam search. ## Running Tests diff --git a/AGENTS.md b/AGENTS.md index fa1aa12..0e12866 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,6 +145,8 @@ See [docs/agent-context/invariants.md](docs/agent-context/invariants.md) for the 3. `make test` — run the test suite. 4. Check `BuildStats` fields to understand what the context engine dropped and why. 5. Use `ContextManager.artifact_store.list_refs()` to inspect intercepted tool outputs. +6. Enable `logging.DEBUG` on `contextweaver.context` to trace pipeline stages (candidate counts, scores, drops, budget usage). +7. Enable `logging.DEBUG` on `contextweaver.routing` to trace beam search expansions and scoring. ## Adding a Feature diff --git a/CHANGELOG.md b/CHANGELOG.md index e4168f3..8e55f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Python `logging` integration with structured events across all subsystems (#111) + - Loggers: `contextweaver.context`, `contextweaver.routing`, `contextweaver.store`, `contextweaver.adapters` + - DEBUG-level messages at each context pipeline stage (candidate generation, scoring, dedup, selection, firewall, sensitivity) + - INFO-level summary messages for context builds and route completions + - Sensitivity guard: item text content is never logged at any level + ## [0.1.5] - 2026-03-07 ### Added diff --git a/src/contextweaver/adapters/a2a.py b/src/contextweaver/adapters/a2a.py index ed532fe..49409d6 100644 --- a/src/contextweaver/adapters/a2a.py +++ b/src/contextweaver/adapters/a2a.py @@ -10,6 +10,7 @@ from __future__ import annotations +import logging from pathlib import Path from typing import Any, Literal @@ -17,6 +18,8 @@ from contextweaver.exceptions import CatalogError from contextweaver.types import ArtifactRef, ContextItem, ItemKind, SelectableItem +logger = logging.getLogger("contextweaver.adapters") + def a2a_agent_to_selectable(agent_card: dict[str, Any]) -> SelectableItem: """Convert an A2A agent card dict to a :class:`SelectableItem`. @@ -62,6 +65,7 @@ def a2a_agent_to_selectable(agent_card: dict[str, Any]) -> SelectableItem: input_modes: list[str] = agent_card.get("defaultInputModes") or [] output_modes: list[str] = agent_card.get("defaultOutputModes") or [] + logger.debug("a2a_agent_to_selectable: name=%s, skills=%d", name, len(skills)) return SelectableItem( id=f"a2a:{name}", kind="agent", @@ -161,6 +165,12 @@ def a2a_result_to_envelope( if ":" in stripped and len(stripped) < 200: facts.append(stripped) + logger.debug( + "a2a_result_to_envelope: agent=%s, status=%s, artifacts=%d", + agent_name, + env_status, + len(artifact_refs), + ) return ResultEnvelope( status=env_status, summary=summary[:500] if len(summary) > 500 else summary, diff --git a/src/contextweaver/adapters/mcp.py b/src/contextweaver/adapters/mcp.py index 97ba98a..5bf7a9f 100644 --- a/src/contextweaver/adapters/mcp.py +++ b/src/contextweaver/adapters/mcp.py @@ -10,6 +10,7 @@ from __future__ import annotations +import logging from pathlib import Path from typing import Any, Literal @@ -17,6 +18,8 @@ from contextweaver.exceptions import CatalogError from contextweaver.types import ArtifactRef, ContextItem, ItemKind, SelectableItem +logger = logging.getLogger("contextweaver.adapters") + def infer_namespace(tool_name: str) -> str: """Infer a namespace from an MCP tool name. @@ -102,6 +105,7 @@ def mcp_tool_to_selectable(tool_def: dict[str, Any]) -> SelectableItem: side_effects = not annotations.get("readOnlyHint", False) cost_hint = float(annotations.get("costHint", 0.0)) + logger.debug("mcp_tool_to_selectable: name=%s, tags=%s", name, sorted(tags)) return SelectableItem( id=f"mcp:{name}", kind="tool", @@ -291,6 +295,13 @@ def mcp_result_to_envelope( artifacts=artifacts, provenance=provenance, ) + logger.debug( + "mcp_result_to_envelope: tool=%s, status=%s, artifacts=%d, facts=%d", + tool_name, + status, + len(artifacts), + len(facts[:20]), + ) return envelope, binaries, full_text diff --git a/src/contextweaver/context/candidates.py b/src/contextweaver/context/candidates.py index eb451cc..368d0bb 100644 --- a/src/contextweaver/context/candidates.py +++ b/src/contextweaver/context/candidates.py @@ -7,11 +7,15 @@ from __future__ import annotations +import logging + from contextweaver.config import ContextPolicy from contextweaver.exceptions import ItemNotFoundError from contextweaver.protocols import EventLog from contextweaver.types import ContextItem, Phase +logger = logging.getLogger("contextweaver.context") + def generate_candidates( event_log: EventLog, @@ -32,7 +36,14 @@ def generate_candidates( A list of :class:`~contextweaver.types.ContextItem` in log order. """ allowed = set(policy.allowed_kinds_per_phase.get(phase, [])) - return [item for item in event_log.all() if item.kind in allowed] + result = [item for item in event_log.all() if item.kind in allowed] + logger.debug( + "generate_candidates: phase=%s, allowed_kinds=%s, candidates=%d", + phase.value, + sorted(k.value for k in allowed), + len(result), + ) + return result def resolve_dependency_closure( @@ -73,4 +84,5 @@ def resolve_dependency_closure( # Preserve log order by re-sorting via original indices all_ids = {item.id: item for item in items + extra} ordered = [item for item in event_log.all() if item.id in all_ids] + logger.debug("dependency_closure: added=%d parents", closures) return ordered, closures diff --git a/src/contextweaver/context/dedup.py b/src/contextweaver/context/dedup.py index 419c61f..e3642b1 100644 --- a/src/contextweaver/context/dedup.py +++ b/src/contextweaver/context/dedup.py @@ -6,9 +6,13 @@ from __future__ import annotations +import logging + from contextweaver._utils import jaccard, tokenize from contextweaver.types import ContextItem +logger = logging.getLogger("contextweaver.context") + # FUTURE: merge compression to combine near-duplicate items instead of dropping. @@ -50,4 +54,5 @@ def deduplicate_candidates( else: removed += 1 + logger.debug("deduplicate_candidates: removed=%d, kept=%d", removed, len(kept)) return kept, removed diff --git a/src/contextweaver/context/firewall.py b/src/contextweaver/context/firewall.py index aa84caa..fc441be 100644 --- a/src/contextweaver/context/firewall.py +++ b/src/contextweaver/context/firewall.py @@ -8,6 +8,7 @@ from __future__ import annotations +import logging from typing import Literal from contextweaver.context.views import ViewRegistry, generate_views @@ -16,6 +17,8 @@ from contextweaver.summarize.extract import extract_facts from contextweaver.types import ContextItem, ItemKind +logger = logging.getLogger("contextweaver.context") + def _default_summary(raw: str, max_chars: int = 500) -> str: """Return a truncated first-paragraph summary of *raw*.""" @@ -114,6 +117,7 @@ def apply_firewall( ) _hook.on_firewall_triggered(item, "tool_result intercepted") + logger.debug("firewall: intercepted item_id=%s, summary_len=%d", item.id, len(summary)) return processed, envelope @@ -147,4 +151,9 @@ def apply_firewall_to_batch( processed.append(p) if env is not None: envelopes.append(env) + logger.debug( + "firewall_batch: processed=%d, intercepted=%d", + len(processed), + len(envelopes), + ) return processed, envelopes diff --git a/src/contextweaver/context/manager.py b/src/contextweaver/context/manager.py index 78895fb..f1235d6 100644 --- a/src/contextweaver/context/manager.py +++ b/src/contextweaver/context/manager.py @@ -14,6 +14,7 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING, Any, Literal from contextweaver.config import ContextBudget, ContextPolicy, ScoringConfig @@ -52,6 +53,8 @@ from contextweaver.routing.catalog import Catalog from contextweaver.routing.router import Router, RouteResult +logger = logging.getLogger("contextweaver.context") + class ContextManager: """Orchestrates the full context compilation pipeline. @@ -146,6 +149,7 @@ def ingest(self, item: ContextItem) -> None: item: The context item to ingest. """ self._event_log.append(item) + logger.debug("ingest: item_id=%s, kind=%s", item.id, item.kind.value) def ingest_sync(self, item: ContextItem) -> None: """Synchronous alias for :meth:`ingest`.""" @@ -205,6 +209,11 @@ def ingest_tool_result( # Shouldn't happen for tool_result items, but be safe envelope = ResultEnvelope(status="ok", summary=raw_output[:500]) self._event_log.append(processed) + logger.debug( + "ingest_tool_result: item_id=%s, firewall=True, output_len=%d", + processed.id, + len(raw_output), + ) return processed, envelope # Small output: extract facts and store in artifact store to enable drilldown @@ -248,6 +257,11 @@ def ingest_tool_result( artifact_ref=ref, ) self._event_log.append(item) + logger.debug( + "ingest_tool_result: item_id=%s, firewall=False, output_len=%d", + item.id, + len(raw_output), + ) return item, envelope def ingest_tool_result_sync( @@ -620,6 +634,14 @@ def _build( pack = ContextPack(prompt=prompt, stats=stats, phase=phase, envelopes=envelopes) self._hook.on_context_built(pack) + logger.info( + "context build: phase=%s, included=%d, dropped=%d, tokens=%d/%d", + phase.value, + stats.included_count, + stats.dropped_count, + sum(stats.tokens_per_section.values()), + effective_budget.for_phase(phase), + ) return pack async def build( diff --git a/src/contextweaver/context/scoring.py b/src/contextweaver/context/scoring.py index f10b576..13b9eb2 100644 --- a/src/contextweaver/context/scoring.py +++ b/src/contextweaver/context/scoring.py @@ -6,10 +6,14 @@ from __future__ import annotations +import logging + from contextweaver._utils import jaccard, tokenize from contextweaver.config import ScoringConfig from contextweaver.types import ContextItem, ItemKind +logger = logging.getLogger("contextweaver.context") + # Higher value → higher priority when included in context _KIND_PRIORITY: dict[ItemKind, float] = { ItemKind.policy: 1.0, @@ -96,4 +100,11 @@ def score_candidates( for i, item in enumerate(items) ] scored.sort(key=lambda x: (-x[0], x[1].id)) + if scored: + logger.debug( + "score_candidates: total=%d, top_score=%.4f, bottom_score=%.4f", + len(scored), + scored[0][0], + scored[-1][0], + ) return scored diff --git a/src/contextweaver/context/selection.py b/src/contextweaver/context/selection.py index 9b33553..fd3c0a8 100644 --- a/src/contextweaver/context/selection.py +++ b/src/contextweaver/context/selection.py @@ -6,11 +6,15 @@ from __future__ import annotations +import logging + from contextweaver.config import ContextBudget, ContextPolicy from contextweaver.envelope import BuildStats from contextweaver.protocols import TokenEstimator from contextweaver.types import ContextItem, Phase +logger = logging.getLogger("contextweaver.context") + def select_and_pack( scored: list[tuple[float, ContextItem]], @@ -81,4 +85,12 @@ def select_and_pack( dropped_count=dropped, dropped_reasons=dropped_reasons, ) + logger.debug( + "select_and_pack: included=%d, dropped=%d, tokens=%d/%d, reasons=%s", + included, + dropped, + tokens_used, + token_limit, + dropped_reasons, + ) return selected, stats diff --git a/src/contextweaver/context/sensitivity.py b/src/contextweaver/context/sensitivity.py index 251a9a3..e92e32b 100644 --- a/src/contextweaver/context/sensitivity.py +++ b/src/contextweaver/context/sensitivity.py @@ -16,12 +16,15 @@ from __future__ import annotations +import logging from dataclasses import replace from contextweaver.config import ContextPolicy from contextweaver.protocols import RedactionHook from contextweaver.types import ContextItem, Sensitivity +logger = logging.getLogger("contextweaver.context") + # Ordered severity levels for comparison. _SENSITIVITY_ORDER: dict[Sensitivity, int] = { Sensitivity.public: 0, @@ -152,4 +155,11 @@ def apply_sensitivity_filter( else: result.append(item) + logger.debug( + "sensitivity_filter: action=%s, floor=%s, passed=%d, dropped=%d", + action, + policy.sensitivity_floor.value, + len(result), + dropped, + ) return result, dropped diff --git a/src/contextweaver/routing/router.py b/src/contextweaver/routing/router.py index ff321bc..85bab36 100644 --- a/src/contextweaver/routing/router.py +++ b/src/contextweaver/routing/router.py @@ -6,6 +6,7 @@ from __future__ import annotations +import logging from dataclasses import dataclass, field from typing import Any @@ -14,6 +15,8 @@ from contextweaver.routing.graph import ChoiceGraph from contextweaver.types import SelectableItem +logger = logging.getLogger("contextweaver.routing") + # --------------------------------------------------------------------------- # RouteResult # --------------------------------------------------------------------------- @@ -295,6 +298,12 @@ def route(self, query: str, *, debug: bool = False) -> RouteResult: if debug: result.debug_trace = trace + logger.info( + "route: top_k=%d, candidates=%d, scores=%s", + self._top_k, + len(result.candidate_ids), + [round(s, 4) for s in result.scores[:5]], + ) return result def _expand_subtree( diff --git a/src/contextweaver/store/artifacts.py b/src/contextweaver/store/artifacts.py index 311feb9..ec3b9d6 100644 --- a/src/contextweaver/store/artifacts.py +++ b/src/contextweaver/store/artifacts.py @@ -7,11 +7,14 @@ from __future__ import annotations import json +import logging from typing import Any from contextweaver.exceptions import ArtifactNotFoundError from contextweaver.types import ArtifactRef +logger = logging.getLogger("contextweaver.store") + # FUTURE: FileArtifactStore backed by local filesystem for persistent storage. @@ -52,6 +55,7 @@ def put( ) self._data[handle] = content self._meta[handle] = ref + logger.debug("artifact_store.put: handle=%s, size=%d", handle, len(content)) return ref def get(self, handle: str) -> bytes: diff --git a/src/contextweaver/store/episodic.py b/src/contextweaver/store/episodic.py index c1c5dda..d673e0d 100644 --- a/src/contextweaver/store/episodic.py +++ b/src/contextweaver/store/episodic.py @@ -7,12 +7,15 @@ from __future__ import annotations +import logging from dataclasses import dataclass, field from typing import Any from contextweaver._utils import TfIdfScorer, jaccard, tokenize from contextweaver.exceptions import ItemNotFoundError +logger = logging.getLogger("contextweaver.store") + # FUTURE: vector retrieval backend for high-dimensional similarity search. @@ -66,6 +69,7 @@ def add(self, episode: Episode) -> None: """ self._episodes.append(episode) self._dirty = True + logger.debug("episodic_store.add: id=%s", episode.episode_id) def get(self, episode_id: str) -> Episode | None: """Return the episode with *episode_id*, or ``None`` if not found. @@ -104,7 +108,9 @@ def search(self, query: str, top_k: int = 5) -> list[Episode]: q_tokens = tokenize(query) scores = [jaccard(q_tokens, tokenize(ep.summary)) for ep in self._episodes] ranked = sorted(range(len(self._episodes)), key=lambda i: scores[i], reverse=True) - return [self._episodes[i] for i in ranked[:top_k]] + result = [self._episodes[i] for i in ranked[:top_k]] + logger.debug("episodic_store.search: query_len=%d, results=%d", len(query), len(result)) + return result def all(self) -> list[Episode]: """Return all episodes in insertion order.""" diff --git a/src/contextweaver/store/event_log.py b/src/contextweaver/store/event_log.py index 78d8354..b27d2ef 100644 --- a/src/contextweaver/store/event_log.py +++ b/src/contextweaver/store/event_log.py @@ -7,11 +7,14 @@ from __future__ import annotations +import logging from typing import Any from contextweaver.exceptions import ItemNotFoundError from contextweaver.types import ContextItem, ItemKind +logger = logging.getLogger("contextweaver.store") + class InMemoryEventLog: """Append-only, in-memory event log. @@ -37,6 +40,7 @@ def append(self, item: ContextItem) -> None: raise ValueError(f"Duplicate item id: {item.id!r}") self._index[item.id] = len(self._items) self._items.append(item) + logger.debug("event_log.append: id=%s, kind=%s", item.id, item.kind.value) def get(self, item_id: str) -> ContextItem: """Return the item with *item_id*. diff --git a/src/contextweaver/store/facts.py b/src/contextweaver/store/facts.py index f1ac51c..e5133ef 100644 --- a/src/contextweaver/store/facts.py +++ b/src/contextweaver/store/facts.py @@ -6,11 +6,14 @@ from __future__ import annotations +import logging from dataclasses import dataclass, field from typing import Any from contextweaver.exceptions import ItemNotFoundError +logger = logging.getLogger("contextweaver.store") + @dataclass class Fact: @@ -62,6 +65,7 @@ def put(self, fact: Fact) -> None: fact: The :class:`Fact` to store. """ self._facts[fact.fact_id] = fact + logger.debug("fact_store.put: id=%s, key=%s", fact.fact_id, fact.key) def get(self, fact_id: str) -> Fact: """Return the fact with *fact_id*. diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..f49551d --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,242 @@ +"""Tests for Python logging integration across contextweaver subsystems.""" + +from __future__ import annotations + +import logging + +import pytest + +from contextweaver.adapters.a2a import a2a_agent_to_selectable, a2a_result_to_envelope +from contextweaver.adapters.mcp import mcp_result_to_envelope, mcp_tool_to_selectable +from contextweaver.context.manager import ContextManager +from contextweaver.routing.router import Router +from contextweaver.routing.tree import TreeBuilder +from contextweaver.store.artifacts import InMemoryArtifactStore +from contextweaver.store.episodic import InMemoryEpisodicStore +from contextweaver.store.event_log import InMemoryEventLog +from contextweaver.store.facts import Fact, InMemoryFactStore +from contextweaver.types import ContextItem, ItemKind, Phase, SelectableItem + +# ------------------------------------------------------------------ +# Logger existence +# ------------------------------------------------------------------ + + +@pytest.mark.parametrize( + "logger_name", + [ + "contextweaver.context", + "contextweaver.routing", + "contextweaver.store", + "contextweaver.adapters", + ], +) +def test_logger_exists(logger_name: str) -> None: + """Each subsystem logger must be retrievable by name.""" + logger = logging.getLogger(logger_name) + assert logger.name == logger_name + + +# ------------------------------------------------------------------ +# Context pipeline logging +# ------------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_context_build_emits_info(caplog: pytest.LogCaptureFixture) -> None: + """A context build must emit an INFO-level summary.""" + log = InMemoryEventLog() + log.append(ContextItem(id="u1", kind=ItemKind.user_turn, text="hello")) + mgr = ContextManager(event_log=log) + + with caplog.at_level(logging.DEBUG, logger="contextweaver.context"): + await mgr.build(phase=Phase.answer, query="hello") + + info_messages = [r for r in caplog.records if r.levelno == logging.INFO] + assert any("context build" in r.message for r in info_messages) + + +@pytest.mark.asyncio +async def test_context_build_emits_debug_stages(caplog: pytest.LogCaptureFixture) -> None: + """DEBUG messages must appear for pipeline stages during a build.""" + log = InMemoryEventLog() + log.append(ContextItem(id="u1", kind=ItemKind.user_turn, text="search")) + mgr = ContextManager(event_log=log) + + with caplog.at_level(logging.DEBUG, logger="contextweaver.context"): + await mgr.build(phase=Phase.answer, query="search") + + messages = [r.message for r in caplog.records] + assert any("generate_candidates" in m for m in messages) + assert any("score_candidates" in m or "select_and_pack" in m for m in messages) + + +@pytest.mark.asyncio +async def test_context_build_no_text_content_logged(caplog: pytest.LogCaptureFixture) -> None: + """Item text must NEVER appear in any log record.""" + secret_text = "SUPER_SECRET_CONTENT_12345" + log = InMemoryEventLog() + log.append(ContextItem(id="u1", kind=ItemKind.user_turn, text=secret_text)) + mgr = ContextManager(event_log=log) + + with caplog.at_level(logging.DEBUG, logger="contextweaver"): + await mgr.build(phase=Phase.answer, query="test") + + for record in caplog.records: + assert secret_text not in record.message, ( + f"Item text content leaked into log: {record.message!r}" + ) + + +# ------------------------------------------------------------------ +# Ingest logging +# ------------------------------------------------------------------ + + +def test_ingest_emits_debug(caplog: pytest.LogCaptureFixture) -> None: + """Ingesting an item must emit a DEBUG log.""" + mgr = ContextManager() + item = ContextItem(id="item-1", kind=ItemKind.user_turn, text="hello") + + with caplog.at_level(logging.DEBUG, logger="contextweaver.context"): + mgr.ingest(item) + + assert any("ingest" in r.message and "item-1" in r.message for r in caplog.records) + + +def test_ingest_tool_result_emits_debug(caplog: pytest.LogCaptureFixture) -> None: + """Ingesting a tool result must emit a DEBUG log with firewall status.""" + mgr = ContextManager() + mgr.ingest(ContextItem(id="call-1", kind=ItemKind.tool_call, text="invoke tool")) + + with caplog.at_level(logging.DEBUG, logger="contextweaver.context"): + mgr.ingest_tool_result("call-1", "short result", tool_name="test_tool") + + assert any("ingest_tool_result" in r.message for r in caplog.records) + + +# ------------------------------------------------------------------ +# Routing logging +# ------------------------------------------------------------------ + + +def test_route_emits_info(caplog: pytest.LogCaptureFixture) -> None: + """A route query must emit an INFO-level summary.""" + items = [ + SelectableItem( + id="tool:search", + kind="tool", + name="search", + description="Search the database", + tags=["search"], + ), + SelectableItem( + id="tool:write", + kind="tool", + name="write", + description="Write to the database", + tags=["write"], + ), + ] + graph = TreeBuilder().build(items) + router = Router(graph, items=items) + + with caplog.at_level(logging.INFO, logger="contextweaver.routing"): + router.route("search") + + assert any("route" in r.message for r in caplog.records) + + +# ------------------------------------------------------------------ +# Adapter logging +# ------------------------------------------------------------------ + + +def test_mcp_tool_to_selectable_emits_debug(caplog: pytest.LogCaptureFixture) -> None: + """MCP tool conversion must emit a DEBUG log.""" + tool_def = {"name": "github.search", "description": "Search repos"} + + with caplog.at_level(logging.DEBUG, logger="contextweaver.adapters"): + mcp_tool_to_selectable(tool_def) + + assert any("mcp_tool_to_selectable" in r.message for r in caplog.records) + + +def test_mcp_result_to_envelope_emits_debug(caplog: pytest.LogCaptureFixture) -> None: + """MCP result conversion must emit a DEBUG log.""" + result = {"content": [{"type": "text", "text": "found 3 repos"}]} + + with caplog.at_level(logging.DEBUG, logger="contextweaver.adapters"): + mcp_result_to_envelope(result, "github.search") + + assert any("mcp_result_to_envelope" in r.message for r in caplog.records) + + +def test_a2a_agent_to_selectable_emits_debug(caplog: pytest.LogCaptureFixture) -> None: + """A2A agent card conversion must emit a DEBUG log.""" + card = {"name": "summarizer", "description": "Summarize text"} + + with caplog.at_level(logging.DEBUG, logger="contextweaver.adapters"): + a2a_agent_to_selectable(card) + + assert any("a2a_agent_to_selectable" in r.message for r in caplog.records) + + +def test_a2a_result_to_envelope_emits_debug(caplog: pytest.LogCaptureFixture) -> None: + """A2A result conversion must emit a DEBUG log.""" + result = {"status": {"state": "completed"}, "artifacts": []} + + with caplog.at_level(logging.DEBUG, logger="contextweaver.adapters"): + a2a_result_to_envelope(result, "summarizer") + + assert any("a2a_result_to_envelope" in r.message for r in caplog.records) + + +# ------------------------------------------------------------------ +# Store logging +# ------------------------------------------------------------------ + + +def test_event_log_append_emits_debug(caplog: pytest.LogCaptureFixture) -> None: + """Event log append must emit a DEBUG log.""" + log = InMemoryEventLog() + item = ContextItem(id="u1", kind=ItemKind.user_turn, text="hello") + + with caplog.at_level(logging.DEBUG, logger="contextweaver.store"): + log.append(item) + + assert any("event_log.append" in r.message and "u1" in r.message for r in caplog.records) + + +def test_artifact_store_put_emits_debug(caplog: pytest.LogCaptureFixture) -> None: + """Artifact store put must emit a DEBUG log.""" + store = InMemoryArtifactStore() + + with caplog.at_level(logging.DEBUG, logger="contextweaver.store"): + store.put("handle:1", b"data", "text/plain", "test") + + assert any("artifact_store.put" in r.message for r in caplog.records) + + +def test_episodic_store_add_emits_debug(caplog: pytest.LogCaptureFixture) -> None: + """Episodic store add must emit a DEBUG log.""" + from contextweaver.store.episodic import Episode + + store = InMemoryEpisodicStore() + ep = Episode(episode_id="ep-1", summary="test episode") + + with caplog.at_level(logging.DEBUG, logger="contextweaver.store"): + store.add(ep) + + assert any("episodic_store.add" in r.message for r in caplog.records) + + +def test_fact_store_put_emits_debug(caplog: pytest.LogCaptureFixture) -> None: + """Fact store put must emit a DEBUG log.""" + store = InMemoryFactStore() + fact = Fact(fact_id="f1", key="lang", value="python") + + with caplog.at_level(logging.DEBUG, logger="contextweaver.store"): + store.put(fact) + + assert any("fact_store.put" in r.message for r in caplog.records) From b6153ab2edf4acd9477dda57e2060d91b5400442 Mon Sep 17 00:00:00 2001 From: dgenio Date: Tue, 10 Mar 2026 06:31:47 +0000 Subject: [PATCH 2/6] fix: use len(envelope.facts) instead of len(facts[:20]) in mcp log --- src/contextweaver/adapters/mcp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contextweaver/adapters/mcp.py b/src/contextweaver/adapters/mcp.py index 5bf7a9f..53ad71c 100644 --- a/src/contextweaver/adapters/mcp.py +++ b/src/contextweaver/adapters/mcp.py @@ -300,7 +300,7 @@ def mcp_result_to_envelope( tool_name, status, len(artifacts), - len(facts[:20]), + len(envelope.facts), ) return envelope, binaries, full_text From 39210c1beb8aa66a6fd4fa0087078bd129e3496b Mon Sep 17 00:00:00 2001 From: dgenio Date: Tue, 10 Mar 2026 06:34:32 +0000 Subject: [PATCH 3/6] fix: guard route INFO log with isEnabledFor to avoid eager eval --- src/contextweaver/routing/router.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/contextweaver/routing/router.py b/src/contextweaver/routing/router.py index 85bab36..46d86ef 100644 --- a/src/contextweaver/routing/router.py +++ b/src/contextweaver/routing/router.py @@ -298,12 +298,13 @@ def route(self, query: str, *, debug: bool = False) -> RouteResult: if debug: result.debug_trace = trace - logger.info( - "route: top_k=%d, candidates=%d, scores=%s", - self._top_k, - len(result.candidate_ids), - [round(s, 4) for s in result.scores[:5]], - ) + if logger.isEnabledFor(logging.INFO): + logger.info( + "route: top_k=%d, candidates=%d, scores=%s", + self._top_k, + len(result.candidate_ids), + [round(s, 4) for s in result.scores[:5]], + ) return result def _expand_subtree( From 580265645ea7bd514f848630194cf9461e08721b Mon Sep 17 00:00:00 2001 From: dgenio Date: Tue, 10 Mar 2026 06:45:18 +0000 Subject: [PATCH 4/6] fix: add query_len to route INFO log for diagnostic value without content leakage --- src/contextweaver/routing/router.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/contextweaver/routing/router.py b/src/contextweaver/routing/router.py index 46d86ef..821ed9a 100644 --- a/src/contextweaver/routing/router.py +++ b/src/contextweaver/routing/router.py @@ -300,7 +300,8 @@ def route(self, query: str, *, debug: bool = False) -> RouteResult: if logger.isEnabledFor(logging.INFO): logger.info( - "route: top_k=%d, candidates=%d, scores=%s", + "route: query_len=%d, top_k=%d, candidates=%d, scores=%s", + len(query), self._top_k, len(result.candidate_ids), [round(s, 4) for s in result.scores[:5]], From b627fefd14aa80700a25e1e02b7c370aaeb765f0 Mon Sep 17 00:00:00 2001 From: dgenio Date: Tue, 10 Mar 2026 06:47:29 +0000 Subject: [PATCH 5/6] test: add redact-mode sensitivity logging guard test --- tests/test_logging.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/test_logging.py b/tests/test_logging.py index f49551d..e287252 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -15,7 +15,8 @@ from contextweaver.store.episodic import InMemoryEpisodicStore from contextweaver.store.event_log import InMemoryEventLog from contextweaver.store.facts import Fact, InMemoryFactStore -from contextweaver.types import ContextItem, ItemKind, Phase, SelectableItem +from contextweaver.config import ContextPolicy +from contextweaver.types import ContextItem, ItemKind, Phase, SelectableItem, Sensitivity # ------------------------------------------------------------------ # Logger existence @@ -88,6 +89,36 @@ async def test_context_build_no_text_content_logged(caplog: pytest.LogCaptureFix ) +@pytest.mark.asyncio +async def test_context_build_redact_mode_no_text_content_logged( + caplog: pytest.LogCaptureFixture, +) -> None: + """In redact mode, original item text must not appear in any log record.""" + secret_text = "REDACT_MODE_SECRET_67890" + log = InMemoryEventLog() + log.append( + ContextItem( + id="s1", + kind=ItemKind.user_turn, + text=secret_text, + sensitivity=Sensitivity.internal, + ) + ) + policy = ContextPolicy( + sensitivity_floor=Sensitivity.internal, + sensitivity_action="redact", + ) + mgr = ContextManager(event_log=log, policy=policy) + + with caplog.at_level(logging.DEBUG, logger="contextweaver"): + await mgr.build(phase=Phase.answer, query="test") + + for record in caplog.records: + assert secret_text not in record.message, ( + f"Original text leaked into log in redact mode: {record.message!r}" + ) + + # ------------------------------------------------------------------ # Ingest logging # ------------------------------------------------------------------ From 5d00f0ae14d3d93d0909107ae2f6328ef502f2d9 Mon Sep 17 00:00:00 2001 From: dgenio Date: Tue, 10 Mar 2026 06:51:25 +0000 Subject: [PATCH 6/6] fix: sort imports in test_logging.py (ruff I001) --- tests/test_logging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_logging.py b/tests/test_logging.py index e287252..6be77c1 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -8,6 +8,7 @@ from contextweaver.adapters.a2a import a2a_agent_to_selectable, a2a_result_to_envelope from contextweaver.adapters.mcp import mcp_result_to_envelope, mcp_tool_to_selectable +from contextweaver.config import ContextPolicy from contextweaver.context.manager import ContextManager from contextweaver.routing.router import Router from contextweaver.routing.tree import TreeBuilder @@ -15,7 +16,6 @@ from contextweaver.store.episodic import InMemoryEpisodicStore from contextweaver.store.event_log import InMemoryEventLog from contextweaver.store.facts import Fact, InMemoryFactStore -from contextweaver.config import ContextPolicy from contextweaver.types import ContextItem, ItemKind, Phase, SelectableItem, Sensitivity # ------------------------------------------------------------------