-
Notifications
You must be signed in to change notification settings - Fork 25
Add tools support in langchain #37
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3d60029
2fb9234
f36d295
93cf06a
0badaae
cdc1e86
070c81e
7d32fdd
08ff1c3
436c230
0ed8921
8c5adad
efb8264
2a95a90
e8194a9
a89f6c7
2f87cdb
459b1bb
4946d98
265ef8a
9bf1906
f3c3627
dcf64f2
f17dde6
72e7724
97772f0
ed6339c
bd79d8c
b8ae7b6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Added tool spans and captured tool definitions on inference spans. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| # Copyright The OpenTelemetry Authors | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| """ | ||
| Tool-calling example without agents, built with LangChain. | ||
|
|
||
| Uses ChatOpenAI with bind_tools to let the model call calculator tools directly, | ||
| then manually dispatches tool calls and feeds results back to the model. | ||
| OpenTelemetry LangChain instrumentation traces the LLM calls. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
|
|
||
| from langchain_core.messages import HumanMessage, ToolMessage | ||
| from langchain_core.tools import tool | ||
| from langchain_openai import ChatOpenAI | ||
|
|
||
| from opentelemetry import _logs, metrics, trace | ||
| from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( | ||
| OTLPLogExporter, | ||
| ) | ||
| from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( | ||
| OTLPMetricExporter, | ||
| ) | ||
| from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( | ||
| OTLPSpanExporter, | ||
| ) | ||
| from opentelemetry.instrumentation.genai.langchain import LangChainInstrumentor | ||
| from opentelemetry.sdk._logs import LoggerProvider | ||
| from opentelemetry.sdk._logs.export import BatchLogRecordProcessor | ||
| from opentelemetry.sdk.metrics import MeterProvider | ||
| from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader | ||
| from opentelemetry.sdk.trace import TracerProvider | ||
| from opentelemetry.sdk.trace.export import BatchSpanProcessor | ||
|
|
||
| # Configure tracing | ||
| trace.set_tracer_provider(TracerProvider()) | ||
| span_processor = BatchSpanProcessor(OTLPSpanExporter()) | ||
| trace.get_tracer_provider().add_span_processor(span_processor) | ||
|
|
||
| # Configure logging | ||
| _logs.set_logger_provider(LoggerProvider()) | ||
| _logs.get_logger_provider().add_log_record_processor( | ||
| BatchLogRecordProcessor(OTLPLogExporter()) | ||
| ) | ||
|
|
||
| # Configure metrics | ||
| metrics.set_meter_provider( | ||
| MeterProvider( | ||
| metric_readers=[ | ||
| PeriodicExportingMetricReader( | ||
| OTLPMetricExporter(), | ||
| ), | ||
| ] | ||
| ) | ||
| ) | ||
|
|
||
|
|
||
| @tool | ||
| def multiply(a: float, b: float) -> float: | ||
| """Multiply two numbers together.""" | ||
| return a * b | ||
|
|
||
|
|
||
| @tool | ||
| def add(a: float, b: float) -> float: | ||
| """Add two numbers together.""" | ||
| return a + b | ||
|
|
||
|
|
||
| TOOLS = [multiply, add] | ||
| TOOLS_BY_NAME = {t.name: t for t in TOOLS} | ||
|
|
||
|
|
||
| def main() -> None: | ||
| instrumentor = LangChainInstrumentor() | ||
| instrumentor.instrument() | ||
|
|
||
| llm = ChatOpenAI( | ||
| model="gpt-3.5-turbo", | ||
| temperature=0.1, | ||
| max_tokens=100, | ||
| top_p=0.9, | ||
| seed=100, | ||
| ) | ||
| llm_with_tools = llm.bind_tools(TOOLS) | ||
|
|
||
| messages = [HumanMessage(content="What is (3 * 4) + 7?")] | ||
|
|
||
| # First LLM call — model may request tool calls | ||
| response = llm_with_tools.invoke(messages) | ||
| messages.append(response) | ||
|
|
||
| # Dispatch tool calls until the model stops requesting them | ||
| while response.tool_calls: | ||
| for tool_call in response.tool_calls: | ||
| selected_tool = TOOLS_BY_NAME[tool_call["name"]] | ||
| tool_output = selected_tool.invoke(tool_call["args"]) | ||
| messages.append( | ||
| ToolMessage( | ||
| content=json.dumps(tool_output), | ||
| tool_call_id=tool_call["id"], | ||
| ) | ||
| ) | ||
|
|
||
| response = llm_with_tools.invoke(messages) | ||
| messages.append(response) | ||
|
|
||
| print("Final answer:", response.content) | ||
|
|
||
| instrumentor.uninstrument() | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| langchain==0.3.21 | ||
| langchain_openai | ||
| langgraph | ||
| opentelemetry-sdk>=1.31.0 | ||
| opentelemetry-exporter-otlp-proto-grpc>=1.31.0 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| from typing import Any, Optional, cast | ||
| from uuid import UUID | ||
|
|
||
|
|
@@ -21,18 +22,21 @@ | |
| from opentelemetry.instrumentation.genai.langchain.utils import ( | ||
| make_input_message, | ||
| make_last_output_message, | ||
| prepare_tool_definitions, | ||
| ) | ||
| from opentelemetry.util.genai.handler import TelemetryHandler | ||
| from opentelemetry.util.genai.invocation import ( | ||
| AgentInvocation, | ||
| InferenceInvocation, | ||
| ToolInvocation, | ||
| WorkflowInvocation, | ||
| ) | ||
| from opentelemetry.util.genai.types import ( | ||
| InputMessage, | ||
| MessagePart, | ||
| OutputMessage, | ||
| Text, | ||
| ToolCallRequest, | ||
| ) | ||
|
|
||
|
|
||
|
|
@@ -286,6 +290,11 @@ def on_chat_model_start( | |
| llm_invocation.seed = seed | ||
| llm_invocation.temperature = temperature | ||
| llm_invocation.max_tokens = max_tokens | ||
| if params is not None: | ||
| tools = params.get("tools") or params.get("functions") | ||
| if tools: | ||
| tool_definitions = prepare_tool_definitions(tools) | ||
| llm_invocation.tool_definitions = tool_definitions | ||
| self._invocation_manager.add_invocation_state( | ||
| run_id=run_id, | ||
| parent_run_id=parent_run_id, | ||
|
|
@@ -333,19 +342,33 @@ def on_llm_end( | |
| ) | ||
| ) | ||
|
|
||
| # Get message content | ||
| parts = [ | ||
| Text( | ||
| content=chat_generation.message.content, | ||
| type="text", | ||
| if finish_reason == "tool_calls": | ||
| tool_calls: list[ToolCallRequest] = [] | ||
| for tool_call in chat_generation.message.tool_calls: | ||
| tool_call_request = ToolCallRequest( | ||
| name=tool_call["name"], | ||
| id=tool_call["id"], | ||
| arguments=tool_call["args"], | ||
| ) | ||
| tool_calls.append(tool_call_request) | ||
| output_message = OutputMessage( | ||
| role=chat_generation.message.type, | ||
| parts=cast(list[MessagePart], tool_calls), | ||
| finish_reason=finish_reason, | ||
| ) | ||
| else: | ||
| parts = [ | ||
| Text( | ||
| content=chat_generation.message.content, | ||
| type="text", | ||
| ) | ||
| ] | ||
| role = chat_generation.message.type | ||
| output_message = OutputMessage( | ||
| role=role, | ||
| parts=cast(list[MessagePart], parts), | ||
| finish_reason=finish_reason, | ||
| ) | ||
| ] | ||
| role = chat_generation.message.type | ||
| output_message = OutputMessage( | ||
| role=role, | ||
| parts=cast(list[MessagePart], parts), | ||
| finish_reason=finish_reason, | ||
| ) | ||
| output_messages.append(output_message) | ||
|
|
||
| # Get token usage if available | ||
|
|
@@ -402,6 +425,72 @@ def on_llm_error( | |
| if not llm_invocation.span.is_recording(): | ||
| self._invocation_manager.delete_invocation_state(run_id=run_id) | ||
|
|
||
| def on_tool_start( | ||
| self, | ||
| serialized: Optional[dict[str, Any]], | ||
| input_str: str, | ||
| *, | ||
| run_id: UUID, | ||
| parent_run_id: Optional[UUID] = None, | ||
| tags: Optional[list[str]] = None, | ||
| metadata: Optional[dict[str, Any]] = None, | ||
| inputs: Optional[dict[str, Any]] = None, | ||
| **kwargs: Any, | ||
| ) -> None: | ||
| name = "unknown" | ||
| description = None | ||
| if serialized is not None: | ||
| name = serialized.get("name") or "unknown" | ||
| description = serialized.get("description") | ||
|
|
||
| raw_arguments: Any = inputs if inputs is not None else input_str | ||
| arguments: str | None | ||
| if isinstance(raw_arguments, dict): | ||
| arguments = json.dumps(raw_arguments) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this necessary? can we pass Any ?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it's necessary. |
||
| elif isinstance(raw_arguments, str): | ||
| arguments = raw_arguments | ||
| else: | ||
| arguments = None | ||
| tool_invocation = self._telemetry_handler.tool( | ||
| name=name, tool_description=description, tool_type="function" | ||
| ) | ||
| tool_invocation.arguments = arguments | ||
| self._invocation_manager.add_invocation_state( | ||
| run_id, parent_run_id, tool_invocation | ||
| ) | ||
|
|
||
| def on_tool_end( | ||
| self, | ||
| output: Any, | ||
| *, | ||
| run_id: UUID, | ||
| parent_run_id: Optional[UUID] = None, | ||
| **_kwargs: Any, | ||
| ) -> None: | ||
| tool_invocation = self._invocation_manager.get_invocation(run_id) | ||
| if not isinstance(tool_invocation, ToolInvocation): | ||
| return | ||
| tool_invocation.tool_call_id = getattr(output, "tool_call_id", None) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is getattr necessary ?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is defensive pattern here given the output:Any - typed parameter |
||
| tool_invocation.tool_result = getattr(output, "content", None) | ||
| tool_invocation.stop() | ||
| if not tool_invocation.span.is_recording(): | ||
| self._invocation_manager.delete_invocation_state(run_id=run_id) | ||
|
|
||
| def on_tool_error( | ||
| self, | ||
| error: BaseException, | ||
| *, | ||
| run_id: UUID, | ||
| parent_run_id: Optional[UUID] = None, | ||
| **_: Any, | ||
| ) -> None: | ||
| tool_invocation = self._invocation_manager.get_invocation(run_id) | ||
| if not isinstance(tool_invocation, ToolInvocation): | ||
| return | ||
| tool_invocation.fail(error) | ||
| if not tool_invocation.span.is_recording(): | ||
| self._invocation_manager.delete_invocation_state(run_id=run_id) | ||
|
|
||
| def _find_nearest_agent( | ||
| self, run_id: Optional[UUID] | ||
| ) -> Optional[AgentInvocation]: | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.