Skip to content

Commit 59c1164

Browse files
authored
Add dynamic node/task summaries to LangGraph plugin (#1612)
* Add dynamic node/task summaries to LangGraph plugin Adds a per-node/per-task summary_fn(args, kwargs) -> str | None (and a plugin-wide default_summary_fn) that computes a Temporal summary at runtime from the node's input. - execute_in="activity" nodes: the result sets the activity summary (user_metadata, shown on each scheduled-activity event). - execute_in="workflow" nodes: the result updates the workflow's current details via workflow.set_current_details (last-writer-wins). A static summary activity option already flowed through to execute_activity; this is now documented. Setting both a static summary and summary_fn on the same node raises ValueError. summary_fn runs in workflow context (must be deterministic and must not raise) and is replay-safe, since summaries ride in user_metadata. * Comment why summary_fn is popped from node opts * Scope summary_fn per-node and normalize static summary - Drop the plugin-level default_summary_fn; summary_fn is now set only per node (Graph metadata) / per task (activity_options), matching the ADK plugin's per-model scoping so each node manages its own summary. - Normalize a static summary into a summary_fn (a constant function), so static and dynamic summaries share one resolution path feeding the activity summary kwarg / set_current_details. - Workflow-bound nodes now clear current details when summary_fn returns empty, so a node no longer shows a stale summary from an earlier node. * Layer node summary settings over defaults instead of conflicting summary and summary_fn are two forms of one setting, but as separate dict keys a default of one form and a node-level value of the other survived the merge together and tripped the exclusivity guard (e.g. default_activity_options={"summary_fn": fn} + one node with a static summary raised ValueError). Merge now drops the inherited form when a node/task supplies either, so the more-specific setting wins, and the exclusivity check only rejects both forms set at the same level (per-node, or in default_activity_options via a new init check).
1 parent d824821 commit 59c1164

5 files changed

Lines changed: 490 additions & 13 deletions

File tree

temporalio/contrib/langgraph/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,46 @@ await g.ainvoke({...}, context=Context(user_id="alice"))
143143

144144
Your `context` object must be serializable by the configured Temporal payload converter, since it crosses the Activity boundary.
145145

146+
## Summaries
147+
148+
Summaries are short, human-readable labels that show up in the Temporal UI and CLI, making it easier to see what each step of a run is doing.
149+
150+
### Static summary
151+
152+
`summary` is an ordinary Activity option, so a fixed per-node label works today — pass it like any other option:
153+
154+
```python
155+
g.add_node("plan", plan, metadata={"execute_in": "activity", "summary": "Planning step"})
156+
```
157+
158+
It is attached to the node's scheduled-activity event (`execute_in="activity"` only).
159+
160+
### Dynamic summary (`summary_fn`)
161+
162+
To derive the label from the node's input at runtime, supply a `summary_fn`. It receives the node's `(args, kwargs)` and returns a summary string, or `None`/`""` for no summary. For a `StateGraph` node `args[0]` is the state; for a Functional `@task` it is the task's arguments.
163+
164+
```python
165+
def summarize(args, kwargs) -> str | None:
166+
state = args[0]
167+
return f"stage={state['stage']} doc={state['doc_id']}"
168+
169+
# Graph API: per-node
170+
g.add_node("plan", plan, metadata={"execute_in": "activity", "summary_fn": summarize})
171+
172+
# Functional API: per-task
173+
plugin = LangGraphPlugin(
174+
tasks=[plan],
175+
activity_options={"plan": {"execute_in": "activity", "summary_fn": summarize}},
176+
)
177+
```
178+
179+
`summary_fn` is set per node/task (like the static `summary`), so different nodes — which receive different inputs — can compute their summaries independently. You can also put a `summary` or `summary_fn` in `default_activity_options` as a fallback for every node; a node/task that sets either form overrides the inherited default (you just can't set both forms at the same level).
180+
181+
- For `execute_in="activity"` nodes the result sets the activity `summary` (one per scheduled-activity event, visible in history).
182+
- For `execute_in="workflow"` nodes there is no activity, so the result updates the workflow's current details via [`workflow.set_current_details()`](https://python.temporal.io/temporalio.workflow.html#set_current_details). This is a single workflow-level slot (last-writer-wins) reflecting the most recent workflow-bound node that defines a `summary_fn`; a `None`/`""` result clears it. It is queryable via `__temporal_workflow_metadata`.
183+
184+
`summary_fn` runs in workflow context on every replay, so it **must be deterministic and must not raise** (an exception fails the workflow task). Setting both a static `summary` and a `summary_fn` on the same node raises `ValueError`.
185+
146186
## Streaming
147187

148188
When `streaming_topic` is set on `LangGraphPlugin`, calls to `langgraph.config.get_stream_writer()` inside a node publish to the named topic on the workflow's [`WorkflowStream`](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/workflow_streams). Activity-side nodes publish via `WorkflowStreamClient` (a signal carrying batched items, controlled by `streaming_batch_interval`); workflow-side nodes publish synchronously to the in-workflow stream (no signal). External subscribers consume the stream with `WorkflowStreamClient.create(...).topic(...).subscribe(...)`.

temporalio/contrib/langgraph/_activity.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ def thread_safe_writer(value: Any) -> None:
109109
def wrap_execute_activity(
110110
afunc: Callable[[ActivityInput], Awaitable[ActivityOutput]],
111111
task_id: str = "",
112+
summary_fn: Callable[[tuple[Any, ...], dict[str, Any]], str | None] | None = None,
112113
**execute_activity_kwargs: Any,
113114
) -> Callable[..., Any]:
114115
"""Wrap an activity function to be called via workflow.execute_activity with caching."""
@@ -156,9 +157,15 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any:
156157
input = ActivityInput(
157158
args=args, kwargs=kwargs, langgraph_config=langgraph_config
158159
)
159-
output = await workflow.execute_activity(
160-
afunc, input, **execute_activity_kwargs
161-
)
160+
# Compute a dynamic activity summary (if configured) on the schedule
161+
# path only; a cache hit above returns before reaching here, so no
162+
# activity is scheduled and no summary is needed.
163+
call_kwargs = dict(execute_activity_kwargs)
164+
if summary_fn is not None:
165+
summary = summary_fn(args, kwargs)
166+
if summary:
167+
call_kwargs["summary"] = summary
168+
output = await workflow.execute_activity(afunc, input, **call_kwargs)
162169
if output.langgraph_interrupts is not None:
163170
raise GraphInterrupt(output.langgraph_interrupts)
164171

temporalio/contrib/langgraph/_plugin.py

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,36 @@
3535
_ACTIVITY_OPTION_KEYS: frozenset[str] = frozenset(
3636
{"execute_in", *inspect.signature(workflow.execute_activity).parameters}
3737
)
38+
# Node/task option keys beyond the raw execute_activity parameters:
39+
# 'summary_fn' is a callable consumed in the workflow (not a Temporal
40+
# option), so it must be split out of Graph API metadata too.
41+
_LANGGRAPH_OPTION_KEYS: frozenset[str] = _ACTIVITY_OPTION_KEYS | frozenset(
42+
{"summary_fn"}
43+
)
44+
45+
46+
def _constant_summary_fn(
47+
value: str,
48+
) -> Callable[[tuple[Any, ...], dict[str, Any]], str]:
49+
"""Adapt a static summary string to the summary_fn interface."""
50+
return lambda args, kwargs: value
51+
52+
53+
def _merge_activity_opts(
54+
defaults: dict[str, Any] | None, specific: dict[str, Any]
55+
) -> dict[str, Any]:
56+
"""Layer per-node/task options over the plugin defaults.
57+
58+
``summary`` and ``summary_fn`` are two forms of one setting, so a node or
59+
task that supplies either form overrides an inherited default of *either*
60+
form, rather than coexisting with it and tripping the exclusivity check.
61+
"""
62+
merged = dict(defaults or {})
63+
if "summary" in specific or "summary_fn" in specific:
64+
merged.pop("summary", None)
65+
merged.pop("summary_fn", None)
66+
merged.update(specific)
67+
return merged
3868

3969

4070
class LangGraphPlugin(SimplePlugin):
@@ -69,7 +99,9 @@ class LangGraphPlugin(SimplePlugin):
6999
Functional API has no per-task ``metadata`` channel.
70100
default_activity_options: Activity options applied to every
71101
activity-bound node and task, overridable per-node (Graph API
72-
``metadata``) or per-task (``activity_options[name]``).
102+
``metadata``) or per-task (``activity_options[name]``). A
103+
node/task that sets ``summary`` or ``summary_fn`` overrides an
104+
inherited default of either form.
73105
streaming_topic: When set, ``langgraph.config.get_stream_writer()``
74106
inside a node publishes to this topic on the workflow's
75107
:class:`WorkflowStream`. The workflow must construct
@@ -130,6 +162,16 @@ def __init__(
130162
"activity_options[task_name] (Functional API)."
131163
)
132164

165+
if (
166+
default_activity_options
167+
and "summary" in default_activity_options
168+
and "summary_fn" in default_activity_options
169+
):
170+
raise ValueError(
171+
"Set either 'summary' or 'summary_fn' in default_activity_options, "
172+
"not both."
173+
)
174+
133175
self.activities: list = []
134176
self._streaming_topic = streaming_topic
135177
self._streaming_batch_interval = streaming_batch_interval
@@ -168,20 +210,22 @@ def __init__(
168210
# the node function via config["metadata"].
169211
node_meta = node.metadata or {}
170212
node_opts = {
171-
k: v for k, v in node_meta.items() if k in _ACTIVITY_OPTION_KEYS
213+
k: v
214+
for k, v in node_meta.items()
215+
if k in _LANGGRAPH_OPTION_KEYS
172216
}
173217
node.metadata = {
174218
k: v
175219
for k, v in node_meta.items()
176-
if k not in _ACTIVITY_OPTION_KEYS
220+
if k not in _LANGGRAPH_OPTION_KEYS
177221
}
178222
if "execute_in" not in node_opts:
179223
raise ValueError(
180224
f"Node {graph_name}.{node_name} is missing required "
181225
f"'execute_in' in metadata. Set it to 'activity' or "
182226
f"'workflow'."
183227
)
184-
opts = {**(default_activity_options or {}), **node_opts}
228+
opts = _merge_activity_opts(default_activity_options, node_opts)
185229
# Route all LangGraph node calls through afunc so the async
186230
# activity wrapper is always used. wrap_activity handles
187231
# sync vs. async user functions inside the activity itself.
@@ -208,10 +252,7 @@ def __init__(
208252
f"activity_options[{name!r}]. Set it to 'activity' or "
209253
f"'workflow'."
210254
)
211-
opts = {
212-
**(default_activity_options or {}),
213-
**task_opts,
214-
}
255+
opts = _merge_activity_opts(default_activity_options, task_opts)
215256

216257
task.func = self.execute(task_id(task.func), task.func, opts)
217258
task.func.__name__ = name
@@ -253,6 +294,18 @@ def execute(
253294
"""Prepare a node or task to execute as an activity or inline in the workflow."""
254295
opts = kwargs or {}
255296
execute_in = opts.pop("execute_in")
297+
# Normalize the node's summary to a single summary_fn. Both keys are
298+
# popped so neither reaches workflow.execute_activity (which takes no
299+
# summary_fn, and would get a duplicate summary kwarg); a static
300+
# summary becomes a summary_fn that ignores its input.
301+
summary_fn = opts.pop("summary_fn", None)
302+
static_summary = opts.pop("summary", None)
303+
if summary_fn is not None and static_summary is not None:
304+
raise ValueError(
305+
f"{activity_name}: set either 'summary' or 'summary_fn', not both."
306+
)
307+
if static_summary is not None:
308+
summary_fn = _constant_summary_fn(static_summary)
256309

257310
if execute_in == "activity":
258311
wrapped = wrap_activity(
@@ -262,9 +315,13 @@ def execute(
262315
)
263316
a = activity.defn(name=activity_name)(wrapped)
264317
self.activities.append(a)
265-
return wrap_execute_activity(a, task_id=task_id(func), **opts)
318+
return wrap_execute_activity(
319+
a, task_id=task_id(func), summary_fn=summary_fn, **opts
320+
)
266321
elif execute_in == "workflow":
267-
return wrap_workflow(func, streaming_topic=self._streaming_topic)
322+
return wrap_workflow(
323+
func, streaming_topic=self._streaming_topic, summary_fn=summary_fn
324+
)
268325
else:
269326
raise ValueError(f"Invalid execute_in value: {execute_in}")
270327

temporalio/contrib/langgraph/_workflow.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def wrap_workflow(
2020
func: Callable[..., Any],
2121
*,
2222
streaming_topic: str | None = None,
23+
summary_fn: Callable[[tuple[Any, ...], dict[str, Any]], str | None] | None = None,
2324
) -> Callable[..., Awaitable[Any]]:
2425
"""Wrap a function as a workflow-side LangGraph node.
2526
@@ -28,9 +29,19 @@ def wrap_workflow(
2829
function with the writer installed. Workflow-side nodes publish
2930
synchronously to the in-workflow ``WorkflowStream`` (no signal
3031
round-trip); activity-side nodes go through ``WorkflowStreamClient``.
32+
33+
Workflow-side nodes have no activity to carry a summary, so a
34+
``summary_fn`` result updates the workflow's current details via
35+
:func:`temporalio.workflow.set_current_details` (last-writer-wins);
36+
an empty result clears it.
3137
"""
3238

3339
async def wrapper(*args: Any, **kwargs: Any) -> Any:
40+
if summary_fn is not None:
41+
# Always write (clearing when empty) so this node never shows a
42+
# stale summary left by an earlier workflow-bound node.
43+
workflow.set_current_details(summary_fn(args, kwargs) or "")
44+
3445
async def run(stream_writer: Callable[[Any], None] | None) -> Any:
3546
token = None
3647
if stream_writer is not None:

0 commit comments

Comments
 (0)