langchain-claude-code-mimir 0.1.1__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_code/__init__.py +22 -0
- langchain_claude_code/claude_chat_model.py +921 -0
- langchain_claude_code/claude_code_tools.py +41 -0
- langchain_claude_code/py.typed +0 -0
- langchain_claude_code_mimir-0.1.1.dist-info/METADATA +207 -0
- langchain_claude_code_mimir-0.1.1.dist-info/RECORD +8 -0
- langchain_claude_code_mimir-0.1.1.dist-info/WHEEL +4 -0
- langchain_claude_code_mimir-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from langchain_claude_code.claude_chat_model import ClaudeCodeChatModel
|
|
2
|
+
from langchain_claude_code.claude_code_tools import (
|
|
3
|
+
ClaudeTool,
|
|
4
|
+
normalize_tools,
|
|
5
|
+
DEFAULT_READ_ONLY,
|
|
6
|
+
DEFAULT_WRITE,
|
|
7
|
+
DEFAULT_NETWORK,
|
|
8
|
+
DEFAULT_SHELL,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
ChatClaudeCode = ClaudeCodeChatModel
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ClaudeCodeChatModel",
|
|
15
|
+
"ChatClaudeCode",
|
|
16
|
+
"ClaudeTool",
|
|
17
|
+
"normalize_tools",
|
|
18
|
+
"DEFAULT_READ_ONLY",
|
|
19
|
+
"DEFAULT_WRITE",
|
|
20
|
+
"DEFAULT_NETWORK",
|
|
21
|
+
"DEFAULT_SHELL",
|
|
22
|
+
]
|
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextvars
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
import queue
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable, get_args, get_origin
|
|
13
|
+
|
|
14
|
+
from langchain_core.callbacks import (
|
|
15
|
+
AsyncCallbackManagerForLLMRun,
|
|
16
|
+
CallbackManagerForLLMRun,
|
|
17
|
+
)
|
|
18
|
+
from langchain_core.language_models import BaseChatModel
|
|
19
|
+
from langchain_core.messages import (
|
|
20
|
+
AIMessage,
|
|
21
|
+
AIMessageChunk,
|
|
22
|
+
BaseMessage,
|
|
23
|
+
HumanMessage,
|
|
24
|
+
SystemMessage as LCSystemMessage,
|
|
25
|
+
ToolMessage,
|
|
26
|
+
)
|
|
27
|
+
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
|
|
28
|
+
from langchain_core.runnables import Runnable, RunnableConfig
|
|
29
|
+
from langchain_core.runnables.config import ensure_config
|
|
30
|
+
from langchain_core.tools import BaseTool
|
|
31
|
+
from pydantic import Field, PrivateAttr
|
|
32
|
+
from pydantic.errors import PydanticInvalidForJsonSchema
|
|
33
|
+
|
|
34
|
+
from claude_agent_sdk import (
|
|
35
|
+
ClaudeAgentOptions,
|
|
36
|
+
ClaudeSDKClient,
|
|
37
|
+
AssistantMessage,
|
|
38
|
+
HookMatcher,
|
|
39
|
+
ResultMessage,
|
|
40
|
+
TextBlock,
|
|
41
|
+
ToolUseBlock,
|
|
42
|
+
ToolResultBlock,
|
|
43
|
+
create_sdk_mcp_server,
|
|
44
|
+
tool as sdk_tool,
|
|
45
|
+
)
|
|
46
|
+
from langchain_claude_code.claude_code_tools import ClaudeTool, normalize_tools
|
|
47
|
+
|
|
48
|
+
log = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _has_runtime_injected_args(tool: BaseTool) -> bool:
|
|
52
|
+
"""Return True if any parameter of the tool's underlying function is
|
|
53
|
+
annotated with a langgraph-runtime injection marker
|
|
54
|
+
(``InjectedToolArg`` or one of its direct-injection subclasses,
|
|
55
|
+
notably ``ToolRuntime``).
|
|
56
|
+
|
|
57
|
+
Such tools require langgraph state (graph runtime, store, channels,
|
|
58
|
+
etc.) to be injected at invocation time by langgraph's ``ToolNode``.
|
|
59
|
+
They can't be bridged through MCP because the MCP transport
|
|
60
|
+
doesn't carry that state — invoking them directly via ``_arun(**args)``
|
|
61
|
+
raises ``TypeError: missing 1 required positional argument: 'runtime'``.
|
|
62
|
+
|
|
63
|
+
Returns ``False`` if the underlying callable can't be introspected
|
|
64
|
+
(defensive — we err on the side of including the tool and letting
|
|
65
|
+
a runtime failure surface, matching pre-fix behavior for non-injected
|
|
66
|
+
tools).
|
|
67
|
+
"""
|
|
68
|
+
# Try the InjectedToolArg base class first (covers Annotated[..., InjectedToolArg(...)]).
|
|
69
|
+
try:
|
|
70
|
+
from langchain_core.tools.base import InjectedToolArg
|
|
71
|
+
except ImportError:
|
|
72
|
+
InjectedToolArg = None # type: ignore[assignment]
|
|
73
|
+
|
|
74
|
+
# Direct-injection subclass is what ToolRuntime extends — params
|
|
75
|
+
# carry the raw type (not Annotated[]). Optional import: older
|
|
76
|
+
# langgraph versions don't have it.
|
|
77
|
+
try:
|
|
78
|
+
from langgraph.prebuilt.tool_node import _DirectlyInjectedToolArg
|
|
79
|
+
except ImportError:
|
|
80
|
+
_DirectlyInjectedToolArg = None # type: ignore[assignment]
|
|
81
|
+
|
|
82
|
+
if InjectedToolArg is None and _DirectlyInjectedToolArg is None:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
# ``StructuredTool``-style: the wrapped callable is exposed as
|
|
86
|
+
# ``coroutine`` (async) or ``func`` (sync). ``BaseTool``-subclass
|
|
87
|
+
# style: the implementation lives in ``_arun`` / ``_run`` methods
|
|
88
|
+
# on the class. Inspect the most-specific class method to skip
|
|
89
|
+
# the framework's default-stub signature on the base class.
|
|
90
|
+
candidates: list[Any] = []
|
|
91
|
+
for attr in ("coroutine", "func"):
|
|
92
|
+
c = getattr(tool, attr, None)
|
|
93
|
+
if c is not None and callable(c):
|
|
94
|
+
candidates.append(c)
|
|
95
|
+
cls = type(tool)
|
|
96
|
+
for attr in ("_arun", "_run"):
|
|
97
|
+
m = cls.__dict__.get(attr)
|
|
98
|
+
if m is None:
|
|
99
|
+
# Walk MRO for overrides above ``BaseTool`` (which defines
|
|
100
|
+
# both as no-ops). Stop at langchain_core to avoid picking
|
|
101
|
+
# up the framework's signature.
|
|
102
|
+
for base in cls.__mro__[1:]:
|
|
103
|
+
if base.__module__.startswith("langchain_core"):
|
|
104
|
+
break
|
|
105
|
+
if attr in base.__dict__:
|
|
106
|
+
m = base.__dict__[attr]
|
|
107
|
+
break
|
|
108
|
+
if m is not None and callable(m):
|
|
109
|
+
candidates.append(m)
|
|
110
|
+
if not candidates:
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
for callable_ in candidates:
|
|
114
|
+
try:
|
|
115
|
+
sig = inspect.signature(callable_)
|
|
116
|
+
except (TypeError, ValueError):
|
|
117
|
+
continue
|
|
118
|
+
if _signature_has_injected_param(
|
|
119
|
+
sig, InjectedToolArg, _DirectlyInjectedToolArg,
|
|
120
|
+
):
|
|
121
|
+
return True
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _signature_has_injected_param(
|
|
126
|
+
sig: inspect.Signature,
|
|
127
|
+
injected_tool_arg: type | None,
|
|
128
|
+
directly_injected_tool_arg: type | None,
|
|
129
|
+
) -> bool:
|
|
130
|
+
"""Helper for :func:`_has_runtime_injected_args` — scan one signature."""
|
|
131
|
+
for param in sig.parameters.values():
|
|
132
|
+
ann = param.annotation
|
|
133
|
+
if ann is inspect.Parameter.empty:
|
|
134
|
+
continue
|
|
135
|
+
# Direct-injection types (ToolRuntime[X, Y]): the param's annotation
|
|
136
|
+
# is the class itself, possibly subscripted with generics.
|
|
137
|
+
if directly_injected_tool_arg is not None:
|
|
138
|
+
origin = get_origin(ann) or ann
|
|
139
|
+
if isinstance(origin, type) and issubclass(
|
|
140
|
+
origin, directly_injected_tool_arg,
|
|
141
|
+
):
|
|
142
|
+
return True
|
|
143
|
+
# Annotated[T, InjectedToolArg(...)] form: walk the metadata list.
|
|
144
|
+
if injected_tool_arg is not None:
|
|
145
|
+
for meta in get_args(ann)[1:] if get_origin(ann) is not None else ():
|
|
146
|
+
if isinstance(meta, type) and issubclass(meta, injected_tool_arg):
|
|
147
|
+
return True
|
|
148
|
+
if isinstance(meta, injected_tool_arg):
|
|
149
|
+
return True
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _generation_info_from_result(msg: "ResultMessage") -> dict[str, Any]:
|
|
154
|
+
"""Build a ``generation_info`` dict from an SDK ``ResultMessage``.
|
|
155
|
+
|
|
156
|
+
Mirrors the SDK's per-request metadata onto LangChain's
|
|
157
|
+
``generation_info`` so downstream consumers can read it off the
|
|
158
|
+
final AIMessage's ``response_metadata``. Used by both ``_generate``
|
|
159
|
+
(non-streaming) and ``_astream`` (streaming) so the two paths
|
|
160
|
+
surface the same field set — previously they diverged: the
|
|
161
|
+
non-streaming path preserved ``num_turns`` / ``is_error`` but
|
|
162
|
+
missed ``finish_reason``, while the streaming path emitted
|
|
163
|
+
``finish_reason`` but dropped ``num_turns`` / ``is_error``
|
|
164
|
+
entirely. Neither path preserved ``stop_reason``.
|
|
165
|
+
|
|
166
|
+
Field shape (all keys present unless explicitly noted):
|
|
167
|
+
|
|
168
|
+
- ``total_cost_usd``, ``duration_ms``, ``duration_api_ms``,
|
|
169
|
+
``session_id`` — SDK fields
|
|
170
|
+
- ``num_turns``, ``is_error`` — SDK fields
|
|
171
|
+
- ``finish_reason`` — LangChain
|
|
172
|
+
convention; ``"error"`` when ``msg.is_error`` else ``"stop"``
|
|
173
|
+
- ``stop_reason`` — granular SDK
|
|
174
|
+
reason (``"end_turn"``, ``"max_turns"``, ``"max_tokens"``,
|
|
175
|
+
etc.); only included when present (newer SDK only) AND
|
|
176
|
+
non-``None``. Access is ``getattr``-guarded so the helper
|
|
177
|
+
works on the SDK ``>= 0.1.10`` floor this package declares.
|
|
178
|
+
- ``usage`` — only when
|
|
179
|
+
``msg.usage`` is non-empty
|
|
180
|
+
"""
|
|
181
|
+
info: dict[str, Any] = {
|
|
182
|
+
"total_cost_usd": msg.total_cost_usd,
|
|
183
|
+
"duration_ms": msg.duration_ms,
|
|
184
|
+
"duration_api_ms": msg.duration_api_ms,
|
|
185
|
+
"session_id": msg.session_id,
|
|
186
|
+
"num_turns": msg.num_turns,
|
|
187
|
+
"is_error": msg.is_error,
|
|
188
|
+
"finish_reason": "error" if msg.is_error else "stop",
|
|
189
|
+
}
|
|
190
|
+
# ``stop_reason`` was added to ResultMessage in a later SDK
|
|
191
|
+
# release; use getattr so the helper stays compatible with the
|
|
192
|
+
# >= 0.1.10 floor pinned in pyproject.toml.
|
|
193
|
+
stop_reason = getattr(msg, "stop_reason", None)
|
|
194
|
+
if stop_reason is not None:
|
|
195
|
+
info["stop_reason"] = stop_reason
|
|
196
|
+
if msg.usage:
|
|
197
|
+
info["usage"] = msg.usage
|
|
198
|
+
return info
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class ClaudeCodeChatModel(BaseChatModel):
|
|
202
|
+
"""LangChain chat model wrapping Claude Code Agent SDK.
|
|
203
|
+
|
|
204
|
+
Uses ClaudeSDKClient for multi-turn conversations with full tool support.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
model: str = Field(default="opus", description="Model to use (opus, sonnet, haiku)")
|
|
208
|
+
fallback_model: str | None = Field(default=None, description="Fallback model")
|
|
209
|
+
system_prompt: str | None = Field(default=None, description="System prompt")
|
|
210
|
+
permission_mode: str = Field(
|
|
211
|
+
default="default",
|
|
212
|
+
description="Permission mode: default, acceptEdits, plan, bypassPermissions",
|
|
213
|
+
)
|
|
214
|
+
allowed_tools: list[str | ClaudeTool] = Field(default_factory=list, description="Allowed tools")
|
|
215
|
+
disallowed_tools: list[str | ClaudeTool] = Field(default_factory=list, description="Disallowed tools")
|
|
216
|
+
max_turns: int | None = Field(default=None, description="Max conversation turns")
|
|
217
|
+
max_budget_usd: float | None = Field(default=None, description="Max budget in USD")
|
|
218
|
+
effort: str | int | None = Field(
|
|
219
|
+
default=None,
|
|
220
|
+
description=(
|
|
221
|
+
"Reasoning effort forwarded to ClaudeAgentOptions.effort "
|
|
222
|
+
"(e.g. 'low'/'medium'/'high'/'max', or an int token budget). "
|
|
223
|
+
"None leaves Claude's adaptive default. Silently dropped if the "
|
|
224
|
+
"installed claude-agent-sdk predates the effort option."
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
cwd: str | Path | None = Field(default=None, description="Working directory")
|
|
228
|
+
include_partial_messages: bool = Field(
|
|
229
|
+
default=False, description="Enable partial message streaming"
|
|
230
|
+
)
|
|
231
|
+
api_key: str | None = Field(default=None, description="Anthropic API key")
|
|
232
|
+
oauth_token: str | None = Field(default=None, description="OAuth token")
|
|
233
|
+
|
|
234
|
+
_mcp_servers: dict[str, Any] = {}
|
|
235
|
+
_bound_tools: list[BaseTool] = []
|
|
236
|
+
_last_result: ResultMessage | None = None
|
|
237
|
+
_tool_results_var: contextvars.ContextVar | None = PrivateAttr(
|
|
238
|
+
default_factory=lambda: contextvars.ContextVar("claude_code_tool_results")
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
class Config:
|
|
242
|
+
arbitrary_types_allowed = True
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def _llm_type(self) -> str:
|
|
246
|
+
return "claude-code-agent"
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def _identifying_params(self) -> dict[str, Any]:
|
|
250
|
+
return {
|
|
251
|
+
"model": self.model,
|
|
252
|
+
"permission_mode": self.permission_mode,
|
|
253
|
+
"system_prompt": self.system_prompt,
|
|
254
|
+
"allowed_tools": self.allowed_tools,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
def invoke(
|
|
258
|
+
self,
|
|
259
|
+
input: Any,
|
|
260
|
+
config: RunnableConfig | None = None,
|
|
261
|
+
*,
|
|
262
|
+
stop: list[str] | None = None,
|
|
263
|
+
**kwargs: Any,
|
|
264
|
+
) -> AIMessage:
|
|
265
|
+
"""Ensure RunnableConfig is available to downstream calls."""
|
|
266
|
+
config = ensure_config(config)
|
|
267
|
+
kwargs.setdefault("_config", config)
|
|
268
|
+
return super().invoke(input, config=config, stop=stop, **kwargs)
|
|
269
|
+
|
|
270
|
+
async def ainvoke(
|
|
271
|
+
self,
|
|
272
|
+
input: Any,
|
|
273
|
+
config: RunnableConfig | None = None,
|
|
274
|
+
*,
|
|
275
|
+
stop: list[str] | None = None,
|
|
276
|
+
**kwargs: Any,
|
|
277
|
+
) -> AIMessage:
|
|
278
|
+
"""Async invoke that propagates RunnableConfig via context."""
|
|
279
|
+
config = ensure_config(config)
|
|
280
|
+
kwargs.setdefault("_config", config)
|
|
281
|
+
return await super().ainvoke(input, config=config, stop=stop, **kwargs)
|
|
282
|
+
|
|
283
|
+
def stream(
|
|
284
|
+
self,
|
|
285
|
+
input: Any,
|
|
286
|
+
config: RunnableConfig | None = None,
|
|
287
|
+
*,
|
|
288
|
+
stop: list[str] | None = None,
|
|
289
|
+
**kwargs: Any,
|
|
290
|
+
) -> Iterator[AIMessageChunk]:
|
|
291
|
+
"""Stream while keeping config in context for session support."""
|
|
292
|
+
config = ensure_config(config)
|
|
293
|
+
kwargs.setdefault("_config", config)
|
|
294
|
+
yield from super().stream(input, config=config, stop=stop, **kwargs)
|
|
295
|
+
|
|
296
|
+
async def astream(
|
|
297
|
+
self,
|
|
298
|
+
input: Any,
|
|
299
|
+
config: RunnableConfig | None = None,
|
|
300
|
+
*,
|
|
301
|
+
stop: list[str] | None = None,
|
|
302
|
+
**kwargs: Any,
|
|
303
|
+
) -> AsyncIterator[AIMessageChunk]:
|
|
304
|
+
"""Async stream while propagating config context."""
|
|
305
|
+
config = ensure_config(config)
|
|
306
|
+
kwargs.setdefault("_config", config)
|
|
307
|
+
async for chunk in super().astream(
|
|
308
|
+
input, config=config, stop=stop, **kwargs
|
|
309
|
+
):
|
|
310
|
+
yield chunk
|
|
311
|
+
|
|
312
|
+
def _build_options(self, **overrides: Any) -> ClaudeAgentOptions:
|
|
313
|
+
"""Build ClaudeAgentOptions from model config."""
|
|
314
|
+
opts = {
|
|
315
|
+
"model": self.model,
|
|
316
|
+
"permission_mode": self.permission_mode,
|
|
317
|
+
"allowed_tools": normalize_tools(list(self.allowed_tools)),
|
|
318
|
+
"disallowed_tools": normalize_tools(list(self.disallowed_tools)),
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if self.fallback_model:
|
|
322
|
+
opts["fallback_model"] = self.fallback_model
|
|
323
|
+
if self.system_prompt:
|
|
324
|
+
opts["system_prompt"] = self.system_prompt
|
|
325
|
+
if self.max_turns is not None:
|
|
326
|
+
opts["max_turns"] = self.max_turns
|
|
327
|
+
if self.max_budget_usd is not None:
|
|
328
|
+
opts["max_budget_usd"] = self.max_budget_usd
|
|
329
|
+
if self.effort is not None:
|
|
330
|
+
# Kept by the allowed_keys filter below iff the installed
|
|
331
|
+
# claude-agent-sdk exposes ``effort`` (else dropped gracefully).
|
|
332
|
+
opts["effort"] = self.effort
|
|
333
|
+
if self.cwd:
|
|
334
|
+
opts["cwd"] = self.cwd
|
|
335
|
+
if self._mcp_servers:
|
|
336
|
+
opts["mcp_servers"] = self._mcp_servers
|
|
337
|
+
|
|
338
|
+
env: dict[str, str] = {}
|
|
339
|
+
if self.api_key:
|
|
340
|
+
env["ANTHROPIC_API_KEY"] = self.api_key
|
|
341
|
+
if self.oauth_token:
|
|
342
|
+
env["CLAUDE_CODE_OAUTH_TOKEN"] = self.oauth_token
|
|
343
|
+
if env:
|
|
344
|
+
opts["env"] = env
|
|
345
|
+
|
|
346
|
+
if self.include_partial_messages:
|
|
347
|
+
opts["include_partial_messages"] = True
|
|
348
|
+
# When tools are allowed/bound, ensure partial messages so tool results stream back.
|
|
349
|
+
if not opts.get("include_partial_messages") and (
|
|
350
|
+
self._bound_tools or opts.get("allowed_tools")
|
|
351
|
+
):
|
|
352
|
+
opts["include_partial_messages"] = True
|
|
353
|
+
|
|
354
|
+
allowed_keys = set(ClaudeAgentOptions.__dataclass_fields__.keys())
|
|
355
|
+
for key, value in overrides.items():
|
|
356
|
+
if key.startswith("_"):
|
|
357
|
+
if key == "_mcp_servers":
|
|
358
|
+
opts["mcp_servers"] = value
|
|
359
|
+
continue
|
|
360
|
+
if key in allowed_keys:
|
|
361
|
+
if key in {"allowed_tools", "disallowed_tools"}:
|
|
362
|
+
opts[key] = normalize_tools(list(value or []))
|
|
363
|
+
else:
|
|
364
|
+
opts[key] = value
|
|
365
|
+
|
|
366
|
+
return ClaudeAgentOptions(**opts)
|
|
367
|
+
|
|
368
|
+
def _convert_messages(
|
|
369
|
+
self,
|
|
370
|
+
messages: list[BaseMessage],
|
|
371
|
+
) -> tuple[str, str | None]:
|
|
372
|
+
"""Convert LangChain messages to prompt and system prompt.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Tuple of (prompt, system_prompt)
|
|
376
|
+
"""
|
|
377
|
+
system_parts: list[str] = []
|
|
378
|
+
conversation_parts: list[str] = []
|
|
379
|
+
|
|
380
|
+
for msg in messages:
|
|
381
|
+
if isinstance(msg, LCSystemMessage):
|
|
382
|
+
system_parts.append(str(msg.content))
|
|
383
|
+
elif isinstance(msg, HumanMessage):
|
|
384
|
+
conversation_parts.append(f"Human: {msg.content}")
|
|
385
|
+
elif isinstance(msg, AIMessage):
|
|
386
|
+
content = str(msg.content) if msg.content else ""
|
|
387
|
+
if getattr(msg, "tool_calls", None):
|
|
388
|
+
tool_info = ", ".join(
|
|
389
|
+
f"{tc['name']}({tc['args']})" for tc in msg.tool_calls
|
|
390
|
+
)
|
|
391
|
+
content = f"{content}\n[Tool calls: {tool_info}]" if content else f"[Tool calls: {tool_info}]"
|
|
392
|
+
conversation_parts.append(f"Assistant: {content}")
|
|
393
|
+
elif isinstance(msg, ToolMessage):
|
|
394
|
+
conversation_parts.append(
|
|
395
|
+
f"Tool ({msg.name}): {msg.content}"
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
system_prompt = "\n\n".join(system_parts) if system_parts else None
|
|
399
|
+
prompt = "\n\n".join(conversation_parts) if conversation_parts else ""
|
|
400
|
+
|
|
401
|
+
return prompt, system_prompt
|
|
402
|
+
|
|
403
|
+
def _parse_assistant_message(
|
|
404
|
+
self,
|
|
405
|
+
message: AssistantMessage,
|
|
406
|
+
) -> tuple[str, list[dict[str, Any]], list[dict[str, Any]]]:
|
|
407
|
+
"""Parse AssistantMessage content blocks.
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
Tuple of (text_content, tool_calls, tool_results)
|
|
411
|
+
"""
|
|
412
|
+
text_parts: list[str] = []
|
|
413
|
+
tool_calls: list[dict[str, Any]] = []
|
|
414
|
+
tool_results: list[dict[str, Any]] = []
|
|
415
|
+
|
|
416
|
+
for block in message.content:
|
|
417
|
+
if isinstance(block, TextBlock):
|
|
418
|
+
text_parts.append(block.text)
|
|
419
|
+
elif isinstance(block, ToolResultBlock):
|
|
420
|
+
content_items: list[str] = []
|
|
421
|
+
if isinstance(block.content, str):
|
|
422
|
+
content_items.append(block.content)
|
|
423
|
+
elif isinstance(block.content, list):
|
|
424
|
+
for item in block.content:
|
|
425
|
+
if isinstance(item, dict) and item.get("type") == "text":
|
|
426
|
+
content_items.append(str(item.get("text", "")))
|
|
427
|
+
else:
|
|
428
|
+
content_items.append(str(item))
|
|
429
|
+
|
|
430
|
+
if content_items:
|
|
431
|
+
text_parts.append("\n".join(content_items))
|
|
432
|
+
|
|
433
|
+
tool_results.append(
|
|
434
|
+
{
|
|
435
|
+
"tool_use_id": getattr(block, "tool_use_id", None),
|
|
436
|
+
"content": block.content,
|
|
437
|
+
"is_error": block.is_error,
|
|
438
|
+
}
|
|
439
|
+
)
|
|
440
|
+
elif isinstance(block, ToolUseBlock):
|
|
441
|
+
tool_calls.append({
|
|
442
|
+
"id": block.id,
|
|
443
|
+
"name": block.name,
|
|
444
|
+
"args": block.input,
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
return "\n".join(text_parts), tool_calls, tool_results
|
|
448
|
+
|
|
449
|
+
def _create_ai_message(
|
|
450
|
+
self,
|
|
451
|
+
content: str,
|
|
452
|
+
tool_calls: list[dict[str, Any]] | None = None,
|
|
453
|
+
generation_info: dict[str, Any] | None = None,
|
|
454
|
+
) -> AIMessage:
|
|
455
|
+
"""Create AIMessage with optional tool calls.
|
|
456
|
+
|
|
457
|
+
Note: tool_calls are stored in response_metadata, NOT as AIMessage.tool_calls.
|
|
458
|
+
Claude Code executes tools internally, so exposing tool_calls would cause
|
|
459
|
+
LangGraph to attempt re-execution of already-completed tool calls.
|
|
460
|
+
"""
|
|
461
|
+
kwargs: dict[str, Any] = {"content": content}
|
|
462
|
+
if generation_info:
|
|
463
|
+
kwargs["response_metadata"] = generation_info
|
|
464
|
+
if tool_calls:
|
|
465
|
+
kwargs.setdefault("response_metadata", {})
|
|
466
|
+
kwargs["response_metadata"]["internal_tool_calls"] = tool_calls
|
|
467
|
+
return AIMessage(**kwargs)
|
|
468
|
+
|
|
469
|
+
def _install_tool_event_hooks(
|
|
470
|
+
self, options: ClaudeAgentOptions,
|
|
471
|
+
) -> list[dict[str, Any]]:
|
|
472
|
+
"""Register PreToolUse / PostToolUse / PostToolUseFailure hooks
|
|
473
|
+
that capture every tool invocation — built-in (Bash, Read, Edit,
|
|
474
|
+
Write, Glob, ToolSearch), bridged LangChain tools, and MCP
|
|
475
|
+
tools — into the returned events list.
|
|
476
|
+
|
|
477
|
+
Why hooks: built-in tools execute entirely inside the claude
|
|
478
|
+
CLI subprocess. Their ``ToolResultBlock``s arrive in
|
|
479
|
+
``UserMessage`` content (per the Anthropic API conversation
|
|
480
|
+
convention), which the message loops in ``_aquery`` / ``_astream``
|
|
481
|
+
don't iterate. The SDK fires PreToolUse / PostToolUse hooks for
|
|
482
|
+
EVERY tool invocation regardless of origin (see
|
|
483
|
+
``claude_agent_sdk._internal.query``'s hook_callback dispatch),
|
|
484
|
+
with ``tool_use_id`` on both pre and post. Capturing via hooks:
|
|
485
|
+
|
|
486
|
+
* Surfaces built-in tool results (the only path that does).
|
|
487
|
+
* Pairs calls and results by ``tool_use_id`` (no name-matching
|
|
488
|
+
ambiguity for bridged tools, where the call carries the
|
|
489
|
+
MCP-prefixed name and the bridged result carries the bare
|
|
490
|
+
``@tool`` name).
|
|
491
|
+
* Preserves the actual call→result→call→result execution order
|
|
492
|
+
(vs. ``_parse_assistant_message`` which splits content blocks
|
|
493
|
+
into parallel lists, losing order).
|
|
494
|
+
|
|
495
|
+
The returned list is later attached to ``generation_info["tool_events"]``
|
|
496
|
+
in ``_aquery`` and to the final result chunk's ``generation_info``
|
|
497
|
+
in ``_astream``. Items have shape:
|
|
498
|
+
|
|
499
|
+
{"type": "tool_call", "tool_use_id": str, "name": str,
|
|
500
|
+
"input": dict, "ts_mono_ns": int}
|
|
501
|
+
{"type": "tool_result", "tool_use_id": str, "name": str,
|
|
502
|
+
"result": Any, "is_error": False, "ts_mono_ns": int}
|
|
503
|
+
{"type": "tool_result", "tool_use_id": str, "name": str,
|
|
504
|
+
"error": str, "is_error": True, "ts_mono_ns": int}
|
|
505
|
+
|
|
506
|
+
Mutates ``options.hooks``: appends our three callbacks to any
|
|
507
|
+
user-supplied hooks; never replaces them. Our callbacks return
|
|
508
|
+
``{}`` so they don't influence control flow when chained with
|
|
509
|
+
user hooks (e.g. permission gates).
|
|
510
|
+
"""
|
|
511
|
+
events: list[dict[str, Any]] = []
|
|
512
|
+
|
|
513
|
+
async def _pre_hook(
|
|
514
|
+
input_data: dict, tool_use_id: str, signal: Any,
|
|
515
|
+
) -> dict:
|
|
516
|
+
events.append({
|
|
517
|
+
"type": "tool_call",
|
|
518
|
+
"ts_mono_ns": time.monotonic_ns(),
|
|
519
|
+
"tool_use_id": tool_use_id,
|
|
520
|
+
"name": input_data.get("tool_name", ""),
|
|
521
|
+
"input": input_data.get("tool_input", {}),
|
|
522
|
+
})
|
|
523
|
+
return {}
|
|
524
|
+
|
|
525
|
+
async def _post_hook(
|
|
526
|
+
input_data: dict, tool_use_id: str, signal: Any,
|
|
527
|
+
) -> dict:
|
|
528
|
+
events.append({
|
|
529
|
+
"type": "tool_result",
|
|
530
|
+
"ts_mono_ns": time.monotonic_ns(),
|
|
531
|
+
"tool_use_id": tool_use_id,
|
|
532
|
+
"name": input_data.get("tool_name", ""),
|
|
533
|
+
"result": input_data.get("tool_response"),
|
|
534
|
+
"is_error": False,
|
|
535
|
+
})
|
|
536
|
+
return {}
|
|
537
|
+
|
|
538
|
+
async def _post_fail_hook(
|
|
539
|
+
input_data: dict, tool_use_id: str, signal: Any,
|
|
540
|
+
) -> dict:
|
|
541
|
+
events.append({
|
|
542
|
+
"type": "tool_result",
|
|
543
|
+
"ts_mono_ns": time.monotonic_ns(),
|
|
544
|
+
"tool_use_id": tool_use_id,
|
|
545
|
+
"name": input_data.get("tool_name", ""),
|
|
546
|
+
"error": input_data.get("error"),
|
|
547
|
+
"is_error": True,
|
|
548
|
+
})
|
|
549
|
+
return {}
|
|
550
|
+
|
|
551
|
+
our_hooks: dict[str, list[HookMatcher]] = {
|
|
552
|
+
"PreToolUse": [HookMatcher(hooks=[_pre_hook])],
|
|
553
|
+
"PostToolUse": [HookMatcher(hooks=[_post_hook])],
|
|
554
|
+
"PostToolUseFailure": [HookMatcher(hooks=[_post_fail_hook])],
|
|
555
|
+
}
|
|
556
|
+
existing = dict(options.hooks) if options.hooks else {}
|
|
557
|
+
for event, matchers in our_hooks.items():
|
|
558
|
+
existing[event] = list(existing.get(event, [])) + matchers
|
|
559
|
+
options.hooks = existing
|
|
560
|
+
return events
|
|
561
|
+
|
|
562
|
+
async def _aquery(
|
|
563
|
+
self,
|
|
564
|
+
prompt: str,
|
|
565
|
+
config: RunnableConfig | None = None,
|
|
566
|
+
run_manager: AsyncCallbackManagerForLLMRun | None = None,
|
|
567
|
+
**kwargs: Any,
|
|
568
|
+
) -> tuple[str, list[dict[str, Any]], dict[str, Any]]:
|
|
569
|
+
"""Execute query and return parsed response.
|
|
570
|
+
|
|
571
|
+
Returns:
|
|
572
|
+
Tuple of (content, tool_calls, generation_info)
|
|
573
|
+
"""
|
|
574
|
+
cfg = ensure_config(config) if config is not None else None
|
|
575
|
+
session_id = kwargs.pop("session_id", None) or kwargs.pop("resume", None)
|
|
576
|
+
if cfg:
|
|
577
|
+
session_id = session_id or cfg.get("configurable", {}).get("session_id")
|
|
578
|
+
|
|
579
|
+
options = self._build_options(**kwargs)
|
|
580
|
+
tool_events = self._install_tool_event_hooks(options)
|
|
581
|
+
|
|
582
|
+
if session_id:
|
|
583
|
+
options.resume = session_id
|
|
584
|
+
options.continue_conversation = True
|
|
585
|
+
|
|
586
|
+
all_text: list[str] = []
|
|
587
|
+
all_tool_calls: list[dict[str, Any]] = []
|
|
588
|
+
all_tool_results: list[dict[str, Any]] = []
|
|
589
|
+
generation_info: dict[str, Any] = {}
|
|
590
|
+
tool_results_token = self._tool_results_var.set([])
|
|
591
|
+
|
|
592
|
+
async with ClaudeSDKClient(options=options) as client:
|
|
593
|
+
await client.query(prompt)
|
|
594
|
+
|
|
595
|
+
async for msg in client.receive_response():
|
|
596
|
+
if isinstance(msg, AssistantMessage):
|
|
597
|
+
text, tool_calls, tool_results = self._parse_assistant_message(msg)
|
|
598
|
+
if text:
|
|
599
|
+
all_text.append(text)
|
|
600
|
+
if run_manager:
|
|
601
|
+
await run_manager.on_llm_new_token(text)
|
|
602
|
+
all_tool_calls.extend(tool_calls)
|
|
603
|
+
all_tool_results.extend(tool_results)
|
|
604
|
+
|
|
605
|
+
elif isinstance(msg, ResultMessage):
|
|
606
|
+
self._last_result = msg
|
|
607
|
+
generation_info = _generation_info_from_result(msg)
|
|
608
|
+
|
|
609
|
+
captured = self._tool_results_var.get()
|
|
610
|
+
if captured:
|
|
611
|
+
all_tool_results.extend(captured)
|
|
612
|
+
self._tool_results_var.reset(tool_results_token)
|
|
613
|
+
|
|
614
|
+
if all_tool_results:
|
|
615
|
+
generation_info["tool_results"] = all_tool_results
|
|
616
|
+
if tool_events:
|
|
617
|
+
generation_info["tool_events"] = tool_events
|
|
618
|
+
|
|
619
|
+
return "\n".join(all_text), all_tool_calls, generation_info
|
|
620
|
+
|
|
621
|
+
def _generate(
|
|
622
|
+
self,
|
|
623
|
+
messages: list[BaseMessage],
|
|
624
|
+
stop: list[str] | None = None,
|
|
625
|
+
run_manager: CallbackManagerForLLMRun | None = None,
|
|
626
|
+
**kwargs: Any,
|
|
627
|
+
) -> ChatResult:
|
|
628
|
+
"""Synchronous generation - runs async in event loop."""
|
|
629
|
+
return self._run_sync(self._agenerate(messages, stop=stop, run_manager=None, **kwargs))
|
|
630
|
+
|
|
631
|
+
async def _agenerate(
|
|
632
|
+
self,
|
|
633
|
+
messages: list[BaseMessage],
|
|
634
|
+
stop: list[str] | None = None,
|
|
635
|
+
run_manager: AsyncCallbackManagerForLLMRun | None = None,
|
|
636
|
+
**kwargs: Any,
|
|
637
|
+
) -> ChatResult:
|
|
638
|
+
"""Async generation - primary implementation."""
|
|
639
|
+
config = kwargs.pop("_config", None)
|
|
640
|
+
prompt, system_prompt = self._convert_messages(messages)
|
|
641
|
+
|
|
642
|
+
if system_prompt and not self.system_prompt:
|
|
643
|
+
kwargs["system_prompt"] = system_prompt
|
|
644
|
+
|
|
645
|
+
content, tool_calls, generation_info = await self._aquery(
|
|
646
|
+
prompt, config=config, run_manager=run_manager, **kwargs
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
ai_message = self._create_ai_message(content, tool_calls, generation_info)
|
|
650
|
+
|
|
651
|
+
if run_manager and ai_message.id is None:
|
|
652
|
+
ai_message.id = f"run-{run_manager.run_id}"
|
|
653
|
+
|
|
654
|
+
generation = ChatGeneration(
|
|
655
|
+
message=ai_message,
|
|
656
|
+
generation_info=generation_info,
|
|
657
|
+
)
|
|
658
|
+
return ChatResult(generations=[generation])
|
|
659
|
+
|
|
660
|
+
def _stream(
|
|
661
|
+
self,
|
|
662
|
+
messages: list[BaseMessage],
|
|
663
|
+
stop: list[str] | None = None,
|
|
664
|
+
run_manager: CallbackManagerForLLMRun | None = None,
|
|
665
|
+
**kwargs: Any,
|
|
666
|
+
) -> Iterator[ChatGenerationChunk]:
|
|
667
|
+
"""Synchronous streaming using background thread to preserve anyio task affinity."""
|
|
668
|
+
try:
|
|
669
|
+
asyncio.get_running_loop()
|
|
670
|
+
except RuntimeError:
|
|
671
|
+
pass
|
|
672
|
+
else:
|
|
673
|
+
raise RuntimeError(
|
|
674
|
+
"Cannot use synchronous streaming while an event loop is running. Use 'astream' instead."
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
chunk_queue: queue.Queue[ChatGenerationChunk | BaseException | None] = queue.Queue()
|
|
678
|
+
|
|
679
|
+
def run_async_stream() -> None:
|
|
680
|
+
async def produce() -> None:
|
|
681
|
+
try:
|
|
682
|
+
async for chunk in self._astream(
|
|
683
|
+
messages, stop=stop, run_manager=None, **kwargs
|
|
684
|
+
):
|
|
685
|
+
chunk_queue.put(chunk)
|
|
686
|
+
chunk_queue.put(None)
|
|
687
|
+
except BaseException as exc:
|
|
688
|
+
chunk_queue.put(exc)
|
|
689
|
+
|
|
690
|
+
loop = asyncio.new_event_loop()
|
|
691
|
+
asyncio.set_event_loop(loop)
|
|
692
|
+
try:
|
|
693
|
+
loop.run_until_complete(produce())
|
|
694
|
+
finally:
|
|
695
|
+
asyncio.set_event_loop(None)
|
|
696
|
+
loop.close()
|
|
697
|
+
|
|
698
|
+
thread = threading.Thread(target=run_async_stream, daemon=True)
|
|
699
|
+
thread.start()
|
|
700
|
+
|
|
701
|
+
try:
|
|
702
|
+
while True:
|
|
703
|
+
item = chunk_queue.get()
|
|
704
|
+
if item is None:
|
|
705
|
+
break
|
|
706
|
+
if isinstance(item, BaseException):
|
|
707
|
+
raise item
|
|
708
|
+
yield item
|
|
709
|
+
finally:
|
|
710
|
+
thread.join(timeout=5.0)
|
|
711
|
+
|
|
712
|
+
async def _astream(
|
|
713
|
+
self,
|
|
714
|
+
messages: list[BaseMessage],
|
|
715
|
+
stop: list[str] | None = None,
|
|
716
|
+
run_manager: AsyncCallbackManagerForLLMRun | None = None,
|
|
717
|
+
**kwargs: Any,
|
|
718
|
+
) -> AsyncIterator[ChatGenerationChunk]:
|
|
719
|
+
"""Async streaming - yields chunks as they arrive."""
|
|
720
|
+
config = kwargs.pop("_config", None)
|
|
721
|
+
prompt, system_prompt = self._convert_messages(messages)
|
|
722
|
+
|
|
723
|
+
if system_prompt and not self.system_prompt:
|
|
724
|
+
kwargs["system_prompt"] = system_prompt
|
|
725
|
+
|
|
726
|
+
kwargs["include_partial_messages"] = True
|
|
727
|
+
cfg = ensure_config(config) if config is not None else None
|
|
728
|
+
session_id = kwargs.pop("session_id", None) or kwargs.pop("resume", None)
|
|
729
|
+
if cfg:
|
|
730
|
+
session_id = session_id or cfg.get("configurable", {}).get("session_id")
|
|
731
|
+
|
|
732
|
+
options = self._build_options(**kwargs)
|
|
733
|
+
tool_events = self._install_tool_event_hooks(options)
|
|
734
|
+
|
|
735
|
+
if session_id:
|
|
736
|
+
options.resume = session_id
|
|
737
|
+
options.continue_conversation = True
|
|
738
|
+
|
|
739
|
+
tool_calls_buffer: list[dict[str, Any]] = []
|
|
740
|
+
tool_results_buffer: list[dict[str, Any]] = []
|
|
741
|
+
|
|
742
|
+
async with ClaudeSDKClient(options=options) as client:
|
|
743
|
+
await client.query(prompt)
|
|
744
|
+
|
|
745
|
+
async for msg in client.receive_response():
|
|
746
|
+
if isinstance(msg, AssistantMessage):
|
|
747
|
+
text, tool_calls, tool_results = self._parse_assistant_message(msg)
|
|
748
|
+
|
|
749
|
+
if text:
|
|
750
|
+
chunk = ChatGenerationChunk(
|
|
751
|
+
message=AIMessageChunk(content=text)
|
|
752
|
+
)
|
|
753
|
+
if run_manager:
|
|
754
|
+
await run_manager.on_llm_new_token(text, chunk=chunk)
|
|
755
|
+
yield chunk
|
|
756
|
+
|
|
757
|
+
tool_calls_buffer.extend(tool_calls)
|
|
758
|
+
tool_results_buffer.extend(tool_results)
|
|
759
|
+
|
|
760
|
+
elif isinstance(msg, ResultMessage):
|
|
761
|
+
self._last_result = msg
|
|
762
|
+
|
|
763
|
+
generation_info: dict[str, Any] = (
|
|
764
|
+
_generation_info_from_result(msg)
|
|
765
|
+
)
|
|
766
|
+
if tool_calls_buffer:
|
|
767
|
+
generation_info["internal_tool_calls"] = tool_calls_buffer
|
|
768
|
+
if tool_results_buffer:
|
|
769
|
+
generation_info["internal_tool_results"] = tool_results_buffer
|
|
770
|
+
if tool_events:
|
|
771
|
+
generation_info["tool_events"] = tool_events
|
|
772
|
+
|
|
773
|
+
yield ChatGenerationChunk(
|
|
774
|
+
message=AIMessageChunk(content="", chunk_position="last"),
|
|
775
|
+
generation_info=generation_info,
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
def bind_tools(
|
|
779
|
+
self,
|
|
780
|
+
tools: Sequence[BaseTool],
|
|
781
|
+
*,
|
|
782
|
+
tool_choice: str | dict[str, Any] | None = None,
|
|
783
|
+
**kwargs: Any,
|
|
784
|
+
) -> Runnable:
|
|
785
|
+
"""Bind LangChain tools to the model via MCP server.
|
|
786
|
+
|
|
787
|
+
Tools whose underlying function takes a langgraph-injected
|
|
788
|
+
parameter (notably ``ToolRuntime`` from
|
|
789
|
+
``langgraph.prebuilt.tool_node``, or any ``InjectedToolArg``
|
|
790
|
+
subclass) are **skipped**: the MCP transport doesn't carry
|
|
791
|
+
langgraph state, so invoking them through the bridge fails
|
|
792
|
+
with ``TypeError: missing 1 required positional argument:
|
|
793
|
+
'runtime'``. Such tools are typically middleware-injected by
|
|
794
|
+
the agent framework (deepagents' filesystem tools, subagent
|
|
795
|
+
tools, etc.) and are already available to the model through
|
|
796
|
+
the framework's native path — bridging them via MCP is both
|
|
797
|
+
redundant and broken.
|
|
798
|
+
"""
|
|
799
|
+
sdk_tools = []
|
|
800
|
+
tool_names = []
|
|
801
|
+
skipped: list[str] = []
|
|
802
|
+
|
|
803
|
+
for lc_tool in tools:
|
|
804
|
+
if _has_runtime_injected_args(lc_tool):
|
|
805
|
+
# Can't bridge through MCP. Log and skip; the framework's
|
|
806
|
+
# native tool path will still serve this tool when the
|
|
807
|
+
# model needs it (it's typically also exposed there).
|
|
808
|
+
skipped.append(lc_tool.name)
|
|
809
|
+
continue
|
|
810
|
+
schema = self._get_tool_schema(lc_tool)
|
|
811
|
+
sdk_func = self._wrap_langchain_tool(lc_tool, schema)
|
|
812
|
+
sdk_tools.append(sdk_func)
|
|
813
|
+
tool_names.append(lc_tool.name)
|
|
814
|
+
|
|
815
|
+
if skipped:
|
|
816
|
+
log.info(
|
|
817
|
+
"skipped %d tool(s) with langgraph-injected args (can't "
|
|
818
|
+
"bridge through MCP): %s",
|
|
819
|
+
len(skipped), ", ".join(skipped),
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
server = create_sdk_mcp_server(
|
|
823
|
+
name="langchain-tools",
|
|
824
|
+
version="1.0.0",
|
|
825
|
+
tools=sdk_tools,
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
allowed = [f"mcp__langchain-tools__{name}" for name in tool_names]
|
|
829
|
+
|
|
830
|
+
return self.model_copy(
|
|
831
|
+
update={
|
|
832
|
+
"_mcp_servers": {"langchain-tools": server},
|
|
833
|
+
"allowed_tools": normalize_tools(list(self.allowed_tools)) + allowed,
|
|
834
|
+
"_bound_tools": list(tools),
|
|
835
|
+
}
|
|
836
|
+
).bind(**kwargs)
|
|
837
|
+
|
|
838
|
+
def _get_tool_schema(self, tool: BaseTool) -> dict[str, Any]:
|
|
839
|
+
"""Extract JSON schema from LangChain tool."""
|
|
840
|
+
if hasattr(tool, "args_schema") and tool.args_schema:
|
|
841
|
+
try:
|
|
842
|
+
return tool.args_schema.model_json_schema()
|
|
843
|
+
except PydanticInvalidForJsonSchema:
|
|
844
|
+
# Some tool arg models include callables that cannot be rendered to JSON Schema.
|
|
845
|
+
return {"type": "object", "properties": {}, "required": []}
|
|
846
|
+
except Exception:
|
|
847
|
+
return {"type": "object", "properties": {}, "required": []}
|
|
848
|
+
return {"type": "object", "properties": {}, "required": []}
|
|
849
|
+
|
|
850
|
+
def _wrap_langchain_tool(
|
|
851
|
+
self,
|
|
852
|
+
tool: BaseTool,
|
|
853
|
+
schema: dict[str, Any],
|
|
854
|
+
) -> Callable[..., Any]:
|
|
855
|
+
"""Wrap LangChain tool as SDK tool function."""
|
|
856
|
+
props = schema.get("properties", {})
|
|
857
|
+
type_map = {
|
|
858
|
+
"string": str,
|
|
859
|
+
"integer": int,
|
|
860
|
+
"number": float,
|
|
861
|
+
"boolean": bool,
|
|
862
|
+
"array": list,
|
|
863
|
+
"object": dict,
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
param_types = {}
|
|
867
|
+
for name, prop in props.items():
|
|
868
|
+
json_type = prop.get("type", "string")
|
|
869
|
+
param_types[name] = type_map.get(json_type, str)
|
|
870
|
+
|
|
871
|
+
@sdk_tool(tool.name, tool.description or "", param_types)
|
|
872
|
+
async def wrapped_tool(args: dict[str, Any]) -> dict[str, Any]:
|
|
873
|
+
try:
|
|
874
|
+
if hasattr(tool, "_arun") and asyncio.iscoroutinefunction(tool._arun):
|
|
875
|
+
result = await tool._arun(**args)
|
|
876
|
+
else:
|
|
877
|
+
result = tool._run(**args)
|
|
878
|
+
|
|
879
|
+
captured = self._tool_results_var.get(None) if self._tool_results_var else None
|
|
880
|
+
if captured is not None:
|
|
881
|
+
captured.append(
|
|
882
|
+
{
|
|
883
|
+
"name": tool.name,
|
|
884
|
+
"args": args,
|
|
885
|
+
"result": result,
|
|
886
|
+
}
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
return {"content": [{"type": "text", "text": str(result)}]}
|
|
890
|
+
except Exception as e:
|
|
891
|
+
return {
|
|
892
|
+
"content": [{"type": "text", "text": f"Error: {e}"}],
|
|
893
|
+
"is_error": True,
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return wrapped_tool
|
|
897
|
+
|
|
898
|
+
def resume_from_thread(self, thread_id: str) -> Runnable:
|
|
899
|
+
"""Return a bound model that resumes the provider session using a LangGraph thread_id."""
|
|
900
|
+
return self.with_config(config={"configurable": {"session_id": thread_id}})
|
|
901
|
+
|
|
902
|
+
def enable_tools(self, tools: list[str | ClaudeTool]) -> Runnable:
|
|
903
|
+
"""Return a new model with the given tools added to the allow list."""
|
|
904
|
+
merged = normalize_tools(normalize_tools(list(self.allowed_tools)) + list(tools))
|
|
905
|
+
return self.model_copy(update={"allowed_tools": merged})
|
|
906
|
+
|
|
907
|
+
@property
|
|
908
|
+
def last_result(self) -> ResultMessage | None:
|
|
909
|
+
"""Get the last ResultMessage with cost/usage info."""
|
|
910
|
+
return self._last_result
|
|
911
|
+
|
|
912
|
+
def _run_sync(self, coro: asyncio.Future) -> Any:
|
|
913
|
+
"""Run coroutine safely from sync context without relying on global loop."""
|
|
914
|
+
try:
|
|
915
|
+
asyncio.get_running_loop()
|
|
916
|
+
except RuntimeError:
|
|
917
|
+
return asyncio.run(coro)
|
|
918
|
+
|
|
919
|
+
raise RuntimeError(
|
|
920
|
+
"Cannot call synchronous ClaudeCodeChatModel methods while an event loop is running. Use 'ainvoke' instead."
|
|
921
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ClaudeTool(str, Enum):
|
|
7
|
+
ASK_USER_QUESTION = "AskUserQuestion"
|
|
8
|
+
BASH = "Bash"
|
|
9
|
+
BASH_OUTPUT = "BashOutput"
|
|
10
|
+
EDIT = "Edit"
|
|
11
|
+
EXIT_PLAN_MODE = "ExitPlanMode"
|
|
12
|
+
GLOB = "Glob"
|
|
13
|
+
GREP = "Grep"
|
|
14
|
+
KILL_SHELL = "KillShell"
|
|
15
|
+
NOTEBOOK_EDIT = "NotebookEdit"
|
|
16
|
+
READ = "Read"
|
|
17
|
+
SKILL = "Skill"
|
|
18
|
+
SLASH_COMMAND = "SlashCommand"
|
|
19
|
+
TASK = "Task"
|
|
20
|
+
TODO_WRITE = "TodoWrite"
|
|
21
|
+
WEB_FETCH = "WebFetch"
|
|
22
|
+
WEB_SEARCH = "WebSearch"
|
|
23
|
+
WRITE = "Write"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DEFAULT_READ_ONLY = [ClaudeTool.GLOB, ClaudeTool.GREP, ClaudeTool.READ]
|
|
27
|
+
DEFAULT_WRITE = [ClaudeTool.EDIT, ClaudeTool.WRITE]
|
|
28
|
+
DEFAULT_NETWORK = [ClaudeTool.WEB_FETCH, ClaudeTool.WEB_SEARCH]
|
|
29
|
+
DEFAULT_SHELL = [ClaudeTool.BASH, ClaudeTool.BASH_OUTPUT, ClaudeTool.KILL_SHELL]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def normalize_tools(tools: list[str | ClaudeTool]) -> list[str]:
|
|
33
|
+
"""Convert enum members/strings to plain strings, preserving order and uniqueness."""
|
|
34
|
+
seen: set[str] = set()
|
|
35
|
+
normalized: list[str] = []
|
|
36
|
+
for tool in tools:
|
|
37
|
+
name = tool.value if isinstance(tool, ClaudeTool) else str(tool)
|
|
38
|
+
if name not in seen:
|
|
39
|
+
seen.add(name)
|
|
40
|
+
normalized.append(name)
|
|
41
|
+
return normalized
|
|
File without changes
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: langchain-claude-code-mimir
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: LangChain chat model wrapper for the Claude Code Agent SDK — Mimir distribution of langchain-claude-code with bundled adapter fixes (upstream PRs #2/#4/#6, unmerged). Import package remains ``langchain_claude_code``.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jasoncarreira/langchain-claude-code
|
|
6
|
+
Project-URL: Repository, https://github.com/jasoncarreira/langchain-claude-code
|
|
7
|
+
Project-URL: Issues, https://github.com/jasoncarreira/langchain-claude-code/issues
|
|
8
|
+
Project-URL: Upstream, https://github.com/agentmish/langchain-claude-code
|
|
9
|
+
Author-email: Tomas Roda <dev@tomasroda.com>
|
|
10
|
+
Maintainer: Jason Carreira
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: agent,anthropic,claude,langchain,llm
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: claude-agent-sdk>=0.1.10
|
|
22
|
+
Requires-Dist: langchain-core>=0.3.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: langgraph>=0.2; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Provides-Extra: examples
|
|
28
|
+
Requires-Dist: ddgs>=9.9.2; extra == 'examples'
|
|
29
|
+
Requires-Dist: deepagents>=0.2.8; extra == 'examples'
|
|
30
|
+
Requires-Dist: langchain-community>=0.4.1; extra == 'examples'
|
|
31
|
+
Requires-Dist: langchain>=1.1.0; extra == 'examples'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# langchain-claude-code
|
|
35
|
+
|
|
36
|
+
[](https://badge.fury.io/py/langchain-claude-code)
|
|
37
|
+
[](https://opensource.org/licenses/MIT)
|
|
38
|
+
[](https://www.python.org/downloads/)
|
|
39
|
+
|
|
40
|
+
LangChain chat model wrapper for the Claude Code Agent SDK. Use Claude Code as a drop-in LangChain `BaseChatModel` with full tool support, streaming, and LangGraph compatibility.
|
|
41
|
+
|
|
42
|
+
## Prerequisites
|
|
43
|
+
|
|
44
|
+
**Claude Code CLI** (required):
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm install -g @anthropic-ai/claude-code
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install langchain-claude-code
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
With example dependencies:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install langchain-claude-code[examples]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Authentication
|
|
63
|
+
|
|
64
|
+
### Option 1: API Key (for Anthropic API users)
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from langchain_claude_code import ClaudeCodeChatModel
|
|
68
|
+
|
|
69
|
+
model = ClaudeCodeChatModel(api_key="sk-ant-...")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Or set the environment variable:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Option 2: OAuth Token (for Claude Max subscribers)
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from langchain_claude_code import ClaudeCodeChatModel
|
|
82
|
+
|
|
83
|
+
model = ClaudeCodeChatModel(oauth_token="...")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Or set the environment variable:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
export CLAUDE_CODE_OAUTH_TOKEN="..."
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Quick Start
|
|
93
|
+
|
|
94
|
+
### Basic Usage
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
import asyncio
|
|
98
|
+
from langchain_claude_code import ClaudeCodeChatModel
|
|
99
|
+
from langchain_core.messages import HumanMessage
|
|
100
|
+
|
|
101
|
+
async def main():
|
|
102
|
+
model = ClaudeCodeChatModel(model="sonnet")
|
|
103
|
+
response = await model.ainvoke([HumanMessage(content="What is 2 + 2?")])
|
|
104
|
+
print(response.content)
|
|
105
|
+
|
|
106
|
+
asyncio.run(main())
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### With Tool Binding
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from langchain_claude_code import ClaudeCodeChatModel
|
|
113
|
+
from langchain_community.tools.ddg_search.tool import DuckDuckGoSearchTool
|
|
114
|
+
|
|
115
|
+
model = ClaudeCodeChatModel(model="haiku")
|
|
116
|
+
model = model.bind_tools([DuckDuckGoSearchTool()])
|
|
117
|
+
|
|
118
|
+
response = await model.ainvoke([
|
|
119
|
+
HumanMessage(content="Search for the latest news about AI")
|
|
120
|
+
])
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Using Claude Code's Built-in Tools
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from langchain_claude_code import ClaudeCodeChatModel, ClaudeTool
|
|
127
|
+
|
|
128
|
+
model = ClaudeCodeChatModel(
|
|
129
|
+
model="sonnet",
|
|
130
|
+
allowed_tools=[
|
|
131
|
+
ClaudeTool.WEB_SEARCH,
|
|
132
|
+
ClaudeTool.WEB_FETCH,
|
|
133
|
+
ClaudeTool.BASH,
|
|
134
|
+
ClaudeTool.READ,
|
|
135
|
+
ClaudeTool.WRITE,
|
|
136
|
+
],
|
|
137
|
+
)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Streaming
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
async for chunk in model.astream([HumanMessage(content="Write a poem")]):
|
|
144
|
+
print(chunk.content, end="", flush=True)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Configuration
|
|
148
|
+
|
|
149
|
+
| Parameter | Type | Default | Description |
|
|
150
|
+
|-----------|------|---------|-------------|
|
|
151
|
+
| `model` | `str` | `"opus"` | Model to use: `opus`, `sonnet`, `haiku` |
|
|
152
|
+
| `permission_mode` | `str` | `"default"` | Permission mode: `default`, `acceptEdits`, `plan`, `bypassPermissions` |
|
|
153
|
+
| `allowed_tools` | `list` | `[]` | List of allowed Claude Code tools |
|
|
154
|
+
| `disallowed_tools` | `list` | `[]` | List of disallowed tools |
|
|
155
|
+
| `system_prompt` | `str` | `None` | Custom system prompt |
|
|
156
|
+
| `max_turns` | `int` | `None` | Maximum conversation turns |
|
|
157
|
+
| `max_budget_usd` | `float` | `None` | Maximum budget in USD |
|
|
158
|
+
| `cwd` | `str` | `None` | Working directory for file operations |
|
|
159
|
+
| `api_key` | `str` | `None` | Anthropic API key |
|
|
160
|
+
| `oauth_token` | `str` | `None` | Claude Code OAuth token |
|
|
161
|
+
|
|
162
|
+
## Permission Modes
|
|
163
|
+
|
|
164
|
+
| Mode | Description |
|
|
165
|
+
|------|-------------|
|
|
166
|
+
| `default` | Prompts for confirmation on potentially dangerous operations |
|
|
167
|
+
| `acceptEdits` | Automatically accepts file edits without confirmation |
|
|
168
|
+
| `plan` | Planning mode - generates plans without executing |
|
|
169
|
+
| `bypassPermissions` | Bypasses all permission checks (use with caution) |
|
|
170
|
+
|
|
171
|
+
> **Security Note**: When using `acceptEdits` or `bypassPermissions`, Claude Code will modify your filesystem without confirmation. Use these modes only in sandboxed environments or when you trust the input.
|
|
172
|
+
|
|
173
|
+
## Available Tools
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from langchain_claude_code import ClaudeTool
|
|
177
|
+
|
|
178
|
+
# File operations
|
|
179
|
+
ClaudeTool.READ
|
|
180
|
+
ClaudeTool.WRITE
|
|
181
|
+
ClaudeTool.EDIT
|
|
182
|
+
ClaudeTool.GLOB
|
|
183
|
+
ClaudeTool.GREP
|
|
184
|
+
|
|
185
|
+
# Shell
|
|
186
|
+
ClaudeTool.BASH
|
|
187
|
+
ClaudeTool.BASH_OUTPUT
|
|
188
|
+
|
|
189
|
+
# Web
|
|
190
|
+
ClaudeTool.WEB_SEARCH
|
|
191
|
+
ClaudeTool.WEB_FETCH
|
|
192
|
+
|
|
193
|
+
# Other
|
|
194
|
+
ClaudeTool.TASK
|
|
195
|
+
ClaudeTool.TODO_WRITE
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Examples
|
|
199
|
+
|
|
200
|
+
See the [examples/](examples/) directory for complete examples:
|
|
201
|
+
|
|
202
|
+
- `bind_tools_example.py` - Using LangChain tools with Claude Code
|
|
203
|
+
- `deepagents_example.py` - Integration with DeepAgents/LangGraph
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
langchain_claude_code/__init__.py,sha256=zjT4kc6tgZVPpBLpnsnAZ4lXGI2Lj-CYS6R4a1i0Ye0,480
|
|
2
|
+
langchain_claude_code/claude_chat_model.py,sha256=iN1jEyq2sg7eUK-17_fKQy9oiBntJN6D-VjdDXfwhLk,36501
|
|
3
|
+
langchain_claude_code/claude_code_tools.py,sha256=jsTQrommRm3Mi9sFjaDw-Aoc2yvVB0lcesBpns_Q-Dk,1226
|
|
4
|
+
langchain_claude_code/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
langchain_claude_code_mimir-0.1.1.dist-info/METADATA,sha256=J_0RuV1rQzdHTLYCjTDYZthd08plCH17_FbbKUhsI94,6133
|
|
6
|
+
langchain_claude_code_mimir-0.1.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
langchain_claude_code_mimir-0.1.1.dist-info/licenses/LICENSE,sha256=wMiZL1kpZb8Faq58ElWag4X6HDd22Cq-MXXiqtll9q4,1067
|
|
8
|
+
langchain_claude_code_mimir-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Tomas Roda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|