agentexec 0.1.5__tar.gz → 0.1.7__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.
- agentexec-0.1.7/.claude/settings.local.json +7 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/CHANGELOG.md +53 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/PKG-INFO +82 -2
- {agentexec-0.1.5 → agentexec-0.1.7}/README.md +80 -1
- agentexec-0.1.7/examples/multi-tenancy/README.md +92 -0
- agentexec-0.1.7/examples/multi-tenancy/example.py +188 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/pyproject.toml +2 -1
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/activity/models.py +49 -3
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/activity/schemas.py +3 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/activity/tracker.py +30 -4
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/config.py +28 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/core/queue.py +34 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/core/task.py +43 -1
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/pipeline.py +10 -4
- agentexec-0.1.7/src/agentexec/schedule.py +144 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/state/__init__.py +50 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/state/backend.py +88 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/state/redis_backend.py +111 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/worker/pool.py +146 -9
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_activity_tracking.py +221 -0
- agentexec-0.1.7/tests/test_schedule.py +447 -0
- agentexec-0.1.7/tests/test_task_locking.py +260 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/ui/package.json +1 -1
- {agentexec-0.1.5 → agentexec-0.1.7}/.claude/skills/prepare-release/SKILL.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/.github/workflows/docker-publish.yml +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/.github/workflows/npm-publish.yml +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/.github/workflows/publish.yml +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/.gitignore +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docker/Dockerfile +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docker/README.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docker/entrypoint.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/api-reference/activity.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/api-reference/core.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/api-reference/pipeline.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/api-reference/runner.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/concepts/activity-tracking.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/concepts/architecture.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/concepts/task-lifecycle.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/concepts/worker-pool.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/contributing.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/deployment/docker.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/deployment/production.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/getting-started/configuration.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/getting-started/installation.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/getting-started/quickstart.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/guides/basic-usage.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/guides/custom-runners.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/guides/fastapi-integration.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/guides/openai-runner.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/guides/pipelines.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/docs/index.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/README.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/alembic/README +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/alembic/env.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/alembic/script.py.mako +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/alembic.ini +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/compose.yml +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/context.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/db.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/main.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/models.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/pipeline.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/pyproject.toml +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/tools.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/.gitignore +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/bun.lock +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/index.html +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/package.json +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/public/vite.svg +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/App.tsx +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/api/agents.ts +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/api/queries.ts +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/components/Layout.tsx +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/index.css +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/main.tsx +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/pages/AgentDetailPage.tsx +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/pages/AgentListPage.tsx +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/styles/github-dark.css +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/tsconfig.json +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/tsconfig.node.json +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/vite.config.ts +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/views.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/worker.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/__init__.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/activity/__init__.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/core/__init__.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/core/db.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/core/logging.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/core/results.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/runners/__init__.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/runners/base.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/runners/openai.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/tracker.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/worker/__init__.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/worker/event.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/worker/logging.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_activity_schemas.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_activity_tracking.py.bak +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_config.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_db.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_pipeline.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_pipeline_flow.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_public_api.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_queue.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_results.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_runners.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_self_describing_results.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_state.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_state_backend.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_task.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_task_types.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_worker_event.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_worker_logging.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_worker_pool.py +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/ui/.gitignore +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/ui/README.md +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/ui/bun.lock +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/ui/src/components/ActiveAgentsBadge.tsx +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/ui/src/components/ProgressBar.tsx +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/ui/src/components/StatusBadge.tsx +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/ui/src/components/TaskDetail.tsx +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/ui/src/components/TaskList.tsx +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/ui/src/components/index.ts +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/ui/src/index.ts +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/ui/src/types.ts +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/ui/tsconfig.json +0 -0
- {agentexec-0.1.5 → agentexec-0.1.7}/ui/vite.config.ts +0 -0
|
@@ -1,5 +1,58 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.1.7
|
|
4
|
+
|
|
5
|
+
### New Features
|
|
6
|
+
|
|
7
|
+
**Scheduled tasks with cron expressions**
|
|
8
|
+
- `@pool.schedule("task_name", "*/5 * * * *")` decorator registers and schedules a task in one step
|
|
9
|
+
- `pool.add_schedule()` for imperative scheduling of already-registered tasks
|
|
10
|
+
- Cron expressions evaluated in configurable timezone (`AGENTEXEC_SCHEDULER_TIMEZONE`, default UTC)
|
|
11
|
+
- Repeat budget: `-1` for forever (default), `0` for one-shot, `N` for N more executions
|
|
12
|
+
- Scheduler runs automatically inside `pool.run()` — no extra setup needed
|
|
13
|
+
- Idempotent registration: keyed by task name, so restarts and multiple pool instances overwrite instead of duplicating
|
|
14
|
+
- Clock-drift resilient: next run computed from intended anchor time, not wall clock
|
|
15
|
+
- Skips missed intervals after downtime instead of enqueuing a burst of catch-up tasks
|
|
16
|
+
- New `croniter` dependency for cron expression parsing
|
|
17
|
+
|
|
18
|
+
### Improvements
|
|
19
|
+
|
|
20
|
+
**State backend sorted set operations**
|
|
21
|
+
- Added `zadd()`, `zrangebyscore()`, `zrem()` to `StateBackend` protocol and Redis implementation
|
|
22
|
+
- Used internally by the scheduler for efficient due-task polling
|
|
23
|
+
|
|
24
|
+
## v0.1.6
|
|
25
|
+
|
|
26
|
+
### New Features
|
|
27
|
+
|
|
28
|
+
**Task-level distributed locking**
|
|
29
|
+
- New `lock_key` parameter on `@pool.task()` and `pool.add_task()` for sequential execution of tasks sharing state
|
|
30
|
+
- String template evaluated against context fields (e.g., `lock_key="user:{user_id}"`)
|
|
31
|
+
- Workers acquire a Redis lock before execution; tasks requeue automatically on contention
|
|
32
|
+
- Lock released in `finally` block on completion or error
|
|
33
|
+
- Configurable TTL via `AGENTEXEC_LOCK_TTL` (default 1800s) as safety net for worker process death
|
|
34
|
+
- Note: strict FIFO ordering is not guaranteed between tasks sharing the same lock key
|
|
35
|
+
|
|
36
|
+
**Activity metadata for multi-tenancy**
|
|
37
|
+
- Attach arbitrary metadata when creating activities (e.g., `metadata={"organization_id": "org-123"}`)
|
|
38
|
+
- Filter activities by metadata in `activity.list()` and `activity.detail()`
|
|
39
|
+
- Metadata accessible as attribute for programmatic use but excluded from API serialization by default to prevent accidental tenant info leakage
|
|
40
|
+
|
|
41
|
+
### Improvements
|
|
42
|
+
|
|
43
|
+
**Redis cleanup on shutdown**
|
|
44
|
+
- `state.clear_keys()` removes all agentexec-prefixed keys and the task queue on shutdown
|
|
45
|
+
- Prevents stale tasks from being picked up on restart
|
|
46
|
+
|
|
47
|
+
**State backend lock primitives**
|
|
48
|
+
- Added `acquire_lock()` and `release_lock()` to `StateBackend` protocol
|
|
49
|
+
- Redis implementation uses atomic `SET NX EX` / `DELETE`
|
|
50
|
+
|
|
51
|
+
### Testing
|
|
52
|
+
|
|
53
|
+
- Added `test_task_locking.py` with 16 tests covering lock acquisition, release, requeue, and template evaluation
|
|
54
|
+
- Fixed `ty` type checker errors in `test_activity_tracking.py` (added narrowing guards for `Activity | None`)
|
|
55
|
+
|
|
3
56
|
## v0.1.5
|
|
4
57
|
|
|
5
58
|
### New Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentexec
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: Production-ready orchestration for OpenAI Agents with Redis-backed coordination, activity tracking, and workflow management
|
|
5
5
|
Project-URL: Homepage, https://github.com/Agent-CI/agentexec
|
|
6
6
|
Project-URL: Documentation, https://github.com/Agent-CI/agentexec#readme
|
|
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
18
|
Requires-Python: >=3.12
|
|
19
|
+
Requires-Dist: croniter>=6.0.0
|
|
19
20
|
Requires-Dist: openai-agents>=0.1.0
|
|
20
21
|
Requires-Dist: pydantic-settings>=2.5.0
|
|
21
22
|
Requires-Dist: pydantic>=2.12.0
|
|
@@ -163,6 +164,25 @@ That's it. Tasks are queued to Redis, workers process them in parallel, progress
|
|
|
163
164
|
|
|
164
165
|
## Supported Patterns
|
|
165
166
|
|
|
167
|
+
### Activity Metadata (Multi-Tenancy)
|
|
168
|
+
|
|
169
|
+
Attach arbitrary metadata when enqueueing tasks for filtering and tenant isolation:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
task = await ax.enqueue(
|
|
173
|
+
"process_document",
|
|
174
|
+
context,
|
|
175
|
+
metadata={"organization_id": "org-123", "user_id": "user-456"}
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Filter activities by metadata
|
|
179
|
+
activities = ax.activity.list(db, metadata_filter={"organization_id": "org-123"})
|
|
180
|
+
detail = ax.activity.detail(db, agent_id, metadata_filter={"organization_id": "org-123"})
|
|
181
|
+
|
|
182
|
+
# Access metadata programmatically (excluded from API serialization by default)
|
|
183
|
+
org_id = detail.metadata["organization_id"]
|
|
184
|
+
```
|
|
185
|
+
|
|
166
186
|
### Automatic Activity Tracking
|
|
167
187
|
|
|
168
188
|
Every task gets full lifecycle tracking without manual updates:
|
|
@@ -195,6 +215,50 @@ Update progress explicitly from your task:
|
|
|
195
215
|
ax.activity.update(agent_id, "Processing batch 3 of 10", percentage=30)
|
|
196
216
|
```
|
|
197
217
|
|
|
218
|
+
### Task Locking
|
|
219
|
+
|
|
220
|
+
When multiple tasks of the same type are queued for the same user, they may need to run sequentially because each task reads and writes shared state. Use `lock_key` to ensure only one task with the same evaluated key runs at a time:
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
@pool.task("associate_observation", lock_key="user:{user_id}")
|
|
224
|
+
async def associate(agent_id: UUID, context: ObservationContext):
|
|
225
|
+
...
|
|
226
|
+
|
|
227
|
+
# Or with add_task()
|
|
228
|
+
pool.add_task("associate_observation", handler, lock_key="user:{user_id}")
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The `lock_key` is a string template evaluated against the task context fields. When a worker dequeues a task whose lock is held, it puts the task back at the end of the queue and moves on. The lock is released automatically when the task completes or errors.
|
|
232
|
+
|
|
233
|
+
The lock TTL (`AGENTEXEC_LOCK_TTL`, default 1800s) is a safety net for worker process death — locks are always explicitly released on task completion or error. Set this higher than your longest expected task duration.
|
|
234
|
+
|
|
235
|
+
**Note:** When a task is requeued due to a held lock, it goes to the back of the queue. This means strict FIFO ordering is not guaranteed between tasks sharing the same lock key — if tasks T2 and T3 are both waiting on T1's lock, either could run next after T1 completes.
|
|
236
|
+
|
|
237
|
+
### Scheduled Tasks
|
|
238
|
+
|
|
239
|
+
Run tasks on a recurring interval using cron expressions:
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
# Decorator — registers the task and schedules it in one step
|
|
243
|
+
@pool.schedule("refresh_cache", "*/5 * * * *")
|
|
244
|
+
async def refresh(agent_id: UUID, context: RefreshContext):
|
|
245
|
+
...
|
|
246
|
+
|
|
247
|
+
# With context and repeat limit
|
|
248
|
+
@pool.schedule("sync_users", "0 * * * *", context=SyncContext(full=True), repeat=3)
|
|
249
|
+
async def sync(agent_id: UUID, context: SyncContext):
|
|
250
|
+
...
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
For tasks registered separately, use `pool.add_schedule()`:
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
pool.add_schedule("refresh_cache", "*/5 * * * *", RefreshContext(scope="all"))
|
|
257
|
+
pool.add_schedule("refresh_cache", "0 * * * *", RefreshContext(scope="users"), repeat=3)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
The scheduler runs automatically inside `pool.run()`. Cron expressions are evaluated in the configured timezone (`AGENTEXEC_SCHEDULER_TIMEZONE`, default UTC) so schedules read naturally regardless of server timezone. Next-run times are computed from the intended anchor time, not wall clock, to prevent cumulative drift.
|
|
261
|
+
|
|
198
262
|
### Priority Queue
|
|
199
263
|
|
|
200
264
|
Control task execution order:
|
|
@@ -411,6 +475,7 @@ Activity tracking uses SQLAlchemy with two tables:
|
|
|
411
475
|
**`agentexec_activity`** - Main activity records
|
|
412
476
|
- `agent_id` - Unique identifier (UUID)
|
|
413
477
|
- `agent_type` - Task name
|
|
478
|
+
- `metadata` - JSON field for custom data (e.g., tenant info)
|
|
414
479
|
- `created_at`, `updated_at` - Timestamps
|
|
415
480
|
|
|
416
481
|
**`agentexec_activity_log`** - Status and progress
|
|
@@ -600,7 +665,15 @@ pool = ax.Pool(database_url="postgresql://...")
|
|
|
600
665
|
@pool.task("name")
|
|
601
666
|
async def handler(agent_id: UUID, context: MyContext) -> None: ...
|
|
602
667
|
|
|
603
|
-
pool.
|
|
668
|
+
@pool.task("name", lock_key="user:{user_id}") # Sequential per user
|
|
669
|
+
async def locked(agent_id: UUID, context: MyContext) -> None: ...
|
|
670
|
+
|
|
671
|
+
@pool.schedule("name", "*/5 * * * *") # Register + schedule in one step
|
|
672
|
+
async def scheduled(agent_id: UUID, context: MyContext) -> None: ...
|
|
673
|
+
|
|
674
|
+
pool.add_schedule("name", "0 * * * *", MyContext(), repeat=3) # Schedule separately
|
|
675
|
+
|
|
676
|
+
pool.run() # Blocking - runs workers + scheduler
|
|
604
677
|
pool.start() # Non-blocking - starts workers in background
|
|
605
678
|
pool.shutdown() # Graceful shutdown
|
|
606
679
|
```
|
|
@@ -700,6 +773,12 @@ AGENTEXEC_TABLE_PREFIX=agentexec_
|
|
|
700
773
|
# Results
|
|
701
774
|
AGENTEXEC_RESULT_TTL=3600
|
|
702
775
|
|
|
776
|
+
# Task locking
|
|
777
|
+
AGENTEXEC_LOCK_TTL=1800
|
|
778
|
+
|
|
779
|
+
# Scheduling
|
|
780
|
+
AGENTEXEC_SCHEDULER_TIMEZONE=UTC
|
|
781
|
+
|
|
703
782
|
# State backend
|
|
704
783
|
AGENTEXEC_STATE_BACKEND=agentexec.state.redis_backend
|
|
705
784
|
AGENTEXEC_KEY_PREFIX=agentexec
|
|
@@ -758,4 +837,5 @@ MIT License - see [LICENSE](LICENSE) for details.
|
|
|
758
837
|
- **npm**: [agentexec-ui](https://www.npmjs.com/package/agentexec-ui)
|
|
759
838
|
- **Documentation**: [docs/](docs/)
|
|
760
839
|
- **Example App**: [examples/openai-agents-fastapi/](examples/openai-agents-fastapi/)
|
|
840
|
+
- **Multi-Tenancy Example**: [examples/multi-tenancy/](examples/multi-tenancy/)
|
|
761
841
|
- **Issues**: [GitHub Issues](https://github.com/Agent-CI/agentexec/issues)
|
|
@@ -138,6 +138,25 @@ That's it. Tasks are queued to Redis, workers process them in parallel, progress
|
|
|
138
138
|
|
|
139
139
|
## Supported Patterns
|
|
140
140
|
|
|
141
|
+
### Activity Metadata (Multi-Tenancy)
|
|
142
|
+
|
|
143
|
+
Attach arbitrary metadata when enqueueing tasks for filtering and tenant isolation:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
task = await ax.enqueue(
|
|
147
|
+
"process_document",
|
|
148
|
+
context,
|
|
149
|
+
metadata={"organization_id": "org-123", "user_id": "user-456"}
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Filter activities by metadata
|
|
153
|
+
activities = ax.activity.list(db, metadata_filter={"organization_id": "org-123"})
|
|
154
|
+
detail = ax.activity.detail(db, agent_id, metadata_filter={"organization_id": "org-123"})
|
|
155
|
+
|
|
156
|
+
# Access metadata programmatically (excluded from API serialization by default)
|
|
157
|
+
org_id = detail.metadata["organization_id"]
|
|
158
|
+
```
|
|
159
|
+
|
|
141
160
|
### Automatic Activity Tracking
|
|
142
161
|
|
|
143
162
|
Every task gets full lifecycle tracking without manual updates:
|
|
@@ -170,6 +189,50 @@ Update progress explicitly from your task:
|
|
|
170
189
|
ax.activity.update(agent_id, "Processing batch 3 of 10", percentage=30)
|
|
171
190
|
```
|
|
172
191
|
|
|
192
|
+
### Task Locking
|
|
193
|
+
|
|
194
|
+
When multiple tasks of the same type are queued for the same user, they may need to run sequentially because each task reads and writes shared state. Use `lock_key` to ensure only one task with the same evaluated key runs at a time:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
@pool.task("associate_observation", lock_key="user:{user_id}")
|
|
198
|
+
async def associate(agent_id: UUID, context: ObservationContext):
|
|
199
|
+
...
|
|
200
|
+
|
|
201
|
+
# Or with add_task()
|
|
202
|
+
pool.add_task("associate_observation", handler, lock_key="user:{user_id}")
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The `lock_key` is a string template evaluated against the task context fields. When a worker dequeues a task whose lock is held, it puts the task back at the end of the queue and moves on. The lock is released automatically when the task completes or errors.
|
|
206
|
+
|
|
207
|
+
The lock TTL (`AGENTEXEC_LOCK_TTL`, default 1800s) is a safety net for worker process death — locks are always explicitly released on task completion or error. Set this higher than your longest expected task duration.
|
|
208
|
+
|
|
209
|
+
**Note:** When a task is requeued due to a held lock, it goes to the back of the queue. This means strict FIFO ordering is not guaranteed between tasks sharing the same lock key — if tasks T2 and T3 are both waiting on T1's lock, either could run next after T1 completes.
|
|
210
|
+
|
|
211
|
+
### Scheduled Tasks
|
|
212
|
+
|
|
213
|
+
Run tasks on a recurring interval using cron expressions:
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
# Decorator — registers the task and schedules it in one step
|
|
217
|
+
@pool.schedule("refresh_cache", "*/5 * * * *")
|
|
218
|
+
async def refresh(agent_id: UUID, context: RefreshContext):
|
|
219
|
+
...
|
|
220
|
+
|
|
221
|
+
# With context and repeat limit
|
|
222
|
+
@pool.schedule("sync_users", "0 * * * *", context=SyncContext(full=True), repeat=3)
|
|
223
|
+
async def sync(agent_id: UUID, context: SyncContext):
|
|
224
|
+
...
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
For tasks registered separately, use `pool.add_schedule()`:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
pool.add_schedule("refresh_cache", "*/5 * * * *", RefreshContext(scope="all"))
|
|
231
|
+
pool.add_schedule("refresh_cache", "0 * * * *", RefreshContext(scope="users"), repeat=3)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The scheduler runs automatically inside `pool.run()`. Cron expressions are evaluated in the configured timezone (`AGENTEXEC_SCHEDULER_TIMEZONE`, default UTC) so schedules read naturally regardless of server timezone. Next-run times are computed from the intended anchor time, not wall clock, to prevent cumulative drift.
|
|
235
|
+
|
|
173
236
|
### Priority Queue
|
|
174
237
|
|
|
175
238
|
Control task execution order:
|
|
@@ -386,6 +449,7 @@ Activity tracking uses SQLAlchemy with two tables:
|
|
|
386
449
|
**`agentexec_activity`** - Main activity records
|
|
387
450
|
- `agent_id` - Unique identifier (UUID)
|
|
388
451
|
- `agent_type` - Task name
|
|
452
|
+
- `metadata` - JSON field for custom data (e.g., tenant info)
|
|
389
453
|
- `created_at`, `updated_at` - Timestamps
|
|
390
454
|
|
|
391
455
|
**`agentexec_activity_log`** - Status and progress
|
|
@@ -575,7 +639,15 @@ pool = ax.Pool(database_url="postgresql://...")
|
|
|
575
639
|
@pool.task("name")
|
|
576
640
|
async def handler(agent_id: UUID, context: MyContext) -> None: ...
|
|
577
641
|
|
|
578
|
-
pool.
|
|
642
|
+
@pool.task("name", lock_key="user:{user_id}") # Sequential per user
|
|
643
|
+
async def locked(agent_id: UUID, context: MyContext) -> None: ...
|
|
644
|
+
|
|
645
|
+
@pool.schedule("name", "*/5 * * * *") # Register + schedule in one step
|
|
646
|
+
async def scheduled(agent_id: UUID, context: MyContext) -> None: ...
|
|
647
|
+
|
|
648
|
+
pool.add_schedule("name", "0 * * * *", MyContext(), repeat=3) # Schedule separately
|
|
649
|
+
|
|
650
|
+
pool.run() # Blocking - runs workers + scheduler
|
|
579
651
|
pool.start() # Non-blocking - starts workers in background
|
|
580
652
|
pool.shutdown() # Graceful shutdown
|
|
581
653
|
```
|
|
@@ -675,6 +747,12 @@ AGENTEXEC_TABLE_PREFIX=agentexec_
|
|
|
675
747
|
# Results
|
|
676
748
|
AGENTEXEC_RESULT_TTL=3600
|
|
677
749
|
|
|
750
|
+
# Task locking
|
|
751
|
+
AGENTEXEC_LOCK_TTL=1800
|
|
752
|
+
|
|
753
|
+
# Scheduling
|
|
754
|
+
AGENTEXEC_SCHEDULER_TIMEZONE=UTC
|
|
755
|
+
|
|
678
756
|
# State backend
|
|
679
757
|
AGENTEXEC_STATE_BACKEND=agentexec.state.redis_backend
|
|
680
758
|
AGENTEXEC_KEY_PREFIX=agentexec
|
|
@@ -733,4 +811,5 @@ MIT License - see [LICENSE](LICENSE) for details.
|
|
|
733
811
|
- **npm**: [agentexec-ui](https://www.npmjs.com/package/agentexec-ui)
|
|
734
812
|
- **Documentation**: [docs/](docs/)
|
|
735
813
|
- **Example App**: [examples/openai-agents-fastapi/](examples/openai-agents-fastapi/)
|
|
814
|
+
- **Multi-Tenancy Example**: [examples/multi-tenancy/](examples/multi-tenancy/)
|
|
736
815
|
- **Issues**: [GitHub Issues](https://github.com/Agent-CI/agentexec/issues)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Multi-Tenancy with Activity Metadata
|
|
2
|
+
|
|
3
|
+
This example demonstrates how to use activity metadata for multi-tenant applications.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
When building multi-tenant applications, you often need to:
|
|
8
|
+
1. Associate background tasks with specific organizations/tenants
|
|
9
|
+
2. Filter activity views to only show tasks belonging to the current tenant
|
|
10
|
+
3. Ensure proper data isolation between tenants
|
|
11
|
+
|
|
12
|
+
The `metadata` parameter on `ax.enqueue()` and `pipeline.enqueue()` enables this by attaching arbitrary key-value pairs to activity records.
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
### Enqueueing with Metadata
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import agentexec as ax
|
|
20
|
+
|
|
21
|
+
# Enqueue a task with organization context
|
|
22
|
+
task = await ax.enqueue(
|
|
23
|
+
"process_document",
|
|
24
|
+
DocumentContext(file_id="doc-123"),
|
|
25
|
+
metadata={"organization_id": "org-456", "user_id": "user-789"}
|
|
26
|
+
)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Filtering Activities by Metadata
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from agentexec import activity
|
|
33
|
+
|
|
34
|
+
# List only activities for a specific organization
|
|
35
|
+
activities = activity.list(
|
|
36
|
+
session,
|
|
37
|
+
metadata_filter={"organization_id": "org-456"}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Get activity detail with tenant validation
|
|
41
|
+
# Returns None if the activity doesn't belong to this organization
|
|
42
|
+
detail = activity.detail(
|
|
43
|
+
session,
|
|
44
|
+
agent_id="...",
|
|
45
|
+
metadata_filter={"organization_id": "org-456"}
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Pipeline Example
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
pipeline = ax.Pipeline(pool)
|
|
53
|
+
|
|
54
|
+
class DocumentPipeline(pipeline.Base):
|
|
55
|
+
@pipeline.step(0)
|
|
56
|
+
async def extract(self, ctx: DocumentContext) -> ExtractedData:
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
# Enqueue pipeline with metadata
|
|
60
|
+
task = await pipeline.enqueue(
|
|
61
|
+
context=DocumentContext(file_id="doc-123"),
|
|
62
|
+
metadata={"organization_id": "org-456"}
|
|
63
|
+
)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Running the Example
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Install dependencies
|
|
70
|
+
pip install agentexec
|
|
71
|
+
|
|
72
|
+
# Run the example
|
|
73
|
+
python example.py
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Database Schema
|
|
77
|
+
|
|
78
|
+
The metadata is stored as a JSON column on the `agentexec_activity` table:
|
|
79
|
+
|
|
80
|
+
```sql
|
|
81
|
+
ALTER TABLE agentexec_activity ADD COLUMN metadata JSON;
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
For PostgreSQL, this maps to JSONB which supports efficient filtering.
|
|
85
|
+
|
|
86
|
+
## Notes
|
|
87
|
+
|
|
88
|
+
- Metadata is immutable once set at enqueue time
|
|
89
|
+
- Filtering uses exact string matching on metadata values
|
|
90
|
+
- **Metadata is excluded from API serialization by default** to prevent accidental leakage of tenant info
|
|
91
|
+
- Access metadata programmatically via the `.metadata` attribute (e.g., `activity.metadata`)
|
|
92
|
+
- To include metadata in API responses, explicitly add it to your response model
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Multi-tenancy example demonstrating activity metadata filtering.
|
|
2
|
+
|
|
3
|
+
This example shows how to:
|
|
4
|
+
1. Attach metadata (like organization_id) when enqueueing tasks
|
|
5
|
+
2. Filter activities by metadata for tenant isolation
|
|
6
|
+
3. Use metadata in both list and detail views
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
from sqlalchemy import create_engine
|
|
14
|
+
from sqlalchemy.orm import sessionmaker
|
|
15
|
+
|
|
16
|
+
import agentexec as ax
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# --- Models ---
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DocumentContext(BaseModel):
|
|
23
|
+
"""Input context for document processing."""
|
|
24
|
+
|
|
25
|
+
file_id: str
|
|
26
|
+
filename: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProcessedDocument(BaseModel):
|
|
30
|
+
"""Result of document processing."""
|
|
31
|
+
|
|
32
|
+
file_id: str
|
|
33
|
+
word_count: int
|
|
34
|
+
summary: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- Setup ---
|
|
38
|
+
|
|
39
|
+
# Create in-memory SQLite database for demo
|
|
40
|
+
engine = create_engine("sqlite:///multi_tenant_demo.db", echo=False)
|
|
41
|
+
SessionLocal = sessionmaker(bind=engine)
|
|
42
|
+
|
|
43
|
+
# Create tables
|
|
44
|
+
ax.Base.metadata.create_all(bind=engine)
|
|
45
|
+
|
|
46
|
+
# Create worker pool
|
|
47
|
+
pool = ax.Pool(engine=engine)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- Task Definition ---
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pool.task("process_document")
|
|
54
|
+
async def process_document(
|
|
55
|
+
*,
|
|
56
|
+
agent_id: UUID,
|
|
57
|
+
context: DocumentContext,
|
|
58
|
+
) -> ProcessedDocument:
|
|
59
|
+
"""Simulate document processing."""
|
|
60
|
+
# Simulate some work
|
|
61
|
+
ax.activity.update(agent_id, "Extracting text...", percentage=25)
|
|
62
|
+
await asyncio.sleep(0.1)
|
|
63
|
+
|
|
64
|
+
ax.activity.update(agent_id, "Analyzing content...", percentage=50)
|
|
65
|
+
await asyncio.sleep(0.1)
|
|
66
|
+
|
|
67
|
+
ax.activity.update(agent_id, "Generating summary...", percentage=75)
|
|
68
|
+
await asyncio.sleep(0.1)
|
|
69
|
+
|
|
70
|
+
return ProcessedDocument(
|
|
71
|
+
file_id=context.file_id,
|
|
72
|
+
word_count=1234,
|
|
73
|
+
summary=f"Summary of {context.filename}",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --- Demo Functions ---
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def enqueue_tasks_for_tenants():
|
|
81
|
+
"""Enqueue tasks for different organizations."""
|
|
82
|
+
print("\n=== Enqueueing Tasks ===\n")
|
|
83
|
+
|
|
84
|
+
# Organization A: Enqueue 2 tasks
|
|
85
|
+
task1 = await ax.enqueue(
|
|
86
|
+
"process_document",
|
|
87
|
+
DocumentContext(file_id="doc-001", filename="report.pdf"),
|
|
88
|
+
metadata={"organization_id": "org-A", "user_id": "user-1"},
|
|
89
|
+
)
|
|
90
|
+
print(f"Org A - Task 1: {task1.agent_id}")
|
|
91
|
+
|
|
92
|
+
task2 = await ax.enqueue(
|
|
93
|
+
"process_document",
|
|
94
|
+
DocumentContext(file_id="doc-002", filename="invoice.pdf"),
|
|
95
|
+
metadata={"organization_id": "org-A", "user_id": "user-2"},
|
|
96
|
+
)
|
|
97
|
+
print(f"Org A - Task 2: {task2.agent_id}")
|
|
98
|
+
|
|
99
|
+
# Organization B: Enqueue 1 task
|
|
100
|
+
task3 = await ax.enqueue(
|
|
101
|
+
"process_document",
|
|
102
|
+
DocumentContext(file_id="doc-003", filename="contract.pdf"),
|
|
103
|
+
metadata={"organization_id": "org-B", "user_id": "user-3"},
|
|
104
|
+
)
|
|
105
|
+
print(f"Org B - Task 1: {task3.agent_id}")
|
|
106
|
+
|
|
107
|
+
return task1, task2, task3
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def list_activities_for_tenant(org_id: str):
|
|
111
|
+
"""List activities filtered by organization."""
|
|
112
|
+
print(f"\n=== Activities for {org_id} ===\n")
|
|
113
|
+
|
|
114
|
+
with SessionLocal() as session:
|
|
115
|
+
result = ax.activity.list(
|
|
116
|
+
session,
|
|
117
|
+
metadata_filter={"organization_id": org_id},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
print(f"Total activities: {result.total}")
|
|
121
|
+
for item in result.items:
|
|
122
|
+
print(f" - {item.agent_id}: {item.agent_type} ({item.status})")
|
|
123
|
+
print(f" Metadata: {item.metadata}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_activity_detail_with_tenant_check(agent_id: UUID, org_id: str):
|
|
127
|
+
"""Get activity detail with tenant validation."""
|
|
128
|
+
print(f"\n=== Detail for {agent_id} (checking {org_id}) ===\n")
|
|
129
|
+
|
|
130
|
+
with SessionLocal() as session:
|
|
131
|
+
# This returns None if the activity doesn't belong to the org
|
|
132
|
+
detail = ax.activity.detail(
|
|
133
|
+
session,
|
|
134
|
+
agent_id,
|
|
135
|
+
metadata_filter={"organization_id": org_id},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if detail:
|
|
139
|
+
print(f"Found: {detail.agent_type}")
|
|
140
|
+
print(f"Metadata: {detail.metadata}")
|
|
141
|
+
print(f"Logs: {len(detail.logs)} entries")
|
|
142
|
+
else:
|
|
143
|
+
print(f"Not found (or doesn't belong to {org_id})")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def main():
|
|
147
|
+
"""Run the multi-tenancy demo."""
|
|
148
|
+
print("Multi-Tenancy Demo with Activity Metadata")
|
|
149
|
+
print("=" * 50)
|
|
150
|
+
|
|
151
|
+
# 1. Enqueue tasks for different organizations
|
|
152
|
+
task1, task2, task3 = await enqueue_tasks_for_tenants()
|
|
153
|
+
|
|
154
|
+
# 2. List all activities (no filter)
|
|
155
|
+
print("\n=== All Activities (no filter) ===\n")
|
|
156
|
+
with SessionLocal() as session:
|
|
157
|
+
all_activities = ax.activity.list(session)
|
|
158
|
+
print(f"Total: {all_activities.total}")
|
|
159
|
+
for item in all_activities.items:
|
|
160
|
+
print(f" - {item.agent_type}: {item.metadata}")
|
|
161
|
+
|
|
162
|
+
# 3. List activities filtered by organization
|
|
163
|
+
list_activities_for_tenant("org-A") # Should show 2 tasks
|
|
164
|
+
list_activities_for_tenant("org-B") # Should show 1 task
|
|
165
|
+
list_activities_for_tenant("org-C") # Should show 0 tasks
|
|
166
|
+
|
|
167
|
+
# 4. Detail view with tenant validation
|
|
168
|
+
# Try to access Org A's task as Org A (should work)
|
|
169
|
+
get_activity_detail_with_tenant_check(task1.agent_id, "org-A")
|
|
170
|
+
|
|
171
|
+
# Try to access Org A's task as Org B (should return None)
|
|
172
|
+
get_activity_detail_with_tenant_check(task1.agent_id, "org-B")
|
|
173
|
+
|
|
174
|
+
# 5. Filter by multiple metadata fields
|
|
175
|
+
print("\n=== Filter by org AND user ===\n")
|
|
176
|
+
with SessionLocal() as session:
|
|
177
|
+
result = ax.activity.list(
|
|
178
|
+
session,
|
|
179
|
+
metadata_filter={"organization_id": "org-A", "user_id": "user-1"},
|
|
180
|
+
)
|
|
181
|
+
print(f"Found {result.total} activities for org-A + user-1")
|
|
182
|
+
|
|
183
|
+
print("\n" + "=" * 50)
|
|
184
|
+
print("Demo complete!")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
asyncio.run(main())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "agentexec"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.7"
|
|
4
4
|
description = "Production-ready orchestration for OpenAI Agents with Redis-backed coordination, activity tracking, and workflow management"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -25,6 +25,7 @@ dependencies = [
|
|
|
25
25
|
"pydantic-settings>=2.5.0",
|
|
26
26
|
"sqlalchemy>=2.0.44",
|
|
27
27
|
"openai-agents>=0.1.0",
|
|
28
|
+
"croniter>=6.0.0",
|
|
28
29
|
]
|
|
29
30
|
|
|
30
31
|
|