Skip to content

Commit 2d38d18

Browse files
committed
修复tool监控丢失的问题
1 parent 74f880e commit 2d38d18

5 files changed

Lines changed: 173 additions & 15 deletions

File tree

trpc_agent_sdk/agents/core/_llm_processor.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,20 +79,36 @@ async def call_llm_async(self,
7979
return
8080

8181
# Step 2: Call the model and process responses with telemetry tracing.
82-
# Avoid start_as_current_span in async generators because cancellation can
83-
# close the generator from a different context, which may trigger
84-
# "Token was created in a different Context" during detach.
85-
span = tracer.start_span('call_llm')
86-
try:
82+
with tracer.start_as_current_span('call_llm'):
8783
event_id = Event.new_id()
8884
final_llm_response = None
85+
aggregated_raw_function_calls: list[dict] = []
86+
aggregated_event_function_calls: list[dict] = []
87+
88+
def _append_function_calls(target: list[dict], calls: list) -> None:
89+
for call in calls or []:
90+
# Keep only telemetry-safe fields for trace attributes.
91+
target.append({
92+
"id": getattr(call, "id", None),
93+
"name": getattr(call, "name", None),
94+
"args": getattr(call, "args", None),
95+
})
8996

9097
async for llm_response in self.model.generate_async(request, stream=stream, ctx=context):
98+
# Collect raw model-level function calls from every chunk.
99+
raw_calls = []
100+
if llm_response.content and llm_response.content.parts:
101+
for part in llm_response.content.parts:
102+
if part.function_call:
103+
raw_calls.append(part.function_call)
104+
_append_function_calls(aggregated_raw_function_calls, raw_calls)
105+
91106
# Create Event directly from LlmResponse
92107
event = self._create_event_from_response(context, event_id, llm_response)
93108

94109
# Process response with planner if available
95110
event = self._process_planning_response(event, context)
111+
_append_function_calls(aggregated_event_function_calls, event.get_function_calls())
96112

97113
# Track the latest non-partial response for tracing
98114
# In streaming mode, only the final (non-partial) response
@@ -111,9 +127,9 @@ async def call_llm_async(self,
111127
event_id,
112128
request,
113129
final_llm_response,
114-
instruction_metadata=instruction_metadata)
115-
finally:
116-
span.end()
130+
instruction_metadata=instruction_metadata,
131+
stream_function_calls_raw=aggregated_raw_function_calls,
132+
stream_function_calls_post_planner=aggregated_event_function_calls)
117133

118134
except Exception as ex: # pylint: disable=broad-except
119135
logger.error("LLM call failed for agent %s: %s", author, ex)

trpc_agent_sdk/agents/core/_tools_processor.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,10 @@ async def execute_tools_async(
221221
state_end.update(merged_event.actions.state_delta)
222222

223223
# Add merged tool call tracing
224-
with tracer.start_as_current_span("execute_tool (merged)"):
224+
with tracer.start_as_current_span(
225+
"execute_tool (merged)",
226+
attributes={"gen_ai.operation.name": "execute_tool"},
227+
):
225228
trace_merged_tool_calls(
226229
response_event_id=merged_event.id,
227230
function_response_event=merged_event,
@@ -285,8 +288,17 @@ async def _execute_tool(self, tool_call: FunctionCall, tool: BaseTool, context:
285288
Event: The result of tool execution
286289
"""
287290

288-
# Wrap tool execution in telemetry span
289-
with tracer.start_as_current_span(f"execute_tool {tool.name}"):
291+
# Wrap tool execution in telemetry span.
292+
# Pass initial attributes so the Galileo sampler can make a sampling
293+
# decision at span-creation time (before trace_tool_call sets them).
294+
with tracer.start_as_current_span(
295+
f"execute_tool {tool.name}",
296+
attributes={
297+
"gen_ai.operation.name": "execute_tool",
298+
"gen_ai.tool.name": tool.name,
299+
"gen_ai.tool.description": tool.description or "",
300+
},
301+
):
290302
# Capture state before tool execution
291303
state_begin = dict(context.session.state)
292304

trpc_agent_sdk/filter/_run_filter.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,7 @@ async def run_stream_filters(ctx: AgentContext, req: Any, filters: list[BaseFilt
5151
if handle is None:
5252
raise ValueError("handle must be provided")
5353
current_handle = partial(stream_handler_adapter, handle)
54-
filters.reverse()
55-
for filter in filters:
54+
for filter in reversed(filters):
5655
current_handle = partial(filter.run_stream, ctx, req, current_handle)
5756
async for event in current_handle():
5857
yield event.rsp
@@ -95,9 +94,8 @@ async def run_filters(ctx: AgentContext, req: Any, filters: list[BaseFilter],
9594
"""
9695
if handle is None:
9796
raise ValueError("handle must be provided")
98-
filters.reverse()
9997
current_handle = partial(coroutine_handler_adapter, handle)
100-
for filter in filters:
98+
for filter in reversed(filters):
10199
current_handle = partial(filter.run, ctx, req, current_handle)
102100
rsp, error = await current_handle()
103101
if error:

trpc_agent_sdk/storage/_sql_common.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,64 @@
4444
from trpc_agent_sdk.types import GroundingMetadata
4545
from trpc_agent_sdk.types import GenerateContentResponseUsageMetadata
4646

47+
LoadDialectHook = Callable[[TypeDecorator, Dialect], Any]
48+
ProcessBindHook = Callable[[TypeDecorator, Any, Dialect], Any]
49+
ProcessResultHook = Callable[[TypeDecorator, Any, Dialect], Any]
50+
51+
52+
class TypeDecoratorHookRegistry:
53+
"""Global hook registry for SQLAlchemy TypeDecorator callbacks."""
54+
55+
_load_dialect_hooks: dict[type[TypeDecorator], list[LoadDialectHook]] = {}
56+
_process_bind_hooks: dict[type[TypeDecorator], list[ProcessBindHook]] = {}
57+
_process_result_hooks: dict[type[TypeDecorator], list[ProcessResultHook]] = {}
58+
59+
@classmethod
60+
def register_load_dialect_hook(cls, decorator_cls: type[TypeDecorator], hook: LoadDialectHook) -> None:
61+
"""Register hook for ``load_dialect_impl``."""
62+
cls._load_dialect_hooks.setdefault(decorator_cls, []).append(hook)
63+
64+
@classmethod
65+
def register_process_bind_hook(cls, decorator_cls: type[TypeDecorator], hook: ProcessBindHook) -> None:
66+
"""Register hook for ``process_bind_param``."""
67+
cls._process_bind_hooks.setdefault(decorator_cls, []).append(hook)
68+
69+
@classmethod
70+
def register_process_result_hook(cls, decorator_cls: type[TypeDecorator], hook: ProcessResultHook) -> None:
71+
"""Register hook for ``process_result_value``."""
72+
cls._process_result_hooks.setdefault(decorator_cls, []).append(hook)
73+
74+
@classmethod
75+
def run_load_dialect_hooks(cls, decorator: TypeDecorator, dialect: Dialect) -> Any:
76+
"""Run load hooks and return first override result."""
77+
for hook in cls._load_dialect_hooks.get(type(decorator), []):
78+
result = hook(decorator, dialect)
79+
if result is not None:
80+
return result
81+
return None
82+
83+
@classmethod
84+
def run_process_bind_hooks(cls, decorator: TypeDecorator, value: Any, dialect: Dialect) -> Any:
85+
"""Run bind hooks and return first override result."""
86+
for hook in cls._process_bind_hooks.get(type(decorator), []):
87+
result = hook(decorator, value, dialect)
88+
if result is not None:
89+
return result
90+
return None
91+
92+
@classmethod
93+
def run_process_result_hooks(cls, decorator: TypeDecorator, value: Any, dialect: Dialect) -> Any:
94+
"""Run result hooks and return first override result."""
95+
for hook in cls._process_result_hooks.get(type(decorator), []):
96+
result = hook(decorator, value, dialect)
97+
if result is not None:
98+
return result
99+
return None
100+
101+
102+
# Global class object used as unified registration entry.
103+
GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY = TypeDecoratorHookRegistry
104+
47105

48106
def decode_content(content: Optional[dict[str, Any]]) -> Optional[Content]:
49107
"""Decode a content object from a JSON dictionary.
@@ -119,13 +177,19 @@ class DynamicJSON(TypeDecorator):
119177
impl = Text # Default implementation is TEXT
120178

121179
def load_dialect_impl(self, dialect: Dialect) -> TypeDecorator:
180+
hook_result = GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.run_load_dialect_hooks(self, dialect)
181+
if hook_result is not None:
182+
return hook_result
122183
if dialect.name == "postgresql":
123184
return dialect.type_descriptor(postgresql.JSONB) # type: ignore
124185
if dialect.name == "mysql":
125186
return dialect.type_descriptor(mysql.LONGTEXT) # type: ignore
126187
return dialect.type_descriptor(Text) # Default to Text for other dialects # type: ignore
127188

128189
def process_bind_param(self, value: Any, dialect: Dialect) -> Any:
190+
hook_result = GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.run_process_bind_hooks(self, value, dialect)
191+
if hook_result is not None:
192+
return hook_result
129193
if value is not None:
130194
if dialect.name == "postgresql":
131195
return value # JSONB handles dict directly
@@ -134,6 +198,9 @@ def process_bind_param(self, value: Any, dialect: Dialect) -> Any:
134198
return value
135199

136200
def process_result_value(self, value: Any, dialect: Dialect) -> Any:
201+
hook_result = GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.run_process_result_hooks(self, value, dialect)
202+
if hook_result is not None:
203+
return hook_result
137204
if value is not None:
138205
if dialect.name == "postgresql":
139206
return value # JSONB returns dict directly
@@ -157,6 +224,9 @@ def __init__(self, length: Optional[int] = None, *args: Any, **kwargs: Any) -> N
157224
self.length = length
158225

159226
def load_dialect_impl(self, dialect: Dialect) -> TypeDecorator:
227+
hook_result = GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.run_load_dialect_hooks(self, dialect)
228+
if hook_result is not None:
229+
return hook_result
160230
if dialect.name == "mysql":
161231
# Use VARCHAR with utf8mb4 charset and utf8mb4_unicode_ci collation
162232
return dialect.type_descriptor(mysql.VARCHAR(self.length, charset='utf8mb4',
@@ -166,6 +236,18 @@ def load_dialect_impl(self, dialect: Dialect) -> TypeDecorator:
166236
return dialect.type_descriptor(String(self.length))
167237
return dialect.type_descriptor(String())
168238

239+
def process_bind_param(self, value: Any, dialect: Dialect) -> Any:
240+
hook_result = GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.run_process_bind_hooks(self, value, dialect)
241+
if hook_result is not None:
242+
return hook_result
243+
return value
244+
245+
def process_result_value(self, value: Any, dialect: Dialect) -> Any:
246+
hook_result = GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.run_process_result_hooks(self, value, dialect)
247+
if hook_result is not None:
248+
return hook_result
249+
return value
250+
169251

170252
class PreciseTimestamp(TypeDecorator):
171253
"""Represents a timestamp precise to the microsecond."""
@@ -174,30 +256,54 @@ class PreciseTimestamp(TypeDecorator):
174256
cache_ok = True
175257

176258
def load_dialect_impl(self, dialect: Dialect) -> TypeDecorator:
259+
hook_result = GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.run_load_dialect_hooks(self, dialect)
260+
if hook_result is not None:
261+
return hook_result
177262
if dialect.name == "mysql":
178263
return dialect.type_descriptor(mysql.DATETIME(fsp=6))
179264
return self.impl
180265

266+
def process_bind_param(self, value: Any, dialect: Dialect) -> Any:
267+
hook_result = GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.run_process_bind_hooks(self, value, dialect)
268+
if hook_result is not None:
269+
return hook_result
270+
return value
271+
272+
def process_result_value(self, value: Any, dialect: Dialect) -> Any:
273+
hook_result = GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.run_process_result_hooks(self, value, dialect)
274+
if hook_result is not None:
275+
return hook_result
276+
return value
277+
181278

182279
class DynamicPickleType(TypeDecorator):
183280
"""Represents a type that can be pickled."""
184281

185282
impl = PickleType
186283

187284
def load_dialect_impl(self, dialect: Dialect) -> TypeDecorator:
285+
hook_result = GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.run_load_dialect_hooks(self, dialect)
286+
if hook_result is not None:
287+
return hook_result
188288
if dialect.name == "spanner+spanner":
189289
return dialect.type_descriptor(SpannerPickleType) # type: ignore
190290
if dialect.name == "mysql":
191291
return dialect.type_descriptor(mysql.LONGBLOB) # type: ignore
192292
return self.impl
193293

194294
def process_bind_param(self, value: Any, dialect: Dialect) -> Any:
295+
hook_result = GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.run_process_bind_hooks(self, value, dialect)
296+
if hook_result is not None:
297+
return hook_result
195298
if value is not None:
196299
if dialect.name == "spanner+spanner":
197300
return pickle.dumps(value)
198301
return value
199302

200303
def process_result_value(self, value: Any, dialect: Dialect) -> Any:
304+
hook_result = GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.run_process_result_hooks(self, value, dialect)
305+
if hook_result is not None:
306+
return hook_result
201307
if value is not None:
202308
if dialect.name == "spanner+spanner":
203309
return pickle.loads(value)

trpc_agent_sdk/telemetry/_trace.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,8 @@ def trace_call_llm(
372372
llm_request: LlmRequest,
373373
llm_response: LlmResponse,
374374
instruction_metadata: Optional[InstructionMetadata] = None,
375+
stream_function_calls_raw: Optional[list[dict[str, Any]]] = None,
376+
stream_function_calls_post_planner: Optional[list[dict[str, Any]]] = None,
375377
):
376378
"""Traces a call to the LLM.
377379
@@ -387,6 +389,10 @@ def trace_call_llm(
387389
with ``name``, ``version``, and ``labels`` attributes. When
388390
provided, these are written directly to the call_llm span for
389391
precise instruction-to-generation association.
392+
stream_function_calls_raw: Optional function calls collected from all
393+
raw LLM stream chunks.
394+
stream_function_calls_post_planner: Optional function calls collected
395+
from post-planner events emitted during stream processing.
390396
"""
391397
global _trpc_agent_span_name # pylint: disable=invalid-name
392398
span = trace.get_current_span()
@@ -414,6 +420,26 @@ def trace_call_llm(
414420
llm_response_json,
415421
)
416422

423+
if stream_function_calls_raw:
424+
span.set_attribute(
425+
f"{_trpc_agent_span_name}.stream_function_calls.raw",
426+
_safe_json_serialize(stream_function_calls_raw),
427+
)
428+
span.set_attribute(
429+
f"{_trpc_agent_span_name}.stream_function_calls.raw_count",
430+
len(stream_function_calls_raw),
431+
)
432+
433+
if stream_function_calls_post_planner:
434+
span.set_attribute(
435+
f"{_trpc_agent_span_name}.stream_function_calls.post_planner",
436+
_safe_json_serialize(stream_function_calls_post_planner),
437+
)
438+
span.set_attribute(
439+
f"{_trpc_agent_span_name}.stream_function_calls.post_planner_count",
440+
len(stream_function_calls_post_planner),
441+
)
442+
417443
if llm_response.usage_metadata is not None:
418444
usage = llm_response.usage_metadata
419445
if usage.prompt_token_count and usage.total_token_count:

0 commit comments

Comments
 (0)