Skip to content

Commit 5d7612f

Browse files
xumapleclaude
andcommitted
Address PR review comments and refine chatbot architecture
- Simplify basic/worker.py to use asyncio.run(main()) pattern - Basic activity now returns str instead of Response (drops pydantic dep) - Remove pydantic_data_converter from basic worker/starter - Replace chatbot signal+poll with update handler (message_from_user) - Wrap update handler body in @Traceable for input/output capture - Inline RetryPolicy in chatbot workflow (no separate RETRY constant) - Add comment about alternative traceable() function-call style - Make PROJECT_NAME a shared constant in starters, pass to client-side @Traceable so traces go to the right LangSmith project - Update READMEs with correct trace hierarchies based on real output - Fix wrap_openai span name (ChatOpenAI, not openai.responses.create) - Simplify tests (mocks match new str return type, use execute_update) - Remove poll_last_response helper Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8a57690 commit 5d7612f

12 files changed

Lines changed: 94 additions & 136 deletions

File tree

langsmith_tracing/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ This sample demonstrates [LangSmith](https://smith.langchain.com/) tracing integ
55
Two examples are included:
66

77
- **[basic/](basic/)** — A one-shot LLM workflow that sends a prompt to OpenAI and returns the response.
8-
- **[chatbot/](chatbot/)** — A long-running conversational workflow with tool calls (save/read notes), signals, and queries.
8+
- **[chatbot/](chatbot/)** — A long-running conversational workflow with tool calls (save/read notes) and update handlers.
99

1010
## Prerequisites
1111

langsmith_tracing/basic/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ uv run --group langsmith-tracing python -m langsmith_tracing.basic.starter
1818

1919
### `add_temporal_runs=False` (default)
2020

21-
Only `@traceable` and `wrap_openai` spans appear. The client-side `@traceable` is the root, and all workflow/activity traces nest under it via context propagation:
21+
Only `@traceable` and `wrap_openai` spans appear. The client-side `@traceable` is the root, and workflow/activity traces nest under it via context propagation.
2222

2323
```
2424
Basic LLM Request (@traceable, client-side)
2525
└── Ask: What is Temporal? (@traceable, workflow)
2626
└── Call OpenAI (@traceable, activity)
27-
└── openai.responses.create (automatic via wrap_openai)
27+
└── ChatOpenAI (automatic via wrap_openai)
2828
```
2929

3030
### `add_temporal_runs=True`
@@ -36,7 +36,7 @@ uv run --group langsmith-tracing python -m langsmith_tracing.basic.worker --add-
3636
uv run --group langsmith-tracing python -m langsmith_tracing.basic.starter --add-temporal-runs
3737
```
3838

39-
Temporal operation spans are added. `StartWorkflow` and `RunWorkflow` are siblings under the client root; `StartActivity` and `RunActivity` are siblings under the workflow:
39+
Temporal operation spans are added. `StartWorkflow`/`RunWorkflow` and `StartActivity`/`RunActivity` appear as sibling pairs:
4040

4141
```
4242
Basic LLM Request (@traceable, client-side)
@@ -46,5 +46,5 @@ Basic LLM Request (@traceable, client-side)
4646
├── StartActivity:call_openai (automatic, Temporal plugin)
4747
└── RunActivity:call_openai (automatic, Temporal plugin)
4848
└── Call OpenAI (@traceable, activity)
49-
└── openai.responses.create (automatic via wrap_openai)
49+
└── ChatOpenAI (automatic via wrap_openai)
5050
```

langsmith_tracing/basic/activities.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from langsmith import traceable
66
from langsmith.wrappers import wrap_openai
77
from openai import AsyncOpenAI
8-
from openai.types.responses import Response
98
from temporalio import activity
109

1110

@@ -18,14 +17,15 @@ class OpenAIRequest:
1817

1918
@traceable(name="Call OpenAI", run_type="llm")
2019
@activity.defn
21-
async def call_openai(request: OpenAIRequest) -> Response:
20+
async def call_openai(request: OpenAIRequest) -> str:
2221
"""Call OpenAI Responses API. Retries handled by Temporal, not the OpenAI client."""
2322
# wrap_openai patches the client so each API call (e.g. responses.create)
2423
# creates its own child span with model parameters and token usage.
2524
client = wrap_openai(AsyncOpenAI(max_retries=0))
26-
return await client.responses.create(
25+
response = await client.responses.create(
2726
model=request.model,
2827
instructions=request.instructions,
2928
input=request.input,
3029
timeout=30,
3130
)
31+
return response.output_text

langsmith_tracing/basic/starter.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from langsmith import traceable
77
from temporalio.client import Client
88
from temporalio.contrib.langsmith import LangSmithPlugin
9-
from temporalio.contrib.pydantic import pydantic_data_converter
109
from temporalio.envconfig import ClientConfig
1110

1211
from langsmith_tracing.basic.workflows import BasicLLMWorkflow
@@ -27,7 +26,6 @@ async def main():
2726

2827
client = await Client.connect(
2928
**config,
30-
data_converter=pydantic_data_converter,
3129
plugins=[plugin],
3230
)
3331

langsmith_tracing/basic/worker.py

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,12 @@
66

77
from temporalio.client import Client
88
from temporalio.contrib.langsmith import LangSmithPlugin
9-
from temporalio.contrib.pydantic import pydantic_data_converter
109
from temporalio.envconfig import ClientConfig
1110
from temporalio.worker import Worker
1211

1312
from langsmith_tracing.basic.activities import call_openai
1413
from langsmith_tracing.basic.workflows import BasicLLMWorkflow
1514

16-
interrupt_event = asyncio.Event()
17-
1815

1916
async def main():
2017
logging.basicConfig(level=logging.INFO)
@@ -33,26 +30,20 @@ async def main():
3330

3431
client = await Client.connect(
3532
**config,
36-
data_converter=pydantic_data_converter,
3733
plugins=[plugin],
3834
)
3935

40-
async with Worker(
36+
worker = Worker(
4137
client,
4238
task_queue="langsmith-basic-task-queue",
4339
workflows=[BasicLLMWorkflow],
4440
activities=[call_openai],
45-
):
46-
label = "with" if add_temporal_runs else "without"
47-
print(f"Worker started ({label} Temporal runs in traces), ctrl+c to exit")
48-
await interrupt_event.wait()
49-
print("Shutting down")
41+
)
42+
43+
label = "with" if add_temporal_runs else "without"
44+
print(f"Worker started ({label} Temporal runs in traces), ctrl+c to exit")
45+
await worker.run()
5046

5147

5248
if __name__ == "__main__":
53-
loop = asyncio.new_event_loop()
54-
try:
55-
loop.run_until_complete(main())
56-
except KeyboardInterrupt:
57-
interrupt_event.set()
58-
loop.run_until_complete(loop.shutdown_asyncgens())
49+
asyncio.run(main())

langsmith_tracing/basic/workflows.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,10 @@ async def run(self, prompt: str) -> str:
2424
tags=["basic-llm"],
2525
)
2626
async def _run() -> str:
27-
response = await workflow.execute_activity(
27+
return await workflow.execute_activity(
2828
call_openai,
2929
OpenAIRequest(model="gpt-4o-mini", input=prompt),
3030
start_to_close_timeout=timedelta(seconds=60),
3131
)
32-
return response.output_text
3332

3433
return await _run()
Lines changed: 31 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Chatbot with Tool Calls
22

3-
A long-running conversational workflow with tool calls, signals, and queries. Demonstrates how LangSmith traces an agentic loop where the model calls tools across multiple turns.
3+
A long-running conversational workflow with tool calls and update handlers. Demonstrates how LangSmith traces an agentic loop where the model calls tools across multiple turns.
44

55
See the [parent README](../README.md) for prerequisites.
66

@@ -25,58 +25,57 @@ The model has two tools, both implemented as `@traceable` methods on the workflo
2525
- **`save_note(name, content)`** — Stores a note in the workflow's in-memory dict. The note is durable because workflow state survives crashes and restarts via Temporal's event history.
2626
- **`read_note(name)`** — Reads a note from the workflow's in-memory dict.
2727

28+
## Architecture
29+
30+
The main `@workflow.run` method runs a loop that processes user messages (calls `_query_openai`, which handles the tool-call loop). The `message_from_user` update handler coordinates: it hands the message to the main loop via a shared `_pending_message` field, then waits for the response.
31+
32+
This means:
33+
- Activity calls and the tool loop happen inside the main workflow run
34+
- The update handler's trace just shows the input/output of the coordination step
35+
2836
## Trace Structure
2937

3038
### `add_temporal_runs=False` (default)
3139

32-
Only `@traceable` and `wrap_openai` spans appear. The client-side `@traceable` wraps `start_workflow`, so the workflow's traces nest under it via context propagation. Signals and queries produce no traces in this mode.
40+
Only `@traceable` and `wrap_openai` spans appear. The client-side `@traceable` wraps `start_workflow` and each `execute_update`, so both workflow and update traces nest under it via context propagation.
3341

3442
```
35-
Chatbot Session a1b2c3d4 (@traceable, client-side)
36-
├── Turn: What's the capital of Fr.. (@traceable, client-side)
37-
├── Turn: Save that as a note call.. (@traceable, client-side)
38-
├── Turn: What did I save about pa.. (@traceable, client-side)
39-
└── Session Apr 17 10:30 (@traceable, workflow)
40-
├── Request: What's the capital of France? (@traceable, per-message)
41-
│ └── Call OpenAI (@traceable, activity)
42-
│ └── openai.responses.create (automatic via wrap_openai)
43-
├── Request: Save that as a note called paris (@traceable, per-message)
44-
│ ├── Call OpenAI (@traceable, activity)
45-
│ │ └── openai.responses.create → function_call: save_note
46-
│ ├── Save Note (@traceable, workflow method)
47-
│ └── Call OpenAI (@traceable, activity)
48-
│ └── openai.responses.create → text response
49-
└── Request: What did I save about paris? (@traceable, per-message)
50-
├── Call OpenAI (@traceable, activity)
51-
│ └── openai.responses.create → function_call: read_note
52-
├── Read Note (@traceable, workflow method)
53-
└── Call OpenAI (@traceable, activity)
54-
└── openai.responses.create → text response
43+
Chatbot Session a1b2c3d4 (@traceable, client-side)
44+
├── Session Apr 17 10:30 (@traceable, workflow main loop)
45+
│ ├── Request: hello (@traceable, per-message in main loop)
46+
│ │ └── Call OpenAI (@traceable, activity)
47+
│ │ └── ChatOpenAI (automatic via wrap_openai)
48+
│ └── Request: save that as note 15 (@traceable, per-message in main loop)
49+
│ ├── Call OpenAI → function_call: save_note
50+
│ ├── Save Note (@traceable, workflow method)
51+
│ └── Call OpenAI → text response
52+
├── Update: hello (@traceable, update handler)
53+
└── Update: save that as note 15 (@traceable, update handler)
5554
```
5655

5756
### `add_temporal_runs=True`
5857

59-
With `--add-temporal-runs`, Temporal operation spans are added. `StartWorkflow` and `RunWorkflow` are siblings under the client root (context propagated via headers at `start_workflow` time). Signals and queries each get their own trace.
58+
With `--add-temporal-runs`, Temporal operation spans are added. `StartWorkflow`/`RunWorkflow` and `StartActivity`/`RunActivity` appear as sibling pairs.
6059

6160
```
6261
Chatbot Session a1b2c3d4 (@traceable, client-side)
6362
├── StartWorkflow:ChatbotWorkflow (automatic, Temporal plugin)
6463
├── RunWorkflow:ChatbotWorkflow (automatic, Temporal plugin)
65-
│ └── Session Apr 17 10:30 (@traceable, workflow)
66-
│ └── Request: Save that as a note called.. (@traceable, per-message)
64+
│ └── Session Apr 17 10:30 (@traceable, workflow main loop)
65+
│ └── Request: save that as note 15 (@traceable, per-message)
6766
│ ├── StartActivity:call_openai (automatic, Temporal plugin)
6867
│ ├── RunActivity:call_openai (automatic, Temporal plugin)
6968
│ │ └── Call OpenAI (@traceable, activity)
70-
│ │ └── openai.responses.create (automatic via wrap_openai)
69+
│ │ └── ChatOpenAI (automatic via wrap_openai)
7170
│ ├── Save Note (@traceable, workflow method)
7271
│ ├── StartActivity:call_openai (automatic, Temporal plugin)
7372
│ └── RunActivity:call_openai (automatic, Temporal plugin)
7473
│ └── Call OpenAI (@traceable, activity)
75-
│ └── openai.responses.create (automatic via wrap_openai)
76-
├── Turn: Save that as a note call.. (@traceable, client-side)
77-
│ ├── SignalWorkflow:user_message (automatic, Temporal plugin)
78-
│ │ └── HandleSignal:user_message (automatic, Temporal plugin)
79-
│ └── QueryWorkflow:last_response (automatic, Temporal plugin)
80-
│ └── HandleQuery:last_response (automatic, Temporal plugin)
74+
│ └── ChatOpenAI (automatic via wrap_openai)
75+
├── StartWorkflowUpdate:message_from_user (automatic, Temporal plugin)
76+
│ └── HandleUpdate:message_from_user (automatic, Temporal plugin)
77+
│ └── Update: save that as note 15 (@traceable, update handler)
8178
└── ...
8279
```
80+
81+
Note that the `Request:` chain (with activity calls) lives under `RunWorkflow` (the main loop), while the `Update:` span lives under `HandleUpdate` (the update handler). They're connected by the shared workflow state but appear as separate subtrees.

langsmith_tracing/chatbot/starter.py

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ async def main():
4040
run_type="chain",
4141
# CRITICAL: Client-side @traceable runs outside the LangSmithPlugin's scope.
4242
# Make sure client-side traces use the same project_name as what is given to
43-
# # the plugin.
43+
# the plugin.
4444
project_name=PROJECT_NAME,
4545
tags=["client-side", "chatbot"],
4646
)
@@ -64,27 +64,10 @@ async def run_session():
6464
print(f"\nWorkflow finished: {result}")
6565
return
6666

67-
# Each turn gets its own trace span
68-
@traceable(
69-
name=f"Turn: {user_input[:40]}",
70-
run_type="chain",
71-
tags=["client-turn"],
67+
response = await wf_handle.execute_update(
68+
ChatbotWorkflow.message_from_user, user_input
7269
)
73-
async def send_and_wait(msg: str):
74-
prev_response = await wf_handle.query(ChatbotWorkflow.last_response)
75-
await wf_handle.signal(ChatbotWorkflow.user_message, msg)
76-
for _ in range(60):
77-
await asyncio.sleep(0.5)
78-
response = await wf_handle.query(ChatbotWorkflow.last_response)
79-
if response != prev_response:
80-
return response
81-
return None
82-
83-
response = await send_and_wait(user_input)
84-
if response:
85-
print(f"Bot: {response}\n")
86-
else:
87-
print("(timed out waiting for response)")
70+
print(f"Bot: {response}\n")
8871

8972
await run_session()
9073

langsmith_tracing/chatbot/workflows.py

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111

1212
from langsmith_tracing.chatbot.activities import OpenAIRequest, call_openai
1313

14-
RETRY = RetryPolicy(initial_interval=timedelta(seconds=2), maximum_attempts=3)
15-
1614
TOOLS = [
1715
{
1816
"type": "function",
@@ -57,45 +55,58 @@ def __init__(self):
5755
self._notes: dict[str, str] = {}
5856
self._done = False
5957

60-
@workflow.signal
61-
async def user_message(self, message: str) -> None:
62-
self._pending_message = message
63-
6458
@workflow.signal
6559
async def exit(self) -> None:
6660
self._done = True
6761

68-
@workflow.query
69-
def last_response(self) -> str:
70-
return self._last_response
71-
7262
@workflow.query
7363
def notes(self) -> dict[str, str]:
7464
return dict(self._notes)
7565

66+
@workflow.update
67+
async def message_from_user(self, message: str) -> str:
68+
"""Hand the message to the main loop and wait for its response."""
69+
70+
# Inner @traceable captures the message as input and response as output.
71+
@traceable(name=f"Update: {message[:60]}", run_type="chain")
72+
async def _traced(msg: str) -> str:
73+
# Wait until any previous message has finished processing
74+
await workflow.wait_condition(lambda: self._pending_message is None)
75+
self._pending_message = msg
76+
# Main loop sets _last_response, then clears _pending_message to signal done
77+
await workflow.wait_condition(lambda: self._pending_message is None)
78+
return self._last_response
79+
80+
return await _traced(message)
81+
7682
# Do not decorate @workflow.run with @traceable — it would violate
7783
# replay safety and produce duplicate or orphaned traces. Instead,
78-
# wrap an inner function.
84+
# wrap an inner function, eg. self._run_inner() in this case.
7985
@workflow.run
8086
async def run(self) -> str:
87+
# Alternative to @traceable decorator: call traceable() as a function
88+
# for dynamic trace names that depend on runtime values.
8189
now = workflow.now().strftime("%b %d %H:%M")
8290
return await traceable(
8391
name=f"Session {now}",
8492
run_type="chain",
8593
metadata={"workflow_id": workflow.info().workflow_id},
8694
tags=["chatbot-session"],
87-
)(self._session)()
95+
)(self._run_inner)()
8896

89-
async def _session(self) -> str:
97+
async def _run_inner(self) -> str:
9098
while not self._done:
9199
await workflow.wait_condition(
92100
lambda: self._pending_message is not None or self._done
93101
)
94102
if self._done:
95103
break
104+
assert self._pending_message is not None
96105
message = self._pending_message
97-
self._pending_message = None
98106
self._last_response = await self._query_openai(message)
107+
# Clear pending_message AFTER setting the response so the update
108+
# handler reads the correct response when its wait_condition fires.
109+
self._pending_message = None
99110

100111
return "Session ended."
101112

@@ -111,14 +122,14 @@ def _save_note(self, name: str, content: str) -> str:
111122
def _read_note(self, name: str) -> str:
112123
return self._notes.get(name, "Note not found.")
113124

114-
async def _query_openai(self, message: str | None) -> str:
125+
async def _query_openai(self, message: str) -> str:
115126
@traceable(
116-
name=f"Request: {(message or '')[:60]}",
127+
name=f"Request: {message[:60]}",
117128
run_type="chain",
118129
tags=["user-message"],
119130
)
120131
async def _traced():
121-
input_for_next: str | list = message or ""
132+
input_for_next: str | list = message
122133
while True:
123134
response = await workflow.execute_activity(
124135
call_openai,
@@ -129,7 +140,9 @@ async def _traced():
129140
previous_response_id=self._previous_response_id,
130141
),
131142
start_to_close_timeout=timedelta(seconds=60),
132-
retry_policy=RETRY,
143+
retry_policy=RetryPolicy(
144+
initial_interval=timedelta(seconds=2), maximum_attempts=3
145+
),
133146
)
134147
self._previous_response_id = response.id
135148

0 commit comments

Comments
 (0)