prefactor-core 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.
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/.gitignore +1 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/PKG-INFO +1 -1
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/pyproject.toml +4 -1
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/src/prefactor_core/__init__.py +6 -2
- prefactor_core-0.2.4/src/prefactor_core/_version.py +7 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/src/prefactor_core/client.py +88 -2
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/src/prefactor_core/exceptions.py +20 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/src/prefactor_core/managers/agent_instance.py +48 -16
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/src/prefactor_core/managers/span.py +8 -1
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/src/prefactor_core/queue/executor.py +11 -2
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/src/prefactor_core/queue/memory.py +4 -4
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/src/prefactor_core/schema_registry.py +25 -1
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/src/prefactor_core/span_context.py +27 -4
- prefactor_core-0.2.4/tests/test_agent_instance_finish_status.py +104 -0
- prefactor_core-0.2.4/tests/test_failure_handling.py +322 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/tests/test_queue.py +31 -0
- prefactor_core-0.2.4/tests/test_sdk_header.py +78 -0
- prefactor_core-0.2.4/tests/test_span_context.py +42 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/tests/test_span_manager.py +14 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/README.md +0 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/examples/agent_e2e.py +0 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/src/prefactor_core/config.py +0 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/src/prefactor_core/context_stack.py +0 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/src/prefactor_core/managers/__init__.py +0 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/src/prefactor_core/models.py +0 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/src/prefactor_core/operations.py +0 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/src/prefactor_core/queue/__init__.py +0 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/src/prefactor_core/queue/base.py +0 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/src/prefactor_core/utils.py +0 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/tests/test_client.py +0 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/tests/test_imports.py +0 -0
- {prefactor_core-0.2.2 → prefactor_core-0.2.4}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "prefactor-core"
|
|
3
|
-
|
|
3
|
+
dynamic = ["version"]
|
|
4
4
|
description = "Core Prefactor SDK with async queue-based operations"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -17,6 +17,9 @@ dependencies = [
|
|
|
17
17
|
requires = ["hatchling"]
|
|
18
18
|
build-backend = "hatchling.build"
|
|
19
19
|
|
|
20
|
+
[tool.hatch.version]
|
|
21
|
+
path = "src/prefactor_core/_version.py"
|
|
22
|
+
|
|
20
23
|
[tool.hatch.build.targets.wheel]
|
|
21
24
|
packages = ["src/prefactor_core"]
|
|
22
25
|
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
This module exports the main classes and functions for the prefactor-core SDK.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from ._version import __version__
|
|
6
9
|
from .client import PrefactorCoreClient
|
|
7
10
|
from .config import PrefactorCoreConfig, QueueConfig
|
|
8
11
|
from .context_stack import SpanContextStack
|
|
@@ -12,6 +15,7 @@ from .exceptions import (
|
|
|
12
15
|
InstanceNotFoundError,
|
|
13
16
|
OperationError,
|
|
14
17
|
PrefactorCoreError,
|
|
18
|
+
PrefactorTelemetryFailureError,
|
|
15
19
|
SpanNotFoundError,
|
|
16
20
|
)
|
|
17
21
|
from .managers.agent_instance import AgentInstanceHandle
|
|
@@ -22,8 +26,6 @@ from .schema_registry import SchemaRegistry
|
|
|
22
26
|
from .span_context import SpanContext
|
|
23
27
|
from .utils import generate_idempotency_key, validate_idempotency_key
|
|
24
28
|
|
|
25
|
-
__version__ = "0.2.1"
|
|
26
|
-
|
|
27
29
|
__all__ = [
|
|
28
30
|
# Client
|
|
29
31
|
"PrefactorCoreClient",
|
|
@@ -40,6 +42,7 @@ __all__ = [
|
|
|
40
42
|
"OperationError",
|
|
41
43
|
"InstanceNotFoundError",
|
|
42
44
|
"SpanNotFoundError",
|
|
45
|
+
"PrefactorTelemetryFailureError",
|
|
43
46
|
# Models
|
|
44
47
|
"AgentInstance",
|
|
45
48
|
"Span",
|
|
@@ -58,4 +61,5 @@ __all__ = [
|
|
|
58
61
|
# Utils
|
|
59
62
|
"generate_idempotency_key",
|
|
60
63
|
"validate_idempotency_key",
|
|
64
|
+
"__version__",
|
|
61
65
|
]
|
|
@@ -13,12 +13,16 @@ from contextlib import asynccontextmanager
|
|
|
13
13
|
from typing import TYPE_CHECKING, Any
|
|
14
14
|
|
|
15
15
|
from prefactor_http.client import PrefactorHttpClient
|
|
16
|
+
from prefactor_http.exceptions import is_permanent_http_error, is_transient_http_error
|
|
16
17
|
|
|
18
|
+
from ._version import PACKAGE_NAME as CORE_PACKAGE_NAME
|
|
19
|
+
from ._version import PACKAGE_VERSION as CORE_PACKAGE_VERSION
|
|
17
20
|
from .config import PrefactorCoreConfig
|
|
18
21
|
from .context_stack import SpanContextStack
|
|
19
22
|
from .exceptions import (
|
|
20
23
|
ClientAlreadyInitializedError,
|
|
21
24
|
ClientNotInitializedError,
|
|
25
|
+
PrefactorTelemetryFailureError,
|
|
22
26
|
)
|
|
23
27
|
from .managers.agent_instance import AgentInstanceManager
|
|
24
28
|
from .managers.span import SpanManager
|
|
@@ -31,6 +35,7 @@ if TYPE_CHECKING:
|
|
|
31
35
|
from .managers.agent_instance import AgentInstanceHandle
|
|
32
36
|
|
|
33
37
|
logger = logging.getLogger(__name__)
|
|
38
|
+
CORE_SDK_HEADER_ENTRY = f"{CORE_PACKAGE_NAME}@{CORE_PACKAGE_VERSION}"
|
|
34
39
|
|
|
35
40
|
|
|
36
41
|
class PrefactorCoreClient:
|
|
@@ -61,6 +66,7 @@ class PrefactorCoreClient:
|
|
|
61
66
|
self,
|
|
62
67
|
config: PrefactorCoreConfig,
|
|
63
68
|
queue: Queue[Operation] | None = None,
|
|
69
|
+
sdk_header_entry: str | None = None,
|
|
64
70
|
) -> None:
|
|
65
71
|
"""Initialize the client.
|
|
66
72
|
|
|
@@ -68,14 +74,30 @@ class PrefactorCoreClient:
|
|
|
68
74
|
config: Configuration for the client.
|
|
69
75
|
queue: Optional custom queue implementation. If not provided,
|
|
70
76
|
an InMemoryQueue is used.
|
|
77
|
+
sdk_header_entry: Optional upstream SDK header entry to prepend.
|
|
71
78
|
"""
|
|
72
79
|
self._config = config
|
|
73
80
|
self._queue = queue or InMemoryQueue()
|
|
81
|
+
self._sdk_header_entry = sdk_header_entry.strip() if sdk_header_entry else None
|
|
74
82
|
self._http: PrefactorHttpClient | None = None
|
|
75
83
|
self._executor: TaskExecutor | None = None
|
|
76
84
|
self._instance_manager: AgentInstanceManager | None = None
|
|
77
85
|
self._span_manager: SpanManager | None = None
|
|
78
86
|
self._initialized = False
|
|
87
|
+
self._telemetry_failure: PrefactorTelemetryFailureError | None = None
|
|
88
|
+
self._telemetry_failure_observed = False
|
|
89
|
+
|
|
90
|
+
def _build_http_sdk_header(self) -> str:
|
|
91
|
+
"""Build the effective SDK header for HTTP requests."""
|
|
92
|
+
if self._sdk_header_entry:
|
|
93
|
+
return f"{self._sdk_header_entry} {CORE_SDK_HEADER_ENTRY}"
|
|
94
|
+
return CORE_SDK_HEADER_ENTRY
|
|
95
|
+
|
|
96
|
+
def _set_sdk_header_entry(self, sdk_header_entry: str | None) -> None:
|
|
97
|
+
"""Set the upstream SDK header entry for this client lifetime."""
|
|
98
|
+
self._sdk_header_entry = sdk_header_entry.strip() if sdk_header_entry else None
|
|
99
|
+
if self._http is not None:
|
|
100
|
+
self._http._sdk_header = self._build_http_sdk_header()
|
|
79
101
|
|
|
80
102
|
async def __aenter__(self) -> "PrefactorCoreClient":
|
|
81
103
|
"""Enter async context manager."""
|
|
@@ -84,7 +106,11 @@ class PrefactorCoreClient:
|
|
|
84
106
|
|
|
85
107
|
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
86
108
|
"""Exit async context manager."""
|
|
87
|
-
|
|
109
|
+
try:
|
|
110
|
+
await self.close()
|
|
111
|
+
except PrefactorTelemetryFailureError:
|
|
112
|
+
if exc_type is None:
|
|
113
|
+
raise
|
|
88
114
|
|
|
89
115
|
async def initialize(self) -> None:
|
|
90
116
|
"""Initialize the client and start processing.
|
|
@@ -101,13 +127,17 @@ class PrefactorCoreClient:
|
|
|
101
127
|
raise ClientAlreadyInitializedError("Client is already initialized")
|
|
102
128
|
|
|
103
129
|
# Initialize HTTP client
|
|
104
|
-
self._http = PrefactorHttpClient(
|
|
130
|
+
self._http = PrefactorHttpClient(
|
|
131
|
+
self._config.http_config,
|
|
132
|
+
sdk_header=self._build_http_sdk_header(),
|
|
133
|
+
)
|
|
105
134
|
await self._http.__aenter__()
|
|
106
135
|
|
|
107
136
|
# Initialize executor
|
|
108
137
|
self._executor = TaskExecutor(
|
|
109
138
|
queue=self._queue,
|
|
110
139
|
handler=self._process_operation,
|
|
140
|
+
is_retryable=self._is_retryable_operation_error,
|
|
111
141
|
num_workers=self._config.queue_config.num_workers,
|
|
112
142
|
max_retries=self._config.queue_config.max_retries,
|
|
113
143
|
)
|
|
@@ -144,6 +174,10 @@ class PrefactorCoreClient:
|
|
|
144
174
|
|
|
145
175
|
self._initialized = False
|
|
146
176
|
|
|
177
|
+
if self._telemetry_failure is not None and not self._telemetry_failure_observed:
|
|
178
|
+
self._telemetry_failure_observed = True
|
|
179
|
+
raise self._telemetry_failure
|
|
180
|
+
|
|
147
181
|
def _ensure_initialized(self) -> None:
|
|
148
182
|
"""Ensure the client is initialized.
|
|
149
183
|
|
|
@@ -156,12 +190,55 @@ class PrefactorCoreClient:
|
|
|
156
190
|
"use as context manager."
|
|
157
191
|
)
|
|
158
192
|
|
|
193
|
+
def _record_telemetry_failure(
|
|
194
|
+
self, cause: Exception, operation_type: OperationType | str
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Latch the first permanent telemetry failure."""
|
|
197
|
+
if self._telemetry_failure is not None:
|
|
198
|
+
return
|
|
199
|
+
if isinstance(operation_type, OperationType):
|
|
200
|
+
operation_name = operation_type.name
|
|
201
|
+
else:
|
|
202
|
+
operation_name = str(operation_type)
|
|
203
|
+
self._telemetry_failure = PrefactorTelemetryFailureError(
|
|
204
|
+
f"Telemetry permanently failed during {operation_name}",
|
|
205
|
+
cause=cause,
|
|
206
|
+
operation_type=operation_name,
|
|
207
|
+
dropped_operations=0,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _increment_dropped_operations(self) -> None:
|
|
211
|
+
"""Increment the dropped operation counter on the latched failure."""
|
|
212
|
+
if self._telemetry_failure is None:
|
|
213
|
+
return
|
|
214
|
+
self._telemetry_failure.dropped_operations += 1
|
|
215
|
+
|
|
216
|
+
def _raise_if_telemetry_failed(self) -> None:
|
|
217
|
+
"""Raise the latched telemetry failure for caller-visible operations."""
|
|
218
|
+
if self._telemetry_failure is None:
|
|
219
|
+
return
|
|
220
|
+
self._telemetry_failure_observed = True
|
|
221
|
+
raise self._telemetry_failure
|
|
222
|
+
|
|
223
|
+
def _is_retryable_operation_error(self, error: Exception) -> bool:
|
|
224
|
+
"""Return True when the worker should retry the operation."""
|
|
225
|
+
if isinstance(error, PrefactorTelemetryFailureError):
|
|
226
|
+
return False
|
|
227
|
+
if is_permanent_http_error(error):
|
|
228
|
+
return False
|
|
229
|
+
if is_transient_http_error(error):
|
|
230
|
+
return True
|
|
231
|
+
return True
|
|
232
|
+
|
|
159
233
|
async def _enqueue(self, operation: Operation) -> None:
|
|
160
234
|
"""Add an operation to the queue.
|
|
161
235
|
|
|
162
236
|
Args:
|
|
163
237
|
operation: The operation to queue.
|
|
164
238
|
"""
|
|
239
|
+
if self._telemetry_failure is not None:
|
|
240
|
+
self._increment_dropped_operations()
|
|
241
|
+
self._raise_if_telemetry_failed()
|
|
165
242
|
await self._queue.put(operation)
|
|
166
243
|
|
|
167
244
|
async def _process_operation(self, operation: Operation) -> None:
|
|
@@ -174,6 +251,9 @@ class PrefactorCoreClient:
|
|
|
174
251
|
"""
|
|
175
252
|
if not self._http:
|
|
176
253
|
return
|
|
254
|
+
if self._telemetry_failure is not None:
|
|
255
|
+
self._increment_dropped_operations()
|
|
256
|
+
return
|
|
177
257
|
|
|
178
258
|
try:
|
|
179
259
|
if operation.type == OperationType.REGISTER_AGENT_INSTANCE:
|
|
@@ -194,6 +274,7 @@ class PrefactorCoreClient:
|
|
|
194
274
|
elif operation.type == OperationType.FINISH_AGENT_INSTANCE:
|
|
195
275
|
await self._http.agent_instances.finish(
|
|
196
276
|
agent_instance_id=operation.payload["instance_id"],
|
|
277
|
+
status=operation.payload.get("status", "complete"),
|
|
197
278
|
timestamp=operation.timestamp,
|
|
198
279
|
idempotency_key=operation.payload.get("idempotency_key"),
|
|
199
280
|
)
|
|
@@ -217,6 +298,8 @@ class PrefactorCoreClient:
|
|
|
217
298
|
)
|
|
218
299
|
|
|
219
300
|
except Exception as e:
|
|
301
|
+
if is_permanent_http_error(e):
|
|
302
|
+
self._record_telemetry_failure(e, operation.type)
|
|
220
303
|
# Log error and re-raise so TaskExecutor retries can run
|
|
221
304
|
logger.error(
|
|
222
305
|
f"Failed to process operation {operation.type}: {e}",
|
|
@@ -262,6 +345,7 @@ class PrefactorCoreClient:
|
|
|
262
345
|
ValueError: If no schema version provided and registry not configured.
|
|
263
346
|
"""
|
|
264
347
|
self._ensure_initialized()
|
|
348
|
+
self._raise_if_telemetry_failed()
|
|
265
349
|
assert self._instance_manager is not None
|
|
266
350
|
|
|
267
351
|
# Determine the agent_schema_version to use
|
|
@@ -318,6 +402,7 @@ class PrefactorCoreClient:
|
|
|
318
402
|
The span ID.
|
|
319
403
|
"""
|
|
320
404
|
self._ensure_initialized()
|
|
405
|
+
self._raise_if_telemetry_failed()
|
|
321
406
|
assert self._span_manager is not None
|
|
322
407
|
|
|
323
408
|
if parent_span_id is None:
|
|
@@ -380,6 +465,7 @@ class PrefactorCoreClient:
|
|
|
380
465
|
SpanContext for the created span.
|
|
381
466
|
"""
|
|
382
467
|
self._ensure_initialized()
|
|
468
|
+
self._raise_if_telemetry_failed()
|
|
383
469
|
assert self._span_manager is not None
|
|
384
470
|
|
|
385
471
|
# Import here to avoid circular import
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Custom exceptions for prefactor-core."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
3
5
|
|
|
4
6
|
class PrefactorCoreError(Exception):
|
|
5
7
|
"""Base exception for all prefactor-core errors."""
|
|
@@ -39,6 +41,23 @@ class SpanNotFoundError(PrefactorCoreError):
|
|
|
39
41
|
pass
|
|
40
42
|
|
|
41
43
|
|
|
44
|
+
class PrefactorTelemetryFailureError(PrefactorCoreError):
|
|
45
|
+
"""Raised when telemetry enters a permanent failure state."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
message: str,
|
|
50
|
+
*,
|
|
51
|
+
cause: Exception,
|
|
52
|
+
operation_type: str | None = None,
|
|
53
|
+
dropped_operations: int = 0,
|
|
54
|
+
) -> None:
|
|
55
|
+
super().__init__(message)
|
|
56
|
+
self.cause = cause
|
|
57
|
+
self.operation_type = operation_type
|
|
58
|
+
self.dropped_operations = dropped_operations
|
|
59
|
+
|
|
60
|
+
|
|
42
61
|
__all__ = [
|
|
43
62
|
"PrefactorCoreError",
|
|
44
63
|
"ClientNotInitializedError",
|
|
@@ -46,4 +65,5 @@ __all__ = [
|
|
|
46
65
|
"OperationError",
|
|
47
66
|
"InstanceNotFoundError",
|
|
48
67
|
"SpanNotFoundError",
|
|
68
|
+
"PrefactorTelemetryFailureError",
|
|
49
69
|
]
|
|
@@ -14,6 +14,7 @@ from ..utils import generate_idempotency_key
|
|
|
14
14
|
|
|
15
15
|
if TYPE_CHECKING:
|
|
16
16
|
from prefactor_http.client import PrefactorHttpClient
|
|
17
|
+
from prefactor_http.models.types import FinishStatus
|
|
17
18
|
|
|
18
19
|
from ..client import PrefactorCoreClient
|
|
19
20
|
|
|
@@ -96,30 +97,57 @@ class AgentInstanceManager:
|
|
|
96
97
|
Args:
|
|
97
98
|
instance_id: The ID of the instance to start.
|
|
98
99
|
"""
|
|
100
|
+
await self.start_with_idempotency_key(instance_id, generate_idempotency_key())
|
|
101
|
+
|
|
102
|
+
async def start_with_idempotency_key(
|
|
103
|
+
self,
|
|
104
|
+
instance_id: str,
|
|
105
|
+
idempotency_key: str,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Queue a start operation using a stable idempotency key."""
|
|
99
108
|
operation = Operation(
|
|
100
109
|
type=OperationType.START_AGENT_INSTANCE,
|
|
101
110
|
payload={
|
|
102
111
|
"instance_id": instance_id,
|
|
103
|
-
"idempotency_key":
|
|
112
|
+
"idempotency_key": idempotency_key,
|
|
104
113
|
},
|
|
105
114
|
timestamp=datetime.now(timezone.utc),
|
|
106
115
|
)
|
|
107
116
|
|
|
108
117
|
await self._enqueue(operation)
|
|
109
118
|
|
|
110
|
-
async def finish(
|
|
119
|
+
async def finish(
|
|
120
|
+
self,
|
|
121
|
+
instance_id: str,
|
|
122
|
+
status: "FinishStatus" = "complete",
|
|
123
|
+
) -> None:
|
|
111
124
|
"""Mark an instance as finished.
|
|
112
125
|
|
|
113
126
|
Queues a finish operation for the instance.
|
|
114
127
|
|
|
115
128
|
Args:
|
|
116
129
|
instance_id: The ID of the instance to finish.
|
|
130
|
+
status: Terminal status for the instance. Defaults to ``"complete"``.
|
|
117
131
|
"""
|
|
132
|
+
await self.finish_with_idempotency_key(
|
|
133
|
+
instance_id,
|
|
134
|
+
generate_idempotency_key(),
|
|
135
|
+
status=status,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def finish_with_idempotency_key(
|
|
139
|
+
self,
|
|
140
|
+
instance_id: str,
|
|
141
|
+
idempotency_key: str,
|
|
142
|
+
status: "FinishStatus" = "complete",
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Queue a finish operation using a stable idempotency key."""
|
|
118
145
|
operation = Operation(
|
|
119
146
|
type=OperationType.FINISH_AGENT_INSTANCE,
|
|
120
147
|
payload={
|
|
121
148
|
"instance_id": instance_id,
|
|
122
|
-
"idempotency_key":
|
|
149
|
+
"idempotency_key": idempotency_key,
|
|
150
|
+
"status": status,
|
|
123
151
|
},
|
|
124
152
|
timestamp=datetime.now(timezone.utc),
|
|
125
153
|
)
|
|
@@ -159,8 +187,8 @@ class AgentInstanceHandle:
|
|
|
159
187
|
"""
|
|
160
188
|
self._instance_id = instance_id
|
|
161
189
|
self._client = client
|
|
162
|
-
self.
|
|
163
|
-
self.
|
|
190
|
+
self._start_idempotency_key = generate_idempotency_key()
|
|
191
|
+
self._finish_idempotency_key = generate_idempotency_key()
|
|
164
192
|
|
|
165
193
|
@property
|
|
166
194
|
def id(self) -> str:
|
|
@@ -176,26 +204,29 @@ class AgentInstanceHandle:
|
|
|
176
204
|
|
|
177
205
|
This queues a start operation for the instance.
|
|
178
206
|
"""
|
|
179
|
-
if self._started:
|
|
180
|
-
return
|
|
181
|
-
|
|
182
207
|
manager = self._client.instance_manager
|
|
183
208
|
assert manager is not None
|
|
184
|
-
await manager.
|
|
185
|
-
|
|
209
|
+
await manager.start_with_idempotency_key(
|
|
210
|
+
self._instance_id,
|
|
211
|
+
self._start_idempotency_key,
|
|
212
|
+
)
|
|
186
213
|
|
|
187
|
-
async def finish(self) -> None:
|
|
214
|
+
async def finish(self, status: "FinishStatus" = "complete") -> None:
|
|
188
215
|
"""Mark the instance as finished.
|
|
189
216
|
|
|
190
217
|
This queues a finish operation for the instance.
|
|
191
|
-
"""
|
|
192
|
-
if self._finished:
|
|
193
|
-
return
|
|
194
218
|
|
|
219
|
+
Args:
|
|
220
|
+
status: Terminal status for the instance — one of ``"complete"``,
|
|
221
|
+
``"failed"``, or ``"cancelled"``. Defaults to ``"complete"``.
|
|
222
|
+
"""
|
|
195
223
|
manager = self._client.instance_manager
|
|
196
224
|
assert manager is not None
|
|
197
|
-
await manager.
|
|
198
|
-
|
|
225
|
+
await manager.finish_with_idempotency_key(
|
|
226
|
+
self._instance_id,
|
|
227
|
+
self._finish_idempotency_key,
|
|
228
|
+
status=status,
|
|
229
|
+
)
|
|
199
230
|
|
|
200
231
|
async def create_span(
|
|
201
232
|
self,
|
|
@@ -215,6 +246,7 @@ class AgentInstanceHandle:
|
|
|
215
246
|
Returns:
|
|
216
247
|
The span ID.
|
|
217
248
|
"""
|
|
249
|
+
self._client._raise_if_telemetry_failed()
|
|
218
250
|
return await self._client.create_span(
|
|
219
251
|
instance_id=self._instance_id,
|
|
220
252
|
schema_name=schema_name,
|
|
@@ -151,6 +151,10 @@ class SpanManager:
|
|
|
151
151
|
del self._spans[temp_id]
|
|
152
152
|
self._spans[api_id] = span
|
|
153
153
|
|
|
154
|
+
for child_span in self._spans.values():
|
|
155
|
+
if child_span.parent_span_id == temp_id:
|
|
156
|
+
child_span.parent_span_id = api_id
|
|
157
|
+
|
|
154
158
|
# Replace temp ID on the context stack
|
|
155
159
|
stack = SpanContextStack.get_stack()
|
|
156
160
|
new_stack = [api_id if s == temp_id else s for s in stack]
|
|
@@ -242,6 +246,7 @@ class SpanManager:
|
|
|
242
246
|
span_id: str,
|
|
243
247
|
result_payload: dict[str, Any] | None = None,
|
|
244
248
|
status: "FinishStatus" = "complete",
|
|
249
|
+
idempotency_key: str | None = None,
|
|
245
250
|
) -> None:
|
|
246
251
|
"""Mark a span as finished.
|
|
247
252
|
|
|
@@ -254,6 +259,8 @@ class SpanManager:
|
|
|
254
259
|
``"cancelled"`` (default: ``"complete"``). The span must be
|
|
255
260
|
``active`` for this to succeed; use ``cancel_unstarted()``
|
|
256
261
|
to cancel a span that was never started.
|
|
262
|
+
idempotency_key: Optional key to make repeated finish requests
|
|
263
|
+
duplicate-safe. When omitted, a new key is generated.
|
|
257
264
|
|
|
258
265
|
Raises:
|
|
259
266
|
KeyError: If the span ID is not known.
|
|
@@ -273,7 +280,7 @@ class SpanManager:
|
|
|
273
280
|
op_payload: dict[str, Any] = {
|
|
274
281
|
"span_id": span_id,
|
|
275
282
|
"status": status,
|
|
276
|
-
"idempotency_key": generate_idempotency_key(),
|
|
283
|
+
"idempotency_key": idempotency_key or generate_idempotency_key(),
|
|
277
284
|
}
|
|
278
285
|
if result_payload is not None:
|
|
279
286
|
op_payload["result_payload"] = result_payload
|
|
@@ -42,6 +42,8 @@ class TaskExecutor:
|
|
|
42
42
|
handler: Callable[[Any], Awaitable[None]],
|
|
43
43
|
num_workers: int = 3,
|
|
44
44
|
max_retries: int = 3,
|
|
45
|
+
*,
|
|
46
|
+
is_retryable: Callable[[Exception], bool] | None = None,
|
|
45
47
|
) -> None:
|
|
46
48
|
"""Initialize the task executor.
|
|
47
49
|
|
|
@@ -50,9 +52,12 @@ class TaskExecutor:
|
|
|
50
52
|
handler: Async function to process each item.
|
|
51
53
|
num_workers: Number of concurrent worker tasks.
|
|
52
54
|
max_retries: Maximum retry attempts per item.
|
|
55
|
+
is_retryable: Optional predicate that decides whether a
|
|
56
|
+
handler failure should be retried.
|
|
53
57
|
"""
|
|
54
58
|
self._queue = queue
|
|
55
59
|
self._handler = handler
|
|
60
|
+
self._is_retryable = is_retryable or (lambda exc: True)
|
|
56
61
|
self._num_workers = num_workers
|
|
57
62
|
self._max_retries = max_retries
|
|
58
63
|
self._workers: list[Task] = []
|
|
@@ -162,13 +167,17 @@ class TaskExecutor:
|
|
|
162
167
|
"""
|
|
163
168
|
last_error: Exception | None = None
|
|
164
169
|
|
|
165
|
-
|
|
170
|
+
total_attempts = self._max_retries + 1
|
|
171
|
+
|
|
172
|
+
for attempt in range(total_attempts):
|
|
166
173
|
try:
|
|
167
174
|
await self._handler(item)
|
|
168
175
|
return
|
|
169
176
|
except Exception as e:
|
|
170
177
|
last_error = e
|
|
171
|
-
if
|
|
178
|
+
if not self._is_retryable(e):
|
|
179
|
+
raise
|
|
180
|
+
if attempt < total_attempts - 1:
|
|
172
181
|
delay = 2**attempt # 1s, 2s, 4s
|
|
173
182
|
logger.warning(
|
|
174
183
|
f"Attempt {attempt + 1} failed, retrying in {delay}s: {e}"
|
|
@@ -6,7 +6,7 @@ for durability requirements.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from asyncio import Queue as AsyncQueue
|
|
9
|
-
from typing import TypeVar
|
|
9
|
+
from typing import TypeVar, cast
|
|
10
10
|
|
|
11
11
|
from .base import Queue, QueueClosedError
|
|
12
12
|
|
|
@@ -34,7 +34,7 @@ class InMemoryQueue(Queue[T]):
|
|
|
34
34
|
|
|
35
35
|
def __init__(self) -> None:
|
|
36
36
|
"""Initialize an empty in-memory queue."""
|
|
37
|
-
self._queue: AsyncQueue[T] = AsyncQueue()
|
|
37
|
+
self._queue: AsyncQueue[T | object] = AsyncQueue()
|
|
38
38
|
self._closed = False
|
|
39
39
|
|
|
40
40
|
async def put(self, item: T) -> None:
|
|
@@ -66,7 +66,7 @@ class InMemoryQueue(Queue[T]):
|
|
|
66
66
|
# Re-raise as closed so the worker exits cleanly.
|
|
67
67
|
if item is self._SENTINEL:
|
|
68
68
|
raise QueueClosedError("Queue is closed and empty")
|
|
69
|
-
return item
|
|
69
|
+
return cast(T, item)
|
|
70
70
|
|
|
71
71
|
def size(self) -> int:
|
|
72
72
|
"""Return the current number of items in the queue.
|
|
@@ -89,7 +89,7 @@ class InMemoryQueue(Queue[T]):
|
|
|
89
89
|
self._closed = True
|
|
90
90
|
# Wake any workers blocked in asyncio.Queue.get() so they can exit.
|
|
91
91
|
for _ in range(num_waiters):
|
|
92
|
-
await self._queue.put(self._SENTINEL)
|
|
92
|
+
await self._queue.put(self._SENTINEL)
|
|
93
93
|
|
|
94
94
|
@property
|
|
95
95
|
def closed(self) -> bool:
|
|
@@ -4,6 +4,8 @@ This module provides a SchemaRegistry that allows registration of span schemas
|
|
|
4
4
|
from multiple packages before agent instances are created.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
7
9
|
from typing import Any
|
|
8
10
|
|
|
9
11
|
|
|
@@ -131,12 +133,13 @@ class SchemaRegistry:
|
|
|
131
133
|
title: str | None = None,
|
|
132
134
|
description: str | None = None,
|
|
133
135
|
template: str | None = None,
|
|
136
|
+
data_risk: dict[str, Any] | None = None,
|
|
134
137
|
) -> None:
|
|
135
138
|
"""Register a full structured span type schema.
|
|
136
139
|
|
|
137
140
|
Adds to ``span_type_schemas``. This is the richest form and supports
|
|
138
141
|
all API fields: params schema, result schema, human-readable title,
|
|
139
|
-
description, and
|
|
142
|
+
description, template, and data risk classification.
|
|
140
143
|
|
|
141
144
|
Args:
|
|
142
145
|
name: Span type name (e.g., "agent:llm")
|
|
@@ -145,6 +148,25 @@ class SchemaRegistry:
|
|
|
145
148
|
title: Optional human-readable title (defaults to name on the API)
|
|
146
149
|
description: Optional description of the span type
|
|
147
150
|
template: Optional display template using ``{{field}}`` interpolation
|
|
151
|
+
data_risk: Optional data risk classification dict. See DataRisk model
|
|
152
|
+
in prefactor_http.models.agent_instance for structure. Must include:
|
|
153
|
+
- action_profile (object): Permitted actions with keys:
|
|
154
|
+
create_data, read_data, update_data, destroy_data,
|
|
155
|
+
financial_transactions, external_communication (values:
|
|
156
|
+
"unknown" | "allowed" | "disallowed")
|
|
157
|
+
- params_data_categories (object): Input data categories with keys
|
|
158
|
+
like personal_identifiers, contact_information,
|
|
159
|
+
financial_information, etc. (values: "unknown" | "included"
|
|
160
|
+
| "excluded")
|
|
161
|
+
- result_data_categories (object): Output data categories,
|
|
162
|
+
same structure as params_data_categories
|
|
163
|
+
All three top-level keys are required; fields within each default
|
|
164
|
+
to "unknown" when omitted.
|
|
165
|
+
Example: {
|
|
166
|
+
"action_profile": {"read_data": "allowed"},
|
|
167
|
+
"params_data_categories": {"personal_identifiers": "included"},
|
|
168
|
+
"result_data_categories": {},
|
|
169
|
+
}
|
|
148
170
|
|
|
149
171
|
Raises:
|
|
150
172
|
ValueError: If name is already registered as a span type schema.
|
|
@@ -161,6 +183,8 @@ class SchemaRegistry:
|
|
|
161
183
|
entry["description"] = description
|
|
162
184
|
if template is not None:
|
|
163
185
|
entry["template"] = template
|
|
186
|
+
if data_risk is not None:
|
|
187
|
+
entry["data_risk"] = data_risk
|
|
164
188
|
|
|
165
189
|
self._span_type_schemas[name] = entry
|
|
166
190
|
|