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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any