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.
@@ -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))