langchain-claude-cli 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.
- langchain_claude_cli/__init__.py +27 -0
- langchain_claude_cli/_compat.py +20 -0
- langchain_claude_cli/_convert.py +394 -0
- langchain_claude_cli/_sessions.py +106 -0
- langchain_claude_cli/chat_models.py +1109 -0
- langchain_claude_cli/tools.py +46 -0
- langchain_claude_cli-0.1.0.dist-info/METADATA +211 -0
- langchain_claude_cli-0.1.0.dist-info/RECORD +10 -0
- langchain_claude_cli-0.1.0.dist-info/WHEEL +4 -0
- langchain_claude_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""langchain-claude-cli — ChatAnthropic drop-in backed by the Claude Code CLI."""
|
|
2
|
+
|
|
3
|
+
from langchain_claude_cli._compat import ClaudeCliCompatWarning
|
|
4
|
+
from langchain_claude_cli.chat_models import (
|
|
5
|
+
ChatClaudeCli,
|
|
6
|
+
ClaudeCliBudgetExceededError,
|
|
7
|
+
)
|
|
8
|
+
from langchain_claude_cli.tools import (
|
|
9
|
+
ALL_TOOLS,
|
|
10
|
+
NETWORK_TOOLS,
|
|
11
|
+
READ_ONLY_TOOLS,
|
|
12
|
+
SHELL_TOOLS,
|
|
13
|
+
WRITE_TOOLS,
|
|
14
|
+
ClaudeTool,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ALL_TOOLS",
|
|
19
|
+
"NETWORK_TOOLS",
|
|
20
|
+
"READ_ONLY_TOOLS",
|
|
21
|
+
"SHELL_TOOLS",
|
|
22
|
+
"WRITE_TOOLS",
|
|
23
|
+
"ChatClaudeCli",
|
|
24
|
+
"ClaudeCliBudgetExceededError",
|
|
25
|
+
"ClaudeCliCompatWarning",
|
|
26
|
+
"ClaudeTool",
|
|
27
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""ChatAnthropic signature-compatibility layer: no-op params and one-shot warnings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ClaudeCliCompatWarning(UserWarning):
|
|
9
|
+
"""A ChatAnthropic parameter was accepted but has no effect via the Claude CLI."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_warned: set[str] = set()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def warn_once(param: str, message: str) -> None:
|
|
16
|
+
"""Emit a ClaudeCliCompatWarning once per process for a given parameter."""
|
|
17
|
+
if param in _warned:
|
|
18
|
+
return
|
|
19
|
+
_warned.add(param)
|
|
20
|
+
warnings.warn(message, ClaudeCliCompatWarning, stacklevel=3)
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Message conversion between LangChain and the Claude Agent SDK / CLI.
|
|
2
|
+
|
|
3
|
+
LangChain -> CLI: messages become stream-json input entries (user/assistant
|
|
4
|
+
dicts with Anthropic-style content blocks). Tool results are NOT sent as
|
|
5
|
+
messages (spike S1: the CLI ignores user tool_result blocks); they are
|
|
6
|
+
collected into a delivery map consumed by the MCP handlers on session resume.
|
|
7
|
+
|
|
8
|
+
CLI -> LangChain: SDK content blocks become ChatAnthropic-style message content
|
|
9
|
+
(anthropic-native block dicts) plus tool_calls, usage_metadata and
|
|
10
|
+
response_metadata.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from langchain_core.messages import (
|
|
19
|
+
AIMessage,
|
|
20
|
+
BaseMessage,
|
|
21
|
+
HumanMessage,
|
|
22
|
+
SystemMessage,
|
|
23
|
+
ToolMessage,
|
|
24
|
+
)
|
|
25
|
+
from langchain_core.messages.ai import UsageMetadata
|
|
26
|
+
|
|
27
|
+
MCP_SERVER_NAME = "lc"
|
|
28
|
+
MCP_TOOL_PREFIX = f"mcp__{MCP_SERVER_NAME}__"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def strip_tool_namespace(name: str) -> str:
|
|
32
|
+
"""mcp__lc__get_weather -> get_weather (non-lc names pass through)."""
|
|
33
|
+
return name[len(MCP_TOOL_PREFIX) :] if name.startswith(MCP_TOOL_PREFIX) else name
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def add_tool_namespace(name: str) -> str:
|
|
37
|
+
return name if name.startswith(MCP_TOOL_PREFIX) else f"{MCP_TOOL_PREFIX}{name}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def canonical_args(args: Any) -> str:
|
|
41
|
+
"""Canonical JSON for tool args, used as delivery-map key."""
|
|
42
|
+
import json
|
|
43
|
+
|
|
44
|
+
return json.dumps(args, sort_keys=True, default=str)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── LangChain -> CLI ─────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _human_item_to_block(item: str | dict) -> dict:
|
|
51
|
+
"""Convert one HumanMessage content item to an Anthropic content block."""
|
|
52
|
+
if isinstance(item, str):
|
|
53
|
+
return {"type": "text", "text": item}
|
|
54
|
+
kind = item.get("type", "")
|
|
55
|
+
if kind == "text":
|
|
56
|
+
return {"type": "text", "text": item.get("text", "")}
|
|
57
|
+
if kind == "image_url":
|
|
58
|
+
img = item.get("image_url", {})
|
|
59
|
+
url = img if isinstance(img, str) else img.get("url", "")
|
|
60
|
+
if url.startswith("data:"):
|
|
61
|
+
header, b64data = url.split(",", 1)
|
|
62
|
+
media_type = header.split(":", 1)[1].split(";", 1)[0]
|
|
63
|
+
return {
|
|
64
|
+
"type": "image",
|
|
65
|
+
"source": {
|
|
66
|
+
"type": "base64",
|
|
67
|
+
"media_type": media_type,
|
|
68
|
+
"data": b64data,
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
return {"type": "image", "source": {"type": "url", "url": url}}
|
|
72
|
+
if kind in ("image", "document"):
|
|
73
|
+
return {k: v for k, v in item.items() if k != "cache_control"}
|
|
74
|
+
if (
|
|
75
|
+
kind == "file" # langchain-core v1 standard file block
|
|
76
|
+
and item.get("source_type") == "base64"
|
|
77
|
+
and item.get("mime_type") == "application/pdf"
|
|
78
|
+
):
|
|
79
|
+
return {
|
|
80
|
+
"type": "document",
|
|
81
|
+
"source": {
|
|
82
|
+
"type": "base64",
|
|
83
|
+
"media_type": "application/pdf",
|
|
84
|
+
"data": item.get("data", ""),
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
return {"type": "text", "text": str(item)}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _human_content_to_blocks(content: str | list) -> list[dict]:
|
|
91
|
+
if isinstance(content, str):
|
|
92
|
+
return [{"type": "text", "text": content}]
|
|
93
|
+
return [_human_item_to_block(item) for item in content]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _ai_message_to_blocks(msg: AIMessage) -> list[dict]:
|
|
97
|
+
"""AIMessage -> assistant content blocks (text + tool_use), for replay/flatten."""
|
|
98
|
+
blocks: list[dict] = []
|
|
99
|
+
if isinstance(msg.content, str):
|
|
100
|
+
if msg.content:
|
|
101
|
+
blocks.append({"type": "text", "text": msg.content})
|
|
102
|
+
else:
|
|
103
|
+
for item in msg.content:
|
|
104
|
+
if isinstance(item, str):
|
|
105
|
+
blocks.append({"type": "text", "text": item})
|
|
106
|
+
elif isinstance(item, dict) and item.get("type") == "text":
|
|
107
|
+
blocks.append({"type": "text", "text": item.get("text", "")})
|
|
108
|
+
# thinking/tool_use dict blocks from a prior turn: skipped — tool_use
|
|
109
|
+
# is re-added below from msg.tool_calls; replayed thinking is noise.
|
|
110
|
+
blocks.extend(
|
|
111
|
+
{
|
|
112
|
+
"type": "tool_use",
|
|
113
|
+
"id": tc["id"],
|
|
114
|
+
"name": add_tool_namespace(tc["name"]),
|
|
115
|
+
"input": tc["args"],
|
|
116
|
+
}
|
|
117
|
+
for tc in msg.tool_calls
|
|
118
|
+
)
|
|
119
|
+
return blocks
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class ConvertedHistory:
|
|
124
|
+
"""LangChain history converted for CLI consumption."""
|
|
125
|
+
|
|
126
|
+
system: str | None = None
|
|
127
|
+
# stream-json input entries, in order: {"type": "user"|"assistant", "message": {...}}
|
|
128
|
+
entries: list[dict] = field(default_factory=list)
|
|
129
|
+
# tool_call_id -> stringified result, from ToolMessages (delivery map)
|
|
130
|
+
tool_results: dict[str, str] = field(default_factory=dict)
|
|
131
|
+
# (un-namespaced tool name, canonical-json args) -> result
|
|
132
|
+
tool_results_by_key: dict[tuple[str, str], str] = field(default_factory=dict)
|
|
133
|
+
# tool name (un-namespaced) -> result, fallback when args don't match
|
|
134
|
+
tool_results_by_name: dict[str, str] = field(default_factory=dict)
|
|
135
|
+
# tool_call_id -> (un-namespaced name, canonical args) for every AI tool call
|
|
136
|
+
tool_call_meta: dict[str, tuple[str, str]] = field(default_factory=dict)
|
|
137
|
+
|
|
138
|
+
def restrict_results(self, ids: set[str]) -> ConvertedHistory:
|
|
139
|
+
"""Copy with delivery maps limited to the given tool_call_ids (pending)."""
|
|
140
|
+
out = ConvertedHistory(system=self.system, entries=self.entries)
|
|
141
|
+
out.tool_call_meta = self.tool_call_meta
|
|
142
|
+
for cid, text in self.tool_results.items():
|
|
143
|
+
if cid not in ids:
|
|
144
|
+
continue
|
|
145
|
+
out.tool_results[cid] = text
|
|
146
|
+
meta = self.tool_call_meta.get(cid)
|
|
147
|
+
if meta:
|
|
148
|
+
out.tool_results_by_key[meta] = text
|
|
149
|
+
out.tool_results_by_name[meta[0]] = text
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def has_pending_tool_results(self) -> bool:
|
|
154
|
+
return bool(self.tool_results)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _tool_message_text(msg: ToolMessage) -> str:
|
|
158
|
+
if isinstance(msg.content, str):
|
|
159
|
+
return msg.content
|
|
160
|
+
parts = []
|
|
161
|
+
for item in msg.content:
|
|
162
|
+
if isinstance(item, str):
|
|
163
|
+
parts.append(item)
|
|
164
|
+
elif isinstance(item, dict) and item.get("type") == "text":
|
|
165
|
+
parts.append(item.get("text", ""))
|
|
166
|
+
else:
|
|
167
|
+
parts.append(str(item))
|
|
168
|
+
return "\n".join(parts)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def convert_lc_messages(messages: list[BaseMessage]) -> ConvertedHistory:
|
|
172
|
+
"""Convert LangChain messages into CLI stream entries + tool-result map."""
|
|
173
|
+
out = ConvertedHistory()
|
|
174
|
+
call_names: dict[str, str] = {}
|
|
175
|
+
call_args: dict[str, str] = {}
|
|
176
|
+
|
|
177
|
+
for msg in messages:
|
|
178
|
+
if isinstance(msg, SystemMessage):
|
|
179
|
+
text = (
|
|
180
|
+
msg.content
|
|
181
|
+
if isinstance(msg.content, str)
|
|
182
|
+
else "\n".join(
|
|
183
|
+
i.get("text", "") if isinstance(i, dict) else str(i)
|
|
184
|
+
for i in msg.content
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
out.system = f"{out.system}\n\n{text}" if out.system else text
|
|
188
|
+
elif isinstance(msg, ToolMessage):
|
|
189
|
+
text = _tool_message_text(msg)
|
|
190
|
+
out.tool_results[msg.tool_call_id] = text
|
|
191
|
+
name = call_names.get(msg.tool_call_id)
|
|
192
|
+
if name:
|
|
193
|
+
short = strip_tool_namespace(name)
|
|
194
|
+
out.tool_results_by_name[short] = text
|
|
195
|
+
args_json = call_args.get(msg.tool_call_id)
|
|
196
|
+
if args_json is not None:
|
|
197
|
+
out.tool_results_by_key[(short, args_json)] = text
|
|
198
|
+
elif isinstance(msg, AIMessage):
|
|
199
|
+
for tc in msg.tool_calls:
|
|
200
|
+
cid = tc["id"]
|
|
201
|
+
if cid is None:
|
|
202
|
+
continue
|
|
203
|
+
call_names[cid] = tc["name"]
|
|
204
|
+
call_args[cid] = canonical_args(tc["args"])
|
|
205
|
+
out.tool_call_meta[cid] = (
|
|
206
|
+
strip_tool_namespace(tc["name"]),
|
|
207
|
+
canonical_args(tc["args"]),
|
|
208
|
+
)
|
|
209
|
+
blocks = _ai_message_to_blocks(msg)
|
|
210
|
+
if blocks:
|
|
211
|
+
out.entries.append(
|
|
212
|
+
{
|
|
213
|
+
"type": "assistant",
|
|
214
|
+
"message": {"role": "assistant", "content": blocks},
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
elif isinstance(msg, HumanMessage):
|
|
218
|
+
out.entries.append(
|
|
219
|
+
{
|
|
220
|
+
"type": "user",
|
|
221
|
+
"message": {
|
|
222
|
+
"role": "user",
|
|
223
|
+
"content": _human_content_to_blocks(msg.content),
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
)
|
|
227
|
+
else: # ChatMessage / unknown role -> user text
|
|
228
|
+
out.entries.append(
|
|
229
|
+
{
|
|
230
|
+
"type": "user",
|
|
231
|
+
"message": {"role": "user", "content": str(msg.content)},
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def flatten_to_single_user(entries: list[dict]) -> dict:
|
|
238
|
+
"""Structured flatten: whole history as ONE user message (spike S3).
|
|
239
|
+
|
|
240
|
+
Multi-message replay triggers a live generation per historical user
|
|
241
|
+
message, so arbitrary histories are collapsed into a single user message.
|
|
242
|
+
Text gets role labels; image/document blocks are preserved verbatim so
|
|
243
|
+
multimodality survives the flatten.
|
|
244
|
+
"""
|
|
245
|
+
blocks: list[dict] = [
|
|
246
|
+
{
|
|
247
|
+
"type": "text",
|
|
248
|
+
"text": (
|
|
249
|
+
"The following is the conversation so far. Continue it by "
|
|
250
|
+
"responding to the last user message.\n"
|
|
251
|
+
),
|
|
252
|
+
}
|
|
253
|
+
]
|
|
254
|
+
for entry in entries:
|
|
255
|
+
role = entry["message"]["role"]
|
|
256
|
+
label = "User" if role == "user" else "Assistant"
|
|
257
|
+
content = entry["message"]["content"]
|
|
258
|
+
if isinstance(content, str):
|
|
259
|
+
blocks.append({"type": "text", "text": f"[{label}]: {content}"})
|
|
260
|
+
continue
|
|
261
|
+
texts: list[str] = []
|
|
262
|
+
for block in content:
|
|
263
|
+
btype = block.get("type")
|
|
264
|
+
if btype == "text":
|
|
265
|
+
texts.append(block["text"])
|
|
266
|
+
elif btype in ("image", "document"):
|
|
267
|
+
if texts:
|
|
268
|
+
blocks.append(
|
|
269
|
+
{"type": "text", "text": f"[{label}]: {' '.join(texts)}"}
|
|
270
|
+
)
|
|
271
|
+
texts = []
|
|
272
|
+
blocks.append({"type": "text", "text": f"[{label} attached {btype}]:"})
|
|
273
|
+
blocks.append(block)
|
|
274
|
+
elif btype == "tool_use":
|
|
275
|
+
texts.append(
|
|
276
|
+
f"<called tool {strip_tool_namespace(block['name'])} "
|
|
277
|
+
f"with {block['input']}>"
|
|
278
|
+
)
|
|
279
|
+
if texts:
|
|
280
|
+
blocks.append({"type": "text", "text": f"[{label}]: {' '.join(texts)}"})
|
|
281
|
+
return {"type": "user", "message": {"role": "user", "content": blocks}}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ── CLI -> LangChain ─────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def sdk_blocks_to_lc(
|
|
288
|
+
assistant_messages: list[Any],
|
|
289
|
+
*,
|
|
290
|
+
deferred: bool,
|
|
291
|
+
delivered_ids: set[str] | None = None,
|
|
292
|
+
delivered_keys: set[tuple[str, str]] | None = None,
|
|
293
|
+
) -> tuple[str | list[dict], list[dict]]:
|
|
294
|
+
"""SDK AssistantMessage blocks -> (LangChain content, tool_calls).
|
|
295
|
+
|
|
296
|
+
Only NEW deferred lc-tool calls become tool_calls: a resumed session
|
|
297
|
+
re-emits the tool_use blocks it re-fired and delivered in-run — those are
|
|
298
|
+
satisfied, not requests to the caller — and when the run ended on anything
|
|
299
|
+
other than tool_deferred there is nothing pending at all. Text emitted
|
|
300
|
+
AFTER the first deferred tool_use is dropped (the model reacting to the
|
|
301
|
+
deferral — spike S1).
|
|
302
|
+
"""
|
|
303
|
+
delivered_ids = delivered_ids or set()
|
|
304
|
+
delivered_keys = delivered_keys or set()
|
|
305
|
+
content_blocks: list[dict] = []
|
|
306
|
+
tool_calls: list[dict] = []
|
|
307
|
+
saw_deferred_tool_use = False
|
|
308
|
+
|
|
309
|
+
for msg in assistant_messages:
|
|
310
|
+
for block in msg.content:
|
|
311
|
+
type_name = type(block).__name__
|
|
312
|
+
if type_name == "TextBlock":
|
|
313
|
+
if deferred and saw_deferred_tool_use:
|
|
314
|
+
continue
|
|
315
|
+
content_blocks.append({"type": "text", "text": block.text})
|
|
316
|
+
elif type_name == "ThinkingBlock":
|
|
317
|
+
content_blocks.append(
|
|
318
|
+
{
|
|
319
|
+
"type": "thinking",
|
|
320
|
+
"thinking": block.thinking,
|
|
321
|
+
"signature": getattr(block, "signature", None),
|
|
322
|
+
}
|
|
323
|
+
)
|
|
324
|
+
elif type_name == "ToolUseBlock":
|
|
325
|
+
if block.name.startswith(MCP_TOOL_PREFIX):
|
|
326
|
+
short = strip_tool_namespace(block.name)
|
|
327
|
+
key = (short, canonical_args(block.input))
|
|
328
|
+
if (
|
|
329
|
+
not deferred
|
|
330
|
+
or block.id in delivered_ids
|
|
331
|
+
or key in delivered_keys
|
|
332
|
+
):
|
|
333
|
+
continue # satisfied in-run, not a request to the caller
|
|
334
|
+
saw_deferred_tool_use = True
|
|
335
|
+
tool_calls.append(
|
|
336
|
+
{
|
|
337
|
+
"name": short,
|
|
338
|
+
"args": block.input,
|
|
339
|
+
"id": block.id,
|
|
340
|
+
"type": "tool_call",
|
|
341
|
+
}
|
|
342
|
+
)
|
|
343
|
+
else:
|
|
344
|
+
content_blocks.append(
|
|
345
|
+
{
|
|
346
|
+
"type": "tool_use",
|
|
347
|
+
"id": block.id,
|
|
348
|
+
"name": block.name,
|
|
349
|
+
"input": block.input,
|
|
350
|
+
}
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Simple string content when there's nothing but text
|
|
354
|
+
if all(b["type"] == "text" for b in content_blocks):
|
|
355
|
+
return "".join(b["text"] for b in content_blocks), tool_calls
|
|
356
|
+
return content_blocks, tool_calls
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def usage_to_usage_metadata(usage: dict[str, Any] | None) -> UsageMetadata | None:
|
|
360
|
+
if not usage:
|
|
361
|
+
return None
|
|
362
|
+
input_tokens = int(usage.get("input_tokens", 0))
|
|
363
|
+
output_tokens = int(usage.get("output_tokens", 0))
|
|
364
|
+
cache_read = int(usage.get("cache_read_input_tokens", 0) or 0)
|
|
365
|
+
cache_creation = int(usage.get("cache_creation_input_tokens", 0) or 0)
|
|
366
|
+
input_total = input_tokens + cache_read + cache_creation
|
|
367
|
+
meta: UsageMetadata = {
|
|
368
|
+
"input_tokens": input_total,
|
|
369
|
+
"output_tokens": output_tokens,
|
|
370
|
+
"total_tokens": input_total + output_tokens,
|
|
371
|
+
}
|
|
372
|
+
if cache_read or cache_creation:
|
|
373
|
+
meta["input_token_details"] = {
|
|
374
|
+
"cache_read": cache_read,
|
|
375
|
+
"cache_creation": cache_creation,
|
|
376
|
+
}
|
|
377
|
+
return meta
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def result_to_response_metadata(result: Any, model: str) -> dict[str, Any]:
|
|
381
|
+
"""ResultMessage -> response_metadata (ChatAnthropic-compatible keys + extras)."""
|
|
382
|
+
meta: dict[str, Any] = {
|
|
383
|
+
"model_name": model,
|
|
384
|
+
"model": model,
|
|
385
|
+
"stop_reason": getattr(result, "stop_reason", None),
|
|
386
|
+
"session_id": getattr(result, "session_id", None),
|
|
387
|
+
}
|
|
388
|
+
if getattr(result, "total_cost_usd", None) is not None:
|
|
389
|
+
meta["total_cost_usd"] = result.total_cost_usd
|
|
390
|
+
if getattr(result, "num_turns", None) is not None:
|
|
391
|
+
meta["num_turns"] = result.num_turns
|
|
392
|
+
if getattr(result, "duration_ms", None) is not None:
|
|
393
|
+
meta["duration_ms"] = result.duration_ms
|
|
394
|
+
return meta
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Session prefix-cache: map LangChain history prefixes to CLI session ids.
|
|
2
|
+
|
|
3
|
+
BaseChatModel is stateless (full history on every invoke) while the CLI is a
|
|
4
|
+
stateful session. The cache lets a growing conversation resume its CLI session
|
|
5
|
+
sending only the new suffix (design D4). Fingerprints are content-based and
|
|
6
|
+
ignore volatile metadata, so the AIMessage we returned last turn fingerprints
|
|
7
|
+
identically when the caller sends it back.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import threading
|
|
15
|
+
from collections import OrderedDict
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Literal
|
|
18
|
+
|
|
19
|
+
from langchain_core.messages import AIMessage, BaseMessage, ToolMessage
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _message_digest(msg: BaseMessage) -> str:
|
|
23
|
+
"""Stable digest of one message: role + normalized content (+ tool linkage)."""
|
|
24
|
+
payload: dict = {"role": msg.type}
|
|
25
|
+
content = msg.content
|
|
26
|
+
if isinstance(content, str):
|
|
27
|
+
payload["content"] = content
|
|
28
|
+
else:
|
|
29
|
+
norm = []
|
|
30
|
+
for item in content:
|
|
31
|
+
if isinstance(item, str):
|
|
32
|
+
norm.append({"type": "text", "text": item})
|
|
33
|
+
elif isinstance(item, dict):
|
|
34
|
+
# volatile keys (signatures, indexes) excluded
|
|
35
|
+
norm.append(
|
|
36
|
+
{
|
|
37
|
+
k: v
|
|
38
|
+
for k, v in item.items()
|
|
39
|
+
if k
|
|
40
|
+
in ("type", "text", "thinking", "source", "name", "input", "id")
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
payload["content"] = norm
|
|
44
|
+
if isinstance(msg, AIMessage) and msg.tool_calls:
|
|
45
|
+
payload["tool_calls"] = [
|
|
46
|
+
{"name": tc["name"], "args": tc["args"], "id": tc["id"]}
|
|
47
|
+
for tc in msg.tool_calls
|
|
48
|
+
]
|
|
49
|
+
if isinstance(msg, ToolMessage):
|
|
50
|
+
payload["tool_call_id"] = msg.tool_call_id
|
|
51
|
+
raw = json.dumps(payload, sort_keys=True, default=str)
|
|
52
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def prefix_fingerprints(messages: list[BaseMessage]) -> list[str]:
|
|
56
|
+
"""Rolling fingerprints: fp[k] covers messages[0..k] inclusive. O(n)."""
|
|
57
|
+
fps: list[str] = []
|
|
58
|
+
acc = hashlib.sha256()
|
|
59
|
+
for msg in messages:
|
|
60
|
+
acc.update(_message_digest(msg).encode())
|
|
61
|
+
fps.append(acc.copy().hexdigest())
|
|
62
|
+
return fps
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Resolution:
|
|
67
|
+
"""How to execute this invoke against the CLI."""
|
|
68
|
+
|
|
69
|
+
strategy: Literal["new", "resume"]
|
|
70
|
+
session_id: str | None = None
|
|
71
|
+
# messages not covered by the resumed session (empty on full-history match)
|
|
72
|
+
suffix: list[BaseMessage] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SessionCache:
|
|
76
|
+
"""Thread-safe LRU mapping history-prefix fingerprints to CLI session ids."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, maxsize: int = 256) -> None:
|
|
79
|
+
self._maxsize = maxsize
|
|
80
|
+
self._lock = threading.Lock()
|
|
81
|
+
self._data: OrderedDict[str, str] = OrderedDict()
|
|
82
|
+
|
|
83
|
+
def register(self, messages: list[BaseMessage], session_id: str) -> None:
|
|
84
|
+
if not messages or not session_id:
|
|
85
|
+
return
|
|
86
|
+
fp = prefix_fingerprints(messages)[-1]
|
|
87
|
+
with self._lock:
|
|
88
|
+
self._data[fp] = session_id
|
|
89
|
+
self._data.move_to_end(fp)
|
|
90
|
+
while len(self._data) > self._maxsize:
|
|
91
|
+
self._data.popitem(last=False)
|
|
92
|
+
|
|
93
|
+
def resolve(self, messages: list[BaseMessage]) -> Resolution:
|
|
94
|
+
"""Longest known prefix wins; no known prefix -> new session."""
|
|
95
|
+
fps = prefix_fingerprints(messages)
|
|
96
|
+
with self._lock:
|
|
97
|
+
for k in range(len(fps) - 1, -1, -1):
|
|
98
|
+
session_id = self._data.get(fps[k])
|
|
99
|
+
if session_id is not None:
|
|
100
|
+
self._data.move_to_end(fps[k])
|
|
101
|
+
return Resolution(
|
|
102
|
+
strategy="resume",
|
|
103
|
+
session_id=session_id,
|
|
104
|
+
suffix=messages[k + 1 :],
|
|
105
|
+
)
|
|
106
|
+
return Resolution(strategy="new", suffix=list(messages))
|