Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
62a6d2d
feat(DirectionA): implement ContextReorder and ContextDedup plugins w…
SNM-SNM Mar 25, 2026
fcc06d0
feat(evaluation): integrate evaluation suite including profiling, san…
SNM-SNM Jun 2, 2026
7cfefc2
ci: run tests using python -m pytest to resolve import issues
SNM-SNM Jun 2, 2026
7f21140
ci: add pytest-asyncio to dev dependencies for async tests
SNM-SNM Jun 2, 2026
84ed725
Add ShadowRadixTree KVCacheLookup
SNM-SNM Jun 4, 2026
25d743d
ci: add pyzmq and msgspec to requirements.txt for kv_lookup plugin
SNM-SNM Jun 4, 2026
07fb05f
docs: update INTEGRATION_GUIDE.md to document KVCacheLookupPlugin
SNM-SNM Jun 4, 2026
4027fe0
feat(plugins): add SkillAwareContextPlugin for dynamic tool schema in…
SNM-SNM Jun 8, 2026
e86fe59
perf(reorder): bypass multiprocessing pool when num_workers is 1 for …
SNM-SNM Jun 9, 2026
fbc1422
feat(evaluation): add core merge mock proxy pipeline and fix Windows …
SNM-SNM Jun 10, 2026
3beeceb
style: apply black formatting to new plugins and tests
SNM-SNM Jun 11, 2026
f2c1090
feat(evaluation): add run_elm_eval.py script for UoE ELM gateway tests
SNM-SNM Jun 15, 2026
dcaef8a
feat(evaluation): add BigCodeBench benchmark runner scripts and updat…
SNM-SNM Jun 16, 2026
016c6c4
Add and configure sandbox evaluation and local Apptainer evaluator
Jun 20, 2026
771cc29
chore: add full evaluation scripts and fix apptainer recipe
Jun 23, 2026
8980775
test: rewrite local evaluator to run unittest suites properly
Jun 24, 2026
09bcb03
feat: implement A/B testing pipeline, caching telemetry, and HTTP/ZMQ…
Jun 27, 2026
554a345
Fix proxy telemetry payload errors and Brotli encoding issues for str…
Jun 28, 2026
1e1ba50
Fix telemetry print missing in client side
SNM-SNM Jun 28, 2026
45d763a
Fix dedup plugin shadow mode and parent seeding
SNM-SNM Jun 29, 2026
4b81179
Fix missing cache-hit extraction in chat completions route
SNM-SNM Jun 29, 2026
fdc3b47
Refactor A/B testing pipeline for sequential proxy telemetry isolation
Jun 29, 2026
052474e
Fix failing unit test by introducing shadow_mode configuration parame…
SNM-SNM Jun 29, 2026
0ecf363
Update DeepSeek model string to deepseek-v4-pro in slurm scripts
SNM-SNM Jun 29, 2026
2d14471
Fix telemetry extraction for DeepSeek V4 (OpenAI prompt_tokens_detail…
SNM-SNM Jun 29, 2026
daf5b93
feat(eval): Add MCP-Atlas Toolkit evaluation pipeline
Jul 3, 2026
b9d4707
Fix MCP-Atlas answers parsing and limit dataset slice to 500 tasks fo…
SNM-SNM Jul 3, 2026
5c5a4f0
fix(eval): Use ungated dataset NobodyExistsOnTheInternet/xlam-functio…
Jul 3, 2026
f140566
fix(eval): Revert to official Salesforce dataset
Jul 3, 2026
5f935df
fix(eval): Translate XLAM parameter format to standard JSON Schema
Jul 3, 2026
a05f026
Fix AWQ evaluation pipeline: VRAM, vLLM config, and API routing
Jul 11, 2026
cfb095a
Fix vLLM metrics logger: Use 50ms polling and target vllm:kv_cache_us…
Jul 12, 2026
a05e579
Fix pip dependency errors in eval scripts
Jul 18, 2026
63b86ef
feat: Add Dynamic Pruning plugin and Multi-Plugin Ablation Study
Jul 18, 2026
edc379f
chore: final production polish and academic attribution
Aug 12, 2026
518cad9
chore: remove incorrect citation block
Aug 12, 2026
fcb0e69
docs: add evaluation data tables and upload missing architecture images
Aug 12, 2026
8b53826
Cleanup: translate recipe.def comments to English, fix README (clone …
SNM-SNM Aug 13, 2026
cd4edd0
feat: add distractor_ratio sweep to run_mcpatlas_eval and restore mis…
Aug 14, 2026
46ce564
fix: remove unused proxy boot from distractor sweep to prevent port c…
Aug 14, 2026
8017f67
fix: remove private extra_body from API kwargs to resolve 400 Bad Req…
Aug 14, 2026
565d176
feat: add 50 synthetic distractor tools for attention dilution test
Aug 14, 2026
4452806
feat: replace synthetic distractors with real XLAM tool sampling to r…
Aug 14, 2026
fdeb50f
Add --seed parameter to BigCodeBench ELM runner for multi-seed evalua…
SNM-SNM Aug 18, 2026
ddcf171
feat(eval): add real distractor sampling and multi-seed temperature n…
Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
OPENAI_API_KEY=your_openai_api_key_here
DEEPSEEK_API_KEY=your_deepseek_api_key_here
# Optional overrides
# BASE_URL=https://api.openai.com/v1
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
run: |
# Skip GPU tests as GitHub Actions runners don't have CUDA
# To run GPU tests locally: pytest tests/ -v -m "gpu"
pytest tests/ -v --tb=short -m "not slow and not gpu and not integration"
python -m pytest tests/ -v --tb=short -m "not slow and not gpu and not integration"

lint:
runs-on: ubuntu-latest
Expand Down
16 changes: 16 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,19 @@ dist/
*/.DS_Store
*.DS_Store

# Evaluation/Profiling ignores
*.prof
evaluation/sandbox/results/*
!evaluation/sandbox/results/.gitkeep
!evaluation/sandbox/results/run_local_eval.py

# Sandbox SIF images
*.sif

# Sandbox Cache
evaluation/sandbox/cache/

# Slurm and Apptainer temporary files
*.err
*.out
build-temp-*/
264 changes: 73 additions & 191 deletions README.md

Large diffs are not rendered by default.

Binary file added assets/architecture.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/vram_chart.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 27 additions & 6 deletions contextpilot/context_index/compute_distance_cpu.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ def compute_distance_matrix_cpu_optimized(contexts: List[List[int]],
start = time.time()
chunk_ids, original_positions, lengths, offsets = prepare_contexts_for_cpu(contexts)
prep_time = time.time() - start
print(f" Prepared in {prep_time:.1f}s")
print(f"+ Prepared in {prep_time:.1f}s")

# Generate batches of pair indices
print(f"\nGenerating pair batches...")
Expand All @@ -290,7 +290,7 @@ def compute_distance_matrix_cpu_optimized(contexts: List[List[int]],
if current_batch:
batches.append(current_batch)

print(f" Generated {len(batches):,} batches")
print(f"+ Generated {len(batches):,} batches")

# Prepare arguments for workers
worker_args = [
Expand All @@ -306,13 +306,13 @@ def compute_distance_matrix_cpu_optimized(contexts: List[List[int]],
start_time = time.time()
processed = 0

with Pool(num_workers) as pool:
for batch_results in pool.imap_unordered(compute_batch_worker, worker_args):
if num_workers == 1:
# Bypass multiprocessing Pool entirely to save initialization overhead
for args in worker_args:
batch_results = compute_batch_worker(args)
for i, j, dist in batch_results:
# Convert (i, j) to condensed index
condensed_idx = n * i - i * (i + 1) // 2 + j - i - 1
condensed_distances[condensed_idx] = dist

processed += 1

# Progress update
Expand All @@ -326,6 +326,27 @@ def compute_distance_matrix_cpu_optimized(contexts: List[List[int]],
f"Rate: {rate:,.0f} pairs/sec | "
f"Elapsed: {elapsed:.1f}s | "
f"ETA: {eta:.1f}s ({eta/60:.1f} min)")
else:
with Pool(num_workers) as pool:
for batch_results in pool.imap_unordered(compute_batch_worker, worker_args):
for i, j, dist in batch_results:
# Convert (i, j) to condensed index
condensed_idx = n * i - i * (i + 1) // 2 + j - i - 1
condensed_distances[condensed_idx] = dist

processed += 1

# Progress update
if processed % 100000 == 0 or processed == num_pairs:
elapsed = time.time() - start_time
rate = processed / elapsed if elapsed > 0 else 0
eta = (num_pairs - processed) / rate if rate > 0 else 0
progress_pct = processed / num_pairs * 100

print(f" {processed:,}/{num_pairs:,} ({progress_pct:.1f}%) | "
f"Rate: {rate:,.0f} pairs/sec | "
f"Elapsed: {elapsed:.1f}s | "
f"ETA: {eta:.1f}s ({eta/60:.1f} min)")

compute_time = time.time() - start_time
total_time = compute_time + prep_time
Expand Down
64 changes: 54 additions & 10 deletions contextpilot/server/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@ class DeduplicateRequest(BaseModel):
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup/shutdown."""
global _aiohttp_session
global _total_prompt_cache_hit_tokens

_total_prompt_cache_hit_tokens = 0

# Initialize config from environment variables
_init_config()
Expand All @@ -239,11 +242,15 @@ async def lifespan(app: FastAPI):
logger.info(f" max_tokens: {_max_tokens}")
logger.info(f" infer_api_url: {_infer_api_url}")

_aiohttp_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=3600))
_aiohttp_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=3600), trust_env=True)
yield
if _aiohttp_session:
await _aiohttp_session.close()
logger.info("ContextPilot Index Server shutting down...")

print("\n=== Final Telemetry Summary ===")
print(f"Total Prompt Cache Hit Tokens: {_total_prompt_cache_hit_tokens}")
# (Note: chars_saved_percentage and tools_filtered_percentage will be output here when plugins are fully integrated)


app = FastAPI(
Expand Down Expand Up @@ -930,21 +937,36 @@ async def proxy_completions(request: Request):
_index.track_request(request_id)

# Pass request_id to inference engine so it can use the same ID for request tracking
# Engine will notify ContextPilot via /evict callback when this request is evicted
# We don't inject request_id into body anymore to avoid breaking strict APIs.
if request_id:
body["rid"] = request_id # SGLang
body["request_id"] = request_id # vLLM
logger.info(f"Proxy: forwarding request with request_id={request_id}")
logger.info(f"Proxy: tracking request with request_id={request_id}")
else:
logger.info("Proxy: forwarding request without rid (no ContextPilot tracking)")

# Forward to inference engine
api_url = f"{infer_api_url}/v1/completions"
logger.debug(f"Proxying to {api_url}")

async with _aiohttp_session.post(api_url, json=body) as response:
# Extract headers to forward
headers = dict(request.headers)
headers.pop("host", None)
headers.pop("content-length", None)
headers["accept-encoding"] = "gzip, deflate"

async with _aiohttp_session.post(api_url, json=body, headers=headers) as response:
result = await response.json()

global _total_prompt_cache_hit_tokens
if response.status == 200 and isinstance(result, dict):
usage = result.get("usage", {})
if isinstance(usage, dict):
# Legacy DeepSeek V3 format
if "prompt_cache_hit_tokens" in usage:
_total_prompt_cache_hit_tokens += int(usage["prompt_cache_hit_tokens"])
# DeepSeek V4 / OpenAI standard format
elif "prompt_tokens_details" in usage and isinstance(usage["prompt_tokens_details"], dict):
_total_prompt_cache_hit_tokens += int(usage["prompt_tokens_details"].get("cached_tokens", 0))

# Token tracking is handled by the inference engine via CONTEXTPILOT_INDEX_URL
# The engine calls /evict after its internal cache eviction

Expand Down Expand Up @@ -999,17 +1021,39 @@ async def proxy_engine(path: str, request: Request):

# Inject rid for SGLang cache tracking (same logic as proxy_completions)
request_id = body.pop("request_id", None) or body.get("rid", None)

# Pop custom proxy parameters so upstream OpenAI doesn't reject them
body.pop("user_id", None)
body.pop("parent_id", None)
body.pop("_required_skills", None)

if not request_id:
request_id = f"req-{uuid.uuid4().hex[:12]}"
logger.debug(f"Auto-assigned request_id={request_id}")
if _index:
_index.track_request(request_id)
if request_id:
body["rid"] = request_id
body["request_id"] = request_id

async with _aiohttp_session.post(target_url, json=body) as response:
# Extract headers to forward (excluding hop-by-hop headers that aiohttp manages)
headers = dict(request.headers)
# Remove proxy-specific headers
headers.pop("host", None)
headers.pop("content-length", None)
headers["accept-encoding"] = "gzip, deflate"

async with _aiohttp_session.post(target_url, json=body, headers=headers) as response:
result = await response.json()

global _total_prompt_cache_hit_tokens
if response.status == 200 and isinstance(result, dict):
usage = result.get("usage", {})
if isinstance(usage, dict):
# Legacy DeepSeek V3 format
if "prompt_cache_hit_tokens" in usage:
_total_prompt_cache_hit_tokens += int(usage["prompt_cache_hit_tokens"])
# DeepSeek V4 / OpenAI standard format
elif "prompt_tokens_details" in usage and isinstance(usage["prompt_tokens_details"], dict):
_total_prompt_cache_hit_tokens += int(usage["prompt_tokens_details"].get("cached_tokens", 0))

return JSONResponse(content=result, status_code=response.status)

except aiohttp.ClientError as e:
Expand Down
32 changes: 16 additions & 16 deletions contextpilot/server/live_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,15 +172,15 @@ def build_and_schedule(self, contexts: List[List[int]],
print("\n1. Building static index...")
self.initial_result = self.fit_transform(contexts)

print(f" Built tree with {self.initial_result.stats['total_nodes']} nodes")
print(f" Leaf nodes: {self.initial_result.stats['leaf_nodes']}")
print(f" + Built tree with {self.initial_result.stats['total_nodes']} nodes")
print(f" + Leaf nodes: {self.initial_result.stats['leaf_nodes']}")

# Step 2: Inter-context scheduling
print("\n2. Scheduling contexts for optimal execution...")
scheduled_reordered, scheduled_originals, final_mapping, groups = \
self.inter_scheduler.schedule_contexts(self.initial_result)

print(f" Created {len(groups)} execution groups")
print(f" + Created {len(groups)} execution groups")

self.scheduled_result = {
'reordered_contexts': scheduled_reordered,
Expand All @@ -197,8 +197,8 @@ def build_and_schedule(self, contexts: List[List[int]],
num_input_contexts=len(contexts)
)

print(f" Initialized {len(self.metadata)} nodes with metadata")
print(f" Auto-assigned {len(request_id_mapping)} request IDs")
print(f" + Initialized {len(self.metadata)} nodes with metadata")
print(f" + Auto-assigned {len(request_id_mapping)} request IDs")

# Add request_id mapping to result (dict and ordered list)
self.scheduled_result['request_id_mapping'] = request_id_mapping
Expand All @@ -208,7 +208,7 @@ def build_and_schedule(self, contexts: List[List[int]],
self.is_live = True

print("\n" + "=" * 80)
print(" INDEX IS NOW LIVE - Ready for dynamic operations")
print("+ INDEX IS NOW LIVE - Ready for dynamic operations")
print("=" * 80 + "\n")

return self.scheduled_result
Expand Down Expand Up @@ -534,8 +534,8 @@ def build_incremental(self, contexts: List[List[int]],
# No match - will build new index for these
unmatched_contexts.append((i, context))

print(f" Found {len(matched_contexts)} contexts with matches")
print(f" Found {len(unmatched_contexts)} contexts without matches")
print(f" + Found {len(matched_contexts)} contexts with matches")
print(f" + Found {len(unmatched_contexts)} contexts without matches")

# Prepare result arrays (will fill in order)
request_ids = [None] * len(contexts)
Expand Down Expand Up @@ -585,7 +585,7 @@ def build_incremental(self, contexts: List[List[int]],
)
temp_result = temp_index.fit_transform(unmatched_only)

print(f" Built temp index with {temp_result.stats['total_nodes']} nodes")
print(f" + Built temp index with {temp_result.stats['total_nodes']} nodes")

# Step 4: Merge temp index into global index
print("\n4. Merging temp index into global index...")
Expand All @@ -606,16 +606,16 @@ def build_incremental(self, contexts: List[List[int]],
context_info.append((orig_idx, merged_request_ids[i], merged_search_paths[i]))

merged_count = len(unmatched_contexts)
print(f" Merged {merged_count} new subtrees under global root")
print(f" + Merged {merged_count} new subtrees under global root")

# Step 5: Schedule execution order
print("\n5. Scheduling execution order for cache reuse...")
scheduled_order = self._schedule_incremental(context_info)
groups = self._group_by_path_prefix(context_info)
print(f" Scheduled {len(scheduled_order)} contexts into {len(groups)} groups")
print(f" + Scheduled {len(scheduled_order)} contexts into {len(groups)} groups")

print("\n" + "=" * 80)
print(f" INCREMENTAL BUILD COMPLETE")
print(f"+ INCREMENTAL BUILD COMPLETE")
print(f" Matched & inserted: {len(matched_contexts)}")
print(f" Built & merged: {merged_count}")
print("=" * 80 + "\n")
Expand Down Expand Up @@ -906,15 +906,15 @@ def schedule_only(self, contexts: List[List[int]]) -> Dict:
print("\n1. Building static index...")
result = self.fit_transform(contexts)

print(f" Built tree with {result.stats['total_nodes']} nodes")
print(f" Leaf nodes: {result.stats['leaf_nodes']}")
print(f" + Built tree with {result.stats['total_nodes']} nodes")
print(f" + Leaf nodes: {result.stats['leaf_nodes']}")

# Step 2: Inter-context scheduling
print("\n2. Scheduling contexts for optimal execution...")
scheduled_reordered, scheduled_originals, final_mapping, groups = \
self.inter_scheduler.schedule_contexts(result)

print(f" Created {len(groups)} execution groups")
print(f" + Created {len(groups)} execution groups")

# Return results without going live (stateless)
scheduled_result = {
Expand All @@ -931,7 +931,7 @@ def schedule_only(self, contexts: List[List[int]]) -> Dict:
}

print("\n" + "=" * 80)
print(" BATCH SCHEDULED (Stateless - no cache tracking)")
print("+ BATCH SCHEDULED (Stateless - no cache tracking)")
print("=" * 80 + "\n")

return scheduled_result
Expand Down
8 changes: 4 additions & 4 deletions docs/guides/multi_turn.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,10 +256,10 @@ print(f"New docs: {result['new_docs']}") # [2]

| Operation | `/reorder` | `/deduplicate` |
|-----------|----------|----------------|
| Index build | | ✗ |
| Clustering | | ✗ |
| Search | | ✗ |
| Deduplication | | |
| Index build | + | ✗ |
| Clustering | + | ✗ |
| Search | + | ✗ |
| Deduplication | + | + |
| **Latency** | ~50-200ms | ~1-5ms |

For multi-turn conversations, Turn 2+ typically doesn't need index operations — just deduplication against conversation history. The `/deduplicate` endpoint is **10-100x faster**.
Expand Down
53 changes: 53 additions & 0 deletions eval_ablation_dynamic_pruning.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/bin/bash
export PYTHONPATH="$(pwd):$PYTHONPATH"
export NO_PROXY="localhost,127.0.0.1,::1"
export no_proxy="localhost,127.0.0.1,::1"

LIMIT=0
while [[ $# -gt 0 ]]; do
case $1 in
--limit) LIMIT="$2"; shift 2 ;;
*) echo "Unknown parameter: $1"; exit 1 ;;
esac
done

echo "============================================="
echo " Dynamic Pruning Hyperparameter Ablation"
echo " Limit: $LIMIT"
echo "============================================="

echo "[1/3] Booting ContextPilot Proxy Server..."
python -m contextpilot.server.http_server --port 8000 --infer-api-url "https://api.openai.com" > proxy_ablation.log 2>&1 &
PROXY_PID=$!
sleep 5

THRESHOLDS=(0.1 0.2 0.4 0.5)

echo "[2/3] Running Python Ablation Iterations..."
for THRESHOLD in "${THRESHOLDS[@]}"; do
echo "--- Running Threshold: $THRESHOLD ---"
python evaluation/benchmarks/run_bigcodebench.py \
--model gpt-5.5 \
--api_base "https://api.openai.com/v1" \
--concurrency 5 \
--limit "$LIMIT" \
--plugins "all" \
--eval_mode with_plugin \
--threshold "$THRESHOLD"

mv evaluation/benchmarks/results_with_plugin_gpt-5.5.jsonl evaluation/benchmarks/results_ablation_${THRESHOLD}_with_plugin_gpt-5.5.jsonl
done

echo "Shutting down Proxy Server to flush telemetry..."
kill -INT $PROXY_PID
sleep 2
cat proxy_ablation.log

echo "[3/3] Sandbox Evaluation..."
for THRESHOLD in "${THRESHOLDS[@]}"; do
echo "--- Evaluating Sandbox for Threshold: $THRESHOLD ---"
cp evaluation/benchmarks/results_ablation_${THRESHOLD}_with_plugin_gpt-5.5.jsonl evaluation/benchmarks/elm_samples_full.jsonl
cd evaluation/benchmarks && bash run_sandbox_eval_full.sh && cd ../..
done

echo "Pipeline Complete!"
Loading