prefactor-core 0.1.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.
@@ -0,0 +1,57 @@
1
+ """Public API for prefactor-core.
2
+
3
+ This module exports the main classes and functions for the prefactor-core SDK.
4
+ """
5
+
6
+ from .client import PrefactorCoreClient
7
+ from .config import PrefactorCoreConfig, QueueConfig
8
+ from .context_stack import SpanContextStack
9
+ from .exceptions import (
10
+ ClientAlreadyInitializedError,
11
+ ClientNotInitializedError,
12
+ InstanceNotFoundError,
13
+ OperationError,
14
+ PrefactorCoreError,
15
+ SpanNotFoundError,
16
+ )
17
+ from .managers.agent_instance import AgentInstanceHandle
18
+ from .models import AgentInstance, Span
19
+ from .operations import Operation, OperationType
20
+ from .queue import InMemoryQueue, Queue, QueueClosedError, TaskExecutor
21
+ from .schema_registry import SchemaRegistry
22
+ from .span_context import SpanContext
23
+
24
+ __version__ = "0.1.0"
25
+
26
+ __all__ = [
27
+ # Client
28
+ "PrefactorCoreClient",
29
+ # Config
30
+ "PrefactorCoreConfig",
31
+ "QueueConfig",
32
+ # Context
33
+ "SpanContext",
34
+ "SpanContextStack",
35
+ # Exceptions
36
+ "PrefactorCoreError",
37
+ "ClientNotInitializedError",
38
+ "ClientAlreadyInitializedError",
39
+ "OperationError",
40
+ "InstanceNotFoundError",
41
+ "SpanNotFoundError",
42
+ # Models
43
+ "AgentInstance",
44
+ "Span",
45
+ # Operations
46
+ "Operation",
47
+ "OperationType",
48
+ # Queue
49
+ "Queue",
50
+ "QueueClosedError",
51
+ "InMemoryQueue",
52
+ "TaskExecutor",
53
+ # Handle
54
+ "AgentInstanceHandle",
55
+ # Schema Registry
56
+ "SchemaRegistry",
57
+ ]
@@ -0,0 +1,405 @@
1
+ """Main client for prefactor-core.
2
+
3
+ This module provides the PrefactorCoreClient, which is the main entry point
4
+ for the SDK. It manages the complete lifecycle of agent instances and spans
5
+ through an async queue-based architecture.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from contextlib import asynccontextmanager
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from prefactor_http.client import PrefactorHttpClient
15
+
16
+ from .config import PrefactorCoreConfig
17
+ from .context_stack import SpanContextStack
18
+ from .exceptions import (
19
+ ClientAlreadyInitializedError,
20
+ ClientNotInitializedError,
21
+ )
22
+ from .managers.agent_instance import AgentInstanceManager
23
+ from .managers.span import SpanManager
24
+ from .operations import Operation, OperationType
25
+ from .queue.base import Queue
26
+ from .queue.executor import TaskExecutor
27
+ from .queue.memory import InMemoryQueue
28
+
29
+ if TYPE_CHECKING:
30
+ from .managers.agent_instance import AgentInstanceHandle
31
+
32
+
33
+ class PrefactorCoreClient:
34
+ """Main entry point for the prefactor-core SDK.
35
+
36
+ This client provides a high-level interface for managing agent instances
37
+ and spans. All operations are queued and processed asynchronously, ensuring
38
+ minimal impact on agent execution flow.
39
+
40
+ The client must be initialized before use, either by calling initialize()
41
+ or using it as an async context manager.
42
+
43
+ Example:
44
+ config = PrefactorCoreConfig(http_config=...)
45
+
46
+ async with PrefactorCoreClient(config) as client:
47
+ instance = await client.create_agent_instance(...)
48
+ await instance.start()
49
+
50
+ async with instance.span("agent:llm") as span:
51
+ span.set_payload({"model": "gpt-4"})
52
+ # Your agent logic here
53
+
54
+ await instance.finish()
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ config: PrefactorCoreConfig,
60
+ queue: Queue[Operation] | None = None,
61
+ ) -> None:
62
+ """Initialize the client.
63
+
64
+ Args:
65
+ config: Configuration for the client.
66
+ queue: Optional custom queue implementation. If not provided,
67
+ an InMemoryQueue is used.
68
+ """
69
+ self._config = config
70
+ self._queue = queue or InMemoryQueue()
71
+ self._http: PrefactorHttpClient | None = None
72
+ self._executor: TaskExecutor | None = None
73
+ self._instance_manager: AgentInstanceManager | None = None
74
+ self._span_manager: SpanManager | None = None
75
+ self._initialized = False
76
+
77
+ async def __aenter__(self) -> "PrefactorCoreClient":
78
+ """Enter async context manager."""
79
+ await self.initialize()
80
+ return self
81
+
82
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
83
+ """Exit async context manager."""
84
+ await self.close()
85
+
86
+ async def initialize(self) -> None:
87
+ """Initialize the client and start processing.
88
+
89
+ This method:
90
+ 1. Initializes the HTTP client
91
+ 2. Starts the task executor
92
+ 3. Initializes managers
93
+
94
+ Raises:
95
+ ClientAlreadyInitializedError: If already initialized.
96
+ """
97
+ if self._initialized:
98
+ raise ClientAlreadyInitializedError("Client is already initialized")
99
+
100
+ # Initialize HTTP client
101
+ self._http = PrefactorHttpClient(self._config.http_config)
102
+ await self._http.__aenter__()
103
+
104
+ # Initialize executor
105
+ self._executor = TaskExecutor(
106
+ queue=self._queue,
107
+ handler=self._process_operation,
108
+ num_workers=self._config.queue_config.num_workers,
109
+ max_retries=self._config.queue_config.max_retries,
110
+ )
111
+ self._executor.start()
112
+
113
+ # Initialize managers
114
+ self._instance_manager = AgentInstanceManager(
115
+ http_client=self._http,
116
+ enqueue=self._enqueue,
117
+ )
118
+ self._span_manager = SpanManager(
119
+ http_client=self._http,
120
+ enqueue=self._enqueue,
121
+ )
122
+
123
+ self._initialized = True
124
+
125
+ async def close(self) -> None:
126
+ """Close the client and cleanup resources.
127
+
128
+ This method gracefully shuts down the executor and closes the
129
+ HTTP client. It should be called when the client is no longer needed.
130
+ """
131
+ if not self._initialized:
132
+ return
133
+
134
+ # Stop executor
135
+ if self._executor:
136
+ await self._executor.stop()
137
+
138
+ # Close HTTP client
139
+ if self._http:
140
+ await self._http.__aexit__(None, None, None)
141
+
142
+ self._initialized = False
143
+
144
+ def _ensure_initialized(self) -> None:
145
+ """Ensure the client is initialized.
146
+
147
+ Raises:
148
+ ClientNotInitializedError: If not initialized.
149
+ """
150
+ if not self._initialized:
151
+ raise ClientNotInitializedError(
152
+ "Client is not initialized. Call initialize() or "
153
+ "use as context manager."
154
+ )
155
+
156
+ async def _enqueue(self, operation: Operation) -> None:
157
+ """Add an operation to the queue.
158
+
159
+ Args:
160
+ operation: The operation to queue.
161
+ """
162
+ await self._queue.put(operation)
163
+
164
+ async def _process_operation(self, operation: Operation) -> None:
165
+ """Process a single operation from the queue.
166
+
167
+ This method routes operations to the appropriate handler based on type.
168
+
169
+ Args:
170
+ operation: The operation to process.
171
+ """
172
+ if not self._http:
173
+ return
174
+
175
+ try:
176
+ if operation.type == OperationType.REGISTER_AGENT_INSTANCE:
177
+ await self._http.agent_instances.register(
178
+ agent_id=operation.payload["agent_id"],
179
+ agent_version=operation.payload["agent_version"],
180
+ agent_schema_version=operation.payload["agent_schema_version"],
181
+ id=operation.payload.get("id"),
182
+ )
183
+
184
+ elif operation.type == OperationType.START_AGENT_INSTANCE:
185
+ await self._http.agent_instances.start(
186
+ agent_instance_id=operation.payload["instance_id"],
187
+ timestamp=operation.timestamp,
188
+ )
189
+
190
+ elif operation.type == OperationType.FINISH_AGENT_INSTANCE:
191
+ await self._http.agent_instances.finish(
192
+ agent_instance_id=operation.payload["instance_id"],
193
+ timestamp=operation.timestamp,
194
+ )
195
+ elif operation.type == OperationType.CREATE_SPAN:
196
+ await self._http.agent_spans.create(
197
+ agent_instance_id=operation.payload["instance_id"],
198
+ schema_name=operation.payload["schema_name"],
199
+ status=operation.payload.get("status", "pending"),
200
+ id=operation.payload.get("span_id"),
201
+ parent_span_id=operation.payload.get("parent_span_id"),
202
+ payload=operation.payload.get("payload"),
203
+ )
204
+
205
+ elif operation.type == OperationType.FINISH_SPAN:
206
+ await self._http.agent_spans.finish(
207
+ agent_span_id=operation.payload["span_id"],
208
+ status=operation.payload.get("status", "complete"),
209
+ result_payload=operation.payload.get("result_payload"),
210
+ )
211
+
212
+ # Note: UPDATE_SPAN_PAYLOAD requires API support or batching
213
+
214
+ except Exception as e:
215
+ # Log error but don't re-raise - we don't want to crash the worker
216
+ import logging
217
+
218
+ logger = logging.getLogger(__name__)
219
+ logger.error(
220
+ f"Failed to process operation {operation.type}: {e}",
221
+ exc_info=True,
222
+ )
223
+ raise
224
+
225
+ async def create_agent_instance(
226
+ self,
227
+ agent_id: str,
228
+ agent_version: dict[str, Any],
229
+ agent_schema_version: dict[str, Any] | None = None,
230
+ instance_id: str | None = None,
231
+ external_schema_version_id: str | None = None,
232
+ ) -> "AgentInstanceHandle":
233
+ """Create a new agent instance.
234
+
235
+ Returns immediately with a handle. The actual registration happens
236
+ asynchronously via the queue.
237
+
238
+ If agent_schema_version is not provided but the client has a schema_registry,
239
+ the registry's schemas will be used automatically.
240
+
241
+ Args:
242
+ agent_id: ID of the agent to create an instance for.
243
+ agent_version: Version information (name, etc.).
244
+ agent_schema_version: Schema version. Uses registry if not provided
245
+ and registry is configured.
246
+ instance_id: Optional custom ID for the instance.
247
+ external_schema_version_id: Optional external identifier for the
248
+ schema version. Defaults to "auto-generated" when using registry.
249
+
250
+ Returns:
251
+ AgentInstanceHandle for the created instance.
252
+
253
+ Raises:
254
+ ClientNotInitializedError: If the client is not initialized.
255
+ ValueError: If no schema version provided and registry not configured.
256
+ """
257
+ self._ensure_initialized()
258
+ assert self._instance_manager is not None
259
+
260
+ # Determine the agent_schema_version to use
261
+ final_schema_version: dict[str, Any]
262
+ if agent_schema_version is not None:
263
+ final_schema_version = agent_schema_version
264
+ elif self._config.schema_registry is not None:
265
+ # Use registry to generate schema version
266
+ ext_id = external_schema_version_id
267
+ if ext_id is None:
268
+ ext_id = f"auto-{agent_id}-{time.time()}"
269
+ final_schema_version = self._config.schema_registry.to_agent_schema_version(
270
+ ext_id
271
+ )
272
+ else:
273
+ msg1 = "agent_schema_version required when no schema_registry configured"
274
+ msg2 = "Either provide agent_schema_version or configure a SchemaRegistry"
275
+ raise ValueError(f"{msg1}. {msg2}.")
276
+
277
+ # Import here to avoid circular import
278
+ from .managers.agent_instance import AgentInstanceHandle
279
+
280
+ instance_id = await self._instance_manager.register(
281
+ agent_id=agent_id,
282
+ agent_version=agent_version,
283
+ agent_schema_version=final_schema_version,
284
+ instance_id=instance_id,
285
+ )
286
+
287
+ return AgentInstanceHandle(
288
+ instance_id=instance_id,
289
+ client=self,
290
+ )
291
+
292
+ async def create_span(
293
+ self,
294
+ instance_id: str,
295
+ schema_name: str,
296
+ parent_span_id: str | None = None,
297
+ payload: dict[str, Any] | None = None,
298
+ ) -> str:
299
+ """Create a span and return its ID without finishing it.
300
+
301
+ Use this for spans that need to stay open across multiple operations.
302
+ Call finish_span() when done.
303
+
304
+ Args:
305
+ instance_id: ID of the agent instance this span belongs to.
306
+ schema_name: Name of the schema for this span.
307
+ parent_span_id: Optional explicit parent span ID.
308
+ payload: Optional initial payload (params/inputs) stored on creation.
309
+
310
+ Returns:
311
+ The span ID.
312
+ """
313
+ self._ensure_initialized()
314
+ assert self._span_manager is not None
315
+
316
+ if parent_span_id is None:
317
+ parent_span_id = SpanContextStack.peek()
318
+
319
+ return await self._span_manager.create(
320
+ instance_id=instance_id,
321
+ schema_name=schema_name,
322
+ parent_span_id=parent_span_id,
323
+ payload=payload,
324
+ )
325
+
326
+ async def finish_span(
327
+ self,
328
+ span_id: str,
329
+ result_payload: dict[str, Any] | None = None,
330
+ ) -> None:
331
+ """Finish a previously created span.
332
+
333
+ Args:
334
+ span_id: The ID of the span to finish.
335
+ result_payload: Optional result data to store on the span.
336
+ """
337
+ self._ensure_initialized()
338
+ assert self._span_manager is not None
339
+
340
+ await self._span_manager.finish(span_id, result_payload=result_payload)
341
+
342
+ @asynccontextmanager
343
+ async def span(
344
+ self,
345
+ instance_id: str,
346
+ schema_name: str,
347
+ parent_span_id: str | None = None,
348
+ span_id: str | None = None,
349
+ payload: dict[str, Any] | None = None,
350
+ ):
351
+ """Context manager for creating and finishing a span.
352
+
353
+ If parent_span_id is not provided, the current span from the
354
+ SpanContextStack is used as the parent.
355
+
356
+ The returned :class:`SpanContext` supports an explicit lifecycle:
357
+
358
+ 1. ``await span.start(payload)`` — POST the span to the API.
359
+ 2. Do work.
360
+ 3. ``await span.complete(result)`` / ``span.fail(result)`` /
361
+ ``span.cancel()`` — finish with a specific status.
362
+
363
+ If ``start()`` or a finish method is not called explicitly, the
364
+ context manager handles them automatically on exit.
365
+
366
+ Args:
367
+ instance_id: ID of the agent instance this span belongs to.
368
+ schema_name: Name of the schema for this span.
369
+ parent_span_id: Optional explicit parent span ID.
370
+ span_id: Ignored (API generates IDs).
371
+ payload: Optional initial payload sent via auto-start on exit
372
+ if ``start()`` is never called explicitly.
373
+
374
+ Yields:
375
+ SpanContext for the created span.
376
+ """
377
+ self._ensure_initialized()
378
+ assert self._span_manager is not None
379
+
380
+ # Import here to avoid circular import
381
+ from .span_context import SpanContext
382
+
383
+ # Auto-detect parent from stack if not explicit
384
+ if parent_span_id is None:
385
+ parent_span_id = SpanContextStack.peek()
386
+
387
+ temp_id = self._span_manager.prepare(
388
+ instance_id=instance_id,
389
+ schema_name=schema_name,
390
+ parent_span_id=parent_span_id,
391
+ )
392
+
393
+ context = SpanContext(
394
+ temp_id=temp_id,
395
+ span_manager=self._span_manager,
396
+ default_payload=payload,
397
+ )
398
+
399
+ try:
400
+ yield context
401
+ finally:
402
+ await context.finish()
403
+
404
+
405
+ __all__ = ["PrefactorCoreClient"]
@@ -0,0 +1,60 @@
1
+ """Configuration for prefactor-core.
2
+
3
+ This module contains configuration classes for the prefactor-core SDK.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from prefactor_http.config import HttpClientConfig
11
+ from pydantic import BaseModel, Field
12
+
13
+ if TYPE_CHECKING:
14
+ pass
15
+
16
+
17
+ class QueueConfig(BaseModel):
18
+ """Configuration for queue processing.
19
+
20
+ Attributes:
21
+ num_workers: Number of concurrent worker tasks.
22
+ max_retries: Maximum retry attempts per failed operation.
23
+ retry_delay_base: Base delay for exponential backoff (seconds).
24
+ """
25
+
26
+ num_workers: int = Field(default=3, ge=1, le=20)
27
+ max_retries: int = Field(default=3, ge=0)
28
+ retry_delay_base: float = Field(default=1.0, gt=0)
29
+
30
+
31
+ class PrefactorCoreConfig(BaseModel):
32
+ """Complete configuration for PrefactorCoreClient.
33
+
34
+ Attributes:
35
+ http_config: Configuration for the HTTP client.
36
+ queue_config: Configuration for queue processing.
37
+ schema_registry: Optional schema registry for aggregating span type definitions.
38
+
39
+ Example:
40
+ from prefactor_core.schema_registry import SchemaRegistry
41
+ from prefactor_core import PrefactorCoreConfig
42
+
43
+ registry = SchemaRegistry()
44
+ registry.register("langchain:llm", {"type": "object"})
45
+
46
+ config = PrefactorCoreConfig(
47
+ http_config=HttpClientConfig(...),
48
+ schema_registry=registry
49
+ )
50
+ """
51
+
52
+ http_config: HttpClientConfig
53
+ queue_config: QueueConfig = Field(default_factory=QueueConfig)
54
+ schema_registry: Any = Field(
55
+ default=None,
56
+ description="Optional SchemaRegistry for aggregating span type definitions",
57
+ )
58
+
59
+
60
+ __all__ = ["QueueConfig", "PrefactorCoreConfig"]
@@ -0,0 +1,118 @@
1
+ """Span context stack for managing nested span relationships.
2
+
3
+ The SpanContextStack provides a stack-based context tracking system for
4
+ nested spans. Each async context maintains its own stack of active span IDs,
5
+ allowing automatic parent detection when creating new spans.
6
+
7
+ This module uses contextvars to ensure proper isolation between concurrent
8
+ async operations.
9
+ """
10
+
11
+ from contextvars import ContextVar
12
+ from typing import Optional
13
+
14
+ # Context variable to hold the span stack for the current async context
15
+ # Each async task gets its own copy of this variable
16
+ _current_span_stack: ContextVar[list[str]] = ContextVar("span_stack", default=[])
17
+
18
+
19
+ class SpanContextStack:
20
+ """Manages a stack of active span IDs within an async context.
21
+
22
+ The stack tracks the hierarchy of nested spans. The top of the stack
23
+ is always the current (innermost) active span, which serves as the
24
+ default parent for any new spans created within the same context.
25
+
26
+ This class uses contextvars to ensure that each async task maintains
27
+ its own independent stack, preventing interference between concurrent
28
+ operations.
29
+
30
+ Example:
31
+ # Root span
32
+ SpanContextStack.push("span-1")
33
+ assert SpanContextStack.peek() == "span-1"
34
+
35
+ # Nested span (child of span-1)
36
+ SpanContextStack.push("span-2")
37
+ assert SpanContextStack.peek() == "span-2"
38
+
39
+ # Exit nested span
40
+ SpanContextStack.pop()
41
+ assert SpanContextStack.peek() == "span-1"
42
+
43
+ # Exit root span
44
+ SpanContextStack.pop()
45
+ assert SpanContextStack.peek() is None
46
+ """
47
+
48
+ @classmethod
49
+ def get_stack(cls) -> list[str]:
50
+ """Get the current span stack for this async context.
51
+
52
+ Returns:
53
+ A list of span IDs, from outermost to innermost.
54
+ Returns an empty list if no spans are active.
55
+ """
56
+ return _current_span_stack.get()
57
+
58
+ @classmethod
59
+ def push(cls, span_id: str) -> None:
60
+ """Push a span ID onto the stack.
61
+
62
+ This marks the span as the current (innermost) active span.
63
+
64
+ Args:
65
+ span_id: The ID of the span to push onto the stack.
66
+ """
67
+ stack = cls.get_stack()
68
+ new_stack = stack + [span_id]
69
+ _current_span_stack.set(new_stack)
70
+
71
+ @classmethod
72
+ def pop(cls) -> Optional[str]:
73
+ """Pop and return the current span ID from the stack.
74
+
75
+ Returns:
76
+ The span ID that was removed from the stack, or None
77
+ if the stack was empty.
78
+ """
79
+ stack = cls.get_stack()
80
+ if not stack:
81
+ return None
82
+
83
+ span_id = stack[-1]
84
+ new_stack = stack[:-1]
85
+ _current_span_stack.set(new_stack)
86
+ return span_id
87
+
88
+ @classmethod
89
+ def peek(cls) -> Optional[str]:
90
+ """Get the current span ID without removing it from the stack.
91
+
92
+ Returns:
93
+ The ID of the current (innermost) span, or None if no
94
+ spans are active in this context.
95
+ """
96
+ stack = cls.get_stack()
97
+ return stack[-1] if stack else None
98
+
99
+ @classmethod
100
+ def depth(cls) -> int:
101
+ """Get the current nesting depth.
102
+
103
+ Returns:
104
+ The number of active spans in the stack (0 if empty).
105
+ """
106
+ return len(cls.get_stack())
107
+
108
+ @classmethod
109
+ def is_empty(cls) -> bool:
110
+ """Check if the stack is empty.
111
+
112
+ Returns:
113
+ True if no spans are currently active.
114
+ """
115
+ return len(cls.get_stack()) == 0
116
+
117
+
118
+ __all__ = ["SpanContextStack"]
@@ -0,0 +1,49 @@
1
+ """Custom exceptions for prefactor-core."""
2
+
3
+
4
+ class PrefactorCoreError(Exception):
5
+ """Base exception for all prefactor-core errors."""
6
+
7
+ pass
8
+
9
+
10
+ class ClientNotInitializedError(PrefactorCoreError):
11
+ """Raised when attempting to use a client that hasn't been initialized."""
12
+
13
+ pass
14
+
15
+
16
+ class ClientAlreadyInitializedError(PrefactorCoreError):
17
+ """Raised when attempting to initialize a client that's already initialized."""
18
+
19
+ pass
20
+
21
+
22
+ class OperationError(PrefactorCoreError):
23
+ """Raised when an operation fails to process."""
24
+
25
+ def __init__(self, message: str, operation_type: str | None = None) -> None:
26
+ super().__init__(message)
27
+ self.operation_type = operation_type
28
+
29
+
30
+ class InstanceNotFoundError(PrefactorCoreError):
31
+ """Raised when an agent instance is not found."""
32
+
33
+ pass
34
+
35
+
36
+ class SpanNotFoundError(PrefactorCoreError):
37
+ """Raised when a span is not found."""
38
+
39
+ pass
40
+
41
+
42
+ __all__ = [
43
+ "PrefactorCoreError",
44
+ "ClientNotInitializedError",
45
+ "ClientAlreadyInitializedError",
46
+ "OperationError",
47
+ "InstanceNotFoundError",
48
+ "SpanNotFoundError",
49
+ ]
@@ -0,0 +1,6 @@
1
+ """Manager exports for prefactor-core."""
2
+
3
+ from .agent_instance import AgentInstanceHandle, AgentInstanceManager
4
+ from .span import SpanManager
5
+
6
+ __all__ = ["AgentInstanceManager", "AgentInstanceHandle", "SpanManager"]