by-framework-langgraph 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- by_framework_langgraph/__init__.py +36 -0
- by_framework_langgraph/_utils.py +64 -0
- by_framework_langgraph/adapter.py +422 -0
- by_framework_langgraph/tools.py +118 -0
- by_framework_langgraph/worker.py +182 -0
- by_framework_langgraph-0.2.0.dist-info/METADATA +69 -0
- by_framework_langgraph-0.2.0.dist-info/RECORD +8 -0
- by_framework_langgraph-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""LangGraph integration for by-framework.
|
|
2
|
+
|
|
3
|
+
Provides two integration modes:
|
|
4
|
+
|
|
5
|
+
1. **Adapter Mode** — Plug existing LangGraph graphs into by-framework::
|
|
6
|
+
|
|
7
|
+
from by_framework_langgraph import LangGraphAdapter
|
|
8
|
+
|
|
9
|
+
adapter = LangGraphAdapter(my_graph, context)
|
|
10
|
+
return await adapter.run(command)
|
|
11
|
+
|
|
12
|
+
2. **Native Mode** — Build LangGraph workers with framework-native tools::
|
|
13
|
+
|
|
14
|
+
from by_framework_langgraph import (
|
|
15
|
+
LangGraphWorker,
|
|
16
|
+
make_remote_agent_tool,
|
|
17
|
+
make_ask_user_tool,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
class MyWorker(LangGraphWorker):
|
|
21
|
+
def build_graph(self, context, command):
|
|
22
|
+
poet = make_remote_agent_tool(context, ...)
|
|
23
|
+
ask = make_ask_user_tool(context)
|
|
24
|
+
# ... build and return compiled graph
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from .adapter import LangGraphAdapter
|
|
28
|
+
from .tools import make_ask_user_tool, make_remote_agent_tool
|
|
29
|
+
from .worker import LangGraphWorker
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"LangGraphAdapter",
|
|
33
|
+
"LangGraphWorker",
|
|
34
|
+
"make_ask_user_tool",
|
|
35
|
+
"make_remote_agent_tool",
|
|
36
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Internal utilities for LangGraph integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from by_framework.core.protocol.commands import ResumeCommand
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def extract_content_text(content: Any) -> str:
|
|
12
|
+
"""Extract plain text from various command content formats.
|
|
13
|
+
|
|
14
|
+
Handles:
|
|
15
|
+
- str → return directly
|
|
16
|
+
- list[dict] (BaiYing message format) → extract text fields
|
|
17
|
+
- other → str() conversion
|
|
18
|
+
"""
|
|
19
|
+
if isinstance(content, str):
|
|
20
|
+
return content
|
|
21
|
+
|
|
22
|
+
if isinstance(content, list):
|
|
23
|
+
texts: list[str] = []
|
|
24
|
+
for item in content:
|
|
25
|
+
if isinstance(item, dict):
|
|
26
|
+
# BaiYing message format: {"type": "text", "text": "..."}
|
|
27
|
+
text = item.get("text", "")
|
|
28
|
+
if text:
|
|
29
|
+
texts.append(str(text))
|
|
30
|
+
# Fallback: {"content": "..."}
|
|
31
|
+
elif "content" in item:
|
|
32
|
+
texts.append(str(item["content"]))
|
|
33
|
+
elif isinstance(item, str):
|
|
34
|
+
texts.append(item)
|
|
35
|
+
return "\n".join(texts) if texts else str(content)
|
|
36
|
+
|
|
37
|
+
return str(content)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def extract_resume_data(command: ResumeCommand) -> str:
|
|
41
|
+
"""Extract resume data from a ResumeCommand.
|
|
42
|
+
|
|
43
|
+
Prioritizes reply_data (from call_agent callback) over content
|
|
44
|
+
(from ask_user reply), converting to string.
|
|
45
|
+
"""
|
|
46
|
+
if command.reply_data is not None:
|
|
47
|
+
if isinstance(command.reply_data, str):
|
|
48
|
+
return command.reply_data
|
|
49
|
+
return str(command.reply_data)
|
|
50
|
+
|
|
51
|
+
if command.content:
|
|
52
|
+
return extract_content_text(command.content)
|
|
53
|
+
|
|
54
|
+
return ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def str_to_uint128(s: str) -> int:
|
|
58
|
+
"""Convert a string to a 128-bit integer (for OTEL TraceId)."""
|
|
59
|
+
return int(hashlib.md5(s.encode()).hexdigest(), 16)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def str_to_uint64(s: str) -> int:
|
|
63
|
+
"""Convert a string to a 64-bit integer (for OTEL SpanId)."""
|
|
64
|
+
return int(hashlib.md5(s.encode()).hexdigest()[:16], 16)
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""LangGraph adapter for by-framework.
|
|
2
|
+
|
|
3
|
+
Bridges any compiled LangGraph StateGraph with by-framework's command lifecycle,
|
|
4
|
+
allowing users to plug in existing LangGraph graphs without rewriting them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from importlib import import_module
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Callable, Iterator
|
|
14
|
+
|
|
15
|
+
from by_framework.common.logger import logger
|
|
16
|
+
from by_framework.core.protocol.agent_state import AgentState
|
|
17
|
+
from by_framework.core.protocol.commands import ResumeCommand
|
|
18
|
+
from by_framework.core.protocol.events import StreamChunkEvent
|
|
19
|
+
from langchain_core.messages import HumanMessage
|
|
20
|
+
from langgraph.types import Command
|
|
21
|
+
|
|
22
|
+
from ._utils import (
|
|
23
|
+
extract_content_text,
|
|
24
|
+
extract_resume_data,
|
|
25
|
+
str_to_uint64,
|
|
26
|
+
str_to_uint128,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from by_framework.core.protocol.commands import GatewayCommand
|
|
31
|
+
from by_framework.worker.context import AgentContext
|
|
32
|
+
from langgraph.graph.state import CompiledStateGraph
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
LANGFUSE_OBSERVATION_ATTR = "_langfuse_observation"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class _AdapterTracingConfig:
|
|
40
|
+
"""Tracing-related adapter config kept separate from core graph handles."""
|
|
41
|
+
|
|
42
|
+
run_name: str
|
|
43
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
callbacks: list[Any] = field(default_factory=list)
|
|
45
|
+
stream: bool = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# pylint: disable=too-few-public-methods
|
|
49
|
+
class LangGraphAdapter:
|
|
50
|
+
"""Adapter that runs a compiled LangGraph inside by-framework's lifecycle.
|
|
51
|
+
|
|
52
|
+
Supports two execution paths:
|
|
53
|
+
- **Initial**: AskAgentCommand → ``graph.ainvoke(initial_state)``
|
|
54
|
+
- **Resume**: ResumeCommand → ``graph.ainvoke(Command(resume=data))``
|
|
55
|
+
|
|
56
|
+
After execution, automatically detects whether the graph is suspended
|
|
57
|
+
(via ``get_state().next``) and returns the appropriate by-framework status.
|
|
58
|
+
|
|
59
|
+
Usage::
|
|
60
|
+
|
|
61
|
+
class MyWorker(ByaiWorker):
|
|
62
|
+
async def process_command(self, command, context):
|
|
63
|
+
graph = build_my_graph()
|
|
64
|
+
adapter = LangGraphAdapter(graph, context)
|
|
65
|
+
return await adapter.run(command)
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
graph: A compiled LangGraph StateGraph.
|
|
69
|
+
context: The AgentContext from the current process_command call.
|
|
70
|
+
thread_id: Thread ID for checkpoint isolation. Defaults to session_id.
|
|
71
|
+
input_mapper: Custom function to convert command content into
|
|
72
|
+
LangGraph input state. Defaults to wrapping in HumanMessage.
|
|
73
|
+
output_handler: Custom async function to handle graph output.
|
|
74
|
+
Receives ``(context, final_answer_str)`` and should handle
|
|
75
|
+
emitting to frontend. Defaults to ``context.emit_chunk()``.
|
|
76
|
+
stream: If True, uses ``astream_events`` for streaming output.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__( # pylint: disable=too-many-arguments
|
|
80
|
+
self,
|
|
81
|
+
graph: CompiledStateGraph,
|
|
82
|
+
context: AgentContext,
|
|
83
|
+
*,
|
|
84
|
+
thread_id: str | None = None,
|
|
85
|
+
input_mapper: Callable[[Any], dict] | None = None,
|
|
86
|
+
output_handler: Callable[..., Any] | None = None,
|
|
87
|
+
run_name: str | None = None,
|
|
88
|
+
metadata: dict[str, Any] | None = None,
|
|
89
|
+
callbacks: list[Any] | None = None,
|
|
90
|
+
stream: bool = False,
|
|
91
|
+
) -> None:
|
|
92
|
+
self._graph = graph
|
|
93
|
+
self._context = context
|
|
94
|
+
self._thread_id = thread_id or context.session_id
|
|
95
|
+
self._state_config = {"configurable": {"thread_id": self._thread_id}}
|
|
96
|
+
self._input_mapper = input_mapper or self._default_input_mapper
|
|
97
|
+
self._output_handler = output_handler
|
|
98
|
+
self._tracing = _AdapterTracingConfig(
|
|
99
|
+
run_name=run_name or self._default_run_name(),
|
|
100
|
+
metadata=dict(metadata or {}),
|
|
101
|
+
callbacks=list(callbacks or []),
|
|
102
|
+
stream=stream,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
async def run(self, command: GatewayCommand) -> Any:
|
|
106
|
+
"""Execute the graph based on command type.
|
|
107
|
+
|
|
108
|
+
- ``AskAgentCommand`` or other initial commands → invoke with input
|
|
109
|
+
- ``ResumeCommand`` → resume from checkpoint with data
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The graph result or a status dict if the graph is suspended.
|
|
113
|
+
"""
|
|
114
|
+
if isinstance(command, ResumeCommand):
|
|
115
|
+
return await self._handle_resume(command)
|
|
116
|
+
return await self._handle_initial(command)
|
|
117
|
+
|
|
118
|
+
async def _handle_initial(self, command: GatewayCommand) -> Any:
|
|
119
|
+
"""Handle first-time graph invocation."""
|
|
120
|
+
content = extract_content_text(getattr(command, "content", ""))
|
|
121
|
+
input_state = self._input_mapper(content)
|
|
122
|
+
|
|
123
|
+
logger.info(
|
|
124
|
+
"[LangGraphAdapter] Initial invoke, thread_id=%s, content_len=%d",
|
|
125
|
+
self._thread_id,
|
|
126
|
+
len(content),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if self._tracing.stream:
|
|
130
|
+
return await self._stream_invoke(input_state)
|
|
131
|
+
return await self._batch_invoke(input_state)
|
|
132
|
+
|
|
133
|
+
async def _handle_resume(self, command: ResumeCommand) -> Any:
|
|
134
|
+
"""Handle resumption from suspended graph."""
|
|
135
|
+
resume_data = extract_resume_data(command)
|
|
136
|
+
|
|
137
|
+
logger.info(
|
|
138
|
+
"[LangGraphAdapter] Resume invoke, thread_id=%s, data_len=%d",
|
|
139
|
+
self._thread_id,
|
|
140
|
+
len(resume_data),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if self._tracing.stream:
|
|
144
|
+
return await self._stream_invoke(Command(resume=resume_data))
|
|
145
|
+
return await self._batch_invoke(Command(resume=resume_data))
|
|
146
|
+
|
|
147
|
+
async def _batch_invoke(self, input_data: Any) -> Any:
|
|
148
|
+
"""Invoke the graph in batch mode (no streaming)."""
|
|
149
|
+
with self._tracing_scope() as scoped_callbacks:
|
|
150
|
+
result = await self._graph.ainvoke(
|
|
151
|
+
input_data,
|
|
152
|
+
config=self._build_config(extra_callbacks=scoped_callbacks),
|
|
153
|
+
)
|
|
154
|
+
return await self._process_result(result)
|
|
155
|
+
|
|
156
|
+
async def _stream_invoke(self, input_data: Any) -> Any:
|
|
157
|
+
"""Invoke the graph in streaming mode via astream_events."""
|
|
158
|
+
full_response = ""
|
|
159
|
+
|
|
160
|
+
with self._tracing_scope() as scoped_callbacks:
|
|
161
|
+
async for event in self._graph.astream_events(
|
|
162
|
+
input_data,
|
|
163
|
+
version="v2",
|
|
164
|
+
config=self._build_config(extra_callbacks=scoped_callbacks),
|
|
165
|
+
):
|
|
166
|
+
kind = event["event"]
|
|
167
|
+
if kind == "on_chat_model_stream":
|
|
168
|
+
chunk = event["data"]["chunk"]
|
|
169
|
+
if chunk.content:
|
|
170
|
+
full_response += chunk.content
|
|
171
|
+
await self._context.emit_chunk(
|
|
172
|
+
chunk.content, content_type="text"
|
|
173
|
+
)
|
|
174
|
+
elif kind == "on_tool_start":
|
|
175
|
+
tool_name = event["name"]
|
|
176
|
+
tool_input = event["data"].get("input")
|
|
177
|
+
# DEBUG: Dump full event structure to see what's available
|
|
178
|
+
logger.debug(
|
|
179
|
+
"[LangGraphAdapter] TOOL_START Event: %s",
|
|
180
|
+
json.dumps(
|
|
181
|
+
{
|
|
182
|
+
"run_id": event.get("run_id"),
|
|
183
|
+
"metadata": event.get("metadata"),
|
|
184
|
+
"data": event.get("data"),
|
|
185
|
+
},
|
|
186
|
+
default=str,
|
|
187
|
+
ensure_ascii=False,
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Use a stable logical ID from metadata if available, fallback to run_id
|
|
192
|
+
stable_id = (
|
|
193
|
+
event.get("metadata", {}).get("tool_call_id")
|
|
194
|
+
or event.get("metadata", {}).get("checkpoint_ns")
|
|
195
|
+
or event.get("metadata", {}).get("langgraph_checkpoint_ns")
|
|
196
|
+
or event.get("run_id", "tool_call")
|
|
197
|
+
)
|
|
198
|
+
chunk_event = StreamChunkEvent(
|
|
199
|
+
tool_calls=[
|
|
200
|
+
{
|
|
201
|
+
"id": stable_id,
|
|
202
|
+
"type": "function",
|
|
203
|
+
"function": {
|
|
204
|
+
"name": tool_name,
|
|
205
|
+
"arguments": json.dumps(
|
|
206
|
+
tool_input, ensure_ascii=False
|
|
207
|
+
)
|
|
208
|
+
if isinstance(tool_input, dict)
|
|
209
|
+
else str(tool_input or ""),
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
]
|
|
213
|
+
)
|
|
214
|
+
await self._context.emit_chunk(chunk_event)
|
|
215
|
+
elif kind == "on_tool_end":
|
|
216
|
+
tool_name = event["name"]
|
|
217
|
+
tool_output = event["data"].get("output")
|
|
218
|
+
stable_id = (
|
|
219
|
+
event.get("metadata", {}).get("tool_call_id")
|
|
220
|
+
or event.get("metadata", {}).get("checkpoint_ns")
|
|
221
|
+
or event.get("metadata", {}).get("langgraph_checkpoint_ns")
|
|
222
|
+
or event.get("run_id", "tool_call")
|
|
223
|
+
)
|
|
224
|
+
chunk_event = StreamChunkEvent(
|
|
225
|
+
role="tool",
|
|
226
|
+
tool_responses=[
|
|
227
|
+
{
|
|
228
|
+
"tool_call_id": stable_id,
|
|
229
|
+
"content": str(tool_output),
|
|
230
|
+
}
|
|
231
|
+
],
|
|
232
|
+
metadata={"tool_name": tool_name},
|
|
233
|
+
)
|
|
234
|
+
await self._context.emit_chunk(chunk_event)
|
|
235
|
+
|
|
236
|
+
# After streaming completes, check if graph is suspended
|
|
237
|
+
if self._is_graph_suspended():
|
|
238
|
+
logger.info(
|
|
239
|
+
"[LangGraphAdapter] Graph suspended after streaming, thread_id=%s",
|
|
240
|
+
self._thread_id,
|
|
241
|
+
)
|
|
242
|
+
return {"status": AgentState.QUEUED.value}
|
|
243
|
+
|
|
244
|
+
# Emit final answer if using custom output handler
|
|
245
|
+
if self._output_handler and full_response:
|
|
246
|
+
await self._output_handler(self._context, full_response)
|
|
247
|
+
|
|
248
|
+
return full_response
|
|
249
|
+
|
|
250
|
+
async def _process_result(self, result: dict) -> Any:
|
|
251
|
+
"""Analyze graph result and determine suspended vs completed."""
|
|
252
|
+
if self._is_graph_suspended():
|
|
253
|
+
logger.info(
|
|
254
|
+
"[LangGraphAdapter] Graph suspended, thread_id=%s",
|
|
255
|
+
self._thread_id,
|
|
256
|
+
)
|
|
257
|
+
return {"status": AgentState.QUEUED.value}
|
|
258
|
+
|
|
259
|
+
# Extract final answer from last message
|
|
260
|
+
messages = result.get("messages", [])
|
|
261
|
+
if not messages:
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
last_msg = messages[-1]
|
|
265
|
+
answer = last_msg.content if hasattr(last_msg, "content") else str(last_msg)
|
|
266
|
+
|
|
267
|
+
# Emit output
|
|
268
|
+
if self._output_handler:
|
|
269
|
+
await self._output_handler(self._context, answer)
|
|
270
|
+
elif answer:
|
|
271
|
+
await self._context.emit_chunk(answer, content_type="text")
|
|
272
|
+
|
|
273
|
+
return answer
|
|
274
|
+
|
|
275
|
+
def _is_graph_suspended(self) -> bool:
|
|
276
|
+
"""Check whether the graph is suspended at an interrupt point.
|
|
277
|
+
|
|
278
|
+
Uses ``graph.get_state(config).next`` which is the authoritative
|
|
279
|
+
LangGraph mechanism — if ``.next`` is non-empty, the graph has
|
|
280
|
+
pending nodes blocked by an interrupt.
|
|
281
|
+
"""
|
|
282
|
+
try:
|
|
283
|
+
snapshot = self._graph.get_state(self._state_config)
|
|
284
|
+
return bool(snapshot.next)
|
|
285
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
def _build_config(self, extra_callbacks: list[Any] | None = None) -> dict[str, Any]:
|
|
289
|
+
"""Build the runnable config passed to LangGraph invocations."""
|
|
290
|
+
config: dict[str, Any] = {
|
|
291
|
+
**self._state_config,
|
|
292
|
+
"run_name": self._tracing.run_name,
|
|
293
|
+
}
|
|
294
|
+
metadata = {
|
|
295
|
+
**self._default_metadata(),
|
|
296
|
+
**self._tracing.metadata,
|
|
297
|
+
}
|
|
298
|
+
if metadata:
|
|
299
|
+
config["metadata"] = metadata
|
|
300
|
+
|
|
301
|
+
callbacks = [*self._tracing.callbacks, *(extra_callbacks or [])]
|
|
302
|
+
if callbacks:
|
|
303
|
+
config["callbacks"] = callbacks
|
|
304
|
+
return config
|
|
305
|
+
|
|
306
|
+
def _default_run_name(self) -> str:
|
|
307
|
+
"""Build the default LangGraph run name for tracing UIs."""
|
|
308
|
+
agent_id = getattr(self._context, "current_agent_id", "") or "langgraph"
|
|
309
|
+
return f"{agent_id}:langgraph"
|
|
310
|
+
|
|
311
|
+
def _default_metadata(self) -> dict[str, Any]:
|
|
312
|
+
"""Build default metadata for LangGraph/Langfuse tracing."""
|
|
313
|
+
command = getattr(self._context, "current_command", None)
|
|
314
|
+
header = getattr(command, "header", None)
|
|
315
|
+
metadata = {
|
|
316
|
+
"langfuse_session_id": getattr(self._context, "session_id", ""),
|
|
317
|
+
"langfuse_user_id": getattr(header, "user_code", ""),
|
|
318
|
+
"by_framework_trace_id": getattr(self._context, "trace_id", ""),
|
|
319
|
+
"by_framework_message_id": getattr(self._context, "message_id", ""),
|
|
320
|
+
"by_framework_parent_message_id": getattr(
|
|
321
|
+
self._context, "parent_message_id", ""
|
|
322
|
+
),
|
|
323
|
+
"by_framework_agent_id": getattr(self._context, "current_agent_id", ""),
|
|
324
|
+
"langgraph_thread_id": self._thread_id,
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
key: value for key, value in metadata.items() if value not in ("", None)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
@contextmanager
|
|
331
|
+
def _tracing_scope(self) -> Iterator[list[Any]]:
|
|
332
|
+
"""Unified tracing scope for Langfuse and Phoenix."""
|
|
333
|
+
callbacks: list[Any] = []
|
|
334
|
+
|
|
335
|
+
with (
|
|
336
|
+
self._phoenix_context_manager(),
|
|
337
|
+
self._langfuse_callback_manager(callbacks),
|
|
338
|
+
):
|
|
339
|
+
yield callbacks
|
|
340
|
+
|
|
341
|
+
@contextmanager
|
|
342
|
+
def _phoenix_context_manager(self) -> Iterator[None]:
|
|
343
|
+
"""Prepare OpenTelemetry context for Phoenix tracing."""
|
|
344
|
+
# pylint: disable=import-outside-toplevel
|
|
345
|
+
try:
|
|
346
|
+
from opentelemetry import context, trace
|
|
347
|
+
from opentelemetry.trace import (
|
|
348
|
+
SpanContext,
|
|
349
|
+
TraceFlags,
|
|
350
|
+
set_span_in_context,
|
|
351
|
+
)
|
|
352
|
+
except ImportError:
|
|
353
|
+
yield
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
# If tracing is disabled or no tracer is available, just yield
|
|
357
|
+
tracer = trace.get_tracer("by-framework")
|
|
358
|
+
if not tracer:
|
|
359
|
+
yield
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
trace_id = getattr(self._context, "trace_id", "")
|
|
363
|
+
message_id = getattr(self._context, "message_id", "")
|
|
364
|
+
if not trace_id or not message_id:
|
|
365
|
+
yield
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
# Reconstruct the OTEL context from framework IDs
|
|
369
|
+
# Parent for LangGraph is the current framework message
|
|
370
|
+
span_context = SpanContext(
|
|
371
|
+
trace_id=str_to_uint128(trace_id),
|
|
372
|
+
span_id=str_to_uint64(message_id),
|
|
373
|
+
is_remote=True,
|
|
374
|
+
trace_flags=TraceFlags(TraceFlags.SAMPLED),
|
|
375
|
+
)
|
|
376
|
+
ctx = set_span_in_context(trace.NonRecordingSpan(span_context))
|
|
377
|
+
token = context.attach(ctx)
|
|
378
|
+
try:
|
|
379
|
+
yield
|
|
380
|
+
finally:
|
|
381
|
+
context.detach(token)
|
|
382
|
+
|
|
383
|
+
@contextmanager
|
|
384
|
+
def _langfuse_callback_manager(self, callbacks: list[Any]) -> Iterator[None]:
|
|
385
|
+
"""Prepare Langfuse callback and observation for LangChain."""
|
|
386
|
+
# pylint: disable=import-outside-toplevel
|
|
387
|
+
try:
|
|
388
|
+
langfuse_config = import_module(
|
|
389
|
+
"by_framework_trace_langfuse"
|
|
390
|
+
).LangfuseConfig
|
|
391
|
+
if langfuse_config.from_env() is None:
|
|
392
|
+
raise ImportError("Langfuse not configured")
|
|
393
|
+
|
|
394
|
+
callback_handler = import_module("langfuse.langchain").CallbackHandler
|
|
395
|
+
get_client = import_module("langfuse").get_client
|
|
396
|
+
except (ImportError, AttributeError):
|
|
397
|
+
yield
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
callbacks.append(callback_handler())
|
|
401
|
+
|
|
402
|
+
framework_observation = getattr(self._context, LANGFUSE_OBSERVATION_ATTR, None)
|
|
403
|
+
if framework_observation is None:
|
|
404
|
+
yield
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
langfuse = get_client()
|
|
408
|
+
with langfuse.start_as_current_observation(
|
|
409
|
+
as_type="span",
|
|
410
|
+
name=self._tracing.run_name,
|
|
411
|
+
trace_context={
|
|
412
|
+
"trace_id": getattr(self._context, "trace_id", ""),
|
|
413
|
+
"parent_span_id": framework_observation.id,
|
|
414
|
+
},
|
|
415
|
+
metadata=self._default_metadata(),
|
|
416
|
+
):
|
|
417
|
+
yield
|
|
418
|
+
|
|
419
|
+
@staticmethod
|
|
420
|
+
def _default_input_mapper(content: str) -> dict:
|
|
421
|
+
"""Default input mapper: wrap content as a HumanMessage."""
|
|
422
|
+
return {"messages": [HumanMessage(content=content)]}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Remote tool factories for bridging by-framework and LangGraph.
|
|
2
|
+
|
|
3
|
+
Provides factory functions to create LangGraph-compatible tools that
|
|
4
|
+
bridge by-framework's call_agent/ask_user with LangGraph's interrupt/resume
|
|
5
|
+
mechanism.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Annotated
|
|
11
|
+
|
|
12
|
+
from langchain_core.tools import BaseTool, InjectedToolCallId, tool
|
|
13
|
+
from langgraph.types import interrupt
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from by_framework.worker.context import AgentContext
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_remote_agent_tool(
|
|
20
|
+
context: AgentContext,
|
|
21
|
+
tool_name: str,
|
|
22
|
+
target_agent_type: str,
|
|
23
|
+
description: str,
|
|
24
|
+
*,
|
|
25
|
+
idempotency_ttl: int = 86400,
|
|
26
|
+
) -> BaseTool:
|
|
27
|
+
"""Create a LangGraph tool that dispatches work to a remote by-framework agent.
|
|
28
|
+
|
|
29
|
+
The generated tool performs:
|
|
30
|
+
1. Redis idempotency check (prevents duplicate dispatch on checkpoint restore)
|
|
31
|
+
2. ``context.call_agent()`` to send AskAgentCommand to the target agent
|
|
32
|
+
3. ``interrupt()`` to suspend the graph until the remote agent replies
|
|
33
|
+
|
|
34
|
+
When the remote agent completes, the framework sends a ResumeCommand back.
|
|
35
|
+
The caller then invokes ``graph.ainvoke(Command(resume=reply_data))``
|
|
36
|
+
which causes ``interrupt()`` to return the reply data.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
context: The current AgentContext from process_command.
|
|
40
|
+
tool_name: Name for the generated tool (used by LLM for tool selection).
|
|
41
|
+
target_agent_type: The agent_type of the remote agent to call.
|
|
42
|
+
description: Tool description for the LLM.
|
|
43
|
+
idempotency_ttl: TTL in seconds for the Redis idempotency key.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A LangChain BaseTool instance ready to bind to an LLM.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
@tool(tool_name, description=description)
|
|
50
|
+
async def remote_agent_tool(
|
|
51
|
+
topic: str,
|
|
52
|
+
tool_call_id: Annotated[str, InjectedToolCallId],
|
|
53
|
+
) -> str:
|
|
54
|
+
# Idempotency guard: checkpoint restore replays tool execution,
|
|
55
|
+
# but we must not re-dispatch the command.
|
|
56
|
+
redis_key = f"dispatched_task:{context.session_id}:{tool_call_id}"
|
|
57
|
+
is_dispatched = await context.redis.exists(redis_key)
|
|
58
|
+
|
|
59
|
+
if not is_dispatched:
|
|
60
|
+
await context.call_agent(
|
|
61
|
+
target_agent_type=target_agent_type,
|
|
62
|
+
content=topic,
|
|
63
|
+
)
|
|
64
|
+
await context.redis.set(redis_key, "1", ex=idempotency_ttl)
|
|
65
|
+
|
|
66
|
+
# Suspend the graph. When resumed, interrupt() returns the reply data.
|
|
67
|
+
result = interrupt(f"Waiting for {target_agent_type} to finish.")
|
|
68
|
+
return str(result)
|
|
69
|
+
|
|
70
|
+
return remote_agent_tool # type: ignore[return-value]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def make_ask_user_tool(
|
|
74
|
+
context: AgentContext,
|
|
75
|
+
*,
|
|
76
|
+
tool_name: str = "ask_user",
|
|
77
|
+
description: str = "向用户提问并等待回复。参数 prompt 是向用户展示的提示信息。",
|
|
78
|
+
idempotency_ttl: int = 86400,
|
|
79
|
+
) -> BaseTool:
|
|
80
|
+
"""Create a LangGraph tool that asks the user for input.
|
|
81
|
+
|
|
82
|
+
The generated tool performs:
|
|
83
|
+
1. Redis idempotency check (prevents duplicate ask on checkpoint restore)
|
|
84
|
+
2. ``context.ask_user()`` to send an AskUserEvent form to the frontend
|
|
85
|
+
3. ``interrupt()`` to suspend the graph until the user replies
|
|
86
|
+
|
|
87
|
+
When the user submits a response, the frontend/client sends a ResumeCommand.
|
|
88
|
+
The caller then invokes ``graph.ainvoke(Command(resume=user_reply))``
|
|
89
|
+
which causes ``interrupt()`` to return the user's reply.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
context: The current AgentContext from process_command.
|
|
93
|
+
tool_name: Name for the generated tool.
|
|
94
|
+
description: Tool description for the LLM.
|
|
95
|
+
idempotency_ttl: TTL in seconds for the Redis idempotency key.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
A LangChain BaseTool instance ready to bind to an LLM.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
@tool(tool_name, description=description)
|
|
102
|
+
async def ask_user_tool(
|
|
103
|
+
prompt: str,
|
|
104
|
+
tool_call_id: Annotated[str, InjectedToolCallId],
|
|
105
|
+
) -> str:
|
|
106
|
+
# Idempotency guard
|
|
107
|
+
redis_key = f"asked_user:{context.session_id}:{tool_call_id}"
|
|
108
|
+
is_asked = await context.redis.exists(redis_key)
|
|
109
|
+
|
|
110
|
+
if not is_asked:
|
|
111
|
+
await context.ask_user(prompt)
|
|
112
|
+
await context.redis.set(redis_key, "1", ex=idempotency_ttl)
|
|
113
|
+
|
|
114
|
+
# Suspend the graph. When resumed, interrupt() returns the user reply.
|
|
115
|
+
user_reply = interrupt(f"ask_user:{prompt}")
|
|
116
|
+
return str(user_reply)
|
|
117
|
+
|
|
118
|
+
return ask_user_tool # type: ignore[return-value]
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""LangGraph worker base class for by-framework.
|
|
2
|
+
|
|
3
|
+
Provides a ready-to-use Worker base that handles the full command lifecycle
|
|
4
|
+
(initial invoke, resume, suspend detection, checkpoint management),
|
|
5
|
+
so subclasses only need to implement ``build_graph()``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import abstractmethod
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from by_framework.common.logger import logger
|
|
14
|
+
from by_framework.worker.byai_worker import ByaiWorker
|
|
15
|
+
from langgraph.checkpoint.memory import MemorySaver
|
|
16
|
+
|
|
17
|
+
from .adapter import LangGraphAdapter
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from by_framework.core.protocol.commands import GatewayCommand
|
|
21
|
+
from by_framework.worker.context import AgentContext
|
|
22
|
+
from langgraph.checkpoint.base import BaseCheckpointSaver
|
|
23
|
+
from langgraph.graph.state import CompiledStateGraph
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LangGraphWorker(ByaiWorker):
|
|
27
|
+
"""Base Worker class for LangGraph-powered agents.
|
|
28
|
+
|
|
29
|
+
Subclasses only need to implement:
|
|
30
|
+
- ``get_agent_types()`` → list of agent type strings
|
|
31
|
+
- ``build_graph(context, command)`` → compiled LangGraph StateGraph
|
|
32
|
+
|
|
33
|
+
The base class automatically handles:
|
|
34
|
+
- AskAgentCommand → ``graph.ainvoke(initial_state)``
|
|
35
|
+
- ResumeCommand → ``graph.ainvoke(Command(resume=data))``
|
|
36
|
+
- Graph suspend detection via ``get_state().next``
|
|
37
|
+
- Checkpoint lifecycle management
|
|
38
|
+
- Streaming output to frontend
|
|
39
|
+
|
|
40
|
+
Example::
|
|
41
|
+
|
|
42
|
+
class MyWorker(LangGraphWorker):
|
|
43
|
+
def get_agent_types(self):
|
|
44
|
+
return ["my-agent"]
|
|
45
|
+
|
|
46
|
+
def build_graph(self, context, command):
|
|
47
|
+
tools = [make_remote_agent_tool(context, ...)]
|
|
48
|
+
llm = ChatOpenAI(model="gpt-4o").bind_tools(tools)
|
|
49
|
+
workflow = StateGraph(AgentState)
|
|
50
|
+
# ... build graph ...
|
|
51
|
+
return workflow.compile(checkpointer=self.get_checkpointer())
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
55
|
+
"""Initialize the worker with a lazy checkpointer placeholder."""
|
|
56
|
+
super().__init__(*args, **kwargs)
|
|
57
|
+
self._checkpointer: BaseCheckpointSaver | None = None
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def build_graph(
|
|
61
|
+
self,
|
|
62
|
+
context: AgentContext,
|
|
63
|
+
command: GatewayCommand,
|
|
64
|
+
) -> CompiledStateGraph:
|
|
65
|
+
"""Build and return a compiled LangGraph StateGraph.
|
|
66
|
+
|
|
67
|
+
Called on every ``process_command`` invocation. The graph should
|
|
68
|
+
include a checkpointer (use ``self.get_checkpointer()``) to enable
|
|
69
|
+
interrupt/resume across multiple ``process_command`` calls.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
context: Current AgentContext with session info and
|
|
73
|
+
framework primitives.
|
|
74
|
+
command: The incoming command (AskAgentCommand or ResumeCommand).
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
A compiled LangGraph StateGraph ready for invocation.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def get_checkpointer(self) -> BaseCheckpointSaver:
|
|
81
|
+
"""Get the checkpoint saver for this worker.
|
|
82
|
+
|
|
83
|
+
Default implementation uses MemorySaver (in-memory, not persistent).
|
|
84
|
+
Override this method to use a persistent checkpointer for production
|
|
85
|
+
(e.g., ``langgraph-checkpoint-postgres``).
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
A LangGraph BaseCheckpointSaver instance.
|
|
89
|
+
"""
|
|
90
|
+
if self._checkpointer is None:
|
|
91
|
+
self._checkpointer = MemorySaver()
|
|
92
|
+
return self._checkpointer
|
|
93
|
+
|
|
94
|
+
def get_thread_id(self, context: AgentContext) -> str:
|
|
95
|
+
"""Determine the thread ID for checkpoint isolation.
|
|
96
|
+
|
|
97
|
+
Default uses ``context.session_id`` so that the same session
|
|
98
|
+
shares checkpoint state across multiple commands.
|
|
99
|
+
|
|
100
|
+
Override to customize thread isolation strategy (e.g., per-message).
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
context: Current AgentContext.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
A string used as the LangGraph thread_id.
|
|
107
|
+
"""
|
|
108
|
+
return context.session_id
|
|
109
|
+
|
|
110
|
+
def get_stream_enabled(self) -> bool:
|
|
111
|
+
"""Whether to use streaming mode for graph execution.
|
|
112
|
+
|
|
113
|
+
Default is True. Override to disable streaming.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
True to stream, False for batch invocation.
|
|
117
|
+
"""
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
def get_langgraph_run_name(
|
|
121
|
+
self,
|
|
122
|
+
context: AgentContext,
|
|
123
|
+
command: GatewayCommand,
|
|
124
|
+
) -> str:
|
|
125
|
+
"""Return the LangGraph run name used by tracing callbacks."""
|
|
126
|
+
del command
|
|
127
|
+
agent_id = context.current_agent_id or "langgraph"
|
|
128
|
+
return f"{agent_id}:langgraph"
|
|
129
|
+
|
|
130
|
+
def get_langgraph_metadata(
|
|
131
|
+
self,
|
|
132
|
+
context: AgentContext,
|
|
133
|
+
command: GatewayCommand,
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
"""Return extra metadata merged into the LangGraph runnable config."""
|
|
136
|
+
del context, command
|
|
137
|
+
return {}
|
|
138
|
+
|
|
139
|
+
def get_langgraph_callbacks(
|
|
140
|
+
self,
|
|
141
|
+
context: AgentContext,
|
|
142
|
+
command: GatewayCommand,
|
|
143
|
+
) -> list[Any]:
|
|
144
|
+
"""Return extra LangChain-compatible callbacks for graph execution."""
|
|
145
|
+
del context, command
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
async def process_command(
|
|
149
|
+
self,
|
|
150
|
+
command: GatewayCommand,
|
|
151
|
+
context: AgentContext,
|
|
152
|
+
) -> Any:
|
|
153
|
+
"""Framework entry point — delegates to LangGraphAdapter.
|
|
154
|
+
|
|
155
|
+
Calls ``build_graph()`` to get the compiled graph, wraps it
|
|
156
|
+
in a ``LangGraphAdapter``, and runs the command.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
command: Incoming GatewayCommand (AskAgentCommand or
|
|
160
|
+
ResumeCommand).
|
|
161
|
+
context: Current AgentContext.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Graph result or status dict if suspended.
|
|
165
|
+
"""
|
|
166
|
+
logger.info(
|
|
167
|
+
"[LangGraphWorker] Processing command, type=%s, session=%s",
|
|
168
|
+
type(command).__name__,
|
|
169
|
+
context.session_id,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
graph = self.build_graph(context, command)
|
|
173
|
+
adapter = LangGraphAdapter(
|
|
174
|
+
graph,
|
|
175
|
+
context,
|
|
176
|
+
thread_id=self.get_thread_id(context),
|
|
177
|
+
run_name=self.get_langgraph_run_name(context, command),
|
|
178
|
+
metadata=self.get_langgraph_metadata(context, command),
|
|
179
|
+
callbacks=self.get_langgraph_callbacks(context, command),
|
|
180
|
+
stream=self.get_stream_enabled(),
|
|
181
|
+
)
|
|
182
|
+
return await adapter.run(command)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: by-framework-langgraph
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: LangGraph integration for by-framework
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: by-framework>=1.0.0
|
|
7
|
+
Requires-Dist: langchain-core>=1.2.28
|
|
8
|
+
Requires-Dist: langgraph>=1.1.6
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: isort>=5.13.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: pyink>=24.0.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: pylint>=3.0.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: ruff>=0.3.0; extra == 'dev'
|
|
16
|
+
Provides-Extra: langfuse
|
|
17
|
+
Requires-Dist: by-framework-trace-langfuse>=0.2.0; extra == 'langfuse'
|
|
18
|
+
Provides-Extra: phoenix
|
|
19
|
+
Requires-Dist: by-framework-trace-phoenix>=0.2.0; extra == 'phoenix'
|
|
20
|
+
Requires-Dist: openinference-instrumentation-langchain>=0.1.0; extra == 'phoenix'
|
|
21
|
+
Requires-Dist: openinference-instrumentation-openai>=0.1.0; extra == 'phoenix'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# by-framework-langgraph
|
|
25
|
+
|
|
26
|
+
LangGraph integration for by-framework. Provides two integration modes:
|
|
27
|
+
|
|
28
|
+
1. **Adapter Mode** — Plug existing LangGraph graphs into by-framework with one line
|
|
29
|
+
2. **Native Mode** — Build LangGraph workers with native `call_agent` / `ask_user` / `resume` support
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv add by-framework-langgraph
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
### Adapter Mode — Plug in existing graphs
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from by_framework.worker import ByaiWorker
|
|
43
|
+
from by_framework_langgraph import LangGraphAdapter
|
|
44
|
+
|
|
45
|
+
class MyWorker(ByaiWorker):
|
|
46
|
+
def get_agent_types(self):
|
|
47
|
+
return ["my-agent"]
|
|
48
|
+
|
|
49
|
+
async def process_command(self, command, context):
|
|
50
|
+
graph = build_my_existing_graph() # your existing LangGraph
|
|
51
|
+
adapter = LangGraphAdapter(graph, context)
|
|
52
|
+
return await adapter.run(command)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Native Mode — Framework-native LangGraph workers
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from by_framework_langgraph import LangGraphWorker, make_remote_agent_tool, make_ask_user_tool
|
|
59
|
+
|
|
60
|
+
class OrchestratorWorker(LangGraphWorker):
|
|
61
|
+
def get_agent_types(self):
|
|
62
|
+
return ["orchestrator"]
|
|
63
|
+
|
|
64
|
+
def build_graph(self, context, command):
|
|
65
|
+
poet = make_remote_agent_tool(context, "invoke_poet", "poet-agent", "调度诗人创作")
|
|
66
|
+
ask = make_ask_user_tool(context)
|
|
67
|
+
llm = ChatOpenAI(model="gpt-4o").bind_tools([poet, ask])
|
|
68
|
+
# ... build and return compiled graph
|
|
69
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
by_framework_langgraph/__init__.py,sha256=x08wIaK4VRnUnUD_mbBxVgI2Zf7CvwAVbr3syP2NQps,1046
|
|
2
|
+
by_framework_langgraph/_utils.py,sha256=KSx6rmbVNzBPVqvtOliK-5M2aLk6bplmklg5IiM-NQs,1944
|
|
3
|
+
by_framework_langgraph/adapter.py,sha256=aIBsGT2amTLMNLtISWCvhFxLIiyZYQ6TZnUz6-ENnRY,16525
|
|
4
|
+
by_framework_langgraph/tools.py,sha256=T7OoNKMPuJT3t6bR5G8KVGSjrSnLP0tiCEzCnBGesMY,4384
|
|
5
|
+
by_framework_langgraph/worker.py,sha256=wFalpBQLqFm-YzJugzAzkA3ciI9lUzxRTv4GmqVCv78,6156
|
|
6
|
+
by_framework_langgraph-0.2.0.dist-info/METADATA,sha256=YTt4XjwS646sCx7nYTmcaiKWbNGGp8kwpgUeamQYdQY,2329
|
|
7
|
+
by_framework_langgraph-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
by_framework_langgraph-0.2.0.dist-info/RECORD,,
|