acm-context-management 0.2.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.
- acm_context_management-0.2.0.dist-info/METADATA +111 -0
- acm_context_management-0.2.0.dist-info/RECORD +30 -0
- acm_context_management-0.2.0.dist-info/WHEEL +4 -0
- acm_context_management-0.2.0.dist-info/entry_points.txt +3 -0
- acm_engine/__init__.py +124 -0
- acm_engine/_vendor/cache_layout.py +218 -0
- acm_engine/_vendor/context_editing.py +813 -0
- acm_engine/_vendor/context_profiles.py +560 -0
- acm_engine/_vendor/relevance.py +688 -0
- acm_engine/_vendor/relevance_encoder.py +222 -0
- acm_engine/_vendor/visual_tool/__init__.py +5 -0
- acm_engine/_vendor/visual_tool/indexer.py +121 -0
- acm_engine/_vendor/visual_tool/rasterizer.py +235 -0
- acm_gateway/__init__.py +5 -0
- acm_gateway/__main__.py +21 -0
- acm_gateway/app.py +1231 -0
- acm_gateway/config.py +170 -0
- acm_gateway/context_windows.py +203 -0
- acm_gateway/droplist.py +478 -0
- acm_gateway/events.py +89 -0
- acm_gateway/pipeline.py +173 -0
- acm_gateway/providers_store.py +190 -0
- acm_gateway/translate.py +111 -0
- acm_gateway/translate_anthropic.py +295 -0
- acm_gateway/upstream.py +287 -0
- acm_gateway/visualizer.py +120 -0
- acm_mcp/__init__.py +5 -0
- acm_mcp/jit.py +106 -0
- acm_mcp/memory_store.py +55 -0
- acm_mcp/server.py +271 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: acm-context-management
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Context-management gateway + MCP server that brings the ACM techniques (trimming, summarisation, image-recall, memory) into any IDE
|
|
5
|
+
Project-URL: Homepage, https://github.com/HarishanA21/agentic_context_management
|
|
6
|
+
Project-URL: Repository, https://github.com/HarishanA21/agentic_context_management
|
|
7
|
+
Author: ACM
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: claude-code,context-management,cursor,gateway,ides,llm,mcp,vscode
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: fastapi>=0.110
|
|
20
|
+
Requires-Dist: httpx>=0.27
|
|
21
|
+
Requires-Dist: langchain-core>=0.3
|
|
22
|
+
Requires-Dist: langchain>=0.3
|
|
23
|
+
Requires-Dist: mcp>=1.2
|
|
24
|
+
Requires-Dist: pillow>=10
|
|
25
|
+
Requires-Dist: pydantic>=2
|
|
26
|
+
Requires-Dist: python-dotenv>=1.0
|
|
27
|
+
Requires-Dist: uvicorn[standard]>=0.29
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: build; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
31
|
+
Requires-Dist: twine; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# ACM Extension — context management for any IDE
|
|
35
|
+
|
|
36
|
+
This folder is **self-contained**. It turns the context-management engine that
|
|
37
|
+
already powers the website (in [`../backend`](../backend)) into something usable
|
|
38
|
+
from VSCode, Cursor, Antigravity, Claude Code, and Windsurf — **without changing
|
|
39
|
+
a single line of the website's code.**
|
|
40
|
+
|
|
41
|
+
It reaches the IDEs through three surfaces (see
|
|
42
|
+
[`../EXTENSION_PLAN.md`](../EXTENSION_PLAN.md) for the why):
|
|
43
|
+
|
|
44
|
+
| Folder | Surface | What it is |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `acm_gateway/` | **Gateway** | A local OpenAI/Anthropic-compatible proxy. The IDE talks to it as if it were the AI; it applies every technique on the wire, then forwards to the real provider. Full feature parity. |
|
|
47
|
+
| `acm_mcp/` | **MCP** | An MCP server exposing `remember` / `recall` / `compact` / `set_profile` tools. Works in *every* MCP-capable IDE. |
|
|
48
|
+
| `adapters/` | **Hooks + config** | Per-IDE glue: Claude Code hooks, Cursor `mcp.json`, VSCode notes. |
|
|
49
|
+
| `acm_engine/` | — | Thin bridge that imports the techniques from `../backend` so there is **one** source of truth. |
|
|
50
|
+
|
|
51
|
+
## How it reuses the website's engine
|
|
52
|
+
|
|
53
|
+
`acm_engine/` puts `../backend` on `sys.path` and re-exports the pure technique
|
|
54
|
+
functions (`trim_tool_results`, `summarise_old_messages`,
|
|
55
|
+
`sliding_window_trim`, `evict_stale_images`, `annotate_cache_breakpoints`) plus
|
|
56
|
+
the `Profile` schema. Nothing in `../backend` is edited. The website and the
|
|
57
|
+
extension share the same code; fix a bug once, both benefit.
|
|
58
|
+
|
|
59
|
+
## Quick start (gateway)
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
cd extension
|
|
63
|
+
uv venv --python 3.11
|
|
64
|
+
source .venv/bin/activate
|
|
65
|
+
uv pip install -e .
|
|
66
|
+
|
|
67
|
+
# point it at any OpenAI-compatible upstream (OpenRouter shown)
|
|
68
|
+
export ACM_UPSTREAM_BASE_URL=https://openrouter.ai/api/v1
|
|
69
|
+
export ACM_UPSTREAM_API_KEY=sk-or-v1-...
|
|
70
|
+
|
|
71
|
+
acm-gateway # serves http://127.0.0.1:8807
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Then tell the IDE the AI lives at `http://127.0.0.1:8807/v1` (Cursor: custom
|
|
75
|
+
OpenAI base URL; Claude Code: `ANTHROPIC_BASE_URL`).
|
|
76
|
+
|
|
77
|
+
## Config
|
|
78
|
+
|
|
79
|
+
A single [`config/acm.config.example.json`](config/acm.config.example.json)
|
|
80
|
+
holds the active profile (which techniques are on). Copy it to
|
|
81
|
+
`config/acm.config.json` and edit, or set `ACM_CONFIG` to its path. The schema
|
|
82
|
+
is the website's `Profile` model — same presets (`minimal`, `long_chat`,
|
|
83
|
+
`power_research`, `cheap_long`, `visual_recall`).
|
|
84
|
+
|
|
85
|
+
## Status
|
|
86
|
+
|
|
87
|
+
- **Gateway** — runnable. OpenAI (`/v1/chat/completions`) **and** Anthropic
|
|
88
|
+
(`/v1/messages`) surfaces; full technique pipeline (trim / summarise / sliding
|
|
89
|
+
window / image-evict / cache breakpoints / **visual method** — rasterise big
|
|
90
|
+
tool outputs to images); **manual message removal** (cascade-safe drop-list,
|
|
91
|
+
persisted to `~/.acm/dropped.json`); **multi-provider routing** (OpenAI /
|
|
92
|
+
OpenRouter / Google / Azure on the OpenAI surface, Anthropic native; creds in
|
|
93
|
+
`~/.acm/providers.json`; route by `x-acm-provider` header, `slug/model`
|
|
94
|
+
prefix, or default — Bedrock via OpenRouter since it needs AWS SigV4); control
|
|
95
|
+
plane (`/status`, `/profile`, `/memory`, `/compact`, `/conversations`,
|
|
96
|
+
`/messages*`, `/providers*`).
|
|
97
|
+
- **MCP server** — runnable: memory (`remember`/`recall`), `compact`,
|
|
98
|
+
`set_profile`/`status`, manual removal (`list_messages`/`drop_message`/
|
|
99
|
+
`restore_message`), providers (`list_providers`/`add_provider`/
|
|
100
|
+
`set_default_provider`), **sub-agent** (`spawn_subagent`), and **JIT retrieval**
|
|
101
|
+
(`find_files`/`read_slice`/`grep_files`).
|
|
102
|
+
- **Claude Code** — gateway + hooks (trim + memory) + MCP. Done.
|
|
103
|
+
- **Cursor** — gateway + hooks (capture + **compact-on-stop**) + MCP. Done.
|
|
104
|
+
- **VSCode** — full extension (Language Model Tools + `@acm` chat participant +
|
|
105
|
+
webview settings) talking to the gateway; MCP drop-in for Antigravity /
|
|
106
|
+
Windsurf. TypeScript is written but not yet `tsc`-compiled here (needs
|
|
107
|
+
`npm install` with `@types/vscode`).
|
|
108
|
+
|
|
109
|
+
Remaining work is marked with `# TODO(acm)` / `TODO(acm)`: orphan-`tool_use`
|
|
110
|
+
pruning on the Anthropic path, React components in the VSCode webview, and
|
|
111
|
+
auto-starting the gateway from the extension.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
acm_engine/__init__.py,sha256=sbBeugN4NF3NDkRm_N4rUe2lkWVRZv_jOWMkflnSmsQ,4167
|
|
2
|
+
acm_engine/_vendor/cache_layout.py,sha256=R5QTIudjbb4hE_JxXMFmXwEy49rMgmc9cYhDbdElWTs,9004
|
|
3
|
+
acm_engine/_vendor/context_editing.py,sha256=VG1EgNazNTrKxy_v6ZKtAOI3GPgvQc6bem-5Cstw2Mg,33463
|
|
4
|
+
acm_engine/_vendor/context_profiles.py,sha256=UYyqBT4n82KXzdjLP_WqWcnREz3fCVb3ulPqbL5raLM,21699
|
|
5
|
+
acm_engine/_vendor/relevance.py,sha256=_jmO4r3VHybkh3jp7IcW0vIaeM7jeEKKtE29WhEqwjs,24931
|
|
6
|
+
acm_engine/_vendor/relevance_encoder.py,sha256=6eVtzbv5U8HEwkpbYvdoSO_FQ1ywmCnH3nBAadm6Lcw,9377
|
|
7
|
+
acm_engine/_vendor/visual_tool/__init__.py,sha256=HjQMBbuMAITqaMDjHbDAl5U_iwjsAUUbYXC5vujuamk,379
|
|
8
|
+
acm_engine/_vendor/visual_tool/indexer.py,sha256=Y8fiVwYGAW491b_fKXs6LSJDmEVY2rZGpuTm0YzfxFY,4518
|
|
9
|
+
acm_engine/_vendor/visual_tool/rasterizer.py,sha256=PlrYhGpARasA82RWlEtRcsxf4dczPXcTYTZg2k7Fruo,8082
|
|
10
|
+
acm_gateway/__init__.py,sha256=g8Q4-CcprvonNmxH2D42Sk5NbEIbzTm7q3AmAT_2vuk,204
|
|
11
|
+
acm_gateway/__main__.py,sha256=6v5YKAOOLgtC-v0_g_SRyo7fqB8Vtf4KoUpjUK-a4Sk,485
|
|
12
|
+
acm_gateway/app.py,sha256=rcjMA4__AQKzSAlDPPVD2uv6fXbJmdO7zKtttHBolC8,48025
|
|
13
|
+
acm_gateway/config.py,sha256=SGVrqSHifiocJdN1tTBs9VKzONlvZB_3lEn6BbxX1bo,6756
|
|
14
|
+
acm_gateway/context_windows.py,sha256=FiqK4nplhJWRkSS0mpjl5hrkYx3Oq4UxzuuTkgMnb44,8533
|
|
15
|
+
acm_gateway/droplist.py,sha256=IOI6gH9MpZ5NTK5WC7S6SfLVbCFsm4sUp6VlhLC-yr4,20237
|
|
16
|
+
acm_gateway/events.py,sha256=le7PaKs7oGOThL8JgYJ7_JbvRM_8jkQmT_Xc-DGp6h8,3248
|
|
17
|
+
acm_gateway/pipeline.py,sha256=emI2SGfYiv6SVzAgySpKk1Lk29oMZNPSXBOO87-zekc,7252
|
|
18
|
+
acm_gateway/providers_store.py,sha256=8uouaxwIjB9xZaABKXtfKwiGHIgc720sG6jLMFxFTus,8273
|
|
19
|
+
acm_gateway/translate.py,sha256=B0UyKMJpZ4J5_FHGJr5_ezdN5PQrKxIFa55sJa5MalU,4364
|
|
20
|
+
acm_gateway/translate_anthropic.py,sha256=knMrQX3OBhQGHWiaNeNBONxEOvnFo9pRhnXnkBLMLzo,10875
|
|
21
|
+
acm_gateway/upstream.py,sha256=h15BsE9OmSGLU0BrjrAmTbr-2n2ZU5HGx9iX2fi14AQ,12246
|
|
22
|
+
acm_gateway/visualizer.py,sha256=meEemUCfsM82lTlTEpckbedyLVk6OeXCa6g95Ec-zuw,4940
|
|
23
|
+
acm_mcp/__init__.py,sha256=2OMuGY08Q_QKB1CZRjqNoeq73uJ9qYn3At8p-1-2s8M,213
|
|
24
|
+
acm_mcp/jit.py,sha256=kWaVZOLmv2wfRGLvKMBYYQR-vbhHvjkMPF9pgu4MHKk,3897
|
|
25
|
+
acm_mcp/memory_store.py,sha256=RmbXAFip1tkR60Vw1ylcTrwJbfR-APkNnEJpS16A5Z4,1835
|
|
26
|
+
acm_mcp/server.py,sha256=nszX5lntSMIoYQPJgmJZfeL72At5lxz4x_oqjuSCQ4E,10107
|
|
27
|
+
acm_context_management-0.2.0.dist-info/METADATA,sha256=mhLiY1MtoAS9LJ0MuTyUgJR1IsUQ1tVtMond_1pPUTw,5364
|
|
28
|
+
acm_context_management-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
29
|
+
acm_context_management-0.2.0.dist-info/entry_points.txt,sha256=ddyNG1InNz_GZKa9Cq5udixszCy1YZ10hCqsSOgFmag,88
|
|
30
|
+
acm_context_management-0.2.0.dist-info/RECORD,,
|
acm_engine/__init__.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Bridge to the website's context-management engine.
|
|
2
|
+
|
|
3
|
+
This is the *one* place the extension reaches into ``../backend``. It puts the
|
|
4
|
+
backend directory on ``sys.path`` and re-exports the **pure** technique
|
|
5
|
+
functions plus the ``Profile`` schema, so the gateway and the website run the
|
|
6
|
+
exact same code. Nothing in ``../backend`` is edited.
|
|
7
|
+
|
|
8
|
+
Why only the pure functions? ``context_editing.apply_context_edits`` is
|
|
9
|
+
LangGraph-specific — it calls ``agent.get_state`` / ``agent.update_state``. The
|
|
10
|
+
gateway has no LangGraph agent; it has a plain list of messages off the wire.
|
|
11
|
+
So we import the lower-level building blocks (which operate on a
|
|
12
|
+
``List[BaseMessage]``) and the gateway's own ``pipeline.py`` orchestrates them
|
|
13
|
+
in the same fixed order ``apply_context_edits`` uses.
|
|
14
|
+
|
|
15
|
+
If an import here ever fails, it's almost always because ``../backend`` moved or
|
|
16
|
+
a dependency in ``backend/requirements.txt`` isn't installed in this venv.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
# Two ways to find the engine, in priority order:
|
|
25
|
+
# 1. The **vendored** copy shipped inside the wheel (acm_engine/_vendor/) —
|
|
26
|
+
# this is what an end user who `pip install`ed the package gets. Refresh it
|
|
27
|
+
# from backend with `python scripts/sync_engine.py`.
|
|
28
|
+
# 2. The repo's ../backend tree — used during local dev so a checkout runs the
|
|
29
|
+
# live engine without a sync step.
|
|
30
|
+
# Whichever wins is put on sys.path so the engine's flat imports
|
|
31
|
+
# (`import context_profiles`, `from context_editing import ...`) resolve.
|
|
32
|
+
_VENDOR = Path(__file__).resolve().parent / "_vendor"
|
|
33
|
+
_BACKEND = Path(__file__).resolve().parents[2] / "backend"
|
|
34
|
+
|
|
35
|
+
if (_VENDOR / "context_profiles.py").is_file():
|
|
36
|
+
_ENGINE_DIR = _VENDOR
|
|
37
|
+
elif (_BACKEND / "context_profiles.py").is_file():
|
|
38
|
+
_ENGINE_DIR = _BACKEND
|
|
39
|
+
else: # pragma: no cover - defensive
|
|
40
|
+
raise ImportError(
|
|
41
|
+
"acm_engine found neither a vendored engine (acm_engine/_vendor/) nor "
|
|
42
|
+
f"the repo backend at {_BACKEND}. Run `python scripts/sync_engine.py`."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_engine_str = str(_ENGINE_DIR)
|
|
46
|
+
if _engine_str not in sys.path:
|
|
47
|
+
sys.path.insert(0, _engine_str)
|
|
48
|
+
|
|
49
|
+
# Re-export the schema (pure pydantic, zero app coupling).
|
|
50
|
+
from context_profiles import ( # noqa: E402
|
|
51
|
+
BUILTIN_PRESETS,
|
|
52
|
+
DEFAULT_PRESET_NAME,
|
|
53
|
+
PRESET_SUMMARY,
|
|
54
|
+
Profile,
|
|
55
|
+
parse_profile,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Re-export the pure, message-list-level techniques. None of these touch the
|
|
59
|
+
# database, FastAPI, or a LangGraph agent — they take a list of messages and
|
|
60
|
+
# return rewrites.
|
|
61
|
+
from context_editing import ( # noqa: E402
|
|
62
|
+
_DEFAULT_SUMMARY_SYSTEM as DEFAULT_SUMMARY_SYSTEM,
|
|
63
|
+
evict_stale_images,
|
|
64
|
+
sliding_window_trim,
|
|
65
|
+
summarise_old_messages,
|
|
66
|
+
trim_tool_results,
|
|
67
|
+
)
|
|
68
|
+
from cache_layout import ( # noqa: E402
|
|
69
|
+
annotate_cache_breakpoints,
|
|
70
|
+
read_cache_tokens,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Task-aware relevance pruning (suggest-only; judge engine ships now, encoder
|
|
74
|
+
# seam ready for Phase 2). Pure, message-list-level like the rest.
|
|
75
|
+
from relevance import ( # noqa: E402
|
|
76
|
+
Episode,
|
|
77
|
+
JudgeSuggester,
|
|
78
|
+
Suggestion,
|
|
79
|
+
active_task,
|
|
80
|
+
build_audit_rows,
|
|
81
|
+
load_audits,
|
|
82
|
+
load_feedback,
|
|
83
|
+
record_audit,
|
|
84
|
+
record_feedback,
|
|
85
|
+
segment_into_episodes,
|
|
86
|
+
suggest_removals,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Local encoder engine (no LLM call). Heavy ML libs load lazily, so importing
|
|
90
|
+
# the class here stays cheap even on a minimal install.
|
|
91
|
+
from relevance_encoder import EncoderSuggester # noqa: E402
|
|
92
|
+
|
|
93
|
+
# Where the engine was actually loaded from (vendored copy or repo backend).
|
|
94
|
+
ENGINE_DIR = _ENGINE_DIR
|
|
95
|
+
BACKEND_DIR = _ENGINE_DIR # back-compat alias
|
|
96
|
+
|
|
97
|
+
__all__ = [
|
|
98
|
+
"ENGINE_DIR",
|
|
99
|
+
"BACKEND_DIR",
|
|
100
|
+
"BUILTIN_PRESETS",
|
|
101
|
+
"DEFAULT_PRESET_NAME",
|
|
102
|
+
"PRESET_SUMMARY",
|
|
103
|
+
"Profile",
|
|
104
|
+
"parse_profile",
|
|
105
|
+
"DEFAULT_SUMMARY_SYSTEM",
|
|
106
|
+
"trim_tool_results",
|
|
107
|
+
"summarise_old_messages",
|
|
108
|
+
"sliding_window_trim",
|
|
109
|
+
"evict_stale_images",
|
|
110
|
+
"annotate_cache_breakpoints",
|
|
111
|
+
"read_cache_tokens",
|
|
112
|
+
"Episode",
|
|
113
|
+
"Suggestion",
|
|
114
|
+
"JudgeSuggester",
|
|
115
|
+
"segment_into_episodes",
|
|
116
|
+
"active_task",
|
|
117
|
+
"suggest_removals",
|
|
118
|
+
"record_feedback",
|
|
119
|
+
"load_feedback",
|
|
120
|
+
"build_audit_rows",
|
|
121
|
+
"record_audit",
|
|
122
|
+
"load_audits",
|
|
123
|
+
"EncoderSuggester",
|
|
124
|
+
]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Provider-agnostic image-recall middleware (cache + eviction at call time).
|
|
2
|
+
|
|
3
|
+
This is the *common* solution for the image-recall techniques — it works for
|
|
4
|
+
every model the app can route to, because it leans on the one thing all
|
|
5
|
+
caching backends share: **a stable, append-only prefix**.
|
|
6
|
+
|
|
7
|
+
* OpenAI / Azure (and OpenAI models via OpenRouter): caching is automatic.
|
|
8
|
+
Keeping the prefix stable is all that's needed; the cache_control hints
|
|
9
|
+
we add are simply ignored.
|
|
10
|
+
* Anthropic / Bedrock-Claude / Gemini (incl. via OpenRouter): caching is
|
|
11
|
+
explicit. OpenRouter forwards the ``cache_control`` markers we put on
|
|
12
|
+
content blocks straight through to the upstream provider.
|
|
13
|
+
|
|
14
|
+
The middleware does both halves of "visual recall", gated by the profile's
|
|
15
|
+
``image_recall.mode``:
|
|
16
|
+
|
|
17
|
+
* eviction (``evict`` / ``cache_evict``) — inside ``wrap_model_call`` we
|
|
18
|
+
drop the pixels of all but the most-recent K image tool-results from the
|
|
19
|
+
request the model sees, replacing them with their REFERENCES + a digest.
|
|
20
|
+
Doing it here (not just between turns) means it also fires inside a single
|
|
21
|
+
agent tool-loop — which is what the single-shot demo needs.
|
|
22
|
+
* caching (``cache`` / ``cache_evict``) — mark the stable prefix with an
|
|
23
|
+
ephemeral breakpoint so re-reading the frozen history is cheap/fast.
|
|
24
|
+
Caching never changes the model's output.
|
|
25
|
+
|
|
26
|
+
Gotchas baked in:
|
|
27
|
+
* Breakpoints go on a *text* block, never an image/tool block (Anthropic
|
|
28
|
+
rejects ``cache_control`` there — langchain issue #34920).
|
|
29
|
+
* String content is widened to a one-element list so we can hang a marker.
|
|
30
|
+
* Everything is defensive: an image-recall hiccup must never break a turn.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import logging
|
|
36
|
+
from typing import Any, Dict, List, Optional
|
|
37
|
+
|
|
38
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
39
|
+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
|
|
40
|
+
|
|
41
|
+
log = logging.getLogger("image_recall")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ─── cache-token reader (used by demo + chat to surface cache details) ───
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_cache_tokens(msg: BaseMessage) -> Dict[str, int]:
|
|
48
|
+
"""Pull cache hit/write counts off an AIMessage, provider-agnostic.
|
|
49
|
+
|
|
50
|
+
Returns ``{cache_read, cache_write}``. Anthropic/Gemini expose these via
|
|
51
|
+
``usage_metadata.input_token_details`` (cache_read / cache_creation);
|
|
52
|
+
OpenAI exposes a ``cache_read`` only. Falls back to ``response_metadata``
|
|
53
|
+
when the normalised field is absent. All-zero when nothing cached.
|
|
54
|
+
"""
|
|
55
|
+
out = {"cache_read": 0, "cache_write": 0}
|
|
56
|
+
um = getattr(msg, "usage_metadata", None) or {}
|
|
57
|
+
details = um.get("input_token_details") or {}
|
|
58
|
+
if isinstance(details, dict):
|
|
59
|
+
out["cache_read"] = int(details.get("cache_read") or 0)
|
|
60
|
+
out["cache_write"] = int(
|
|
61
|
+
details.get("cache_creation") or details.get("cache_write") or 0
|
|
62
|
+
)
|
|
63
|
+
if out["cache_read"] == 0 and out["cache_write"] == 0:
|
|
64
|
+
# OpenAI-style raw usage on response_metadata.
|
|
65
|
+
rm = getattr(msg, "response_metadata", None) or {}
|
|
66
|
+
usage = rm.get("usage") or rm.get("token_usage") or {}
|
|
67
|
+
ptd = (usage.get("prompt_tokens_details") or {}) if isinstance(usage, dict) else {}
|
|
68
|
+
if isinstance(ptd, dict):
|
|
69
|
+
out["cache_read"] = int(ptd.get("cached_tokens") or 0)
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ─── cache breakpoint placement ──────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _last_stable_index(messages: List[BaseMessage]) -> Optional[int]:
|
|
77
|
+
"""Index of the last message in the *settled* prefix (just before the
|
|
78
|
+
final HumanMessage). The newest turn stays dynamic / reprocessed fresh."""
|
|
79
|
+
if len(messages) < 2:
|
|
80
|
+
return None
|
|
81
|
+
last_human = None
|
|
82
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
83
|
+
if isinstance(messages[i], HumanMessage):
|
|
84
|
+
last_human = i
|
|
85
|
+
break
|
|
86
|
+
boundary = (last_human - 1) if last_human is not None else (len(messages) - 1)
|
|
87
|
+
return boundary if boundary >= 0 else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _mark_block(msg: BaseMessage, ttl: str) -> bool:
|
|
91
|
+
"""Attach an ephemeral cache_control marker to a *text* block of ``msg``.
|
|
92
|
+
Skips image / non-text blocks. Returns True if a marker was placed."""
|
|
93
|
+
cc: Dict[str, Any] = {"type": "ephemeral"}
|
|
94
|
+
if ttl == "1h":
|
|
95
|
+
cc["ttl"] = "1h"
|
|
96
|
+
content = getattr(msg, "content", None)
|
|
97
|
+
if isinstance(content, str):
|
|
98
|
+
if not content.strip():
|
|
99
|
+
return False
|
|
100
|
+
msg.content = [{"type": "text", "text": content, "cache_control": cc}]
|
|
101
|
+
return True
|
|
102
|
+
if isinstance(content, list):
|
|
103
|
+
for block in reversed(content):
|
|
104
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
105
|
+
block["cache_control"] = cc
|
|
106
|
+
return True
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def annotate_cache_breakpoints(
|
|
111
|
+
messages: List[BaseMessage],
|
|
112
|
+
*,
|
|
113
|
+
system: Optional[BaseMessage] = None,
|
|
114
|
+
ttl: str = "5m",
|
|
115
|
+
) -> int:
|
|
116
|
+
"""Place ephemeral breakpoints on the stable prefix (system + last settled
|
|
117
|
+
message). Mutates in place; returns how many breakpoints were set."""
|
|
118
|
+
placed = 0
|
|
119
|
+
if isinstance(system, SystemMessage) and _mark_block(system, ttl):
|
|
120
|
+
placed += 1
|
|
121
|
+
idx = _last_stable_index(messages)
|
|
122
|
+
if idx is not None:
|
|
123
|
+
target = messages[idx]
|
|
124
|
+
if target.__class__.__name__ == "ToolMessage":
|
|
125
|
+
# Don't mark a bare tool block; walk back to an AI/System text block.
|
|
126
|
+
for j in range(idx, -1, -1):
|
|
127
|
+
if isinstance(messages[j], (AIMessage, SystemMessage)):
|
|
128
|
+
target = messages[j]
|
|
129
|
+
break
|
|
130
|
+
if _mark_block(target, ttl):
|
|
131
|
+
placed += 1
|
|
132
|
+
return placed
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ─── the middleware ──────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ImageRecallMiddleware(AgentMiddleware):
|
|
139
|
+
"""Applies the selected image-recall technique on every model call.
|
|
140
|
+
|
|
141
|
+
``mode`` ∈ {off, cache, evict, cache_evict}. Provider-agnostic: caching is
|
|
142
|
+
a no-op effect for auto-caching backends and active for explicit ones.
|
|
143
|
+
Attach only when ``mode != off``.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
*,
|
|
149
|
+
mode: str = "off",
|
|
150
|
+
keep_recent_images: int = 3,
|
|
151
|
+
ttl: str = "5m",
|
|
152
|
+
) -> None:
|
|
153
|
+
super().__init__()
|
|
154
|
+
self.mode = mode if mode in {"off", "cache", "evict", "cache_evict"} else "off"
|
|
155
|
+
self.keep_recent_images = max(1, int(keep_recent_images or 3))
|
|
156
|
+
self.ttl = ttl if ttl in {"5m", "1h"} else "5m"
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def caching_enabled(self) -> bool:
|
|
160
|
+
return self.mode in {"cache", "cache_evict"}
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def eviction_enabled(self) -> bool:
|
|
164
|
+
return self.mode in {"evict", "cache_evict"}
|
|
165
|
+
|
|
166
|
+
def _apply(self, request) -> None:
|
|
167
|
+
"""Mutate request.messages in place: evict stale images, then mark
|
|
168
|
+
cache breakpoints. Order matters — eviction first so the breakpoint
|
|
169
|
+
lands on the de-imaged prefix."""
|
|
170
|
+
msgs = getattr(request, "messages", None)
|
|
171
|
+
if not msgs:
|
|
172
|
+
return
|
|
173
|
+
if self.eviction_enabled:
|
|
174
|
+
# Lazy import avoids a context_editing <-> cache_layout cycle.
|
|
175
|
+
from context_editing import evict_stale_images
|
|
176
|
+
|
|
177
|
+
replacements, info = evict_stale_images(
|
|
178
|
+
msgs, keep_recent_images=self.keep_recent_images
|
|
179
|
+
)
|
|
180
|
+
if replacements:
|
|
181
|
+
by_id = {getattr(r, "id", None): r for r in replacements}
|
|
182
|
+
request.messages = [
|
|
183
|
+
by_id.get(getattr(m, "id", None), m) for m in msgs
|
|
184
|
+
]
|
|
185
|
+
msgs = request.messages
|
|
186
|
+
log.info(
|
|
187
|
+
"[image_recall] evicted %d stale image(s), kept %d as pixels, freed ~%d tokens",
|
|
188
|
+
info["evicted"], info["kept_recent"], info["freed_tokens"],
|
|
189
|
+
)
|
|
190
|
+
if self.caching_enabled:
|
|
191
|
+
placed = annotate_cache_breakpoints(
|
|
192
|
+
msgs,
|
|
193
|
+
system=getattr(request, "system_message", None),
|
|
194
|
+
ttl=self.ttl,
|
|
195
|
+
)
|
|
196
|
+
if placed:
|
|
197
|
+
log.info(
|
|
198
|
+
"[image_recall] placed %d cache breakpoint(s) (ttl=%s) on the stable prefix",
|
|
199
|
+
placed, self.ttl,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def wrap_model_call(self, request, handler): # type: ignore[override]
|
|
203
|
+
try:
|
|
204
|
+
self._apply(request)
|
|
205
|
+
except Exception as e: # never break the turn over image-recall
|
|
206
|
+
log.warning("[image_recall] apply failed: %r", e)
|
|
207
|
+
return handler(request)
|
|
208
|
+
|
|
209
|
+
async def awrap_model_call(self, request, handler): # type: ignore[override]
|
|
210
|
+
try:
|
|
211
|
+
self._apply(request)
|
|
212
|
+
except Exception as e:
|
|
213
|
+
log.warning("[image_recall] apply failed: %r", e)
|
|
214
|
+
return await handler(request)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# Back-compat alias (earlier name during development).
|
|
218
|
+
CachePrefixMiddleware = ImageRecallMiddleware
|