agentex-sdk 0.6.5__py3-none-any.whl → 0.7.0__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.
Files changed (50) hide show
  1. agentex/_models.py +37 -15
  2. agentex/_streaming.py +12 -10
  3. agentex/_types.py +3 -2
  4. agentex/_utils/_sync.py +3 -31
  5. agentex/_version.py +1 -1
  6. agentex/lib/cli/templates/default/manifest.yaml.j2 +5 -6
  7. agentex/lib/cli/templates/default/project/acp.py.j2 +34 -7
  8. agentex/lib/cli/templates/sync/manifest.yaml.j2 +6 -7
  9. agentex/lib/cli/templates/temporal/manifest.yaml.j2 +4 -8
  10. agentex/lib/core/temporal/plugins/claude_agents/__init__.py +72 -0
  11. agentex/lib/core/temporal/plugins/claude_agents/activities.py +154 -0
  12. agentex/lib/core/temporal/plugins/claude_agents/hooks/__init__.py +11 -0
  13. agentex/lib/core/temporal/plugins/claude_agents/hooks/hooks.py +212 -0
  14. agentex/lib/core/temporal/plugins/claude_agents/message_handler.py +178 -0
  15. agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py +4 -2
  16. agentex/lib/core/temporal/plugins/openai_agents/models/temporal_streaming_model.py +29 -1
  17. agentex/lib/environment_variables.py +6 -0
  18. agentex/lib/utils/completions.py +14 -0
  19. agentex/resources/agents.py +16 -0
  20. agentex/resources/messages/messages.py +8 -0
  21. agentex/resources/spans.py +8 -0
  22. agentex/resources/states.py +16 -0
  23. agentex/resources/tasks.py +8 -0
  24. agentex/resources/tracker.py +16 -0
  25. agentex/types/agent_list_params.py +6 -0
  26. agentex/types/agent_rpc_result.py +8 -0
  27. agentex/types/data_delta.py +2 -0
  28. agentex/types/message_list_params.py +5 -0
  29. agentex/types/reasoning_content_delta.py +2 -0
  30. agentex/types/reasoning_summary_delta.py +2 -0
  31. agentex/types/span_list_params.py +4 -0
  32. agentex/types/state.py +10 -0
  33. agentex/types/state_list_params.py +6 -0
  34. agentex/types/task_list_params.py +4 -0
  35. agentex/types/task_list_response.py +2 -0
  36. agentex/types/task_message.py +6 -0
  37. agentex/types/task_message_update.py +8 -0
  38. agentex/types/task_retrieve_by_name_response.py +2 -0
  39. agentex/types/task_retrieve_response.py +2 -0
  40. agentex/types/text_content.py +2 -0
  41. agentex/types/text_content_param.py +2 -0
  42. agentex/types/text_delta.py +2 -0
  43. agentex/types/tool_request_delta.py +2 -0
  44. agentex/types/tool_response_delta.py +2 -0
  45. agentex/types/tracker_list_params.py +6 -0
  46. {agentex_sdk-0.6.5.dist-info → agentex_sdk-0.7.0.dist-info}/METADATA +8 -4
  47. {agentex_sdk-0.6.5.dist-info → agentex_sdk-0.7.0.dist-info}/RECORD +50 -45
  48. {agentex_sdk-0.6.5.dist-info → agentex_sdk-0.7.0.dist-info}/WHEEL +0 -0
  49. {agentex_sdk-0.6.5.dist-info → agentex_sdk-0.7.0.dist-info}/entry_points.txt +0 -0
  50. {agentex_sdk-0.6.5.dist-info → agentex_sdk-0.7.0.dist-info}/licenses/LICENSE +0 -0
agentex/_models.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import inspect
5
+ import weakref
5
6
  from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast
6
7
  from datetime import date, datetime
7
8
  from typing_extensions import (
@@ -256,15 +257,16 @@ class BaseModel(pydantic.BaseModel):
256
257
  mode: Literal["json", "python"] | str = "python",
257
258
  include: IncEx | None = None,
258
259
  exclude: IncEx | None = None,
260
+ context: Any | None = None,
259
261
  by_alias: bool | None = None,
260
262
  exclude_unset: bool = False,
261
263
  exclude_defaults: bool = False,
262
264
  exclude_none: bool = False,
265
+ exclude_computed_fields: bool = False,
263
266
  round_trip: bool = False,
264
267
  warnings: bool | Literal["none", "warn", "error"] = True,
265
- context: dict[str, Any] | None = None,
266
- serialize_as_any: bool = False,
267
268
  fallback: Callable[[Any], Any] | None = None,
269
+ serialize_as_any: bool = False,
268
270
  ) -> dict[str, Any]:
269
271
  """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump
270
272
 
@@ -272,16 +274,24 @@ class BaseModel(pydantic.BaseModel):
272
274
 
273
275
  Args:
274
276
  mode: The mode in which `to_python` should run.
275
- If mode is 'json', the dictionary will only contain JSON serializable types.
276
- If mode is 'python', the dictionary may contain any Python objects.
277
- include: A list of fields to include in the output.
278
- exclude: A list of fields to exclude from the output.
277
+ If mode is 'json', the output will only contain JSON serializable types.
278
+ If mode is 'python', the output may contain non-JSON-serializable Python objects.
279
+ include: A set of fields to include in the output.
280
+ exclude: A set of fields to exclude from the output.
281
+ context: Additional context to pass to the serializer.
279
282
  by_alias: Whether to use the field's alias in the dictionary key if defined.
280
- exclude_unset: Whether to exclude fields that are unset or None from the output.
281
- exclude_defaults: Whether to exclude fields that are set to their default value from the output.
282
- exclude_none: Whether to exclude fields that have a value of `None` from the output.
283
- round_trip: Whether to enable serialization and deserialization round-trip support.
284
- warnings: Whether to log warnings when invalid fields are encountered.
283
+ exclude_unset: Whether to exclude fields that have not been explicitly set.
284
+ exclude_defaults: Whether to exclude fields that are set to their default value.
285
+ exclude_none: Whether to exclude fields that have a value of `None`.
286
+ exclude_computed_fields: Whether to exclude computed fields.
287
+ While this can be useful for round-tripping, it is usually recommended to use the dedicated
288
+ `round_trip` parameter instead.
289
+ round_trip: If True, dumped values should be valid as input for non-idempotent types such as Json[T].
290
+ warnings: How to handle serialization errors. False/"none" ignores them, True/"warn" logs errors,
291
+ "error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError].
292
+ fallback: A function to call when an unknown value is encountered. If not provided,
293
+ a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError] error is raised.
294
+ serialize_as_any: Whether to serialize fields with duck-typing serialization behavior.
285
295
 
286
296
  Returns:
287
297
  A dictionary representation of the model.
@@ -298,6 +308,8 @@ class BaseModel(pydantic.BaseModel):
298
308
  raise ValueError("serialize_as_any is only supported in Pydantic v2")
299
309
  if fallback is not None:
300
310
  raise ValueError("fallback is only supported in Pydantic v2")
311
+ if exclude_computed_fields != False:
312
+ raise ValueError("exclude_computed_fields is only supported in Pydantic v2")
301
313
  dumped = super().dict( # pyright: ignore[reportDeprecated]
302
314
  include=include,
303
315
  exclude=exclude,
@@ -314,15 +326,17 @@ class BaseModel(pydantic.BaseModel):
314
326
  self,
315
327
  *,
316
328
  indent: int | None = None,
329
+ ensure_ascii: bool = False,
317
330
  include: IncEx | None = None,
318
331
  exclude: IncEx | None = None,
332
+ context: Any | None = None,
319
333
  by_alias: bool | None = None,
320
334
  exclude_unset: bool = False,
321
335
  exclude_defaults: bool = False,
322
336
  exclude_none: bool = False,
337
+ exclude_computed_fields: bool = False,
323
338
  round_trip: bool = False,
324
339
  warnings: bool | Literal["none", "warn", "error"] = True,
325
- context: dict[str, Any] | None = None,
326
340
  fallback: Callable[[Any], Any] | None = None,
327
341
  serialize_as_any: bool = False,
328
342
  ) -> str:
@@ -354,6 +368,10 @@ class BaseModel(pydantic.BaseModel):
354
368
  raise ValueError("serialize_as_any is only supported in Pydantic v2")
355
369
  if fallback is not None:
356
370
  raise ValueError("fallback is only supported in Pydantic v2")
371
+ if ensure_ascii != False:
372
+ raise ValueError("ensure_ascii is only supported in Pydantic v2")
373
+ if exclude_computed_fields != False:
374
+ raise ValueError("exclude_computed_fields is only supported in Pydantic v2")
357
375
  return super().json( # type: ignore[reportDeprecated]
358
376
  indent=indent,
359
377
  include=include,
@@ -573,6 +591,9 @@ class CachedDiscriminatorType(Protocol):
573
591
  __discriminator__: DiscriminatorDetails
574
592
 
575
593
 
594
+ DISCRIMINATOR_CACHE: weakref.WeakKeyDictionary[type, DiscriminatorDetails] = weakref.WeakKeyDictionary()
595
+
596
+
576
597
  class DiscriminatorDetails:
577
598
  field_name: str
578
599
  """The name of the discriminator field in the variant class, e.g.
@@ -615,8 +636,9 @@ class DiscriminatorDetails:
615
636
 
616
637
 
617
638
  def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, ...]) -> DiscriminatorDetails | None:
618
- if isinstance(union, CachedDiscriminatorType):
619
- return union.__discriminator__
639
+ cached = DISCRIMINATOR_CACHE.get(union)
640
+ if cached is not None:
641
+ return cached
620
642
 
621
643
  discriminator_field_name: str | None = None
622
644
 
@@ -669,7 +691,7 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any,
669
691
  discriminator_field=discriminator_field_name,
670
692
  discriminator_alias=discriminator_alias,
671
693
  )
672
- cast(CachedDiscriminatorType, union).__discriminator__ = details
694
+ DISCRIMINATOR_CACHE.setdefault(union, details)
673
695
  return details
674
696
 
675
697
 
agentex/_streaming.py CHANGED
@@ -54,11 +54,12 @@ class Stream(Generic[_T]):
54
54
  process_data = self._client._process_response_data
55
55
  iterator = self._iter_events()
56
56
 
57
- for sse in iterator:
58
- yield process_data(data=sse.json(), cast_to=cast_to, response=response)
59
-
60
- # As we might not fully consume the response stream, we need to close it explicitly
61
- response.close()
57
+ try:
58
+ for sse in iterator:
59
+ yield process_data(data=sse.json(), cast_to=cast_to, response=response)
60
+ finally:
61
+ # Ensure the response is closed even if the consumer doesn't read all data
62
+ response.close()
62
63
 
63
64
  def __enter__(self) -> Self:
64
65
  return self
@@ -117,11 +118,12 @@ class AsyncStream(Generic[_T]):
117
118
  process_data = self._client._process_response_data
118
119
  iterator = self._iter_events()
119
120
 
120
- async for sse in iterator:
121
- yield process_data(data=sse.json(), cast_to=cast_to, response=response)
122
-
123
- # As we might not fully consume the response stream, we need to close it explicitly
124
- await response.aclose()
121
+ try:
122
+ async for sse in iterator:
123
+ yield process_data(data=sse.json(), cast_to=cast_to, response=response)
124
+ finally:
125
+ # Ensure the response is closed even if the consumer doesn't read all data
126
+ await response.aclose()
125
127
 
126
128
  async def __aenter__(self) -> Self:
127
129
  return self
agentex/_types.py CHANGED
@@ -243,6 +243,9 @@ _T_co = TypeVar("_T_co", covariant=True)
243
243
  if TYPE_CHECKING:
244
244
  # This works because str.__contains__ does not accept object (either in typeshed or at runtime)
245
245
  # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285
246
+ #
247
+ # Note: index() and count() methods are intentionally omitted to allow pyright to properly
248
+ # infer TypedDict types when dict literals are used in lists assigned to SequenceNotStr.
246
249
  class SequenceNotStr(Protocol[_T_co]):
247
250
  @overload
248
251
  def __getitem__(self, index: SupportsIndex, /) -> _T_co: ...
@@ -251,8 +254,6 @@ if TYPE_CHECKING:
251
254
  def __contains__(self, value: object, /) -> bool: ...
252
255
  def __len__(self) -> int: ...
253
256
  def __iter__(self) -> Iterator[_T_co]: ...
254
- def index(self, value: Any, start: int = 0, stop: int = ..., /) -> int: ...
255
- def count(self, value: Any, /) -> int: ...
256
257
  def __reversed__(self) -> Iterator[_T_co]: ...
257
258
  else:
258
259
  # just point this to a normal `Sequence` at runtime to avoid having to special case
agentex/_utils/_sync.py CHANGED
@@ -1,10 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- import sys
4
3
  import asyncio
5
4
  import functools
6
- import contextvars
7
- from typing import Any, TypeVar, Callable, Awaitable
5
+ from typing import TypeVar, Callable, Awaitable
8
6
  from typing_extensions import ParamSpec
9
7
 
10
8
  import anyio
@@ -15,34 +13,11 @@ T_Retval = TypeVar("T_Retval")
15
13
  T_ParamSpec = ParamSpec("T_ParamSpec")
16
14
 
17
15
 
18
- if sys.version_info >= (3, 9):
19
- _asyncio_to_thread = asyncio.to_thread
20
- else:
21
- # backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread
22
- # for Python 3.8 support
23
- async def _asyncio_to_thread(
24
- func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs
25
- ) -> Any:
26
- """Asynchronously run function *func* in a separate thread.
27
-
28
- Any *args and **kwargs supplied for this function are directly passed
29
- to *func*. Also, the current :class:`contextvars.Context` is propagated,
30
- allowing context variables from the main thread to be accessed in the
31
- separate thread.
32
-
33
- Returns a coroutine that can be awaited to get the eventual result of *func*.
34
- """
35
- loop = asyncio.events.get_running_loop()
36
- ctx = contextvars.copy_context()
37
- func_call = functools.partial(ctx.run, func, *args, **kwargs)
38
- return await loop.run_in_executor(None, func_call)
39
-
40
-
41
16
  async def to_thread(
42
17
  func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs
43
18
  ) -> T_Retval:
44
19
  if sniffio.current_async_library() == "asyncio":
45
- return await _asyncio_to_thread(func, *args, **kwargs)
20
+ return await asyncio.to_thread(func, *args, **kwargs)
46
21
 
47
22
  return await anyio.to_thread.run_sync(
48
23
  functools.partial(func, *args, **kwargs),
@@ -53,10 +28,7 @@ async def to_thread(
53
28
  def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]:
54
29
  """
55
30
  Take a blocking function and create an async one that receives the same
56
- positional and keyword arguments. For python version 3.9 and above, it uses
57
- asyncio.to_thread to run the function in a separate thread. For python version
58
- 3.8, it uses locally defined copy of the asyncio.to_thread function which was
59
- introduced in python 3.9.
31
+ positional and keyword arguments.
60
32
 
61
33
  Usage:
62
34
 
agentex/_version.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
2
2
 
3
3
  __title__ = "agentex"
4
- __version__ = "0.6.5" # x-release-please-version
4
+ __version__ = "0.7.0" # x-release-please-version
@@ -86,7 +86,7 @@ agent:
86
86
 
87
87
  # Optional: Set Environment variables for running your agent locally as well
88
88
  # as for deployment later on
89
- # env:
89
+ env: {}
90
90
  # OPENAI_API_KEY: "<YOUR_OPENAI_API_KEY_HERE>"
91
91
  # OPENAI_BASE_URL: "<YOUR_OPENAI_BASE_URL_HERE>"
92
92
  # OPENAI_ORG_ID: "<YOUR_OPENAI_ORG_ID_HERE>"
@@ -100,13 +100,12 @@ deployment:
100
100
  repository: "" # Update with your container registry
101
101
  tag: "latest" # Default tag, should be versioned in production
102
102
 
103
+ imagePullSecrets: [] # Update with your image pull secret names
104
+ # - name: my-registry-secret
105
+
103
106
  # Global deployment settings that apply to all clusters
104
- # These can be overridden in cluster-specific files (deploy/*.yaml)
107
+ # These can be overridden in cluster-specific environments (environments.yaml)
105
108
  global:
106
- agent:
107
- name: "{{ agent_name }}"
108
- description: "{{ description }}"
109
-
110
109
  # Default replica count
111
110
  replicaCount: 1
112
111
 
@@ -1,29 +1,56 @@
1
1
  from agentex.lib.sdk.fastacp.fastacp import FastACP
2
2
  from agentex.lib.types.fastacp import AsyncACPConfig
3
3
  from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams
4
+ from agentex.lib.utils.logging import make_logger
5
+ from agentex.types.text_content import TextContent
6
+ from agentex.lib import adk
7
+
8
+
9
+ logger = make_logger(__name__)
4
10
 
5
11
 
6
12
  # Create an ACP server
13
+ # This sets up the core server that will handle task creation, events, and cancellation
14
+ # The `type="base"` configuration is the default configuration for the ACP server
7
15
  acp = FastACP.create(
8
16
  acp_type="async",
9
- config=AsyncACPConfig(type="base")
17
+ config=AsyncACPConfig(
18
+ type="base",
19
+ ),
10
20
  )
11
21
 
12
22
 
23
+ # This handler is called first whenever a new task is created.
24
+ # It's a good place to initialize any state or resources needed for the task.
13
25
  @acp.on_task_event_send
14
26
  async def handle_task_event_send(params: SendEventParams):
15
- # For this tutorial, we print the parameters sent to the handler
16
- # so you can see where and how messages within a task are handled
17
- print(f"Hello world! I just received this message: {params}")
27
+ # For this tutorial, we log the parameters sent to the handler
28
+ # so you can see where and how messages within a long running task are handled
29
+ logger.info(f"Received task event send rpc: {params}")
30
+
31
+ # 1. Echo back the client's message to show it in the UI. This is not done by default so the agent developer has full control over what is shown to the user.
32
+ await adk.messages.create(task_id=params.task.id, content=params.event.content)
33
+
34
+ # 2. Send a simple response message.
35
+ # In future tutorials, this is where we'll add more sophisticated response logic.
36
+ await adk.messages.create(
37
+ task_id=params.task.id,
38
+ content=TextContent(
39
+ author="agent",
40
+ content=f"Hello! I've received your message. I can't respond right now, but in future tutorials we'll see how you can get me to intelligently respond to your message.",
41
+ ),
42
+ )
18
43
 
19
44
  @acp.on_task_cancel
20
45
  async def handle_task_canceled(params: CancelTaskParams):
21
46
  # For this tutorial, we print the parameters sent to the handler
22
47
  # so you can see where and how task cancellation is handled
23
- print(f"Hello world! Task canceled: {params.task.id}")
48
+ logger.info(f"Received task cancel rpc: {params}")
24
49
 
25
50
  @acp.on_task_create
26
51
  async def handle_task_create(params: CreateTaskParams):
27
- # For this tutorial, we print the parameters sent to the handler
52
+ # For this tutorial, we log the parameters sent to the handler
28
53
  # so you can see where and how task creation is handled
29
- print(f"Hello world! Task created: {params.task.id}")
54
+
55
+ # Here is where you can initialize any state or resources needed for the task.
56
+ logger.info(f"Received task create rpc: {params}")
@@ -74,14 +74,14 @@ agent:
74
74
  # Optional: Credentials mapping
75
75
  # Maps Kubernetes secrets to environment variables
76
76
  # Common credentials include:
77
- # credentials:
77
+ credentials: [] # Update with your credentials
78
78
  # - env_var_name: OPENAI_API_KEY
79
79
  # secret_name: openai-api-key
80
80
  # secret_key: api-key
81
81
 
82
82
  # Optional: Set Environment variables for running your agent locally as well
83
83
  # as for deployment later on
84
- # env:
84
+ env: {} # Update with your environment variables
85
85
  # OPENAI_API_KEY: "<YOUR_OPENAI_API_KEY_HERE>"
86
86
  # OPENAI_BASE_URL: "<YOUR_OPENAI_BASE_URL_HERE>"
87
87
  # OPENAI_ORG_ID: "<YOUR_OPENAI_ORG_ID_HERE>"
@@ -95,14 +95,13 @@ deployment:
95
95
  image:
96
96
  repository: "" # Update with your container registry
97
97
  tag: "latest" # Default tag, should be versioned in production
98
+
99
+ imagePullSecrets: [] # Update with your image pull secret names
100
+ # - name: my-registry-secret
98
101
 
99
102
  # Global deployment settings that apply to all clusters
100
- # These can be overridden in cluster-specific files (deploy/*.yaml)
103
+ # These can be overridden in cluster-specific environments (environments.yaml)
101
104
  global:
102
- agent:
103
- name: "{{ agent_name }}"
104
- description: "{{ description }}"
105
-
106
105
  # Default replica count
107
106
  replicaCount: 1
108
107
 
@@ -106,7 +106,7 @@ agent:
106
106
 
107
107
  # Optional: Set Environment variables for running your agent locally as well
108
108
  # as for deployment later on
109
- # env:
109
+ env: {}
110
110
  # OPENAI_API_KEY: "<YOUR_OPENAI_API_KEY_HERE>"
111
111
  # OPENAI_BASE_URL: "<YOUR_OPENAI_BASE_URL_HERE>"
112
112
  # OPENAI_ORG_ID: "<YOUR_OPENAI_ORG_ID_HERE>"
@@ -121,16 +121,12 @@ deployment:
121
121
  repository: "" # Update with your container registry
122
122
  tag: "latest" # Default tag, should be versioned in production
123
123
 
124
- imagePullSecrets:
125
- - name: my-registry-secret # Update with your image pull secret name
124
+ imagePullSecrets: [] # Update with your image pull secret name
125
+ # - name: my-registry-secret
126
126
 
127
127
  # Global deployment settings that apply to all clusters
128
- # These can be overridden using --override-file with custom configuration files
128
+ # These can be overridden in cluster-specific environments (environments.yaml)
129
129
  global:
130
- agent:
131
- name: "{{ agent_name }}"
132
- description: "{{ description }}"
133
-
134
130
  # Default replica count
135
131
  replicaCount: 1
136
132
 
@@ -0,0 +1,72 @@
1
+ """Claude Agents SDK integration with Temporal.
2
+
3
+ This plugin provides integration between Claude Agents SDK and AgentEx's
4
+ Temporal-based orchestration platform.
5
+
6
+ Features:
7
+ - Temporal activity wrapper for Claude SDK calls
8
+ - Real-time streaming to Redis/UI
9
+ - Session resume for conversation context
10
+ - Tool call visibility (Read, Write, Bash, etc.)
11
+ - Subagent support with nested tracing
12
+ - Workspace isolation per task
13
+
14
+ Architecture:
15
+ - activities.py: Temporal activity definitions
16
+ - message_handler.py: Message parsing and streaming logic
17
+ - Reuses OpenAI's ContextInterceptor for context threading
18
+
19
+ Usage:
20
+ from agentex.lib.core.temporal.plugins.claude_agents import (
21
+ run_claude_agent_activity,
22
+ create_workspace_directory,
23
+ ContextInterceptor,
24
+ )
25
+
26
+ # In worker
27
+ worker = AgentexWorker(
28
+ task_queue=queue_name,
29
+ interceptors=[ContextInterceptor()],
30
+ )
31
+
32
+ activities = get_all_activities()
33
+ activities.extend([run_claude_agent_activity, create_workspace_directory])
34
+
35
+ await worker.run(activities=activities, workflow=YourWorkflow)
36
+ """
37
+
38
+ from agentex.lib.core.temporal.plugins.claude_agents.hooks import (
39
+ TemporalStreamingHooks,
40
+ create_streaming_hooks,
41
+ )
42
+ from agentex.lib.core.temporal.plugins.claude_agents.activities import (
43
+ run_claude_agent_activity,
44
+ create_workspace_directory,
45
+ )
46
+ from agentex.lib.core.temporal.plugins.claude_agents.message_handler import (
47
+ ClaudeMessageHandler,
48
+ )
49
+
50
+ # Reuse OpenAI's context threading - this is the key to streaming!
51
+ from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import (
52
+ ContextInterceptor,
53
+ streaming_task_id,
54
+ streaming_trace_id,
55
+ streaming_parent_span_id,
56
+ )
57
+
58
+ __all__ = [
59
+ # Activities
60
+ "run_claude_agent_activity",
61
+ "create_workspace_directory",
62
+ # Message handling
63
+ "ClaudeMessageHandler",
64
+ # Hooks
65
+ "create_streaming_hooks",
66
+ "TemporalStreamingHooks",
67
+ # Context threading (reused from OpenAI)
68
+ "ContextInterceptor",
69
+ "streaming_task_id",
70
+ "streaming_trace_id",
71
+ "streaming_parent_span_id",
72
+ ]
@@ -0,0 +1,154 @@
1
+ """Temporal activities for Claude Agents SDK integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ from temporalio import activity
9
+ from claude_agent_sdk import AgentDefinition, ClaudeSDKClient, ClaudeAgentOptions
10
+
11
+ from agentex.lib.utils.logging import make_logger
12
+ from agentex.lib.core.temporal.plugins.claude_agents.hooks import create_streaming_hooks
13
+ from agentex.lib.core.temporal.plugins.claude_agents.message_handler import ClaudeMessageHandler
14
+ from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import (
15
+ streaming_task_id,
16
+ streaming_trace_id,
17
+ streaming_parent_span_id,
18
+ )
19
+
20
+ logger = make_logger(__name__)
21
+
22
+
23
+ @activity.defn
24
+ async def create_workspace_directory(task_id: str, workspace_root: str | None = None) -> str:
25
+ """Create workspace directory for task - runs as Temporal activity
26
+
27
+ Args:
28
+ task_id: Task ID for workspace directory name
29
+ workspace_root: Root directory for workspaces (defaults to .claude-workspace/ in cwd)
30
+
31
+ Returns:
32
+ Absolute path to created workspace
33
+ """
34
+ if workspace_root is None:
35
+ # Default to .claude-workspace in current directory
36
+ # Follows Claude SDK's .claude/ convention
37
+ workspace_root = os.path.join(os.getcwd(), ".claude-workspace")
38
+
39
+ workspace_path = os.path.join(workspace_root, task_id)
40
+ os.makedirs(workspace_path, exist_ok=True)
41
+ logger.info(f"Created workspace: {workspace_path}")
42
+ return workspace_path
43
+
44
+
45
+ @activity.defn(name="run_claude_agent_activity")
46
+ async def run_claude_agent_activity(
47
+ prompt: str,
48
+ workspace_path: str,
49
+ allowed_tools: list[str],
50
+ permission_mode: str = "acceptEdits",
51
+ system_prompt: str | None = None,
52
+ resume_session_id: str | None = None,
53
+ agents: dict[str, Any] | None = None,
54
+ ) -> dict[str, Any]:
55
+ """Execute Claude SDK - wrapped in Temporal activity
56
+
57
+ This activity:
58
+ 1. Gets task_id from ContextVar (set by ContextInterceptor)
59
+ 2. Configures Claude with workspace isolation and session resume
60
+ 3. Runs Claude SDK and processes messages via ClaudeMessageHandler
61
+ 4. Streams messages to UI in real-time
62
+ 5. Returns session_id, usage, and cost for next turn
63
+
64
+ Args:
65
+ prompt: User message to send to Claude
66
+ workspace_path: Directory for file operations (cwd)
67
+ allowed_tools: List of tools Claude can use (include "Task" for subagents)
68
+ permission_mode: Permission mode (default: acceptEdits)
69
+ system_prompt: Optional system prompt override
70
+ resume_session_id: Optional session ID to resume conversation context
71
+ agents: Optional dict of subagent definitions for Task tool
72
+
73
+ Returns:
74
+ dict with "messages", "session_id", "usage", and "cost_usd" keys
75
+ """
76
+
77
+ # Get streaming context from ContextVars (set by interceptor)
78
+ task_id = streaming_task_id.get()
79
+ trace_id = streaming_trace_id.get()
80
+ parent_span_id = streaming_parent_span_id.get()
81
+
82
+ logger.info(
83
+ f"[run_claude_agent_activity] Starting - "
84
+ f"task_id={task_id}, workspace={workspace_path}, tools={allowed_tools}, "
85
+ f"resume={'YES' if resume_session_id else 'NO (new session)'}, "
86
+ f"subagents={list(agents.keys()) if agents else 'NONE'}"
87
+ )
88
+
89
+ # Reconstruct AgentDefinition objects from serialized dicts
90
+ # Temporal serializes dataclasses to dicts, need to recreate them
91
+ agent_defs = None
92
+ if agents:
93
+ agent_defs = {}
94
+ for name, agent_data in agents.items():
95
+ if isinstance(agent_data, AgentDefinition):
96
+ agent_defs[name] = agent_data
97
+ else:
98
+ # Reconstruct from dict
99
+ agent_defs[name] = AgentDefinition(
100
+ description=agent_data.get('description', ''),
101
+ prompt=agent_data.get('prompt', ''),
102
+ tools=agent_data.get('tools'),
103
+ model=agent_data.get('model'),
104
+ )
105
+
106
+ # Create hooks for streaming tool calls and subagent execution
107
+ hooks = create_streaming_hooks(
108
+ task_id=task_id,
109
+ trace_id=trace_id,
110
+ parent_span_id=parent_span_id,
111
+ )
112
+
113
+ # Configure Claude with workspace isolation, session resume, subagents, and hooks
114
+ options = ClaudeAgentOptions(
115
+ cwd=workspace_path,
116
+ allowed_tools=allowed_tools,
117
+ permission_mode=permission_mode, # type: ignore
118
+ system_prompt=system_prompt,
119
+ resume=resume_session_id,
120
+ agents=agent_defs,
121
+ hooks=hooks, # Tool lifecycle hooks for streaming!
122
+ )
123
+
124
+ # Create message handler for streaming
125
+ handler = ClaudeMessageHandler(
126
+ task_id=task_id,
127
+ trace_id=trace_id,
128
+ parent_span_id=parent_span_id,
129
+ )
130
+
131
+ # Run Claude and process messages
132
+ try:
133
+ await handler.initialize()
134
+
135
+ async with ClaudeSDKClient(options=options) as client:
136
+ await client.query(prompt)
137
+
138
+ # Use receive_response() instead of receive_messages()
139
+ # receive_response() yields messages until ResultMessage, then stops
140
+ # receive_messages() is infinite and never completes!
141
+ async for message in client.receive_response():
142
+ await handler.handle_message(message)
143
+
144
+ logger.debug(f"Message loop completed, cleaning up...")
145
+ await handler.cleanup()
146
+
147
+ results = handler.get_results()
148
+ logger.debug(f"Returning results with keys: {results.keys()}")
149
+ return results
150
+
151
+ except Exception as e:
152
+ logger.error(f"[run_claude_agent_activity] Error: {e}", exc_info=True)
153
+ await handler.cleanup()
154
+ raise
@@ -0,0 +1,11 @@
1
+ """Claude SDK hooks for streaming lifecycle events to AgentEx UI."""
2
+
3
+ from agentex.lib.core.temporal.plugins.claude_agents.hooks.hooks import (
4
+ TemporalStreamingHooks,
5
+ create_streaming_hooks,
6
+ )
7
+
8
+ __all__ = [
9
+ "create_streaming_hooks",
10
+ "TemporalStreamingHooks",
11
+ ]