contextpilot-ai 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from contextpilot.config import ContextPilotConfig
6
+ from contextpilot.pipeline import Pipeline
7
+
8
+
9
+ def wrap(client: Any, config: ContextPilotConfig | dict | None = None) -> Any:
10
+ """Wrap an OpenAI or Anthropic client with ContextPilot compression.
11
+
12
+ Usage:
13
+ from openai import OpenAI
14
+ import contextpilot
15
+ pilot = contextpilot.wrap(OpenAI())
16
+ response = pilot.chat.completions.create(model="gpt-4o", messages=messages)
17
+ """
18
+ if isinstance(config, dict):
19
+ cfg = ContextPilotConfig.model_validate(config)
20
+ elif config is None:
21
+ cfg = ContextPilotConfig.load()
22
+ else:
23
+ cfg = config
24
+
25
+ pipeline = Pipeline(cfg)
26
+
27
+ module = type(client).__module__
28
+ name = type(client).__name__
29
+
30
+ if "openai" in module.lower() or name in ("OpenAI", "AsyncOpenAI"):
31
+ from contextpilot.adapters.openai_adapter import OpenAIWrapper
32
+ return OpenAIWrapper(client, pipeline)
33
+
34
+ if "anthropic" in module.lower() or name in ("Anthropic", "AsyncAnthropic"):
35
+ from contextpilot.adapters.anthropic_adapter import AnthropicWrapper
36
+ return AnthropicWrapper(client, pipeline)
37
+
38
+ raise ValueError(
39
+ f"Unsupported client type '{name}'. Supported: openai.OpenAI, anthropic.Anthropic"
40
+ )
41
+
42
+
43
+ __all__ = ["wrap", "ContextPilotConfig"]
File without changes
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from contextpilot.pipeline import Pipeline
4
+
5
+
6
+ class _MessagesWrapper:
7
+ def __init__(self, messages_api: object, pipeline: Pipeline) -> None:
8
+ self._messages_api = messages_api
9
+ self._pipeline = pipeline
10
+
11
+ def create(self, *, model: str, messages: list[dict], **kwargs: object) -> object:
12
+ system: str | None = kwargs.pop("system", None) # type: ignore[assignment]
13
+ optimized_msgs, optimized_sys, _ = self._pipeline.optimize(
14
+ messages, system=system, provider="anthropic", model=model
15
+ )
16
+ if optimized_sys is not None:
17
+ kwargs["system"] = optimized_sys
18
+ return self._messages_api.create(model=model, messages=optimized_msgs, **kwargs) # type: ignore[union-attr]
19
+
20
+ def __getattr__(self, name: str) -> object:
21
+ return getattr(self._messages_api, name)
22
+
23
+
24
+ class AnthropicWrapper:
25
+ """FR-001: Drop-in wrapper for anthropic.Anthropic.
26
+
27
+ Intercepts messages.create() calls, runs the compression pipeline on the
28
+ message list and optional system prompt, and forwards the optimised payload.
29
+ All other attributes delegate to the original client unchanged.
30
+ """
31
+
32
+ def __init__(self, client: object, pipeline: Pipeline) -> None:
33
+ self._client = client
34
+ self._pipeline = pipeline
35
+ self.messages = _MessagesWrapper(client.messages, pipeline) # type: ignore[union-attr]
36
+
37
+ def __getattr__(self, name: str) -> object:
38
+ return getattr(self._client, name)
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from contextpilot.pipeline import Pipeline
4
+
5
+
6
+ class _CompletionsWrapper:
7
+ def __init__(self, completions: object, pipeline: Pipeline) -> None:
8
+ self._completions = completions
9
+ self._pipeline = pipeline
10
+
11
+ def create(self, *, model: str, messages: list[dict], **kwargs: object) -> object:
12
+ optimized, _, _ = self._pipeline.optimize(messages, provider="openai", model=model)
13
+ return self._completions.create(model=model, messages=optimized, **kwargs) # type: ignore[union-attr]
14
+
15
+
16
+ class _ChatWrapper:
17
+ def __init__(self, chat: object, pipeline: Pipeline) -> None:
18
+ self._chat = chat
19
+ self.completions = _CompletionsWrapper(chat.completions, pipeline) # type: ignore[union-attr]
20
+
21
+ def __getattr__(self, name: str) -> object:
22
+ return getattr(self._chat, name)
23
+
24
+
25
+ class OpenAIWrapper:
26
+ """FR-001: Drop-in wrapper for openai.OpenAI.
27
+
28
+ Intercepts chat.completions.create() calls, runs the compression pipeline,
29
+ and forwards the optimised payload. All other attributes delegate to the
30
+ original client unchanged.
31
+ """
32
+
33
+ def __init__(self, client: object, pipeline: Pipeline) -> None:
34
+ self._client = client
35
+ self._pipeline = pipeline
36
+ self.chat = _ChatWrapper(client.chat, pipeline) # type: ignore[union-attr]
37
+
38
+ def __getattr__(self, name: str) -> object:
39
+ return getattr(self._client, name)
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from typing import Sequence
7
+
8
+ import numpy as np
9
+ from sklearn.feature_extraction.text import TfidfVectorizer
10
+ from sklearn.metrics.pairwise import cosine_similarity
11
+
12
+ from contextpilot.config import ContextPilotConfig
13
+
14
+
15
+ class BlockClass(str, Enum):
16
+ ESSENTIAL = "essential"
17
+ COMPRESSIBLE = "compressible"
18
+ DROPPABLE = "droppable"
19
+
20
+
21
+ @dataclass
22
+ class MessageBlock:
23
+ index: int
24
+ role: str
25
+ content: str
26
+ staleness: float # 0.0 = fresh (recent), 1.0 = stale (old)
27
+ redundancy: float # 0.0 = unique, 1.0 = duplicate of another block
28
+ relevance: float # 0.0 = irrelevant, 1.0 = highly relevant to latest query
29
+ density: float # 0.0 = sparse, 1.0 = information-dense
30
+ classification: BlockClass = BlockClass.ESSENTIAL
31
+ token_count: int = 0 # approximate word-based count
32
+
33
+ @property
34
+ def composite_score(self) -> float:
35
+ """Value of this block — higher means keep it. Used for triage decisions."""
36
+ return (
37
+ self.relevance * 0.4
38
+ + self.density * 0.3
39
+ + (1.0 - self.staleness) * 0.2
40
+ + (1.0 - self.redundancy) * 0.1
41
+ )
42
+
43
+
44
+ def _word_tokens(text: str) -> list[str]:
45
+ return re.findall(r"\b\w+\b", text.lower())
46
+
47
+
48
+ def _density(text: str) -> float:
49
+ tokens = _word_tokens(text)
50
+ if not tokens:
51
+ return 0.0
52
+ return len(set(tokens)) / len(tokens)
53
+
54
+
55
+ def _word_count(text: str) -> int:
56
+ return len(text.split())
57
+
58
+
59
+ class Analyzer:
60
+ """FR-002: Context analysis engine.
61
+
62
+ Scores each message block across four dimensions — staleness, redundancy,
63
+ relevance, density — and classifies it as essential / compressible / droppable.
64
+ Analysis target: < 50 ms for up to 100K tokens (technical doc §8).
65
+ """
66
+
67
+ def __init__(self, config: ContextPilotConfig) -> None:
68
+ self.config = config
69
+
70
+ def analyze(
71
+ self, messages: list[dict], system: str | None = None
72
+ ) -> list[MessageBlock]:
73
+ n = len(messages)
74
+ if n == 0:
75
+ return []
76
+
77
+ texts = [m.get("content") or "" for m in messages]
78
+
79
+ # Most recent user message drives relevance scoring
80
+ recent_user = next(
81
+ (m.get("content") or "" for m in reversed(messages) if m.get("role") == "user"),
82
+ "",
83
+ )
84
+
85
+ redundancies, relevances = self._score_tfidf(texts, recent_user, n)
86
+
87
+ blocks: list[MessageBlock] = []
88
+ for i, msg in enumerate(messages):
89
+ content = texts[i]
90
+ role = msg.get("role", "user")
91
+ # Index 0 = oldest = most stale
92
+ staleness = 1.0 - (i / max(n - 1, 1))
93
+ density = _density(content)
94
+ redundancy = float(redundancies[i])
95
+ relevance = float(relevances[i])
96
+
97
+ classification = self._classify(
98
+ role=role,
99
+ index=i,
100
+ total=n,
101
+ staleness=staleness,
102
+ redundancy=redundancy,
103
+ relevance=relevance,
104
+ )
105
+
106
+ blocks.append(
107
+ MessageBlock(
108
+ index=i,
109
+ role=role,
110
+ content=content,
111
+ staleness=staleness,
112
+ redundancy=redundancy,
113
+ relevance=relevance,
114
+ density=density,
115
+ classification=classification,
116
+ token_count=_word_count(content),
117
+ )
118
+ )
119
+
120
+ return blocks
121
+
122
+ def _score_tfidf(
123
+ self, texts: list[str], recent_user: str, n: int
124
+ ) -> tuple[list[float], list[float]]:
125
+ corpus = texts + ([recent_user] if recent_user else [])
126
+ try:
127
+ vec = TfidfVectorizer(min_df=1, token_pattern=r"(?u)\b\w+\b")
128
+ tfidf = vec.fit_transform(corpus)
129
+ msg_vecs = tfidf[:n]
130
+
131
+ # Redundancy: max cosine similarity against all *other* messages
132
+ sim = cosine_similarity(msg_vecs)
133
+ np.fill_diagonal(sim, 0.0)
134
+ redundancies = sim.max(axis=1).tolist()
135
+
136
+ # Relevance: cosine similarity to most recent user message
137
+ if recent_user:
138
+ user_vec = tfidf[n]
139
+ relevances = cosine_similarity(msg_vecs, user_vec.reshape(1, -1)).flatten().tolist()
140
+ else:
141
+ relevances = [0.5] * n
142
+ except Exception:
143
+ redundancies = [0.0] * n
144
+ relevances = [0.5] * n
145
+
146
+ return redundancies, relevances
147
+
148
+ def _classify(
149
+ self,
150
+ role: str,
151
+ index: int,
152
+ total: int,
153
+ staleness: float,
154
+ redundancy: float,
155
+ relevance: float,
156
+ ) -> BlockClass:
157
+ # System messages and the most recent window are always kept
158
+ if role == "system":
159
+ return BlockClass.ESSENTIAL
160
+ window = self.config.compression.history_window
161
+ if index >= total - window:
162
+ return BlockClass.ESSENTIAL
163
+
164
+ level = self.config.compression.level
165
+ if level == "conservative":
166
+ # Only drop near-exact duplicates
167
+ if redundancy > 0.95:
168
+ return BlockClass.DROPPABLE
169
+ if redundancy > 0.7:
170
+ return BlockClass.COMPRESSIBLE
171
+ return BlockClass.ESSENTIAL
172
+
173
+ if level == "aggressive":
174
+ if redundancy > 0.6 or (staleness > 0.5 and relevance < 0.15):
175
+ return BlockClass.DROPPABLE
176
+ if staleness > 0.3 or redundancy > 0.3:
177
+ return BlockClass.COMPRESSIBLE
178
+ return BlockClass.ESSENTIAL
179
+
180
+ # balanced (default)
181
+ if redundancy > 0.85 or (staleness > 0.7 and relevance < 0.2):
182
+ return BlockClass.DROPPABLE
183
+ if staleness > 0.5 or redundancy > 0.5:
184
+ return BlockClass.COMPRESSIBLE
185
+ return BlockClass.ESSENTIAL
contextpilot/cli.py ADDED
@@ -0,0 +1,161 @@
1
+ """ContextPilot CLI — entry point for all four surfaces.
2
+
3
+ Commands
4
+ --------
5
+ contextpilot proxy — Surface B: local proxy server (FR-009)
6
+ contextpilot migrate — Surface D: AST-based migration agent (FR-011)
7
+ contextpilot report — Show local token savings summary
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ import click
15
+
16
+ _LOCAL_LOG = Path.home() / ".contextpilot" / "events.jsonl"
17
+
18
+ # Rough $/1M-token rates for common models (input side).
19
+ _PRICING: dict[str, float] = {
20
+ "gpt-4o": 5.00,
21
+ "gpt-4o-mini": 0.15,
22
+ "gpt-4-turbo": 10.00,
23
+ "gpt-4": 30.00,
24
+ "gpt-3.5-turbo": 0.50,
25
+ "claude-opus": 15.00,
26
+ "claude-sonnet": 3.00,
27
+ "claude-haiku": 0.25,
28
+ }
29
+ _DEFAULT_RATE = 5.00 # $/1M tokens fallback
30
+
31
+
32
+ def _rate_for(model: str) -> float:
33
+ m = model.lower()
34
+ for key, rate in _PRICING.items():
35
+ if key in m:
36
+ return rate
37
+ return _DEFAULT_RATE
38
+
39
+
40
+ @click.group()
41
+ @click.version_option(package_name="contextpilot")
42
+ def main() -> None:
43
+ """ContextPilot — Intelligent LLM context optimization middleware."""
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Surface B: proxy
48
+ # ---------------------------------------------------------------------------
49
+
50
+ @main.command()
51
+ @click.option("--port", default=8432, show_default=True, help="TCP port to listen on.")
52
+ @click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.")
53
+ @click.option("--config", "config_path", default=None, help="Path to contextpilot.yaml.")
54
+ def proxy(port: int, host: str, config_path: str | None) -> None:
55
+ """Start the local proxy server (Surface B, FR-009).
56
+
57
+ \b
58
+ Set one environment variable to route requests through ContextPilot:
59
+
60
+ Anthropic / Claude Code:
61
+ export ANTHROPIC_BASE_URL=http://localhost:8432
62
+
63
+ OpenAI / GPT Codex / Aider:
64
+ export OPENAI_BASE_URL=http://localhost:8432/v1
65
+ """
66
+ from contextpilot.proxy import run_proxy
67
+ run_proxy(host=host, port=port, config_path=config_path)
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Surface D: migrate
72
+ # ---------------------------------------------------------------------------
73
+
74
+ @main.command()
75
+ @click.argument("path", default=".", metavar="PATH")
76
+ @click.option(
77
+ "--dry-run",
78
+ is_flag=True,
79
+ default=False,
80
+ help="Show what would change without writing files (default if neither flag is given).",
81
+ )
82
+ @click.option("--apply", "apply_changes", is_flag=True, default=False, help="Write changes to files.")
83
+ @click.option("--config", "config_path", default=None, help="Path to contextpilot.yaml.")
84
+ def migrate(path: str, dry_run: bool, apply_changes: bool, config_path: str | None) -> None:
85
+ """Wrap existing LLM API calls with ContextPilot (Surface D, FR-011).
86
+
87
+ \b
88
+ Scans PATH (file or directory) for OpenAI and Anthropic client
89
+ instantiations and wraps them with contextpilot.wrap():
90
+
91
+ contextpilot migrate ./src/ --dry-run # preview changes
92
+ contextpilot migrate ./src/ --apply # rewrite files
93
+ """
94
+ from contextpilot.migrate import MigrationAgent
95
+
96
+ # Default to dry-run when neither flag is explicitly given
97
+ effective_dry_run = dry_run or not apply_changes
98
+
99
+ agent = MigrationAgent(config_path=config_path)
100
+ agent.run(path=path, dry_run=effective_dry_run, apply=apply_changes)
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Report: local savings summary
105
+ # ---------------------------------------------------------------------------
106
+
107
+ @main.command()
108
+ @click.option("--tail", default=0, help="Show only the last N events (0 = all).")
109
+ def report(tail: int) -> None:
110
+ """Show token savings from the local event log (~/.contextpilot/events.jsonl)."""
111
+ if not _LOCAL_LOG.exists():
112
+ click.echo("No events recorded yet. Run a few API calls through ContextPilot first.")
113
+ return
114
+
115
+ events: list[dict] = []
116
+ with _LOCAL_LOG.open(encoding="utf-8") as f:
117
+ for line in f:
118
+ line = line.strip()
119
+ if line:
120
+ try:
121
+ events.append(json.loads(line))
122
+ except json.JSONDecodeError:
123
+ pass
124
+
125
+ if not events:
126
+ click.echo("Event log is empty.")
127
+ return
128
+
129
+ if tail:
130
+ events = events[-tail:]
131
+
132
+ total = len(events)
133
+ fallbacks = sum(1 for e in events if e.get("fallback_triggered"))
134
+ orig_tokens = sum(e.get("tokens_input_original", 0) for e in events)
135
+ comp_tokens = sum(e.get("tokens_input_compressed", 0) for e in events)
136
+ saved_tokens = orig_tokens - comp_tokens
137
+ avg_quality = sum(e.get("quality_score", 100) for e in events) / total
138
+
139
+ # Dollar savings estimate
140
+ saved_usd = sum(
141
+ (e.get("tokens_input_original", 0) - e.get("tokens_input_compressed", 0))
142
+ / 1_000_000
143
+ * _rate_for(e.get("model", ""))
144
+ for e in events
145
+ )
146
+
147
+ ratio = (saved_tokens / orig_tokens * 100) if orig_tokens else 0.0
148
+
149
+ click.echo()
150
+ click.echo(" ContextPilot — Savings Report")
151
+ click.echo(" " + "─" * 36)
152
+ click.echo(f" Total calls logged : {total:,}")
153
+ click.echo(f" Fallback rate : {fallbacks}/{total} ({fallbacks/total*100:.1f}%)")
154
+ click.echo(f" Tokens in (original) : {orig_tokens:,}")
155
+ click.echo(f" Tokens in (sent) : {comp_tokens:,}")
156
+ click.echo(f" Tokens saved : {saved_tokens:,} ({ratio:.1f}% reduction)")
157
+ click.echo(f" Avg quality score : {avg_quality:.1f}/100")
158
+ click.echo(f" Est. cost saved : ${saved_usd:.4f}")
159
+ click.echo()
160
+ click.echo(f" Log: {_LOCAL_LOG}")
161
+ click.echo()
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from contextpilot.analyzer import MessageBlock
4
+ from contextpilot.config import ContextPilotConfig
5
+ from contextpilot.strategies.dedup import SystemPromptDeduplicator
6
+ from contextpilot.strategies.history import summarize_old_turns
7
+ from contextpilot.strategies.rag_pruner import prune_rag_chunks
8
+ from contextpilot.strategies.structural import apply_structural_stripping
9
+
10
+
11
+ class Compressor:
12
+ """FR-003: Multi-stage compression pipeline.
13
+
14
+ Applies strategies in order:
15
+ 1. Conversation history summarization
16
+ 2. RAG chunk pruning
17
+ 3. Structural formatting stripping
18
+ 4. System prompt deduplication (aggressive mode)
19
+
20
+ Each strategy is applied in-place on the message list. The compressor is
21
+ surface-agnostic — the same pipeline runs for the Python library, proxy,
22
+ and MCP server surfaces.
23
+ """
24
+
25
+ def __init__(self, config: ContextPilotConfig) -> None:
26
+ self.config = config
27
+ self._dedup = SystemPromptDeduplicator()
28
+
29
+ def compress(
30
+ self,
31
+ messages: list[dict],
32
+ blocks: list[MessageBlock],
33
+ system: str | None = None,
34
+ ) -> tuple[list[dict], str | None]:
35
+ if not messages:
36
+ return messages, system
37
+
38
+ query = next(
39
+ (m.get("content") or "" for m in reversed(messages) if m.get("role") == "user"),
40
+ "",
41
+ )
42
+
43
+ result = list(messages)
44
+
45
+ # Stage 1: history summarization
46
+ result = summarize_old_turns(result, blocks, self.config)
47
+
48
+ # Stage 2: RAG chunk pruning
49
+ if query:
50
+ result = prune_rag_chunks(result, query, self.config)
51
+
52
+ # Stage 3: structural stripping
53
+ result = apply_structural_stripping(result, self.config)
54
+
55
+ # Stage 4: system prompt dedup (aggressive only — changes provider-visible content)
56
+ compressed_system = system
57
+ if system and self.config.compression.level == "aggressive":
58
+ compressed_system = self._dedup.process(system, self.config)
59
+
60
+ return result, compressed_system
61
+
62
+ def reset(self) -> None:
63
+ """Reset stateful strategies (e.g. between independent sessions)."""
64
+ self._dedup.reset()
contextpilot/config.py ADDED
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import yaml
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class CompressionConfig(BaseModel):
12
+ level: str = "balanced" # conservative | balanced | aggressive
13
+ quality_threshold: float = 85.0 # fallback below this score
14
+ history_window: int = 6 # keep last N turns verbatim
15
+ rag_relevance_min: float = 0.15 # drop RAG chunks below this TF-IDF score
16
+
17
+
18
+ class ShadowTestingConfig(BaseModel):
19
+ enabled: bool = False
20
+ sample_rate: float = 0.05 # fraction of calls shadowed
21
+
22
+
23
+ class TelemetryConfig(BaseModel):
24
+ enabled: bool = True
25
+ endpoint: str = "https://api.contextpilot.dev/v1/telemetry"
26
+ api_key: Optional[str] = None
27
+ flush_interval: int = 60 # seconds
28
+ flush_size: int = 100 # events
29
+
30
+
31
+ class ContextPilotConfig(BaseModel):
32
+ compression: CompressionConfig = Field(default_factory=CompressionConfig)
33
+ shadow_testing: ShadowTestingConfig = Field(default_factory=ShadowTestingConfig)
34
+ telemetry: TelemetryConfig = Field(default_factory=TelemetryConfig)
35
+
36
+ @classmethod
37
+ def load(cls, path: str | Path | None = None) -> "ContextPilotConfig":
38
+ """Load config from YAML file, then apply environment variable overrides."""
39
+ if path is None:
40
+ for candidate in ("contextpilot.yaml", "contextpilot.yml"):
41
+ if Path(candidate).exists():
42
+ path = candidate
43
+ break
44
+
45
+ data: dict = {}
46
+ if path and Path(str(path)).exists():
47
+ with open(path) as f:
48
+ data = yaml.safe_load(f) or {}
49
+
50
+ comp = data.setdefault("compression", {})
51
+ if val := os.getenv("CONTEXTPILOT_QUALITY_THRESHOLD"):
52
+ comp["quality_threshold"] = float(val)
53
+ if val := os.getenv("CONTEXTPILOT_COMPRESSION_LEVEL"):
54
+ comp["level"] = val
55
+ if val := os.getenv("CONTEXTPILOT_HISTORY_WINDOW"):
56
+ comp["history_window"] = int(val)
57
+
58
+ tele = data.setdefault("telemetry", {})
59
+ if val := os.getenv("CONTEXTPILOT_API_KEY"):
60
+ tele["api_key"] = val
61
+ if val := os.getenv("CONTEXTPILOT_TELEMETRY_ENDPOINT"):
62
+ tele["endpoint"] = val
63
+
64
+ return cls.model_validate(data)
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from contextpilot.config import ContextPilotConfig
4
+ from contextpilot.strategies.agent_memory import compress_agent_handoff
5
+
6
+
7
+ class AgentMemory:
8
+ """FR-008: Agent memory middleware for LangChain / CrewAI / AutoGen.
9
+
10
+ Compresses inter-agent context handoffs, targeting 70–90% token reduction
11
+ in agentic workflows (technical doc §3.5).
12
+
13
+ Usage:
14
+ from contextpilot.middleware import AgentMemory
15
+ memory = AgentMemory(compression_level="aggressive",
16
+ preserve_keys=["final_answer", "tool_outputs"])
17
+ compressed = memory.compress_handoff(agent_a_output)
18
+ agent_b_output = agent_b.run(task, context=compressed)
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ compression_level: str = "balanced",
24
+ preserve_keys: list[str] | None = None,
25
+ config: ContextPilotConfig | None = None,
26
+ ) -> None:
27
+ if config is None:
28
+ cfg_data = {"compression": {"level": compression_level}}
29
+ self._config = ContextPilotConfig.model_validate(cfg_data)
30
+ else:
31
+ self._config = config
32
+ self._preserve_keys = preserve_keys or []
33
+
34
+ def compress_handoff(self, agent_output: str) -> str:
35
+ """Compress an agent's output before passing it to the next agent."""
36
+ return compress_agent_handoff(agent_output, self._preserve_keys, self._config)