prefactor-langchain 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -69,3 +69,4 @@ htmlcov/
69
69
  # Env
70
70
  .env
71
71
  mise.local.toml
72
+ prefactor.json
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prefactor-langchain
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: LangChain integration for Prefactor observability
5
5
  Author-email: Prefactor Pty Ltd <josh@prefactor.tech>
6
6
  License: MIT
7
7
  Requires-Python: <4.0.0,>=3.11.0
8
8
  Requires-Dist: langchain-core>=1.0.0
9
- Requires-Dist: prefactor-core>=0.2.1
9
+ Requires-Dist: prefactor-core>=0.2.2
10
10
  Description-Content-Type: text/markdown
11
11
 
12
12
  # prefactor-langchain
@@ -24,13 +24,26 @@ pip install prefactor-langchain
24
24
  ### Factory pattern (quickest setup)
25
25
 
26
26
  ```python
27
- from prefactor_langchain import PrefactorMiddleware
27
+ from prefactor_langchain import LangChainToolSchemaConfig, PrefactorMiddleware
28
28
 
29
29
  middleware = PrefactorMiddleware.from_config(
30
30
  api_url="https://api.prefactor.ai",
31
31
  api_token="your-api-token",
32
32
  agent_id="my-agent",
33
33
  agent_name="My Agent", # optional
34
+ tool_schemas={
35
+ "send_email": LangChainToolSchemaConfig(
36
+ span_type="send-email",
37
+ input_schema={
38
+ "type": "object",
39
+ "properties": {
40
+ "to": {"type": "string", "format": "email"},
41
+ "subject": {"type": "string"},
42
+ },
43
+ "required": ["to", "subject"],
44
+ },
45
+ )
46
+ },
34
47
  )
35
48
 
36
49
  # Use with LangChain's create_agent()
@@ -38,6 +51,7 @@ middleware = PrefactorMiddleware.from_config(
38
51
  # - Agent execution (langchain:agent)
39
52
  # - LLM calls (langchain:llm)
40
53
  # - Tool executions (langchain:tool)
54
+ # - Tool-specific executions (for example langchain:tool:send-email)
41
55
 
42
56
  result = agent.invoke({"messages": [...]})
43
57
 
@@ -73,6 +87,47 @@ await middleware.close() # closes the agent instance only
73
87
  await client.close()
74
88
  ```
75
89
 
90
+ ### SchemaRegistry composition
91
+
92
+ Use a shared ``SchemaRegistry`` when you want custom workflow span types and
93
+ LangChain tool schemas to be published together.
94
+
95
+ ```python
96
+ from prefactor_core import SchemaRegistry
97
+ from prefactor_langchain import (
98
+ LangChainToolSchemaConfig,
99
+ PrefactorMiddleware,
100
+ register_langchain_schemas,
101
+ )
102
+
103
+ registry = SchemaRegistry()
104
+ registry.register_type(
105
+ name="workflow:run",
106
+ params_schema={"type": "object"},
107
+ result_schema={"type": "object"},
108
+ )
109
+ register_langchain_schemas(
110
+ registry,
111
+ tool_schemas={
112
+ "send_email": LangChainToolSchemaConfig(
113
+ span_type="send-email",
114
+ input_schema={
115
+ "type": "object",
116
+ "properties": {"to": {"type": "string"}},
117
+ "required": ["to"],
118
+ },
119
+ )
120
+ },
121
+ )
122
+
123
+ middleware = PrefactorMiddleware.from_config(
124
+ api_url="https://api.prefactor.ai",
125
+ api_token="your-api-token",
126
+ agent_id="my-agent",
127
+ schema_registry=registry,
128
+ )
129
+ ```
130
+
76
131
  ### Pre-configured instance (spans outside the agent)
77
132
 
78
133
  Pass an `AgentInstanceHandle` you created yourself when you also need to
@@ -111,6 +166,12 @@ await instance.finish()
111
166
  await client.close()
112
167
  ```
113
168
 
169
+ If you pass ``tool_schemas=...`` with a pre-created instance, the middleware
170
+ uses those mappings to emit the right tool span types at runtime. The instance's
171
+ already-registered schema version is not mutated, so you must register matching
172
+ tool schemas before creating the instance if you want those per-tool span types
173
+ to appear in the backend schema version.
174
+
114
175
  ## Span Types
115
176
 
116
177
  This package creates LangChain-specific spans with the `langchain:*` namespace:
@@ -13,13 +13,26 @@ pip install prefactor-langchain
13
13
  ### Factory pattern (quickest setup)
14
14
 
15
15
  ```python
16
- from prefactor_langchain import PrefactorMiddleware
16
+ from prefactor_langchain import LangChainToolSchemaConfig, PrefactorMiddleware
17
17
 
18
18
  middleware = PrefactorMiddleware.from_config(
19
19
  api_url="https://api.prefactor.ai",
20
20
  api_token="your-api-token",
21
21
  agent_id="my-agent",
22
22
  agent_name="My Agent", # optional
23
+ tool_schemas={
24
+ "send_email": LangChainToolSchemaConfig(
25
+ span_type="send-email",
26
+ input_schema={
27
+ "type": "object",
28
+ "properties": {
29
+ "to": {"type": "string", "format": "email"},
30
+ "subject": {"type": "string"},
31
+ },
32
+ "required": ["to", "subject"],
33
+ },
34
+ )
35
+ },
23
36
  )
24
37
 
25
38
  # Use with LangChain's create_agent()
@@ -27,6 +40,7 @@ middleware = PrefactorMiddleware.from_config(
27
40
  # - Agent execution (langchain:agent)
28
41
  # - LLM calls (langchain:llm)
29
42
  # - Tool executions (langchain:tool)
43
+ # - Tool-specific executions (for example langchain:tool:send-email)
30
44
 
31
45
  result = agent.invoke({"messages": [...]})
32
46
 
@@ -62,6 +76,47 @@ await middleware.close() # closes the agent instance only
62
76
  await client.close()
63
77
  ```
64
78
 
79
+ ### SchemaRegistry composition
80
+
81
+ Use a shared ``SchemaRegistry`` when you want custom workflow span types and
82
+ LangChain tool schemas to be published together.
83
+
84
+ ```python
85
+ from prefactor_core import SchemaRegistry
86
+ from prefactor_langchain import (
87
+ LangChainToolSchemaConfig,
88
+ PrefactorMiddleware,
89
+ register_langchain_schemas,
90
+ )
91
+
92
+ registry = SchemaRegistry()
93
+ registry.register_type(
94
+ name="workflow:run",
95
+ params_schema={"type": "object"},
96
+ result_schema={"type": "object"},
97
+ )
98
+ register_langchain_schemas(
99
+ registry,
100
+ tool_schemas={
101
+ "send_email": LangChainToolSchemaConfig(
102
+ span_type="send-email",
103
+ input_schema={
104
+ "type": "object",
105
+ "properties": {"to": {"type": "string"}},
106
+ "required": ["to"],
107
+ },
108
+ )
109
+ },
110
+ )
111
+
112
+ middleware = PrefactorMiddleware.from_config(
113
+ api_url="https://api.prefactor.ai",
114
+ api_token="your-api-token",
115
+ agent_id="my-agent",
116
+ schema_registry=registry,
117
+ )
118
+ ```
119
+
65
120
  ### Pre-configured instance (spans outside the agent)
66
121
 
67
122
  Pass an `AgentInstanceHandle` you created yourself when you also need to
@@ -100,6 +155,12 @@ await instance.finish()
100
155
  await client.close()
101
156
  ```
102
157
 
158
+ If you pass ``tool_schemas=...`` with a pre-created instance, the middleware
159
+ uses those mappings to emit the right tool span types at runtime. The instance's
160
+ already-registered schema version is not mutated, so you must register matching
161
+ tool schemas before creating the instance if you want those per-tool span types
162
+ to appear in the backend schema version.
163
+
103
164
  ## Span Types
104
165
 
105
166
  This package creates LangChain-specific spans with the `langchain:*` namespace:
@@ -50,7 +50,7 @@ def _safe_eval(node: ast.expr) -> float:
50
50
  if isinstance(node, ast.Constant):
51
51
  return node.n # type: ignore[return-value]
52
52
  if isinstance(node, ast.BinOp):
53
- return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
53
+ return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right)) # type: ignore[invalid-argument-type]
54
54
  if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
55
55
  return -_safe_eval(node.operand)
56
56
  raise ValueError(f"Unsupported expression: {ast.dump(node)}")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prefactor-langchain"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "LangChain integration for Prefactor observability"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -9,7 +9,7 @@ authors = [
9
9
  ]
10
10
  requires-python = ">=3.11.0, <4.0.0"
11
11
  dependencies = [
12
- "prefactor-core>=0.2.1",
12
+ "prefactor-core>=0.2.2",
13
13
  "langchain-core>=1.0.0",
14
14
  ]
15
15
 
@@ -1,14 +1,19 @@
1
1
  """Prefactor LangChain - LangChain integration for Prefactor observability."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  from prefactor_langchain.metadata_extractor import (
4
6
  extract_error_info,
5
7
  extract_token_usage,
6
8
  )
7
9
  from prefactor_langchain.middleware import PrefactorMiddleware
8
10
  from prefactor_langchain.schemas import (
11
+ DEFAULT_LANGCHAIN_AGENT_SCHEMA,
9
12
  LANGCHAIN_AGENT_SCHEMA,
10
13
  LANGCHAIN_LLM_SCHEMA,
11
14
  LANGCHAIN_TOOL_SCHEMA,
15
+ LangChainToolSchemaConfig,
16
+ compile_langchain_agent_schema,
12
17
  register_langchain_schemas,
13
18
  )
14
19
  from prefactor_langchain.spans import (
@@ -20,7 +25,7 @@ from prefactor_langchain.spans import (
20
25
  ToolSpan,
21
26
  )
22
27
 
23
- __version__ = "0.2.2"
28
+ __version__ = "0.2.4"
24
29
 
25
30
  __all__ = [
26
31
  # Middleware
@@ -30,6 +35,9 @@ __all__ = [
30
35
  "extract_error_info",
31
36
  # Schemas
32
37
  "register_langchain_schemas",
38
+ "compile_langchain_agent_schema",
39
+ "LangChainToolSchemaConfig",
40
+ "DEFAULT_LANGCHAIN_AGENT_SCHEMA",
33
41
  "LANGCHAIN_AGENT_SCHEMA",
34
42
  "LANGCHAIN_LLM_SCHEMA",
35
43
  "LANGCHAIN_TOOL_SCHEMA",
@@ -1,9 +1,12 @@
1
1
  """LangChain middleware for automatic tracing via prefactor-core."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import asyncio
4
6
  import hashlib
5
7
  import json
6
8
  import logging
9
+ from collections.abc import Mapping
7
10
  from typing import TYPE_CHECKING, Any, AsyncContextManager, Callable
8
11
 
9
12
  from langchain.agents.middleware import AgentMiddleware
@@ -20,7 +23,7 @@ if TYPE_CHECKING:
20
23
  pass
21
24
 
22
25
  from .metadata_extractor import extract_token_usage
23
- from .schemas import register_langchain_schemas
26
+ from .schemas import LangChainToolSchemaConfig, register_langchain_schemas
24
27
  from .spans import AgentSpan, LLMSpan, ToolSpan
25
28
 
26
29
  logger = logging.getLogger("prefactor_langchain.middleware")
@@ -111,6 +114,8 @@ class PrefactorMiddleware(AgentMiddleware):
111
114
  agent_id: str = "langchain-agent",
112
115
  agent_name: str | None = None,
113
116
  instance: AgentInstanceHandle | None = None,
117
+ tool_schemas: Mapping[str, LangChainToolSchemaConfig | Mapping[str, Any]]
118
+ | None = None,
114
119
  ):
115
120
  """Initialize the middleware with a pre-configured client or instance.
116
121
 
@@ -148,6 +153,9 @@ class PrefactorMiddleware(AgentMiddleware):
148
153
  self._current_parent_span_id: str | None = None
149
154
  self._loop: asyncio.AbstractEventLoop | None = None
150
155
  self._pending_emit_futures: list[asyncio.Task[None]] = []
156
+ self._tool_span_types = (
157
+ self._register_tool_schemas(None, tool_schemas) if tool_schemas else {}
158
+ )
151
159
  return
152
160
 
153
161
  if client is None:
@@ -179,6 +187,10 @@ class PrefactorMiddleware(AgentMiddleware):
179
187
  self._current_parent_span_id: str | None = None
180
188
  self._loop: asyncio.AbstractEventLoop | None = None
181
189
  self._pending_emit_futures: list[asyncio.Task[None]] = []
190
+ self._tool_span_types = {}
191
+
192
+ if tool_schemas:
193
+ self._tool_span_types = self._register_tool_schemas(client, tool_schemas)
182
194
 
183
195
  @classmethod
184
196
  def from_config(
@@ -189,6 +201,8 @@ class PrefactorMiddleware(AgentMiddleware):
189
201
  agent_name: str | None = None,
190
202
  schema_registry: SchemaRegistry | None = None,
191
203
  include_langchain_schemas: bool = True,
204
+ tool_schemas: Mapping[str, LangChainToolSchemaConfig | Mapping[str, Any]]
205
+ | None = None,
192
206
  ) -> "PrefactorMiddleware":
193
207
  """Factory method to create middleware from configuration.
194
208
 
@@ -203,6 +217,8 @@ class PrefactorMiddleware(AgentMiddleware):
203
217
  schema_registry: Optional SchemaRegistry for registering span schemas.
204
218
  include_langchain_schemas: If True and schema_registry is provided,
205
219
  automatically register LangChain-specific schemas.
220
+ tool_schemas: Optional per-tool schema configuration for tool-specific
221
+ span types.
206
222
 
207
223
  Returns:
208
224
  A configured PrefactorMiddleware instance with lazy initialization.
@@ -223,8 +239,12 @@ class PrefactorMiddleware(AgentMiddleware):
223
239
 
224
240
  # Create or augment schema registry
225
241
  registry = schema_registry or SchemaRegistry()
226
- if include_langchain_schemas and not registry.has_schema("langchain:llm"):
227
- register_langchain_schemas(registry)
242
+ tool_span_types: dict[str, str] = {}
243
+ if include_langchain_schemas or tool_schemas:
244
+ tool_span_types = register_langchain_schemas(
245
+ registry,
246
+ tool_schemas=tool_schemas,
247
+ )
228
248
 
229
249
  config = PrefactorCoreConfig(
230
250
  http_config=http_config,
@@ -245,9 +265,32 @@ class PrefactorMiddleware(AgentMiddleware):
245
265
  middleware._current_parent_span_id = None
246
266
  middleware._loop = None
247
267
  middleware._pending_emit_futures = []
268
+ middleware._tool_span_types = tool_span_types
248
269
  logger.debug("PrefactorMiddleware created via from_config()")
249
270
  return middleware
250
271
 
272
+ def _register_tool_schemas(
273
+ self,
274
+ client: PrefactorCoreClient | None,
275
+ tool_schemas: Mapping[str, LangChainToolSchemaConfig | Mapping[str, Any]],
276
+ ) -> dict[str, str]:
277
+ """Register tool schemas into a client registry when available."""
278
+ if client is None:
279
+ return register_langchain_schemas(
280
+ SchemaRegistry(),
281
+ tool_schemas=tool_schemas,
282
+ )
283
+
284
+ registry = client._config.schema_registry
285
+ if registry is None:
286
+ registry = SchemaRegistry()
287
+ client._config.schema_registry = registry
288
+
289
+ return register_langchain_schemas(
290
+ registry,
291
+ tool_schemas=tool_schemas,
292
+ )
293
+
251
294
  async def _ensure_initialized(self) -> AgentInstanceHandle:
252
295
  """Ensure the agent instance is initialized (lazily created if needed).
253
296
 
@@ -419,6 +462,18 @@ class PrefactorMiddleware(AgentMiddleware):
419
462
  "tool_name": tool_call.get("name", "unknown"),
420
463
  "arguments": tool_call.get("args", {}),
421
464
  }
465
+ if hasattr(request, "tool") and hasattr(request.tool, "name"):
466
+ return {
467
+ "tool_name": getattr(request.tool, "name", "unknown"),
468
+ "arguments": getattr(request, "args", {}),
469
+ }
470
+ if hasattr(request, "name"):
471
+ return {
472
+ "tool_name": request.name,
473
+ "arguments": getattr(
474
+ request, "input", getattr(request, "args", {})
475
+ ),
476
+ }
422
477
  return {}
423
478
  except Exception as e:
424
479
  logger.error("Error extracting tool inputs: %s", e, exc_info=True)
@@ -460,12 +515,14 @@ class PrefactorMiddleware(AgentMiddleware):
460
515
  result["error"] = span_data.error.to_dict()
461
516
  return result
462
517
 
463
- def _build_tool_params(self, span_data: Any) -> dict[str, Any]:
518
+ def _build_tool_params(self, span_data: Any, schema_name: str) -> dict[str, Any]:
464
519
  """Build the payload (params) dict for a tool span."""
465
520
  params: dict[str, Any] = {}
466
521
  if span_data.tool_name:
467
522
  params["tool_name"] = span_data.tool_name
468
- if span_data.inputs:
523
+ if schema_name != "langchain:tool":
524
+ params["inputs"] = span_data.arguments
525
+ elif span_data.inputs:
469
526
  params["inputs"] = span_data.inputs
470
527
  return params
471
528
 
@@ -526,7 +583,15 @@ class PrefactorMiddleware(AgentMiddleware):
526
583
  if loop is None or loop.is_closed():
527
584
  return ""
528
585
 
529
- import asyncio
586
+ try:
587
+ running_loop = asyncio.get_running_loop()
588
+ except RuntimeError:
589
+ running_loop = None
590
+
591
+ if running_loop is not None and running_loop is loop:
592
+ raise RuntimeError(
593
+ "_create_agent_span_sync must be called from a non-event-loop thread"
594
+ )
530
595
 
531
596
  # Use whatever parent was set by set_parent_span() from the async side.
532
597
  # We cannot read the SpanContextStack here because run_in_executor does
@@ -546,7 +611,7 @@ class PrefactorMiddleware(AgentMiddleware):
546
611
  self._current_parent_span_id = span_id
547
612
  return span_id
548
613
 
549
- def _emit_child_span(self, span_data: Any) -> None:
614
+ def _emit_child_span(self, span_data: Any, schema_name: str | None = None) -> None:
550
615
  """Schedule a child span (LLM/tool) emission with parent tracking.
551
616
 
552
617
  Uses the current parent span ID from _current_parent_span_id.
@@ -558,15 +623,15 @@ class PrefactorMiddleware(AgentMiddleware):
558
623
  return
559
624
 
560
625
  instance = self._instance
561
- schema_name: str = span_data.type
626
+ schema_name = schema_name or span_data.type
562
627
  parent_span_id = self._current_parent_span_id
563
628
  pending = self._pending_emit_futures
564
629
 
565
630
  if schema_name == "langchain:llm":
566
631
  params = self._build_llm_params(span_data)
567
632
  result = self._build_llm_result(span_data)
568
- elif schema_name == "langchain:tool":
569
- params = self._build_tool_params(span_data)
633
+ elif schema_name.startswith("langchain:tool"):
634
+ params = self._build_tool_params(span_data, schema_name)
570
635
  result = self._build_tool_result(span_data)
571
636
  else:
572
637
  return
@@ -664,6 +729,17 @@ class PrefactorMiddleware(AgentMiddleware):
664
729
  async def _finish() -> None:
665
730
  await instance.finish_span(span_id, result_payload=result)
666
731
 
732
+ try:
733
+ running_loop = asyncio.get_running_loop()
734
+ except RuntimeError:
735
+ running_loop = None
736
+
737
+ if running_loop is not None and running_loop is loop:
738
+ raise RuntimeError(
739
+ "after_agent must be called from a non-event-loop thread; "
740
+ "use aafter_agent for async execution"
741
+ )
742
+
667
743
  future = asyncio.run_coroutine_threadsafe(_finish(), loop)
668
744
  future.result(timeout=5.0)
669
745
 
@@ -718,8 +794,16 @@ class PrefactorMiddleware(AgentMiddleware):
718
794
  # SpanContextStack.peek() here returns the outer workflow span
719
795
  # because we are executing in the async event loop context.
720
796
  self._agent_span_cm = instance.span("langchain:agent")
721
- self._agent_span_context = await self._agent_span_cm.__aenter__()
722
- await self._agent_span_context.start(params)
797
+ try:
798
+ self._agent_span_context = await self._agent_span_cm.__aenter__()
799
+ await self._agent_span_context.start(params)
800
+ except BaseException:
801
+ import sys
802
+
803
+ await self._agent_span_cm.__aexit__(*sys.exc_info())
804
+ self._agent_span_cm = None
805
+ self._agent_span_context = None
806
+ raise
723
807
 
724
808
  self._agent_span_id = self._agent_span_context.id
725
809
  logger.debug("Created agent span %s (async)", self._agent_span_id)
@@ -872,7 +956,9 @@ class PrefactorMiddleware(AgentMiddleware):
872
956
  type="langchain:tool",
873
957
  inputs=inputs,
874
958
  tool_name=tool_name,
959
+ arguments=inputs.get("arguments", {}),
875
960
  )
961
+ schema_name = self._resolve_tool_schema_name(tool_name)
876
962
 
877
963
  try:
878
964
  response = handler(request)
@@ -880,13 +966,13 @@ class PrefactorMiddleware(AgentMiddleware):
880
966
  outputs = self._extract_tool_output(response)
881
967
  span_data.complete(outputs=outputs)
882
968
 
883
- self._emit_child_span(span_data)
969
+ self._emit_child_span(span_data, schema_name=schema_name)
884
970
  logger.debug("Tool call completed (%s)", tool_name)
885
971
  return response
886
972
 
887
973
  except Exception as e:
888
974
  span_data.fail(e)
889
- self._emit_child_span(span_data)
975
+ self._emit_child_span(span_data, schema_name=schema_name)
890
976
  logger.error("Tool call failed: %s", e, exc_info=True)
891
977
  raise
892
978
 
@@ -914,10 +1000,12 @@ class PrefactorMiddleware(AgentMiddleware):
914
1000
  type="langchain:tool",
915
1001
  inputs=inputs,
916
1002
  tool_name=tool_name,
1003
+ arguments=inputs.get("arguments", {}),
917
1004
  )
1005
+ schema_name = self._resolve_tool_schema_name(tool_name)
918
1006
 
919
- async with instance.span("langchain:tool") as ctx:
920
- await ctx.start(self._build_tool_params(span_data))
1007
+ async with instance.span(schema_name) as ctx:
1008
+ await ctx.start(self._build_tool_params(span_data, schema_name))
921
1009
  try:
922
1010
  response = await handler(request)
923
1011
 
@@ -932,3 +1020,7 @@ class PrefactorMiddleware(AgentMiddleware):
932
1020
  await ctx.fail(self._build_tool_result(span_data))
933
1021
  logger.error("Tool call failed: %s", e, exc_info=True)
934
1022
  raise
1023
+
1024
+ def _resolve_tool_schema_name(self, tool_name: str) -> str:
1025
+ """Resolve a tool's configured span type, if one exists."""
1026
+ return self._tool_span_types.get(tool_name, "langchain:tool")