agentexec 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,267 @@
1
+ import uuid
2
+
3
+ from sqlalchemy.orm import Session
4
+
5
+ from agentexec.activity.models import Activity, ActivityLog, Status
6
+ from agentexec.activity.schemas import (
7
+ ActivityDetailSchema,
8
+ ActivityListItemSchema,
9
+ ActivityListSchema,
10
+ )
11
+
12
+
13
+ def generate_agent_id() -> uuid.UUID:
14
+ """Generate a new UUID for an agent.
15
+
16
+ This is the centralized function for generating agent IDs.
17
+ Users can override this if they need custom ID generation logic.
18
+
19
+ Returns:
20
+ A new UUID4 object
21
+ """
22
+ return uuid.uuid4()
23
+
24
+
25
+ def normalize_agent_id(agent_id: str | uuid.UUID) -> uuid.UUID:
26
+ """Normalize agent_id to UUID object.
27
+
28
+ Args:
29
+ agent_id: Either a string UUID or UUID object
30
+
31
+ Returns:
32
+ UUID object
33
+
34
+ Raises:
35
+ ValueError: If string is not a valid UUID
36
+ """
37
+ if isinstance(agent_id, str):
38
+ return uuid.UUID(agent_id)
39
+ return agent_id
40
+
41
+
42
+ def create(
43
+ task_name: str,
44
+ message: str = "Agent queued",
45
+ agent_id: str | uuid.UUID | None = None,
46
+ session: Session | None = None,
47
+ ) -> uuid.UUID:
48
+ """Create a new agent activity record with initial queued status.
49
+
50
+ Args:
51
+ task_name: Name/type of the task (e.g., "research", "analysis")
52
+ initial_message: Initial log message (default: "Agent queued")
53
+ agent_id: Optional custom agent ID (string or UUID). If not provided, one will be auto-generated.
54
+
55
+ Returns:
56
+ The agent_id (as UUID object) of the created record
57
+ """
58
+ agent_id = normalize_agent_id(agent_id) if agent_id else generate_agent_id()
59
+
60
+ if session is None:
61
+ from agentexec.core.worker import get_worker_session
62
+ db = get_worker_session()
63
+ else:
64
+ db = session
65
+
66
+ activity_record = Activity(
67
+ agent_id=agent_id,
68
+ agent_type=task_name,
69
+ )
70
+ db.add(activity_record)
71
+ db.flush()
72
+
73
+ log = ActivityLog(
74
+ activity_id=activity_record.id,
75
+ message=message,
76
+ status=Status.QUEUED,
77
+ completion_percentage=0,
78
+ )
79
+ db.add(log)
80
+ db.commit()
81
+
82
+ return agent_id
83
+
84
+
85
+ def update(
86
+ agent_id: str | uuid.UUID,
87
+ message: str,
88
+ completion_percentage: int | None = None,
89
+ status: Status | None = None,
90
+ session: Session | None = None,
91
+ ) -> bool:
92
+ """Update an agent's activity by adding a new log message.
93
+
94
+ This function will set the status to RUNNING unless a different status is explicitly provided.
95
+
96
+ Args:
97
+ agent_id: The agent_id of the agent to update
98
+ message: Log message to append
99
+ completion_percentage: Optional completion percentage (0-100)
100
+ status: Optional status to set (default: RUNNING)
101
+ session: Optional SQLAlchemy session. If not provided, uses global session factory.
102
+
103
+ Returns:
104
+ True if successful
105
+
106
+ Raises:
107
+ ValueError: If agent_id not found
108
+ """
109
+ if session is None:
110
+ from agentexec.core.worker import get_worker_session
111
+ db = get_worker_session()
112
+ else:
113
+ db = session
114
+
115
+ Activity.append_log(
116
+ session=db,
117
+ agent_id=normalize_agent_id(agent_id),
118
+ message=message,
119
+ status=status if status else Status.RUNNING,
120
+ completion_percentage=completion_percentage,
121
+ )
122
+ return True
123
+
124
+
125
+ def complete(
126
+ agent_id: str | uuid.UUID,
127
+ message: str = "Agent completed",
128
+ completion_percentage: int = 100,
129
+ session: Session | None = None,
130
+ ) -> bool:
131
+ """Mark an agent activity as complete.
132
+
133
+ Args:
134
+ agent_id: The agent_id of the agent to mark as complete
135
+ message: Log message (default: "Agent completed")
136
+ completion_percentage: Completion percentage (default: 100)
137
+ session: Optional SQLAlchemy session. If not provided, uses global session factory.
138
+
139
+ Returns:
140
+ True if successful
141
+
142
+ Raises:
143
+ ValueError: If agent_id not found
144
+ """
145
+ if session is None:
146
+ from agentexec.core.worker import get_worker_session
147
+ db = get_worker_session()
148
+ else:
149
+ db = session
150
+
151
+ Activity.append_log(
152
+ session=db,
153
+ agent_id=normalize_agent_id(agent_id),
154
+ message=message,
155
+ status=Status.COMPLETE,
156
+ completion_percentage=completion_percentage,
157
+ )
158
+ return True
159
+
160
+
161
+ def error(
162
+ agent_id: str | uuid.UUID,
163
+ message: str = "Agent failed",
164
+ completion_percentage: int = 100,
165
+ session: Session | None = None,
166
+ ) -> bool:
167
+ """Mark an agent activity as failed.
168
+
169
+ Args:
170
+ agent_id: The agent_id of the agent to mark as failed
171
+ message: Log message (default: "Agent failed")
172
+ completion_percentage: Completion percentage (default: 100)
173
+ session: Optional SQLAlchemy session. If not provided, uses global session factory.
174
+
175
+ Returns:
176
+ True if successful
177
+
178
+ Raises:
179
+ ValueError: If agent_id not found
180
+ """
181
+ if session is None:
182
+ from agentexec.core.worker import get_worker_session
183
+ db = get_worker_session()
184
+ else:
185
+ db = session
186
+
187
+ Activity.append_log(
188
+ session=db,
189
+ agent_id=normalize_agent_id(agent_id),
190
+ message=message,
191
+ status=Status.ERROR,
192
+ completion_percentage=completion_percentage,
193
+ )
194
+ return True
195
+
196
+
197
+ def cancel_pending(
198
+ session: Session | None = None,
199
+ ) -> int:
200
+ """Mark all queued and running agents as canceled.
201
+
202
+ Useful during application shutdown to clean up pending tasks.
203
+
204
+ Returns:
205
+ Number of agents that were canceled
206
+ """
207
+ if session is None:
208
+ from agentexec.core.worker import get_worker_session
209
+ db = get_worker_session()
210
+ else:
211
+ db = session
212
+
213
+ pending_agent_ids = Activity.get_pending_ids(db)
214
+ for agent_id in pending_agent_ids:
215
+ Activity.append_log(
216
+ session=db,
217
+ agent_id=agent_id,
218
+ message="Canceled due to shutdown",
219
+ status=Status.CANCELED,
220
+ completion_percentage=None,
221
+ )
222
+
223
+ return len(pending_agent_ids)
224
+
225
+
226
+ def list(
227
+ session: Session,
228
+ page: int = 1,
229
+ page_size: int = 50,
230
+ ) -> ActivityListSchema:
231
+ """List activities with pagination.
232
+
233
+ Args:
234
+ session: SQLAlchemy session to use for the query
235
+ page: Page number (1-indexed)
236
+ page_size: Number of items per page
237
+
238
+ Returns:
239
+ ActivityList with list of ActivityListItemSchema items
240
+ """
241
+ total = session.query(Activity).count()
242
+ rows = Activity.get_list(session, page=page, page_size=page_size)
243
+
244
+ return ActivityListSchema(
245
+ items=[ActivityListItemSchema.model_validate(row) for row in rows],
246
+ total=total,
247
+ page=page,
248
+ page_size=page_size,
249
+ )
250
+
251
+
252
+ def detail(
253
+ session: Session,
254
+ agent_id: str | uuid.UUID,
255
+ ) -> ActivityDetailSchema | None:
256
+ """Get a single activity by agent_id with all logs.
257
+
258
+ Args:
259
+ session: SQLAlchemy session to use for the query
260
+ agent_id: The agent_id to look up
261
+
262
+ Returns:
263
+ ActivityDetailSchema with full log history, or None if not found
264
+ """
265
+ if item := Activity.get_by_agent_id(session, agent_id):
266
+ return ActivityDetailSchema.model_validate(item)
267
+ return None
agentexec/config.py ADDED
@@ -0,0 +1,72 @@
1
+ from pydantic import AliasChoices, Field
2
+ from pydantic_settings import BaseSettings, SettingsConfigDict
3
+
4
+
5
+ class Config(BaseSettings):
6
+ model_config = SettingsConfigDict(
7
+ env_file=".env",
8
+ env_file_encoding="utf-8",
9
+ case_sensitive=False,
10
+ )
11
+
12
+ debug: bool = Field(
13
+ default=False,
14
+ description="Enable debug logging",
15
+ validation_alias=AliasChoices("AGENTEXEC_DEBUG", "DEBUG"),
16
+ )
17
+
18
+ table_prefix: str = Field(
19
+ default="agentexec_",
20
+ description="Prefix for database table names",
21
+ validation_alias="AGENTEXEC_TABLE_PREFIX",
22
+ )
23
+ queue_name: str = Field(
24
+ default="agentexec_tasks",
25
+ description="Name of the Redis list to use as task queue",
26
+ validation_alias="AGENTEXEC_QUEUE_NAME",
27
+ )
28
+ num_workers: int = Field(
29
+ default=4,
30
+ description="Number of worker processes to spawn",
31
+ validation_alias="AGENTEXEC_NUM_WORKERS",
32
+ )
33
+ graceful_shutdown_timeout: int = Field(
34
+ default=300,
35
+ description="Maximum seconds to wait for workers to finish on shutdown",
36
+ validation_alias="AGENTEXEC_GRACEFUL_SHUTDOWN_TIMEOUT",
37
+ )
38
+
39
+ activity_message_create: str = Field(
40
+ default="Waiting to start.",
41
+ description="Default message when creating a new agent activity",
42
+ validation_alias="AGENTEXEC_ACTIVITY_MESSAGE_CREATE",
43
+ )
44
+ activity_message_complete: str = Field(
45
+ default="Completed successfully.",
46
+ description="Default message when an agent activity completes successfully",
47
+ validation_alias="AGENTEXEC_ACTIVITY_MESSAGE_COMPLETE",
48
+ )
49
+ activity_message_error: str = Field(
50
+ default="An error occurred during execution.",
51
+ description="Default message when an agent activity encounters an error",
52
+ validation_alias="AGENTEXEC_ACTIVITY_MESSAGE_ERROR",
53
+ )
54
+
55
+ redis_url: str = Field(
56
+ default="redis://localhost:6379/0",
57
+ description="Redis connection URL",
58
+ validation_alias=AliasChoices("AGENTEXEC_REDIS_URL", "REDIS_URL"),
59
+ )
60
+ redis_pool_size: int = Field(
61
+ default=10,
62
+ description="Redis connection pool size",
63
+ validation_alias=AliasChoices("AGENTEXEC_REDIS_POOL_SIZE", "REDIS_POOL_SIZE"),
64
+ )
65
+ redis_pool_timeout: int = Field(
66
+ default=5,
67
+ description="Redis connection pool timeout in seconds",
68
+ validation_alias=AliasChoices("AGENTEXEC_REDIS_POOL_TIMEOUT", "REDIS_POOL_TIMEOUT"),
69
+ )
70
+
71
+
72
+ CONF = Config()
File without changes
@@ -0,0 +1,23 @@
1
+ """Database configuration for agent-runner.
2
+
3
+ This module provides the declarative base class for all SQLAlchemy models.
4
+ """
5
+
6
+ from sqlalchemy.orm import DeclarativeBase
7
+
8
+
9
+ class Base(DeclarativeBase):
10
+ """Base class for all SQLAlchemy models in agent-runner.
11
+
12
+ Users can reference this base in their Alembic migrations to include
13
+ agent-runner's tables alongside their own application tables.
14
+
15
+ Example:
16
+ # In alembic/env.py
17
+ import agentexec as ax
18
+ from models import Base
19
+
20
+ target_metadata = [Base.metadata, ax.Base.metadata]
21
+ """
22
+
23
+ pass
@@ -0,0 +1,109 @@
1
+ import logging
2
+ from enum import Enum
3
+ from typing import Any, cast
4
+
5
+ from agentexec.config import CONF
6
+ from agentexec.core.redis_client import get_redis
7
+ from agentexec.core.task import Task
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class Priority(str, Enum):
13
+ """Task priority levels.
14
+
15
+ HIGH: Push to front of queue (processed first).
16
+ LOW: Push to back of queue (processed later).
17
+ """
18
+
19
+ HIGH = "high"
20
+ LOW = "low"
21
+
22
+
23
+ def enqueue(
24
+ task_name: str,
25
+ payload: dict[str, Any],
26
+ *,
27
+ priority: Priority = Priority.LOW,
28
+ queue_name: str | None = None,
29
+ ) -> Task:
30
+ """Enqueue a task for background execution with automatic activity tracking.
31
+
32
+ This function automatically creates an activity record for the task and
33
+ enqueues it to Redis for worker processing.
34
+
35
+ Args:
36
+ task: Task to enqueue.
37
+ db: Database session for activity tracking.
38
+ priority: Task priority (Priority.HIGH or Priority.LOW).
39
+ queue_name: Redis list name to use. If not provided, uses
40
+ agentexec.CONF.queue_name.
41
+
42
+ Returns:
43
+ agent_id: UUID for tracking the task's progress.
44
+
45
+ Example:
46
+ task = Task(task_type="research", payload={"company": "Acme"})
47
+ agent_id = enqueue(task, db=session, priority=Priority.HIGH)
48
+ # Track progress: ax.activity.get_activity(db, agent_id)
49
+ """
50
+
51
+ try:
52
+ redis = get_redis()
53
+ redis_push = {
54
+ Priority.HIGH: redis.rpush,
55
+ Priority.LOW: redis.lpush,
56
+ }[priority]
57
+ except KeyError:
58
+ raise ValueError(f"Invalid priority: {priority}. Must be Priority.HIGH or Priority.LOW")
59
+
60
+ task = Task.create(
61
+ task_name=task_name,
62
+ payload=payload,
63
+ )
64
+ redis_push(
65
+ queue_name or CONF.queue_name,
66
+ task.model_dump_json(),
67
+ )
68
+ logger.info(f"Enqueued task {task.task_name} with agent_id {task.agent_id}")
69
+ # TODO track errors
70
+
71
+ return task
72
+
73
+
74
+ def dequeue(queue_name: str | None = None, timeout: int = 1) -> Task | None:
75
+ """Dequeue a task from the queue.
76
+
77
+ Blocks for up to timeout seconds waiting for a task. Returns None if
78
+ no task is available within the timeout period.
79
+
80
+ Args:
81
+ queue_name: Redis list name to use. If not provided, uses
82
+ agentexec.CONF.queue_name.
83
+ timeout: Maximum seconds to wait for a task.
84
+
85
+ Returns:
86
+ Task if available, None otherwise.
87
+
88
+ Example:
89
+ task = dequeue(timeout=5)
90
+ if task is not None:
91
+ # Process task
92
+ pass
93
+ """
94
+ redis = get_redis()
95
+
96
+ result = cast(
97
+ tuple[str, str] | None,
98
+ redis.brpop(
99
+ [queue_name or CONF.queue_name],
100
+ timeout=timeout,
101
+ ),
102
+ )
103
+
104
+ if result is None:
105
+ # No task available within the timeout
106
+ return None
107
+
108
+ _, task_json = result
109
+ return Task.model_validate_json(task_json)
@@ -0,0 +1,40 @@
1
+ from redis import Redis
2
+ from agentexec.config import CONF
3
+
4
+ _redis_client: Redis | None = None
5
+
6
+
7
+ def get_redis() -> Redis:
8
+ """Get the Redis client instance.
9
+
10
+ Creates and caches a Redis client on first call. Subsequent calls
11
+ return the cached instance.
12
+
13
+ The client is configured from agentexec.CONF settings:
14
+ - redis_url: Connection URL
15
+ - redis_pool_size: Connection pool size
16
+ - redis_pool_timeout: Connection timeout
17
+
18
+ Returns:
19
+ Redis client instance.
20
+ """
21
+ global _redis_client
22
+
23
+ if _redis_client is None:
24
+ _redis_client = Redis.from_url(
25
+ CONF.redis_url,
26
+ max_connections=CONF.redis_pool_size,
27
+ socket_connect_timeout=CONF.redis_pool_timeout,
28
+ decode_responses=True, # Automatically decode bytes to strings
29
+ )
30
+
31
+ return _redis_client
32
+
33
+
34
+ def close_redis() -> None:
35
+ """Close the Redis connection and reset the client."""
36
+ global _redis_client
37
+
38
+ if _redis_client is not None:
39
+ _redis_client.close()
40
+ _redis_client = None
agentexec/core/task.py ADDED
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Protocol, TypedDict, Unpack
3
+ from uuid import UUID
4
+ from pydantic import BaseModel, Field
5
+
6
+ from agentexec import activity
7
+
8
+
9
+ class TaskHandlerKwargs(TypedDict):
10
+ """Type for kwargs passed to task handlers.
11
+
12
+ Handlers receive the payload as a dict and agent_id as a separate parameter.
13
+ """
14
+
15
+ agent_id: UUID
16
+ payload: dict[str, Any]
17
+
18
+
19
+ class TaskHandler(Protocol):
20
+ """Protocol for task handler functions.
21
+
22
+ Handlers accept **kwargs matching HandlerKwargs structure.
23
+ Return value is ignored. Can be sync or async.
24
+ """
25
+
26
+ def __call__(self, **kwargs: Unpack[TaskHandlerKwargs]) -> None: ...
27
+
28
+
29
+ class Task(BaseModel):
30
+ """Represents a background task.
31
+
32
+ Tasks are serialized to JSON and enqueued to Redis for workers to process.
33
+ Each task has a type (matching a registered handler), a payload with
34
+ parameters, and an agent_id for tracking.
35
+ """
36
+
37
+ task_name: str
38
+ payload: dict[str, Any] = Field(default_factory=dict)
39
+ agent_id: UUID
40
+
41
+ @classmethod
42
+ def create(cls, task_name: str, payload: dict[str, Any]) -> Task:
43
+ """Create a new task with automatic activity tracking.
44
+
45
+ This is a convenience method that creates both a Task instance and
46
+ its corresponding activity record in one step.
47
+
48
+ Args:
49
+ task_name: Name/type of the task (e.g., "research", "analysis")
50
+ payload: Task parameters to pass to the handler
51
+
52
+ Returns:
53
+ Task instance with agent_id set
54
+
55
+ Example:
56
+ task = Task.create("research_company", {"company": "Acme Corp"})
57
+ # Activity record is created automatically
58
+ """
59
+ agent_id = activity.create(
60
+ task_name=task_name,
61
+ message="Waiting to start.",
62
+ )
63
+ return cls(
64
+ task_name=task_name,
65
+ payload=payload,
66
+ agent_id=agent_id,
67
+ )
68
+
69
+ def started(self) -> None:
70
+ """Mark the task as started in the activity log.
71
+
72
+ Updates the activity status to RUNNING with a starting message.
73
+
74
+ Example:
75
+ task = Task.create("research", {"company": "Acme"})
76
+ task.started()
77
+ """
78
+ activity.update(
79
+ agent_id=self.agent_id,
80
+ message="Task started.",
81
+ completion_percentage=0,
82
+ )
83
+
84
+ def completed(self) -> None:
85
+ """Mark the task as completed in the activity log.
86
+
87
+ Updates the activity status to COMPLETE with a completion message.
88
+
89
+ Example:
90
+ task = Task.create("research", {"company": "Acme"})
91
+ task.completed()
92
+ """
93
+ activity.update(
94
+ agent_id=self.agent_id,
95
+ message="Task completed successfully.",
96
+ completion_percentage=100,
97
+ status=activity.Status.COMPLETE,
98
+ )
99
+
100
+ def errored(self, exception: Exception) -> None:
101
+ """Mark the task as errored in the activity log.
102
+
103
+ Updates the activity status to ERROR with the provided error message.
104
+
105
+ Args:
106
+ error_message: Description of the error that occurred
107
+
108
+ Example:
109
+ task = Task.create("research", {"company": "Acme"})
110
+ task.error("Failed to fetch data from API.")
111
+ """
112
+ activity.update(
113
+ agent_id=self.agent_id,
114
+ message=f"Task failed with error: {exception}",
115
+ status=activity.Status.ERROR,
116
+ )
117
+
118
+ @property
119
+ def handler_kwargs(self) -> TaskHandlerKwargs:
120
+ """Get kwargs to pass to the task handler.
121
+
122
+ Builds a dictionary containing the task's payload and agent_id,
123
+ matching the TaskHandlerKwargs structure expected by handlers.
124
+
125
+ Returns:
126
+ Dict with 'payload' (task parameters) and 'agent_id' (tracking ID)
127
+
128
+ Example:
129
+ kwargs = task._handler_kwargs
130
+ # Returns: {"payload": {"company": "Acme"}, "agent_id": UUID(...)}
131
+ """
132
+ return {"agent_id": self.agent_id, "payload": self.payload}