pyagentic-core 2.11.1.dev1__py3-none-any.whl → 2.12.1.dev2__py3-none-any.whl

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.
@@ -1,3 +1,4 @@
1
+ import copy
1
2
  import inspect
2
3
  import json
3
4
  import asyncio
@@ -29,7 +30,7 @@ if TYPE_CHECKING:
29
30
  from pyagentic._base._agent._agent_linking import _LinkedAgentDefinition
30
31
  from pyagentic._base._mcp import _MCPDefinition
31
32
 
32
- from pyagentic.models.response import ToolResponse, AgentResponse
33
+ from pyagentic.models.response import ToolResponse, AgentResponse, ErrorResponse
33
34
  from pyagentic.models.llm import Message, ToolCall, LLMResponse
34
35
  from pyagentic.models.tracing import SpanKind
35
36
 
@@ -410,6 +411,42 @@ class BaseAgent(metaclass=AgentMeta):
410
411
 
411
412
  return {"self": self.state.model_dump(), **linked_agent_references}
412
413
 
414
+ def fork(self) -> "BaseAgent":
415
+ """Create a fresh, isolated copy of this agent for a single invocation.
416
+
417
+ Used to call a non-shared linked agent without races or context
418
+ pollution. The fork shares this agent's provider, dependency-injected
419
+ (``Depends``) fields, linked sub-agent templates, and any connected MCP
420
+ clients, but gets its own conversation state reset to construction time
421
+ (an empty message history), so concurrent or repeated calls stay isolated.
422
+
423
+ Returns:
424
+ BaseAgent: A new instance of the same class sharing immutable
425
+ resources with this agent but holding fresh, independent state.
426
+ """
427
+ state_kwargs = {
428
+ name: copy.deepcopy(value)
429
+ for name, value in self.__initial_state_values__.items()
430
+ }
431
+ clone = type(self)(provider=self.provider, **state_kwargs, **self.__construct_args__)
432
+ # Share already-connected MCP clients so forks don't reconnect per call.
433
+ if getattr(self, "_mcp_connected", False):
434
+ clone._mcp_connected = True
435
+ clone._mcp_clients = self._mcp_clients
436
+ clone._mcp_tool_routing = self._mcp_tool_routing
437
+ clone.__tool_defs__ = self.__tool_defs__
438
+ clone.__tool_response_models__ = self.__tool_response_models__
439
+ return clone
440
+
441
+ @staticmethod
442
+ def _get_call_lock(agent: "BaseAgent") -> asyncio.Lock:
443
+ """Return (creating on first use) the lock serializing a shared agent's calls."""
444
+ lock = agent.__dict__.get("_call_lock")
445
+ if lock is None:
446
+ lock = asyncio.Lock()
447
+ agent.__dict__["_call_lock"] = lock
448
+ return lock
449
+
413
450
  @traced(SpanKind.INFERENCE)
414
451
  async def _process_llm_inference(
415
452
  self,
@@ -478,22 +515,31 @@ class BaseAgent(metaclass=AgentMeta):
478
515
  # Add tool call message to conversation history
479
516
  self.state._messages.append(self.provider.to_tool_call_message(tool_call))
480
517
 
481
- # Get the linked agent instance and share tracer
482
- agent = getattr(self, tool_call.name)
518
+ # By default each call runs on a fresh, isolated fork of the linked agent
519
+ # so concurrent/repeated calls neither race nor pollute one another. A
520
+ # `shared=True` link keeps one persistent instance (calls are serialized).
521
+ linked_def = self.__linked_agents__[tool_call.name]
522
+ template = getattr(self, tool_call.name)
523
+ shared = bool(linked_def.info and getattr(linked_def.info, "shared", False))
524
+ agent = template if shared else template.fork()
483
525
  agent.tracer = self.tracer
484
526
 
485
527
  try:
486
528
  # Parse arguments and call the linked agent
487
529
  kwargs = json.loads(tool_call.arguments)
488
530
  self.tracer.set_attributes(**kwargs)
489
- response = await agent(**kwargs)
531
+ if shared:
532
+ async with self._get_call_lock(template):
533
+ response = await agent(**kwargs)
534
+ else:
535
+ response = await agent(**kwargs)
490
536
  result = f"Agent {tool_call.name}: {response.final_output}"
491
537
  self.tracer.set_attributes(result=response.model_dump())
492
538
  except Exception as e:
493
539
  # Handle agent execution errors
494
540
  self.tracer.record_exception(str(e))
495
541
  result = f"Agent `{tool_call.name}` failed: {e}. Please kindly state to the user that is failed, provide state, and ask if they want to try again." # noqa E501
496
- response = AgentResponse(final_output=result, provider_info=agent.provider._info)
542
+ response = ErrorResponse(name=tool_call.name, kind="agent", error=result)
497
543
 
498
544
  # Add agent result to conversation history
499
545
  stringified_result = (
@@ -540,44 +586,55 @@ class BaseAgent(metaclass=AgentMeta):
540
586
 
541
587
  # Parse and validate tool arguments
542
588
  kwargs = json.loads(tool_call.arguments)
589
+ error: str | None = None
590
+ compiled_args = None
543
591
  try:
544
592
  # Compile args converts raw JSON to typed parameters (e.g., dict -> Pydantic models)
545
593
  compiled_args = tool_def.compile_args(**kwargs)
546
594
  except ValidationError as e:
547
595
  # Handle validation errors for tool arguments
548
- result = f"Function Args were invalid: {str(e)}"
549
- compiled_args = None
596
+ error = f"Function Args were invalid: {str(e)}"
550
597
  self.tracer.record_exception(str(e))
551
598
  logger.exception(e)
552
- try:
553
- if compiled_args is not None:
599
+ if compiled_args is not None:
600
+ try:
554
601
  result = await _safe_run(handler, **compiled_args)
555
602
  self.tracer.set_attributes(result=result)
556
- except TypeError as e:
557
- self.tracer.record_exception(str(e))
558
- logger.exception(e)
559
- raise InvalidToolDefinition(
560
- tool_name=tool_call.name,
561
- message=f"Tool must have a serializable return type; {tool_def.return_type} failed to be casted to a string.",
603
+ except TypeError as e:
604
+ self.tracer.record_exception(str(e))
605
+ logger.exception(e)
606
+ raise InvalidToolDefinition(
607
+ tool_name=tool_call.name,
608
+ message=f"Tool must have a serializable return type; {tool_def.return_type} failed to be casted to a string.",
609
+ )
610
+ except Exception as e:
611
+ # Handle any other tool execution errors
612
+ self.tracer.record_exception(str(e))
613
+ logger.exception(e)
614
+ error = f"Tool `{tool_call.name}` failed: {e}. Please kindly state to the user that is failed, provide state, and ask if they want to try again." # noqa E501
615
+
616
+ # Add the tool result (or the error) to conversation history for the LLM
617
+ if error is not None:
618
+ message = error
619
+ else:
620
+ message = (
621
+ result.model_dump_json(indent=2)
622
+ if issubclass(result.__class__, BaseModel)
623
+ else str(result)
562
624
  )
563
- except Exception as e:
564
- # Handle any other tool execution errors
565
- self.tracer.record_exception(str(e))
566
- logger.exception(e)
567
- result = f"Tool `{tool_call.name}` failed: {e}. Please kindly state to the user that is failed, provide state, and ask if they want to try again." # noqa E501
568
-
569
- stringified_result = (
570
- result.model_dump_json(indent=2)
571
- if issubclass(result.__class__, BaseModel)
572
- else str(result)
573
- )
574
- # Add tool result to conversation history for LLM
575
625
  self.state._messages.append(
576
- self.provider.to_tool_call_result_message(result=stringified_result, id_=tool_call.id)
626
+ self.provider.to_tool_call_result_message(result=message, id_=tool_call.id)
577
627
  )
578
628
 
579
629
  if self.phases:
580
630
  self.state._update_state_machine(phases=self.phases)
631
+
632
+ # A failed call (bad args or a raising handler) returns a typed ErrorResponse
633
+ # rather than a half-built ToolResponse (avoids unpacking None compiled_args).
634
+ if error is not None:
635
+ return ErrorResponse(
636
+ name=tool_call.name, kind="tool", error=error, call_depth=call_depth
637
+ )
581
638
  # Build and return the structured tool response
582
639
  ToolResponseModel = self.__tool_response_models__[tool_call.name]
583
640
  return ToolResponseModel(
@@ -601,6 +658,7 @@ class BaseAgent(metaclass=AgentMeta):
601
658
  client, original_name = self._mcp_tool_routing[tool_call.name]
602
659
  kwargs = json.loads(tool_call.arguments)
603
660
 
661
+ error: str | None = None
604
662
  try:
605
663
  mcp_result = await client.call_tool(original_name, kwargs)
606
664
  # CallToolResult has .content (list of content blocks)
@@ -615,16 +673,22 @@ class BaseAgent(metaclass=AgentMeta):
615
673
  except Exception as e:
616
674
  self.tracer.record_exception(str(e))
617
675
  logger.exception(e)
618
- result = f"MCP tool `{tool_call.name}` failed: {e}. Please kindly state to the user that it failed, provide state, and ask if they want to try again." # noqa E501
676
+ error = f"MCP tool `{tool_call.name}` failed: {e}. Please kindly state to the user that it failed, provide state, and ask if they want to try again." # noqa E501
619
677
 
620
- # Add result to conversation history
678
+ # Add result (or error) to conversation history
621
679
  self.state._messages.append(
622
- self.provider.to_tool_call_result_message(result=result, id_=tool_call.id)
680
+ self.provider.to_tool_call_result_message(
681
+ result=error if error is not None else result, id_=tool_call.id
682
+ )
623
683
  )
624
684
 
625
685
  if self.phases:
626
686
  self.state._update_state_machine(phases=self.phases)
627
687
 
688
+ if error is not None:
689
+ return ErrorResponse(
690
+ name=tool_call.name, kind="tool", error=error, call_depth=call_depth
691
+ )
628
692
  # Return a base ToolResponse (not a class-time typed subclass,
629
693
  # since MCP tools are discovered at runtime)
630
694
  return ToolResponse(
pyagentic/_base/_info.py CHANGED
@@ -48,6 +48,7 @@ class AgentInfo(_SpecInfo):
48
48
 
49
49
  condition: MaybeRef[Callable] | None = None
50
50
  phases: list[str] | None = None
51
+ shared: bool = False
51
52
 
52
53
 
53
54
  @dataclass
@@ -1,3 +1,4 @@
1
+ import copy
1
2
  import inspect
2
3
  import threading
3
4
  import warnings
@@ -626,6 +627,19 @@ class AgentMeta(type):
626
627
  continue # State fields already set on state object
627
628
  setattr(self, name, val)
628
629
 
630
+ # Capture the construction recipe so the agent can fork() fresh,
631
+ # isolated copies for non-shared linked-agent calls: a deep snapshot
632
+ # of the construct-time state, plus the shared linked templates,
633
+ # dependencies, and provider config to re-pass.
634
+ self.__initial_state_values__ = {
635
+ name: copy.deepcopy(compiled[name]) for name in self.__state_defs__
636
+ }
637
+ construct_args = {name: compiled[name] for name in self.__linked_agents__}
638
+ for name in (*self.__dependencies__, "model", "api_key", "max_call_depth"):
639
+ if name in bound.arguments:
640
+ construct_args[name] = bound.arguments[name]
641
+ self.__construct_args__ = construct_args
642
+
629
643
  # Call post-initialization hook
630
644
  self.__post_init__()
631
645
 
pyagentic/_base/_spec.py CHANGED
@@ -121,6 +121,7 @@ class spec:
121
121
  default_factory: Callable = None,
122
122
  condition: Callable = None,
123
123
  phases: list[str] | None = None,
124
+ shared: bool = False,
124
125
  ) -> AgentInfo:
125
126
  """
126
127
  Creates an AgentInfo descriptor for configuring linked agent fields.
@@ -131,12 +132,21 @@ class spec:
131
132
  condition (Callable, optional): A callable determining when this agent link is active
132
133
  phases (list[str], optional): A list of phases of when this agent will be available.
133
134
  When None, will show for all phases. Defaults to None.
135
+ shared (bool): If False (default), each call to the linked agent runs on a fresh,
136
+ isolated fork (its own conversation state, reset to construction time), so
137
+ concurrent or repeated calls neither race nor pollute each other. If True, all
138
+ calls share one persistent instance whose state accumulates across calls — use
139
+ this when the parent needs to read the linked agent's state via ``ref``.
134
140
 
135
141
  Returns:
136
142
  AgentInfo: A configured AgentInfo descriptor
137
143
  """
138
144
  return AgentInfo(
139
- default=default, default_factory=default_factory, condition=condition, phases=phases
145
+ default=default,
146
+ default_factory=default_factory,
147
+ condition=condition,
148
+ phases=phases,
149
+ shared=shared,
140
150
  )
141
151
 
142
152
  @staticmethod
@@ -1,5 +1,5 @@
1
1
  from pydantic import BaseModel, Field, create_model
2
- from typing import Type, Self, Union, Any
2
+ from typing import Type, Self, Union, Any, Literal, Optional
3
3
 
4
4
  from pyagentic._base._tool import _ToolDefinition
5
5
  from pyagentic._base._agent._agent_state import _AgentState
@@ -65,6 +65,27 @@ class ToolResponse(BaseModel):
65
65
  )
66
66
 
67
67
 
68
+ class ErrorResponse(BaseModel):
69
+ """A failed tool or linked-agent call.
70
+
71
+ Returned in place of a ToolResponse / AgentResponse when a tool or linked
72
+ agent raises, so a failure does not have to masquerade as a successful result
73
+ (whose schema — a structured ``final_output``, a populated ``state`` — a
74
+ failure cannot satisfy). It is included in both the ``tool_responses`` and
75
+ ``agent_responses`` unions of every generated agent response model, so either
76
+ kind of failure validates.
77
+ """
78
+
79
+ name: str = Field(description="Name of the tool or linked agent that failed.")
80
+ kind: Literal["tool", "agent"] = Field(
81
+ description="Whether the failure was a tool or a linked-agent call."
82
+ )
83
+ error: str = Field(description="The failure message, as surfaced to the model.")
84
+ call_depth: Optional[int] = Field(
85
+ default=None, description="Tool-calling loop depth at failure (tools only)."
86
+ )
87
+
88
+
68
89
  class AgentResponse(BaseModel):
69
90
  """
70
91
  Agent response class that captures the final output from an pyagentic agent and the raw
@@ -102,11 +123,14 @@ class AgentResponse(BaseModel):
102
123
  """
103
124
  fields = {}
104
125
  if tool_response_models:
105
- # Include base ToolResponse so runtime-discovered tools (e.g. MCP) pass validation
106
- ToolResult = Union[tuple([*tool_response_models, ToolResponse])]
126
+ # Include base ToolResponse so runtime-discovered tools (e.g. MCP) pass
127
+ # validation, and ErrorResponse so a failed tool call validates.
128
+ ToolResult = Union[tuple([*tool_response_models, ToolResponse, ErrorResponse])]
107
129
  fields["tool_responses"] = (list[ToolResult], ...)
108
130
  if linked_agents_response_models:
109
- AgentResult = Union[tuple(linked_agents_response_models)]
131
+ # Include ErrorResponse so a failed linked-agent call — which yields an
132
+ # ErrorResponse, not the linked agent's own response type — validates.
133
+ AgentResult = Union[tuple([*linked_agents_response_models, ErrorResponse])]
110
134
  fields["agent_responses"] = (list[AgentResult], ...)
111
135
  if ResponseFormat:
112
136
  fields["final_output"] = (ResponseFormat, ...)
@@ -183,7 +183,15 @@ class AgentTracer(ABC):
183
183
  self._record_exception(span, e)
184
184
  raise
185
185
  finally:
186
- _current_span.reset(token)
186
+ try:
187
+ _current_span.reset(token)
188
+ except ValueError:
189
+ # The token can belong to a different context than the one we are
190
+ # unwinding in — e.g. when a span is opened inside a concurrently
191
+ # scheduled task (linked-agent calls run via asyncio.create_task) or
192
+ # is torn down across an async-generator boundary. Restore the parent
193
+ # span directly instead of resetting a foreign token.
194
+ _current_span.set(parent)
187
195
  self.end_span(span)
188
196
 
189
197
  @asynccontextmanager
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyagentic-core
3
- Version: 2.11.1.dev1
3
+ Version: 2.12.1.dev2
4
4
  Summary: Build LLM Agents in a Pythonic way
5
5
  Author-email: Ryan Mikulec <rmikulec.dev@gmail.com>
6
6
  License: MIT
@@ -5,16 +5,16 @@ pyagentic/updates.py,sha256=JrBxbmTRxS-4tfSrai_wPK-dxSyCiNrYwC5MgrzGj54,870
5
5
  pyagentic/_base/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  pyagentic/_base/_depends.py,sha256=O59cvg4uc4feDrPjn9HqryrMGzqNyulyv8intAmQ8PE,2188
7
7
  pyagentic/_base/_exceptions.py,sha256=r_wcuWgc_Y3g_D7DwLQL8NbTphIAeeNpIp-WDHMvvOk,3746
8
- pyagentic/_base/_info.py,sha256=2bY9_Ua9N7m94hcv6gLJCBZ2guxXTJf3YVOuJAyG4ns,2995
8
+ pyagentic/_base/_info.py,sha256=LeXn3VbEJkP4KmMpjY6bqXjHYnE-ZHLuKoRcrmrqqhs,3020
9
9
  pyagentic/_base/_mcp.py,sha256=YPRaqVFrgKDA5BZtvVrcScyADjo66cdB4_h6nXpRJBI,8329
10
- pyagentic/_base/_metaclasses.py,sha256=0PQ3ipidOPKo6xE5Ra2ZBhme74s3Ss9BLj9O0RehueA,34452
10
+ pyagentic/_base/_metaclasses.py,sha256=MWwW6uGJQYAm8wmbUyE86iGFbL76iTjBjkYqZCZAYxw,35241
11
11
  pyagentic/_base/_ref.py,sha256=Ez1Iexxl7NLBK91frcgz_eFbrXfLZpm9fvkYHMFWA_w,3179
12
- pyagentic/_base/_spec.py,sha256=TH-DZTYFcxXmzxGympH4vATPPlpI2o88Wlv6S7UuNlQ,7112
12
+ pyagentic/_base/_spec.py,sha256=jV0iJZycJEG8fUi9qcH0NxpxoRHiB8M_YrRrmoMwSGQ,7672
13
13
  pyagentic/_base/_state.py,sha256=swjMl22rOgaIVEuqyL1bG7gpYpumuOZ6ytNImV_2lGw,1794
14
14
  pyagentic/_base/_tool.py,sha256=6Zkd20iwQX1uS-rw4i9IA8doM8qjchEZ3EmMk5Li9SE,11040
15
15
  pyagentic/_base/_validation.py,sha256=BBscZMrXB09LNNP17pYOAY6I4iCW8r2GBVStqBd-9f8,3833
16
16
  pyagentic/_base/_agent/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- pyagentic/_base/_agent/_agent.py,sha256=P35dpRp4Ihpc7AzGLW8bo_aj1pR2orvxu3a5777NIL8,39549
17
+ pyagentic/_base/_agent/_agent.py,sha256=VyvTfjobGncz8XZU4NWSGqK9BBdhDElXi0eY9e_HHAU,42596
18
18
  pyagentic/_base/_agent/_agent_linking.py,sha256=gSHQkbH-BeKG8moROKDZiLs0ktLzW9bOVo0DFq3UNs8,1841
19
19
  pyagentic/_base/_agent/_agent_state.py,sha256=gvx0rQ1gaWfZHHAONX4YNsO_HE0xYQ14_GBKMP4Af-o,11507
20
20
  pyagentic/_utils/_typing.py,sha256=5aRZZOVzR0ZgnBfejkw__1yoARhn70P5QzA81sxUkls,3681
@@ -45,7 +45,7 @@ pyagentic/llm/_openai.py,sha256=NSM7Xd0N0P779ooU2bmUtfmVeHdgsSzndtiH02aEhK8,6105
45
45
  pyagentic/llm/_openaiv1.py,sha256=_E_Z-Fo9rdpmohT85kNpjNyiOowfYjDmfZh4mFbtLz4,8168
46
46
  pyagentic/llm/_provider.py,sha256=A3BDEJlLwMTdXnU1_GdaOjcFCYx_YKVHAdRhk2lBV9E,3265
47
47
  pyagentic/models/llm.py,sha256=QSTXIqkcnAC52I7m0ifebJeKdeAG90c6uqofIrOH2qU,2386
48
- pyagentic/models/response.py,sha256=pH_1WF9PPMYmxPhN7SoQszKJG-qq7gapTIyC3hBFH20,4660
48
+ pyagentic/models/response.py,sha256=Zs4_yLKyZue3ysKrGQcjECmPAXJi_NJ_bEP8tINBVJo,5894
49
49
  pyagentic/models/tracing.py,sha256=5_cGxOAb5I9qjLVwSdvIcxUzrZy56OUe5fxlxOkud_w,1218
50
50
  pyagentic/policies/__init__.py,sha256=M5XQ4xubSjTFdUBVaRb65T_O7b9lxluJVyo4baoZgjY,68
51
51
  pyagentic/policies/_events.py,sha256=PZRGhMr8LMR1CFA7OAOZgZOqgQJ97T2r92NiaWuUvHE,1604
@@ -53,9 +53,9 @@ pyagentic/policies/_policy.py,sha256=9oLMyEJlCIudbItuXILDZHUAi29vGF18BgH-RZue3Wc
53
53
  pyagentic/tracing/__init__.py,sha256=ddMOl_-Nf3WOhu-PYxSJ3kqHZ3eQXAOM5aySn9YmDtA,150
54
54
  pyagentic/tracing/_basic.py,sha256=jUBKdkOPveCjBzEhHKS_Rvp5l38iZg-5nFIl6xEwOVk,8906
55
55
  pyagentic/tracing/_langfuse.py,sha256=5b5tdT-gyRJP5HDcvCS4tn_k3AFDI6eNsXOd6On5gKQ,13986
56
- pyagentic/tracing/_tracer.py,sha256=HMisWfRLAQ9sdcjOvImiLij6OASd3siM8K-7nUfM3xs,7656
57
- pyagentic_core-2.11.1.dev1.dist-info/licenses/LICENSE,sha256=wcOzTj82hOc96HztJk2VzLB2hFHpdIGpjz8vvbpP1_s,1069
58
- pyagentic_core-2.11.1.dev1.dist-info/METADATA,sha256=GOf2IQSJPjEu105ZiXEf0WfET_7YC2la3d1LH1_Y77I,8212
59
- pyagentic_core-2.11.1.dev1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
60
- pyagentic_core-2.11.1.dev1.dist-info/top_level.txt,sha256=lAWfd-ay434uEpSg2FM0ySN0o4vgNGE6D9dJkIhGyfY,10
61
- pyagentic_core-2.11.1.dev1.dist-info/RECORD,,
56
+ pyagentic/tracing/_tracer.py,sha256=0uQeqJxPRjfQtvgglF2BPDQM_1fpLBxWB0PpU6KY1eA,8157
57
+ pyagentic_core-2.12.1.dev2.dist-info/licenses/LICENSE,sha256=wcOzTj82hOc96HztJk2VzLB2hFHpdIGpjz8vvbpP1_s,1069
58
+ pyagentic_core-2.12.1.dev2.dist-info/METADATA,sha256=sNURBjbhlVa4Tg7_CDu-BLnvzJMaC0RPuPMyrO03F1o,8212
59
+ pyagentic_core-2.12.1.dev2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
60
+ pyagentic_core-2.12.1.dev2.dist-info/top_level.txt,sha256=lAWfd-ay434uEpSg2FM0ySN0o4vgNGE6D9dJkIhGyfY,10
61
+ pyagentic_core-2.12.1.dev2.dist-info/RECORD,,