Skip to content
Merged
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Progressive disclosure for tool results: view registry + drilldown loop (#17)
- `ViewRegistry` class in `context/views.py` — maps content-type patterns to `ViewSpec` generators
- Built-in view generators for `application/json`, `text/csv`, `text/plain`, and binary/image content
- `generate_views()` function for auto-generating `ViewSpec` entries from artifact data
- `drilldown_tool_spec()` helper — generates a `SelectableItem` exposing drilldown as an agent-callable tool
- `ContextManager.drilldown()` / `drilldown_sync()` — agent-facing wrapper for `ArtifactStore.drilldown()` with optional context injection
- `ContextManager.view_registry` property for accessing/extending the view registry
- Auto-generated `ViewSpec` entries during `ingest_tool_result()` (both large and small outputs)
- Auto-generated `ViewSpec` entries during `apply_firewall()` via view registry
- Content-type detection heuristics for generic `application/octet-stream` artifacts
- Small tool outputs now stored in artifact store with `artifact_ref` for drilldown support

## [0.1.2] - 2026-03-04

### Added
Expand Down
4 changes: 4 additions & 0 deletions src/contextweaver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from contextweaver.config import ContextBudget, ContextPolicy, ScoringConfig
from contextweaver.context.manager import ContextManager
from contextweaver.context.sensitivity import MaskRedactionHook, register_redaction_hook
from contextweaver.context.views import ViewRegistry, drilldown_tool_spec, generate_views
from contextweaver.envelope import (
BuildStats,
ChoiceCard,
Expand Down Expand Up @@ -136,6 +137,9 @@
# context engine
"ContextManager",
"MaskRedactionHook",
"ViewRegistry",
"drilldown_tool_spec",
"generate_views",
"register_redaction_hook",
# routing engine
"Catalog",
Expand Down
4 changes: 4 additions & 0 deletions src/contextweaver/context/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,21 @@
apply_sensitivity_filter,
register_redaction_hook,
)
from contextweaver.context.views import ViewRegistry, drilldown_tool_spec, generate_views

__all__ = [
"ContextManager",
"MaskRedactionHook",
"ViewRegistry",
"apply_firewall",
"apply_firewall_to_batch",
"apply_sensitivity_filter",
"register_redaction_hook",
"build_schema_header",
"deduplicate_candidates",
"drilldown_tool_spec",
"generate_candidates",
"generate_views",
"render_context",
"render_item",
"resolve_dependency_closure",
Expand Down
10 changes: 9 additions & 1 deletion src/contextweaver/context/firewall.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from typing import Literal

from contextweaver.context.views import ViewRegistry, generate_views
from contextweaver.envelope import ResultEnvelope
from contextweaver.protocols import ArtifactStore, EventHook, NoOpHook
from contextweaver.summarize.extract import extract_facts
Expand All @@ -28,6 +29,7 @@ def apply_firewall(
item: ContextItem,
artifact_store: ArtifactStore,
hook: EventHook | None = None,
view_registry: ViewRegistry | None = None,
) -> tuple[ContextItem, ResultEnvelope | None]:
"""Intercept a ``tool_result`` item and store its content out-of-band.

Expand All @@ -38,6 +40,7 @@ def apply_firewall(
item: The candidate item to inspect.
artifact_store: Where to store the raw content.
hook: Optional lifecycle hook to notify on firewall trigger.
view_registry: Optional custom view registry for auto-view generation.

Returns:
A 2-tuple ``(processed_item, envelope_or_none)``. When the firewall
Expand Down Expand Up @@ -75,11 +78,14 @@ def apply_firewall(
facts = []
status = "error" if status == "error" else "partial"

views = generate_views(ref, raw_bytes, registry=view_registry)

envelope = ResultEnvelope(
status=status,
summary=summary,
facts=facts,
artifacts=[ref],
views=views,
provenance={"source_item_id": item.id},
)

Expand All @@ -101,21 +107,23 @@ def apply_firewall_to_batch(
items: list[ContextItem],
artifact_store: ArtifactStore,
hook: EventHook | None = None,
view_registry: ViewRegistry | None = None,
) -> tuple[list[ContextItem], list[ResultEnvelope]]:
"""Apply the firewall to a list of items.

Args:
items: Candidate items (may contain a mix of kinds).
artifact_store: Where to store raw tool outputs.
hook: Optional lifecycle hook.
view_registry: Optional custom view registry for auto-view generation.

Returns:
A 2-tuple of ``(processed_items, envelopes)``.
"""
processed = []
envelopes = []
for item in items:
p, env = apply_firewall(item, artifact_store, hook)
p, env = apply_firewall(item, artifact_store, hook, view_registry)
processed.append(p)
if env is not None:
envelopes.append(env)
Expand Down
104 changes: 100 additions & 4 deletions src/contextweaver/context/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from contextweaver.context.scoring import score_candidates
from contextweaver.context.selection import select_and_pack
from contextweaver.context.sensitivity import apply_sensitivity_filter
from contextweaver.context.views import ViewRegistry, generate_views
from contextweaver.envelope import ContextPack, ResultEnvelope
from contextweaver.protocols import (
ArtifactStore,
Expand Down Expand Up @@ -91,6 +92,7 @@ def __init__(
self._scoring = scoring_config or ScoringConfig()
self._estimator: TokenEstimator = estimator or CharDivFourEstimator()
self._hook: EventHook = hook or NoOpHook()
self._view_registry: ViewRegistry = ViewRegistry()

# ------------------------------------------------------------------
# Properties
Expand All @@ -116,6 +118,11 @@ def fact_store(self) -> InMemoryFactStore:
"""The underlying fact store."""
return self._fact_store

@property
def view_registry(self) -> ViewRegistry:
"""The view registry for auto-generating drilldown views."""
return self._view_registry

# ------------------------------------------------------------------
# Ingestion helpers
# ------------------------------------------------------------------
Expand Down Expand Up @@ -147,7 +154,10 @@ def ingest_tool_result(
"""Ingest a raw tool result through the context firewall.

If the raw output exceeds *firewall_threshold* characters it is stored
in the artifact store and the LLM sees only a summary.
in the artifact store and the LLM sees only a summary. Small outputs
are also stored in the artifact store (with ``artifact_ref`` set on the
returned item) to enable drilldown on all tool results regardless of
size.

Args:
tool_call_id: ID of the originating tool call.
Expand All @@ -158,7 +168,8 @@ def ingest_tool_result(
stores the raw output out-of-band.

Returns:
A ``(ContextItem, ResultEnvelope)`` tuple.
A ``(ContextItem, ResultEnvelope)`` tuple. The item always has a
non-``None`` ``artifact_ref``.
"""
item = ContextItem(
id=f"result:{tool_call_id}",
Expand All @@ -170,23 +181,46 @@ def ingest_tool_result(
)

if len(raw_output) > firewall_threshold:
processed, envelope = apply_firewall(item, self._artifact_store, self._hook)
processed, envelope = apply_firewall(
item, self._artifact_store, self._hook, self._view_registry
)
if envelope is None:
# Shouldn't happen for tool_result items, but be safe
envelope = ResultEnvelope(status="ok", summary=raw_output[:500])
self._event_log.append(processed)
return processed, envelope

# Small output: still extract facts but no artifact storage
# Small output: extract facts and store in artifact store to enable drilldown
from contextweaver.summarize.extract import extract_facts

facts = extract_facts(raw_output, item.metadata)
# For small outputs, store in artifact store to enable drilldown
raw_bytes = raw_output.encode("utf-8")
handle = f"artifact:{item.id}"
ref = self._artifact_store.put(
handle=handle,
content=raw_bytes,
media_type=media_type,
label=f"raw tool result for {item.id}",
)
views = generate_views(ref, raw_bytes, registry=self._view_registry)
envelope = ResultEnvelope(
status="ok",
summary=raw_output,
facts=facts,
artifacts=[ref],
views=views,
provenance={"source_item_id": item.id, "tool_name": tool_name},
)
item = ContextItem(
id=item.id,
kind=item.kind,
text=item.text,
token_estimate=item.token_estimate,
metadata=dict(item.metadata),
parent_id=item.parent_id,
artifact_ref=ref,
)
self._event_log.append(item)
return item, envelope

Expand Down Expand Up @@ -336,6 +370,68 @@ def add_episode_sync(
"""Synchronous alias for :meth:`add_episode`."""
self.add_episode(episode_id, summary, metadata)

# ------------------------------------------------------------------
# Drilldown
# ------------------------------------------------------------------

def drilldown(
self,
handle: str,
selector: dict[str, Any],
*,
inject: bool = False,
parent_id: str | None = None,
) -> str:
"""Fetch a slice of a stored artifact via the drilldown protocol.

Wraps :meth:`~contextweaver.protocols.ArtifactStore.drilldown` and
optionally injects the result as a new :class:`ContextItem` in the
event log for subsequent context builds.

Args:
handle: Artifact handle to drill into.
selector: Drilldown selector dict (see
:meth:`~contextweaver.store.artifacts.InMemoryArtifactStore.drilldown`).
inject: If ``True``, append the drilldown result as a
``tool_result`` :class:`ContextItem` to the event log.
parent_id: Optional parent item ID for dependency closure when
*inject* is ``True``.

Returns:
The drilldown result text.

Raises:
ArtifactNotFoundError: If *handle* is not in the store.
ValueError: If the selector type is unknown.
"""
result = self._artifact_store.drilldown(handle, selector)

if inject:
sel_type = selector.get("type", "unknown")
item_id = f"drilldown:{handle}:{sel_type}:{self._event_log.count()}"
item = ContextItem(
id=item_id,
kind=ItemKind.tool_result,
text=result,
token_estimate=self._estimator.estimate(result),
metadata={"drilldown_handle": handle, "selector": selector},
parent_id=parent_id,
)
self._event_log.append(item)

return result

def drilldown_sync(
self,
handle: str,
selector: dict[str, Any],
*,
inject: bool = False,
parent_id: str | None = None,
) -> str:
"""Synchronous alias for :meth:`drilldown`."""
return self.drilldown(handle, selector, inject=inject, parent_id=parent_id)

# ------------------------------------------------------------------
# Core pipeline
# ------------------------------------------------------------------
Expand Down
Loading