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.
agentexec/__init__.py ADDED
@@ -0,0 +1,73 @@
1
+ """Agent Runner - Production-ready orchestration for OpenAI Agents.
2
+
3
+ This library provides:
4
+ - Background worker pool with Redis-backed task queue
5
+ - Activity tracking with pluggable storage backends
6
+ - OpenAI Agents SDK runner with max turns recovery
7
+ - Coordination primitives (counters, locks, barriers)
8
+ - Workflow engine for complex agent orchestration
9
+
10
+ Example:
11
+ from agents import Agent
12
+ import agentexec as ax
13
+
14
+
15
+ # create a pool to manage background tasks
16
+ pool = ax.Pool()
17
+
18
+ # register a background task
19
+ @pool.task("research_company")
20
+ async def research_company(payload: dict, agent_id: str):
21
+ runner = ax.OpenAIRunner(
22
+ status_tracking=True,
23
+ max_turns=5,
24
+ )
25
+ agent = Agent(tools=[runner.tools.report_status], ...)
26
+ result = await runner.run(
27
+ agent,
28
+ input=f"Research {payload['company_name']}",
29
+ )
30
+ return result
31
+
32
+ # fork and start the worker pool
33
+ pool.start()
34
+
35
+ # queue a task from anywhere
36
+ task = ax.Task(task_type="research_company", payload={"company_name": "Acme Corp"})
37
+ agent_id = ax.enqueue(task)
38
+ """
39
+
40
+ from importlib.metadata import PackageNotFoundError, version
41
+
42
+ from agentexec.config import CONF
43
+ from agentexec.core.models import Base
44
+ from agentexec.core.queue import Priority, dequeue, enqueue
45
+ from agentexec.core.task import Task, TaskHandler, TaskHandlerKwargs
46
+ from agentexec.core.worker import WorkerPool
47
+ from agentexec.runners import BaseAgentRunner
48
+
49
+ try:
50
+ __version__ = version("agent-runner")
51
+ except PackageNotFoundError:
52
+ __version__ = "0.0.0.dev"
53
+
54
+ __all__ = [
55
+ "CONF",
56
+ "Base",
57
+ "WorkerPool",
58
+ "TaskHandler",
59
+ "Task",
60
+ "TaskHandlerKwargs",
61
+ "Priority",
62
+ "enqueue",
63
+ "dequeue",
64
+ "BaseAgentRunner",
65
+ ]
66
+
67
+ # OpenAI runner is only available if agents package is installed
68
+ try:
69
+ from agentexec.runners import OpenAIRunner
70
+
71
+ __all__.append("OpenAIRunner")
72
+ except ImportError:
73
+ pass
@@ -0,0 +1,50 @@
1
+ """Activity tracking for agent execution."""
2
+
3
+ from agentexec.activity import tracker as activity
4
+ from agentexec.activity.models import Activity, ActivityLog, Status
5
+ from agentexec.activity.schemas import (
6
+ ActivityDetailSchema,
7
+ ActivityListItemSchema,
8
+ ActivityListSchema,
9
+ ActivityLogSchema,
10
+ )
11
+ from agentexec.activity.tracker import (
12
+ cancel_pending,
13
+ complete,
14
+ create,
15
+ detail,
16
+ error,
17
+ generate_agent_id,
18
+ list,
19
+ normalize_agent_id,
20
+ update,
21
+ )
22
+
23
+ __all__ = [
24
+ # Namespace for activity tracking
25
+ "activity",
26
+ # Models
27
+ "Activity",
28
+ "ActivityLog",
29
+ "Status",
30
+ # Schemas
31
+ "ActivityLogSchema",
32
+ "ActivityDetailSchema",
33
+ "ActivityListItemSchema",
34
+ "ActivityListSchema",
35
+ # UUID Helpers
36
+ "generate_agent_id",
37
+ "normalize_agent_id",
38
+ # Lifecycle API
39
+ "create",
40
+ "update",
41
+ "complete",
42
+ "error",
43
+ "cancel_pending",
44
+ # Query API
45
+ "list",
46
+ "detail",
47
+ ]
48
+
49
+ # Users manage their own database setup with SQLAlchemy
50
+ # See examples/fastapi-app/ for a complete example
@@ -0,0 +1,294 @@
1
+ from __future__ import annotations
2
+ from enum import Enum as PyEnum
3
+ import uuid
4
+ from datetime import UTC, datetime
5
+
6
+ from sqlalchemy import (
7
+ DateTime,
8
+ Enum,
9
+ ForeignKey,
10
+ Integer,
11
+ String,
12
+ Text,
13
+ Uuid,
14
+ case,
15
+ func,
16
+ insert,
17
+ select,
18
+ )
19
+ from sqlalchemy.engine import RowMapping
20
+ from sqlalchemy.orm import Mapped, Session, aliased, mapped_column, relationship, declared_attr
21
+
22
+ from agentexec.config import CONF
23
+ from agentexec.core.models import Base
24
+
25
+
26
+ class Status(str, PyEnum):
27
+ """Agent execution status."""
28
+
29
+ QUEUED = "queued"
30
+ RUNNING = "running"
31
+ COMPLETE = "complete"
32
+ ERROR = "error"
33
+ CANCELED = "canceled"
34
+
35
+
36
+ class Activity(Base):
37
+ """Tracks background agent execution sessions.
38
+
39
+ Each record represents a single agent run. The current status is inferred
40
+ from the latest log message.
41
+ """
42
+
43
+ @declared_attr.directive
44
+ def __tablename__(cls) -> str:
45
+ return f"{CONF.table_prefix}activity"
46
+
47
+ id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
48
+ agent_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, unique=True, index=True)
49
+ agent_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
50
+ created_at: Mapped[datetime] = mapped_column(
51
+ DateTime(timezone=True),
52
+ nullable=False,
53
+ default=lambda: datetime.now(UTC),
54
+ )
55
+ updated_at: Mapped[datetime] = mapped_column(
56
+ DateTime(timezone=True),
57
+ nullable=False,
58
+ default=lambda: datetime.now(UTC),
59
+ onupdate=lambda: datetime.now(UTC),
60
+ )
61
+
62
+ logs: Mapped[list[ActivityLog]] = relationship(
63
+ "ActivityLog",
64
+ back_populates="activity",
65
+ cascade="all, delete-orphan",
66
+ order_by="ActivityLog.created_at",
67
+ )
68
+
69
+ @classmethod
70
+ def append_log(
71
+ cls,
72
+ session: Session,
73
+ agent_id: uuid.UUID,
74
+ message: str,
75
+ status: Status,
76
+ completion_percentage: int | None = None,
77
+ ) -> None:
78
+ """Append a log entry to the activity for the given agent_id.
79
+
80
+ This uses a single query to look up the activity_id and insert the log,
81
+ avoiding the need to load the Activity record first.
82
+
83
+ Args:
84
+ session: SQLAlchemy session
85
+ agent_id: The agent_id to append the log to
86
+ message: Log message
87
+ status: Current status of the agent
88
+ completion_percentage: Optional completion percentage (0-100)
89
+
90
+ Raises:
91
+ ValueError: If agent_id not found (foreign key constraint will fail)
92
+ """
93
+ # Scalar subquery to get activity.id from agent_id
94
+ activity_id_subq = select(cls.id).where(cls.agent_id == agent_id).scalar_subquery()
95
+
96
+ # Insert the log using the subquery for activity_id
97
+ stmt = insert(ActivityLog).values(
98
+ activity_id=activity_id_subq,
99
+ message=message,
100
+ status=status,
101
+ completion_percentage=completion_percentage,
102
+ )
103
+
104
+ try:
105
+ session.execute(stmt)
106
+ session.commit()
107
+ except Exception as e:
108
+ session.rollback()
109
+ raise ValueError(f"Failed to append log for agent_id {agent_id}") from e
110
+
111
+ @classmethod
112
+ def get_by_agent_id(
113
+ cls,
114
+ session: Session,
115
+ agent_id: str | uuid.UUID,
116
+ ) -> Activity | None:
117
+ """Get an activity by agent_id.
118
+
119
+ Args:
120
+ session: SQLAlchemy session
121
+ agent_id: The agent_id to look up (string or UUID)
122
+
123
+ Returns:
124
+ Activity object or None if not found
125
+
126
+ Example:
127
+ activity = Activity.get_by_agent_id(session, "abc-123")
128
+ # Or with UUID object
129
+ activity = Activity.get_by_agent_id(session, uuid.UUID("abc-123..."))
130
+ if activity:
131
+ print(f"Found activity: {activity.agent_type}")
132
+ """
133
+ # Normalize to UUID if string
134
+ if isinstance(agent_id, str):
135
+ agent_id = uuid.UUID(agent_id)
136
+ return session.query(cls).filter_by(agent_id=agent_id).first()
137
+
138
+ @classmethod
139
+ def get_list(
140
+ cls,
141
+ session: Session,
142
+ page: int = 1,
143
+ page_size: int = 50,
144
+ ) -> list[RowMapping]:
145
+ """Get a paginated list of activities with summary information.
146
+
147
+ Args:
148
+ session: SQLAlchemy session to use for the query
149
+ page: Page number (1-indexed)
150
+ page_size: Number of items per page
151
+
152
+ Returns:
153
+ List of RowMapping objects (dict-like) with keys matching ActivitySummarySchema:
154
+ agent_id, agent_type, latest_log_message, status, latest_log_timestamp,
155
+ completion_percentage, started_at
156
+
157
+ Example:
158
+ results = Activity.get_list(session, page=1, page_size=20)
159
+ for row in results:
160
+ print(f"{row['agent_id']}: {row['latest_log_message']}")
161
+ """
162
+ # Subquery to get the latest log for each agent
163
+ latest_log_subq = select(
164
+ ActivityLog.activity_id,
165
+ ActivityLog.message,
166
+ ActivityLog.status,
167
+ ActivityLog.created_at,
168
+ ActivityLog.completion_percentage,
169
+ func.row_number()
170
+ .over(
171
+ partition_by=ActivityLog.activity_id,
172
+ order_by=ActivityLog.created_at.desc(),
173
+ )
174
+ .label("rn"),
175
+ ).subquery()
176
+
177
+ # Subquery to get start time (first log timestamp)
178
+ started_at_subq = (
179
+ select(
180
+ ActivityLog.activity_id,
181
+ func.min(ActivityLog.created_at).label("started_at"),
182
+ )
183
+ .group_by(ActivityLog.activity_id)
184
+ .subquery()
185
+ )
186
+
187
+ # Alias for the subqueries
188
+ latest_log = aliased(latest_log_subq)
189
+ started_at = aliased(started_at_subq)
190
+
191
+ # Build base query - select only the columns we need with aliases matching schema
192
+ query = (
193
+ select(
194
+ cls.agent_id,
195
+ cls.agent_type,
196
+ latest_log.c.message.label("latest_log_message"),
197
+ latest_log.c.status,
198
+ latest_log.c.created_at.label("latest_log_timestamp"),
199
+ latest_log.c.completion_percentage,
200
+ started_at.c.started_at,
201
+ )
202
+ .outerjoin(
203
+ latest_log,
204
+ (cls.id == latest_log.c.activity_id) & (latest_log.c.rn == 1),
205
+ )
206
+ .outerjoin(started_at, cls.id == started_at.c.activity_id)
207
+ )
208
+
209
+ # Custom ordering: active agents (running, queued) at the top
210
+ is_active = case(
211
+ (latest_log.c.status.in_([Status.RUNNING, Status.QUEUED]), 0),
212
+ else_=1,
213
+ )
214
+ active_priority = case(
215
+ (latest_log.c.status == Status.RUNNING, 1),
216
+ (latest_log.c.status == Status.QUEUED, 2),
217
+ else_=3,
218
+ )
219
+ query = query.order_by(
220
+ is_active, active_priority, started_at.c.started_at.desc().nullslast()
221
+ )
222
+
223
+ # Apply pagination and execute
224
+ offset = (page - 1) * page_size
225
+ return list(session.execute(query.offset(offset).limit(page_size)).mappings().all())
226
+
227
+ @classmethod
228
+ def get_pending_ids(cls, session: Session) -> list[uuid.UUID]:
229
+ """Get agent_ids for all activities with QUEUED or RUNNING status.
230
+
231
+ Args:
232
+ session: SQLAlchemy session to use for the query
233
+
234
+ Returns:
235
+ List of agent_id UUIDs for pending (queued or running) activities
236
+
237
+ Example:
238
+ pending_ids = Activity.get_pending_ids(session)
239
+ for agent_id in pending_ids:
240
+ print(f"Pending agent: {agent_id}")
241
+ """
242
+ # Subquery to get the latest log status for each activity
243
+ latest_log_subq = select(
244
+ ActivityLog.activity_id,
245
+ ActivityLog.status,
246
+ func.row_number()
247
+ .over(
248
+ partition_by=ActivityLog.activity_id,
249
+ order_by=ActivityLog.created_at.desc(),
250
+ )
251
+ .label("rn"),
252
+ ).subquery()
253
+
254
+ # Query for agent_ids where latest status is queued or running
255
+ result = (
256
+ session.query(cls.agent_id)
257
+ .join(
258
+ latest_log_subq,
259
+ (cls.id == latest_log_subq.c.activity_id) & (latest_log_subq.c.rn == 1),
260
+ )
261
+ .filter(latest_log_subq.c.status.in_([Status.QUEUED, Status.RUNNING]))
262
+ .all()
263
+ )
264
+
265
+ # Extract UUIDs from result tuples
266
+ return [agent_id for (agent_id,) in result]
267
+
268
+
269
+ class ActivityLog(Base):
270
+ """Individual log messages from background agents.
271
+
272
+ Each log entry represents a single update/message from an agent
273
+ during its execution, including the agent's status at that point in time.
274
+ """
275
+
276
+ @declared_attr.directive
277
+ def __tablename__(cls) -> str:
278
+ return f"{CONF.table_prefix}activity_log"
279
+
280
+ id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
281
+ activity_id: Mapped[uuid.UUID] = mapped_column(
282
+ Uuid, ForeignKey("agentexec_activity.id"), nullable=False, index=True
283
+ )
284
+ message: Mapped[str] = mapped_column(Text, nullable=False)
285
+ status: Mapped[Status] = mapped_column(Enum(Status), nullable=False, index=True)
286
+ completion_percentage: Mapped[int | None] = mapped_column(Integer, nullable=True)
287
+ created_at: Mapped[datetime] = mapped_column(
288
+ DateTime(timezone=True),
289
+ nullable=False,
290
+ default=lambda: datetime.now(UTC),
291
+ )
292
+
293
+ # Relationship back to activity
294
+ activity: Mapped[Activity] = relationship("Activity", back_populates="logs")
@@ -0,0 +1,70 @@
1
+ import uuid
2
+ from datetime import datetime
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field, computed_field
5
+
6
+ from agentexec.activity.models import Status
7
+
8
+
9
+ class ActivityLogSchema(BaseModel):
10
+ """Schema for an agent activity log entry."""
11
+
12
+ model_config = ConfigDict(from_attributes=True)
13
+
14
+ id: uuid.UUID
15
+ message: str
16
+ status: Status
17
+ completion_percentage: int = 0
18
+ created_at: datetime
19
+
20
+
21
+ class ActivityDetailSchema(BaseModel):
22
+ """Schema for an agent activity record with optional logs."""
23
+
24
+ model_config = ConfigDict(from_attributes=True)
25
+
26
+ id: uuid.UUID
27
+ agent_id: uuid.UUID
28
+ agent_type: str
29
+ created_at: datetime
30
+ updated_at: datetime
31
+ logs: list[ActivityLogSchema] = Field(default_factory=list)
32
+
33
+
34
+ class ActivityListItemSchema(BaseModel):
35
+ """Lightweight summary of agent activity showing only latest update.
36
+
37
+ Note: Elapsed time can be calculated on the frontend as:
38
+ latest_log_timestamp - started_at
39
+ """
40
+
41
+ model_config = ConfigDict(from_attributes=True)
42
+
43
+ agent_id: uuid.UUID
44
+ agent_type: str
45
+ status: Status
46
+ latest_log_message: str | None = None
47
+ latest_log_timestamp: datetime | None = None
48
+ completion_percentage: int = 0
49
+ started_at: datetime | None = None
50
+
51
+ @computed_field
52
+ def elapsed_time_seconds(self) -> int:
53
+ if self.latest_log_timestamp and self.started_at:
54
+ return int((self.latest_log_timestamp - self.started_at).total_seconds())
55
+ return 0
56
+
57
+
58
+ class ActivityListSchema(BaseModel):
59
+ """Paginated list of activity summaries."""
60
+
61
+ model_config = ConfigDict(from_attributes=True)
62
+
63
+ items: list[ActivityListItemSchema]
64
+ total: int
65
+ page: int
66
+ page_size: int
67
+
68
+ @computed_field
69
+ def total_pages(self) -> int:
70
+ return (self.total + self.page_size - 1) // self.page_size