Skip to content

Commit ba9e9a1

Browse files
authored
LangSmith sample: starter cleanup, plugin on Worker, chatbot Response decoupling (#295)
* Clean up LangSmith sample per PR #292 review - basic/starter.py: move `@traceable` decorator directly onto `main`, removing the nested `run_workflow` closure (addresses reviewer comment) - basic/worker.py, chatbot/worker.py: move `LangSmithPlugin` from the `Client` to the `Worker`, matching our recommended pattern (plugin on the worker in worker code; on the client in client code) * Return workflow result from basic starter's main @Traceable captures the decorated function's return value as the LangSmith trace output, so implicitly returning None left the trace's output field empty. Return `result` (and annotate the return type) so the trace shows the workflow response. * Return minimal ChatResponse from chatbot call_openai activity The activity previously returned `openai.types.responses.Response` directly. The OpenAI API currently returns `"prompt_cache_retention": "in_memory"` (underscore), but openai SDK v2.32.0 declares that field as `Literal["in-memory", "24h"]`. The openai client parses laxly so the activity succeeds, but Temporal's `pydantic_data_converter` uses strict `TypeAdapter(Response).validate_json` on the way into the workflow and rejects the underscore value, failing every workflow task. Define minimal `ChatResponse` and `ToolCall` pydantic models in `activities.py` exposing only the fields the workflow uses (id, output_text, tool_calls). The activity projects the openai Response down to this shape so the sample is no longer coupled to SDK drift in fields it doesn't use. Update the workflow loop to iterate `response.tool_calls` directly and the test mocks/helpers to build `ChatResponse` instead of constructing openai Response objects.
1 parent b199e50 commit ba9e9a1

7 files changed

Lines changed: 66 additions & 89 deletions

File tree

langsmith_tracing/basic/starter.py

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,16 @@
1313
PROJECT_NAME = "langsmith-basic"
1414

1515

16-
async def main():
16+
@traceable(
17+
name="Basic LLM Request",
18+
run_type="chain",
19+
# CRITICAL: Client-side @traceable runs outside the LangSmithPlugin's scope.
20+
# Make sure client-side traces use the same project_name as what is given to
21+
# the plugin.
22+
project_name=PROJECT_NAME,
23+
tags=["client-side"],
24+
)
25+
async def main() -> str:
1726
add_temporal_runs = "--add-temporal-runs" in sys.argv
1827

1928
config = ClientConfig.load_client_connect_config()
@@ -29,25 +38,14 @@ async def main():
2938
plugins=[plugin],
3039
)
3140

32-
@traceable(
33-
name="Basic LLM Request",
34-
run_type="chain",
35-
# CRITICAL: Client-side @traceable runs outside the LangSmithPlugin's scope.
36-
# Make sure client-side traces use the same project_name as what is given to
37-
# # the plugin.
38-
project_name=PROJECT_NAME,
39-
tags=["client-side"],
41+
result = await client.execute_workflow(
42+
BasicLLMWorkflow.run,
43+
"What is Temporal?",
44+
id="langsmith-basic-workflow-id",
45+
task_queue="langsmith-basic-task-queue",
4046
)
41-
async def run_workflow(prompt: str) -> str:
42-
return await client.execute_workflow(
43-
BasicLLMWorkflow.run,
44-
prompt,
45-
id="langsmith-basic-workflow-id",
46-
task_queue="langsmith-basic-task-queue",
47-
)
48-
49-
result = await run_workflow("What is Temporal?")
5047
print(f"Workflow result: {result}")
48+
return result
5149

5250

5351
if __name__ == "__main__":

langsmith_tracing/basic/worker.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,14 @@ async def main():
2828
add_temporal_runs=add_temporal_runs,
2929
)
3030

31-
client = await Client.connect(
32-
**config,
33-
plugins=[plugin],
34-
)
31+
client = await Client.connect(**config)
3532

3633
worker = Worker(
3734
client,
3835
task_queue="langsmith-basic-task-queue",
3936
workflows=[BasicLLMWorkflow],
4037
activities=[call_openai],
38+
plugins=[plugin],
4139
)
4240

4341
label = "with" if add_temporal_runs else "without"

langsmith_tracing/chatbot/activities.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,22 @@
66
from langsmith import traceable
77
from langsmith.wrappers import wrap_openai
88
from openai import AsyncOpenAI
9-
from openai.types.responses import Response
9+
from pydantic import BaseModel, Field
1010
from temporalio import activity
1111

1212

13+
class ToolCall(BaseModel):
14+
call_id: str
15+
name: str
16+
arguments: str
17+
18+
19+
class ChatResponse(BaseModel):
20+
id: str
21+
output_text: str = ""
22+
tool_calls: list[ToolCall] = Field(default_factory=list)
23+
24+
1325
@dataclass
1426
class OpenAIRequest:
1527
model: str
@@ -25,7 +37,7 @@ class OpenAIRequest:
2537

2638
@traceable(name="Call OpenAI", run_type="llm")
2739
@activity.defn
28-
async def call_openai(request: OpenAIRequest) -> Response:
40+
async def call_openai(request: OpenAIRequest) -> ChatResponse:
2941
"""Call OpenAI Responses API. Retries handled by Temporal, not the OpenAI client."""
3042
# wrap_openai patches the client so each API call (e.g. responses.create)
3143
# creates its own child span with model parameters and token usage.
@@ -42,4 +54,13 @@ async def call_openai(request: OpenAIRequest) -> Response:
4254
response_args["tools"] = request.tools
4355
if request.previous_response_id:
4456
response_args["previous_response_id"] = request.previous_response_id
45-
return await client.responses.create(**response_args)
57+
response = await client.responses.create(**response_args)
58+
return ChatResponse(
59+
id=response.id,
60+
output_text=response.output_text or "",
61+
tool_calls=[
62+
ToolCall(call_id=item.call_id, name=item.name, arguments=item.arguments)
63+
for item in response.output
64+
if item.type == "function_call"
65+
],
66+
)

langsmith_tracing/chatbot/worker.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,22 @@ async def main():
2222
config = ClientConfig.load_client_connect_config()
2323
config.setdefault("target_host", "localhost:7233")
2424

25+
plugin = LangSmithPlugin(
26+
project_name="langsmith-chatbot",
27+
add_temporal_runs=add_temporal_runs,
28+
)
29+
2530
client = await Client.connect(
2631
**config,
2732
data_converter=pydantic_data_converter,
28-
plugins=[
29-
LangSmithPlugin(
30-
project_name="langsmith-chatbot",
31-
add_temporal_runs=add_temporal_runs,
32-
)
33-
],
3433
)
3534

3635
worker = Worker(
3736
client,
3837
task_queue="langsmith-chatbot-task-queue",
3938
workflows=[ChatbotWorkflow],
4039
activities=[call_openai],
40+
plugins=[plugin],
4141
)
4242

4343
label = "with" if add_temporal_runs else "without"

langsmith_tracing/chatbot/workflows.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -147,25 +147,23 @@ async def _traced():
147147
self._previous_response_id = response.id
148148

149149
tool_results = []
150-
for item in response.output:
151-
if item.type != "function_call":
152-
continue
153-
args = json.loads(item.arguments)
154-
if item.name == "save_note":
150+
for tc in response.tool_calls:
151+
args = json.loads(tc.arguments)
152+
if tc.name == "save_note":
155153
result = self._save_note(args["name"], args["content"])
156154
tool_results.append(
157155
{
158156
"type": "function_call_output",
159-
"call_id": item.call_id,
157+
"call_id": tc.call_id,
160158
"output": result,
161159
}
162160
)
163-
elif item.name == "read_note":
161+
elif tc.name == "read_note":
164162
result = self._read_note(args["name"])
165163
tool_results.append(
166164
{
167165
"type": "function_call_output",
168-
"call_id": item.call_id,
166+
"call_id": tc.call_id,
169167
"output": result,
170168
}
171169
)

tests/langsmith_tracing/helpers.py

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,8 @@
11
"""Shared test helpers for LangSmith tracing tests."""
22

3-
from openai.types.responses import Response
4-
from openai.types.responses.response_output_message import ResponseOutputMessage
5-
from openai.types.responses.response_output_text import ResponseOutputText
3+
from langsmith_tracing.chatbot.activities import ChatResponse
64

75

8-
def make_text_response(text: str) -> Response:
9-
"""Build a minimal OpenAI Response with a text output."""
10-
return Response.model_construct(
11-
id="resp_mock",
12-
created_at=0.0,
13-
model="gpt-4o-mini",
14-
object="response",
15-
output=[
16-
ResponseOutputMessage.model_construct(
17-
id="msg_mock",
18-
type="message",
19-
role="assistant",
20-
status="completed",
21-
content=[
22-
ResponseOutputText.model_construct(
23-
type="output_text",
24-
text=text,
25-
annotations=[],
26-
)
27-
],
28-
)
29-
],
30-
parallel_tool_calls=False,
31-
tool_choice="auto",
32-
tools=[],
33-
)
6+
def make_text_response(text: str) -> ChatResponse:
7+
"""Build a minimal ChatResponse with a text output."""
8+
return ChatResponse(id="resp_mock", output_text=text)

tests/langsmith_tracing/test_chatbot.py

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,29 @@
11
import json
22
import uuid
33

4-
from openai.types.responses import Response
5-
from openai.types.responses.response_function_tool_call import (
6-
ResponseFunctionToolCall,
7-
)
84
from temporalio import activity
95
from temporalio.client import Client
106
from temporalio.contrib.langsmith import LangSmithPlugin
117
from temporalio.testing import WorkflowEnvironment
128
from temporalio.worker import Worker
139

14-
from langsmith_tracing.chatbot.activities import OpenAIRequest
10+
from langsmith_tracing.chatbot.activities import ChatResponse, OpenAIRequest, ToolCall
1511
from langsmith_tracing.chatbot.workflows import ChatbotWorkflow
1612
from tests.langsmith_tracing.helpers import make_text_response
1713

1814

1915
def _make_function_call_response(
2016
name: str, arguments: dict, call_id: str = "call_123"
21-
) -> Response:
22-
return Response.model_construct(
17+
) -> ChatResponse:
18+
return ChatResponse(
2319
id="resp_tool",
24-
created_at=0.0,
25-
model="gpt-4o-mini",
26-
object="response",
27-
output=[
28-
ResponseFunctionToolCall.model_construct(
29-
id="fc_mock",
30-
type="function_call",
20+
tool_calls=[
21+
ToolCall(
22+
call_id=call_id,
3123
name=name,
3224
arguments=json.dumps(arguments),
33-
call_id=call_id,
34-
status="completed",
3525
)
3626
],
37-
parallel_tool_calls=False,
38-
tool_choice="auto",
39-
tools=[],
4027
)
4128

4229

@@ -45,7 +32,7 @@ async def test_chatbot_save_note(client: Client, env: WorkflowEnvironment):
4532
call_count = 0
4633

4734
@activity.defn(name="call_openai")
48-
async def mock_call_openai(request: OpenAIRequest) -> Response:
35+
async def mock_call_openai(request: OpenAIRequest) -> ChatResponse:
4936
nonlocal call_count
5037
call_count += 1
5138
if call_count == 1:
@@ -86,7 +73,7 @@ async def test_chatbot_read_note(client: Client, env: WorkflowEnvironment):
8673
call_count = 0
8774

8875
@activity.defn(name="call_openai")
89-
async def mock_call_openai(request: OpenAIRequest) -> Response:
76+
async def mock_call_openai(request: OpenAIRequest) -> ChatResponse:
9077
nonlocal call_count
9178
call_count += 1
9279
if call_count == 1:

0 commit comments

Comments
 (0)