keble-task 2.22.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.
- keble_task/__init__.py +143 -0
- keble_task/actions.py +304 -0
- keble_task/agent/__init__.py +27 -0
- keble_task/agent/chat_provider.py +117 -0
- keble_task/agent/deps.py +38 -0
- keble_task/agent/tools/__init__.py +14 -0
- keble_task/agent/tools/mutation.py +100 -0
- keble_task/agent/tools/query.py +160 -0
- keble_task/crud.py +347 -0
- keble_task/exceptions.py +62 -0
- keble_task/main.py +2708 -0
- keble_task/schemas/__init__.py +1295 -0
- keble_task/schemas/for_agent.py +177 -0
- keble_task/task_tree.py +106 -0
- keble_task/utils.py +58 -0
- keble_task-2.22.0.dist-info/METADATA +1083 -0
- keble_task-2.22.0.dist-info/RECORD +18 -0
- keble_task-2.22.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keble-task
|
|
3
|
+
Version: 2.22.0
|
|
4
|
+
Author-email: zhenhao-ma <bob0103779@gmail.com>
|
|
5
|
+
Requires-Python: <3.14,>=3.13
|
|
6
|
+
Requires-Dist: keble-db<2.0.0,>=1.7.1
|
|
7
|
+
Requires-Dist: keble-helpers<2.0.0,>=1.40.0
|
|
8
|
+
Requires-Dist: pydantic-ai-slim<2.0.0,>=1.41.0
|
|
9
|
+
Requires-Dist: tenacity<10.0.0,>=9
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: httpx[socks]<1.0.0,>=0.27; extra == 'test'
|
|
12
|
+
Requires-Dist: pydantic-ai-slim[openai]<2.0.0,>=1.41.0; extra == 'test'
|
|
13
|
+
Requires-Dist: pytest-asyncio<1.0.0,>=0; extra == 'test'
|
|
14
|
+
Requires-Dist: pytest<9.0.0,>=8; extra == 'test'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Keble Task
|
|
18
|
+
|
|
19
|
+
Keble Task is a package for managing asynchronous task execution with MongoDB storage and Redis locking. It provides a client for creating, tracking, and managing tasks with support for retries, timeouts, and token consumption.
|
|
20
|
+
|
|
21
|
+
## Version 2.22.0 Update
|
|
22
|
+
|
|
23
|
+
- Added the cross-repo Keble pytest marker vocabulary and strict marker config.
|
|
24
|
+
- Default tests no longer fail at collection when `tests/.env` is absent:
|
|
25
|
+
Mongo/Redis-backed tests skip at fixture setup when `MONGO_DB_URI` or
|
|
26
|
+
`REDIS_URI` is not configured.
|
|
27
|
+
- The live LLM agent-tool test now requires both `RUN_LLM_LIVE=1` and
|
|
28
|
+
`LIVE_LLM_*` credentials, so full local pytest does not spend model tokens by
|
|
29
|
+
accident.
|
|
30
|
+
- Added `AGENTS.md`, `CLAUDE.md`, and `pyrightconfig.json` for future test
|
|
31
|
+
workers.
|
|
32
|
+
- Removed the legacy custom classifier rejected by package upload validation.
|
|
33
|
+
|
|
34
|
+
## Testing
|
|
35
|
+
|
|
36
|
+
Default fast test command:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv run pytest -m "not live and not slow and not eval and not local_stack"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Run all non-live tests with local Mongo/Redis configured:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv run pytest -q
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Run the live LLM canary:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
RUN_LLM_LIVE=1 uv run pytest -m "live and llm"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Run static syntax/type checking:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx --yes pyright .
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Version 2.21.0 Update
|
|
61
|
+
|
|
62
|
+
Task agent tool metadata now names
|
|
63
|
+
`keble_helpers.AgentToolRegistrationConfig` directly. Package code and tests use
|
|
64
|
+
`AgentToolApprovalMode` for approval semantics; do not reintroduce bool approval
|
|
65
|
+
aliases in task provider contracts.
|
|
66
|
+
|
|
67
|
+
Build packaging explicitly excludes local agent scratch directories
|
|
68
|
+
(`.claude/`, `.worktrees/`, `.wt-discovery/`) so private symlinks never enter
|
|
69
|
+
release artifacts.
|
|
70
|
+
|
|
71
|
+
## Version 2.18.0 Update
|
|
72
|
+
|
|
73
|
+
Code-breaking agent-schema naming + placement refactor (no backward-compat
|
|
74
|
+
aliases), mirroring the shipped `keble-positioning` convention. Re-rebased onto
|
|
75
|
+
the settled `origin/main` 2.17.0 (the `AgenticActionEventSource` SCREAMING_SNAKE
|
|
76
|
+
realignment + the no-emit-without-drain static guard) and bumped above main to
|
|
77
|
+
2.18.0:
|
|
78
|
+
|
|
79
|
+
- `schemas.py` is now the `keble_task.schemas` PACKAGE (`schemas/__init__.py`
|
|
80
|
+
keeps all prior exports identical) so agent tool I/O schemas can live in the
|
|
81
|
+
new `schemas/for_agent.py`.
|
|
82
|
+
- Renames: `TaskSummaryView` -> `TaskSummaryForAgent`, `TaskQueryToolsConfig` ->
|
|
83
|
+
`TaskAgentQueryToolsConfig`, `TaskMutationToolsConfig` ->
|
|
84
|
+
`TaskAgentMutationToolsConfig` (all three now DEFINED in
|
|
85
|
+
`schemas/for_agent.py`).
|
|
86
|
+
- Registrars moved to a new `keble_task.agent.tools` subpackage (behavior only,
|
|
87
|
+
zero `BaseModel`): `tools/query.py::register_query_tools` and
|
|
88
|
+
`tools/mutation.py::register_mutation_tools`. The old bare `register` /
|
|
89
|
+
`register_action_tools` names and the `agent/registry.py` /
|
|
90
|
+
`agent/query_registry.py` modules are REMOVED.
|
|
91
|
+
- New convention guard `tests/schemas/test_for_agent.py`.
|
|
92
|
+
|
|
93
|
+
## Agentic schema convention (`ForAgent` + `agent/tools/`)
|
|
94
|
+
|
|
95
|
+
Every schema lands in exactly one bucket; the bucket dictates its NAME and its
|
|
96
|
+
LOCATION. This is enforced by `tests/schemas/test_for_agent.py`.
|
|
97
|
+
|
|
98
|
+
| Bucket | What it is | Name | Lives in |
|
|
99
|
+
| --- | --- | --- | --- |
|
|
100
|
+
| Agent tool I/O | typed input to / return projection of a pydantic-ai `@agent.tool` | `*ForAgent` suffix | `schemas/for_agent.py` only — never in `agent/` |
|
|
101
|
+
| Agent tool config/enum | tool-registration config or approval/mode enum used only by agent tools | `*Agent…Config` / `*Agent…Policy` infix | `schemas/for_agent.py` |
|
|
102
|
+
| Mutation action payload | the typed `@agent.tool` mutation input | unchanged | `keble_task/actions.py` (external action module) |
|
|
103
|
+
| Persisted / CRUD / event | `*Base`/`*Update`/`*MongoObject`/`*Event` | unchanged | `schemas/` |
|
|
104
|
+
|
|
105
|
+
The registrars under `agent/tools/` (`mutation.py`, `query.py`) are BEHAVIOR
|
|
106
|
+
ONLY — they import contracts from `schemas/` (and `actions.py`) and define zero
|
|
107
|
+
`BaseModel` classes. Do NOT reintroduce `*View`/`*Display` names for agent tool
|
|
108
|
+
payloads.
|
|
109
|
+
|
|
110
|
+
## Version 2.12.0 Update
|
|
111
|
+
|
|
112
|
+
Added `TaskClient.apublic_list_indexable(task_types, stages, limit) -> list[TaskPublicRef]`:
|
|
113
|
+
a lean, projected (`{_id, updated}`) public read backing consumer-side SEO sitemaps —
|
|
114
|
+
filters `sharing_scope=PUBLIC` + `task_type $in` + `stage $in`, newest-`updated` first,
|
|
115
|
+
capped by `limit`, no childs/redis. Backed by the new
|
|
116
|
+
`sharing_scope_task_type_stage_updated_desc_idx` (the package owns its indexes via
|
|
117
|
+
`aensure_task_indexes`). Also completed the latent `apublic_get_multi` overload contract
|
|
118
|
+
(`stages`/`title_contains` are now honored, mirroring `aowner_get_multi`).
|
|
119
|
+
|
|
120
|
+
## Package Line
|
|
121
|
+
|
|
122
|
+
1. Package version: `2.22.0`
|
|
123
|
+
2. Python baseline: `>=3.13,<3.14`
|
|
124
|
+
3. Runtime ownership:
|
|
125
|
+
- `TaskBase.language` is the canonical, task-owned output/report language;
|
|
126
|
+
report-generating handlers and child-task creation read `task.language`
|
|
127
|
+
instead of parsing domain-specific metadata blobs (optional for back-compat)
|
|
128
|
+
- flat task lists return root tasks unless `parent_task` is explicitly filtered
|
|
129
|
+
- `TaskRelationType.MAPPED` records viewport-map edges without changing parentage
|
|
130
|
+
- `TaskClient.aapply_actions(...)` creates related tasks through a pure generic action layer; feature packages own their own typed tools
|
|
131
|
+
- `TaskClient.astart(...)` is the single task-start entrypoint
|
|
132
|
+
- uncaught handler exceptions are finalized into terminal `FAILURE`
|
|
133
|
+
- start locks are always cleared before the run returns
|
|
134
|
+
- package events use direct `keble_helpers.AgenticActionEvent` JSON; backend
|
|
135
|
+
transports them without room-specific wrapper schemas
|
|
136
|
+
- `TaskClient` can emit canonical task lifecycle events after persisted
|
|
137
|
+
`PROCESSING`, `SUCCESS`, `FAILURE`, and timeout stage transitions
|
|
138
|
+
- downstream TypeScript room consumers should use `keble-core 0.1.32+`
|
|
139
|
+
direct-event builders rather than task-owned workspace snapshots
|
|
140
|
+
|
|
141
|
+
## Version 2.5.1 Update
|
|
142
|
+
|
|
143
|
+
1. Hardened the agentic `mutate_task_workspace` tool against ID hallucination:
|
|
144
|
+
`CreateRelatedTaskAction.parent_task_id` and `from_task_ids` now carry model-facing
|
|
145
|
+
`Field(description=...)` guidance to leave them unset (attach under the current task) unless
|
|
146
|
+
real ids are provided — never invent ids.
|
|
147
|
+
2. The tool boundary (`agent/registry.py`) now maps model-fixable action-validation errors
|
|
148
|
+
(`ClientSideInvalidParams` / `ClientSideMissingParams` / `ServerSideInvalidParams` from
|
|
149
|
+
`aapply_actions`, e.g. a hallucinated `parent_task_id`) to `pydantic_ai.ModelRetry`, so the
|
|
150
|
+
model self-corrects instead of aborting the run. Wiring/infra faults still propagate.
|
|
151
|
+
3. Added a deterministic `FunctionModel` self-correction test (bad `parentTaskId` -> ModelRetry
|
|
152
|
+
-> recover) and made the live LLM prompt explicit about omitting the ids.
|
|
153
|
+
4. Package metadata synced to `2.5.1` across `pyproject.toml`, `pyproject.poetry.toml`, `uv.lock`.
|
|
154
|
+
|
|
155
|
+
## Version 2.5.0 Update
|
|
156
|
+
|
|
157
|
+
1. Added `TaskBase.language: Optional[Language]` — a typed, task-owned output/report language so
|
|
158
|
+
the generic task framework is the single source of truth for the language a handler should
|
|
159
|
+
produce output in, instead of consumers digging it out of domain-specific metadata dicts.
|
|
160
|
+
2. `TaskClient.acreate(..., language=...)` threads the value onto the created task.
|
|
161
|
+
3. Optional with a `None` default, so legacy callers and legacy persisted documents still validate
|
|
162
|
+
and load.
|
|
163
|
+
4. Package metadata synced to `2.5.0` across `pyproject.toml`, `pyproject.poetry.toml`, `uv.lock`.
|
|
164
|
+
|
|
165
|
+
## Version 2.4.23 Update
|
|
166
|
+
|
|
167
|
+
1. Added owner/public root-list indexes — `CRUDTask.aensure_task_indexes` now also creates
|
|
168
|
+
`owner_parent_task_created_desc_idx` and `sharing_scope_parent_task_created_desc_idx` so
|
|
169
|
+
`aowner_get_multi` / `apublic_get_multi` root lists filter `owner` / `sharing_scope` on an
|
|
170
|
+
index instead of residually after a `parent_task` scan (created at `ainit`/startup).
|
|
171
|
+
2. `CREATE_RELATED_TASK` is now self-healing: standalone Mongo has no multi-document
|
|
172
|
+
transactions, so if a relation write fails after the child task is created, a saga
|
|
173
|
+
compensation (`_arollback_created_related_task`) deletes the orphaned child and any partial
|
|
174
|
+
relation rows, then re-raises — the workspace never keeps a child without its relations.
|
|
175
|
+
3. All package tests are pyright-clean (`pyright keble_task` and `pyright tests` both 0 errors);
|
|
176
|
+
removed stale tracked `dist/keble_task-0.0.0-*` build artifacts (`dist/` stays gitignored).
|
|
177
|
+
4. Package metadata synced to `2.4.23` across `pyproject.toml`, `pyproject.poetry.toml`, `uv.lock`.
|
|
178
|
+
|
|
179
|
+
## Version 2.4.22 Update
|
|
180
|
+
|
|
181
|
+
1. `build_task_tree(...)` now **fails hard on corrupted persisted references** instead of
|
|
182
|
+
silently shrinking trees: a child whose `parent_task` is absent from the loaded root
|
|
183
|
+
tree or a missing requested root raises `keble_exceptions.ObjectNotFound`, and a parent
|
|
184
|
+
cycle raises `keble_exceptions.DataIntegrityCompromised`. The sibling sort was simplified
|
|
185
|
+
to use the non-Optional persisted `created` datetime (no `try/except`). Behavior change:
|
|
186
|
+
list/tree endpoints surface corruption as typed errors rather than partial results.
|
|
187
|
+
2. The gated live agent-tool test guards its OpenAI-model imports with
|
|
188
|
+
`pytest.importorskip` so collection never fails where the openai extra is absent, and the
|
|
189
|
+
`test` dependency group now includes `pydantic-ai-slim[openai]` so `uv run pytest` can run
|
|
190
|
+
it when `LIVE_LLM_*` creds are present.
|
|
191
|
+
3. Package metadata synced to `2.4.22` across `pyproject.toml`, `pyproject.poetry.toml`, and
|
|
192
|
+
`uv.lock`.
|
|
193
|
+
|
|
194
|
+
## Version 2.4.18 Update
|
|
195
|
+
|
|
196
|
+
1. `TaskCostAggregateResponse.build(...)` now takes `costs: Sequence[TaskCostBase]`
|
|
197
|
+
(read-only/covariant) so CRUD's `list[TaskCostMongoObject]` is accepted without a
|
|
198
|
+
list-invariance type error, and folds rows through new typed
|
|
199
|
+
`TaskCostAggregateBucket.empty()` / `accumulate()` methods instead of an untyped
|
|
200
|
+
`dict[str, Any]` accumulator — bucket math lives on the type that owns it.
|
|
201
|
+
2. The task collection now has tree/stage read indexes via
|
|
202
|
+
`CRUDTask.aensure_task_indexes(...)` (`root_task+created`, `parent_task+created`,
|
|
203
|
+
`stage+created`), wired into `TaskClient.aensure_indexes(...)`/`ainit(...)` so
|
|
204
|
+
root-tree, root-list, and retry/timeout sweeps are index-backed (idempotent).
|
|
205
|
+
3. `TaskMetadata = dict[str, JsonValue]` is the single strongly-typed contract for
|
|
206
|
+
free-form task metadata; `TaskBase.metadata` and the `TaskClient` metadata params
|
|
207
|
+
use it instead of a bare `dict`.
|
|
208
|
+
4. The agent registrar uses `AgentToolRegistrationConfig` directly, removing
|
|
209
|
+
the old local config shadowing that caused `ToolFuncContext` assignment
|
|
210
|
+
errors.
|
|
211
|
+
5. Added tests for timeout lifecycle, the processing guard, zero-usage cost rollup,
|
|
212
|
+
parent+child cost rollup by root, and a gated live-LLM `mutate_task_workspace`
|
|
213
|
+
agent-tool run (`LIVE_LLM_*` creds in `tests/.env`).
|
|
214
|
+
|
|
215
|
+
## Version 2.4.16 Update
|
|
216
|
+
|
|
217
|
+
1. `TaskCostCreate.from_task_usage(...)` is the canonical cost-row constructor;
|
|
218
|
+
it denormalizes owner and task type from the persisted task row.
|
|
219
|
+
2. `TaskCostAggregateResponse.build(...)` owns in-memory response aggregation
|
|
220
|
+
from already-filtered rows.
|
|
221
|
+
3. Runtime task-cost create/list/aggregate paths no longer create Mongo
|
|
222
|
+
indexes; callers must use `TaskClient.ainit(...)` or startup wiring.
|
|
223
|
+
4. `TaskHandlerRequest` inherits `AgentDbDeps`, so `usage_recorder` travels with
|
|
224
|
+
the same DB-rooted request object without making the task package own pricing
|
|
225
|
+
or persistence policy outside cost rows.
|
|
226
|
+
|
|
227
|
+
## Version 2.4.15 Update
|
|
228
|
+
|
|
229
|
+
1. `TaskCostFilterBase` is now the single source of truth for task-cost read
|
|
230
|
+
filters shared by list and aggregate requests.
|
|
231
|
+
2. `TaskCostListRequest` owns only pagination fields on top of the shared
|
|
232
|
+
filter contract.
|
|
233
|
+
3. `TaskCostAggregateRequest` owns only aggregation controls on top of the
|
|
234
|
+
shared filter contract.
|
|
235
|
+
4. Task-cost tag filters remain all-tags filters: every requested tag must be
|
|
236
|
+
present on the cost row.
|
|
237
|
+
5. Package metadata is synchronized across `pyproject.toml`,
|
|
238
|
+
`pyproject.poetry.toml`, and `uv.lock`.
|
|
239
|
+
|
|
240
|
+
## Version 2.4.14 Update
|
|
241
|
+
|
|
242
|
+
1. `TaskClient.ainit(amongo=...)` exposes the package-owned Mongo index setup
|
|
243
|
+
as a backend startup hook over existing `aensure_indexes(...)`.
|
|
244
|
+
|
|
245
|
+
## Version 2.4.13 Update
|
|
246
|
+
|
|
247
|
+
1. Added separate task-cost storage through `TaskCostBase`, `TaskCostCreate`,
|
|
248
|
+
`TaskCostMongoObject`, `TaskCostListRequest`, and
|
|
249
|
+
`TaskCostAggregateRequest`.
|
|
250
|
+
2. `TaskClient.acreate_task_cost(...)` now denormalizes root task, owner, and
|
|
251
|
+
task type from the persisted task row before writing the cost row.
|
|
252
|
+
3. `TaskClient.alist_task_costs(...)` and
|
|
253
|
+
`TaskClient.aaggregate_task_costs(...)` provide indexed admin reporting with
|
|
254
|
+
total/hour/day/week/month buckets and optional tag fan-out.
|
|
255
|
+
4. Aggregation uses `RunUsage`, per-million `Money` rates, and caller-provided
|
|
256
|
+
`ExchangeRateInUsd` values while preserving sub-cent Decimal token costs.
|
|
257
|
+
5. `TaskClient.aensure_indexes(...)` creates public-id, relation, and task-cost
|
|
258
|
+
indexes from one startup-friendly entrypoint.
|
|
259
|
+
|
|
260
|
+
## Version 2.4.12 Update
|
|
261
|
+
|
|
262
|
+
1. `keble-task` now declares `tenacity>=9,<10.0.0` directly because the task
|
|
263
|
+
runtime imports it for handler retry.
|
|
264
|
+
2. `TaskClient.acreate_task_relations(...)` rejects duplicate relation edge
|
|
265
|
+
payloads before inserting any row.
|
|
266
|
+
3. `TaskClient.aapply_actions(...)` rejects duplicate `from_task_ids` before
|
|
267
|
+
creating the child task, preventing partial child-task persistence when an
|
|
268
|
+
action would generate duplicate relation edges.
|
|
269
|
+
4. Previously skipped start/retry/timeout tests now exercise the real async
|
|
270
|
+
handler path with `ExtendedAsyncRedis`.
|
|
271
|
+
|
|
272
|
+
## Version 2.4.11 Update
|
|
273
|
+
|
|
274
|
+
1. Added `TaskEventType.TASK_STAGE_CHANGED` as the package-owned lifecycle
|
|
275
|
+
event for task stage transitions.
|
|
276
|
+
2. Added `TaskLifecycleEventPayload(task=TaskMongoObject)` and
|
|
277
|
+
`TaskLifecycleEvent`, reusing the persisted task schema instead of adding
|
|
278
|
+
UI-specific state payloads.
|
|
279
|
+
3. `TaskClient.astart(...)`, `aon_task_processing(...)`,
|
|
280
|
+
`aon_task_success(...)`, `aon_task_failure(...)`, and
|
|
281
|
+
`aon_task_timeout(...)` can emit lifecycle events after DB updates reload
|
|
282
|
+
the final task row.
|
|
283
|
+
4. Consumers should use lifecycle events for room task state, while
|
|
284
|
+
`CREATE_RELATED_TASK` events remain the canonical child/relation creation
|
|
285
|
+
events.
|
|
286
|
+
|
|
287
|
+
## Version 2.4.8 Update
|
|
288
|
+
|
|
289
|
+
1. `CreateRelatedTaskAction` relation rows now persist `action.metadata` on
|
|
290
|
+
`TaskRelationCreate.metadata` instead of writing `{}`.
|
|
291
|
+
2. `TaskActionCreatedRelation` remains a slim public DTO but now includes
|
|
292
|
+
relation metadata so downstream event consumers can render edge context.
|
|
293
|
+
3. No new relation schema was added; `TaskRelationBase.metadata` is the
|
|
294
|
+
canonical extension point for task-action relation annotations.
|
|
295
|
+
|
|
296
|
+
## Version 2.4.9 Update
|
|
297
|
+
|
|
298
|
+
1. Relation metadata now uses the package-owned `TaskRelationMetadata`
|
|
299
|
+
JSON-safe contract before persistence and event emission.
|
|
300
|
+
2. `CreateRelatedTaskAction.metadata`, `TaskRelationBase.metadata`, and
|
|
301
|
+
`TaskActionCreatedRelation.metadata` normalize BSON ObjectIds, datetimes,
|
|
302
|
+
and Pydantic models into JSON-compatible values.
|
|
303
|
+
3. Unsupported runtime objects are rejected at the metadata boundary instead of
|
|
304
|
+
leaking into SSE or TypeScript `JsonObject` consumers.
|
|
305
|
+
|
|
306
|
+
## Version 2.4.10 Update
|
|
307
|
+
|
|
308
|
+
1. Unsupported relation metadata now raises `ValueError` inside the JSON
|
|
309
|
+
fallback so Pydantic wraps model construction failures as `ValidationError`.
|
|
310
|
+
2. Regression coverage protects both `CreateRelatedTaskAction.metadata` and
|
|
311
|
+
`TaskRelationCreate.metadata` from surfacing raw `TypeError`.
|
|
312
|
+
|
|
313
|
+
## Installation
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
pip install keble-task
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## Core Concepts
|
|
320
|
+
|
|
321
|
+
### TaskClient
|
|
322
|
+
|
|
323
|
+
The `TaskClient` is the main entry point for backend APIs that need to create and manage tasks. All operations are asynchronous with 'a' prefix (e.g., `acreate`, `astart`, `aget`).
|
|
324
|
+
|
|
325
|
+
`astart(...)` now owns the terminal failure boundary for task execution:
|
|
326
|
+
|
|
327
|
+
1. it loads the task and checks the Redis start lock
|
|
328
|
+
2. it moves the task to `PROCESSING`
|
|
329
|
+
3. it runs the configured handler with retry support
|
|
330
|
+
4. if the handler still raises, it writes a terminal `FAILURE` row unless the handler already finalized the task
|
|
331
|
+
5. it always clears the Redis start lock before returning
|
|
332
|
+
|
|
333
|
+
### Task Tree And Relations
|
|
334
|
+
|
|
335
|
+
Task trees and task relations are intentionally separate:
|
|
336
|
+
|
|
337
|
+
1. `root_task` scopes the whole workspace.
|
|
338
|
+
2. `parent_task` stores the one canonical creator/trigger edge.
|
|
339
|
+
3. `TaskRelationMongoObject` stores extra lineage edges such as multi-source `REDUCED` children and viewport-map `MAPPED` children.
|
|
340
|
+
|
|
341
|
+
Flat task list APIs now return only root tasks by default. Use `include_childs=True`
|
|
342
|
+
for task trees, `parent_task=<id>` for direct child listing, and relation APIs for
|
|
343
|
+
non-tree edges.
|
|
344
|
+
|
|
345
|
+
The package does not expose a workspace snapshot schema. Backend routes should fetch the existing task tree and relation rows separately, then decide how to compose them for frontend views.
|
|
346
|
+
|
|
347
|
+
### Task Room Resolution
|
|
348
|
+
|
|
349
|
+
Task-room identity is intentionally generic and task-tree-only:
|
|
350
|
+
|
|
351
|
+
1. `TaskClient.aresolve_task_room(...)` accepts any owner-visible task id.
|
|
352
|
+
2. It returns:
|
|
353
|
+
- `root_task_id`
|
|
354
|
+
- `requested_task_id`
|
|
355
|
+
3. The package does not know feature focus shape, selection metadata, or chat state.
|
|
356
|
+
4. Backend/frontend layers should use the returned root task id as room identity
|
|
357
|
+
and keep feature-specific metadata outside this package.
|
|
358
|
+
|
|
359
|
+
### Task Room Graph Context
|
|
360
|
+
|
|
361
|
+
`TaskRoomGraphContext` is the compact agent-facing room graph. It is generic:
|
|
362
|
+
task ids, parent edges, task type/title/subtitle, and sidecar `MAPPED` /
|
|
363
|
+
`REDUCED` relation edges. It does not include positioning ids, grid ids, UX
|
|
364
|
+
selection, or frontend state.
|
|
365
|
+
|
|
366
|
+
```python
|
|
367
|
+
context = await task_client.aget_task_room_graph_context(
|
|
368
|
+
amongo=amongo,
|
|
369
|
+
owner=owner,
|
|
370
|
+
root_task_id=root_task_id,
|
|
371
|
+
focused_task_id=focused_task_id,
|
|
372
|
+
)
|
|
373
|
+
prompt_text = context.to_prompt_text()
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
```python
|
|
377
|
+
relations = await task_client.acreate_task_relations(
|
|
378
|
+
amongo=amongo,
|
|
379
|
+
objs_in=[
|
|
380
|
+
TaskRelationCreate(
|
|
381
|
+
root_task=root.id,
|
|
382
|
+
from_task_id=source_a.id,
|
|
383
|
+
to_task_id=child.id,
|
|
384
|
+
relation_type=TaskRelationType.REDUCED,
|
|
385
|
+
),
|
|
386
|
+
TaskRelationCreate(
|
|
387
|
+
root_task=root.id,
|
|
388
|
+
from_task_id=source_b.id,
|
|
389
|
+
to_task_id=child.id,
|
|
390
|
+
relation_type=TaskRelationType.REDUCED,
|
|
391
|
+
),
|
|
392
|
+
TaskRelationCreate(
|
|
393
|
+
root_task=root.id,
|
|
394
|
+
from_task_id=source_a.id,
|
|
395
|
+
to_task_id=mapped_view_task.id,
|
|
396
|
+
relation_type=TaskRelationType.MAPPED,
|
|
397
|
+
),
|
|
398
|
+
],
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
root_relations = await task_client.alist_task_relations_by_root(
|
|
402
|
+
amongo=amongo,
|
|
403
|
+
root_task=root.id,
|
|
404
|
+
)
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### Task Cost Tracking
|
|
408
|
+
|
|
409
|
+
Task costs are stored in a dedicated collection and are denormalized from the
|
|
410
|
+
task row at creation time. The task collection stays focused on execution state,
|
|
411
|
+
while cost reports can filter by task, root task, owner, task type, tags, and
|
|
412
|
+
`occurred_at` windows.
|
|
413
|
+
|
|
414
|
+
```python
|
|
415
|
+
result = await agent.run(prompt, deps=deps)
|
|
416
|
+
|
|
417
|
+
await task_client.acreate_task_cost(
|
|
418
|
+
amongo=amongo,
|
|
419
|
+
task_id=task.id,
|
|
420
|
+
tags=["positioning", "classify_cells"],
|
|
421
|
+
run_usage=result.usage,
|
|
422
|
+
token_rates_per_million=TaskCostTokenRates(
|
|
423
|
+
input_tokens=Money(float_money=1.25, currency=Currency.USD),
|
|
424
|
+
output_tokens=Money(float_money=10.00, currency=Currency.USD),
|
|
425
|
+
),
|
|
426
|
+
additional_cost=Money(float_money=0.01, currency=Currency.USD),
|
|
427
|
+
seconds=12,
|
|
428
|
+
retry=max(task.attempts - 1, 0),
|
|
429
|
+
metadata={"model": "gateway/openai:gpt-5.2"},
|
|
430
|
+
)
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### Task Handler
|
|
434
|
+
|
|
435
|
+
For packages that want to support keble-task, implementing a task handler function is required rather than creating a TaskClient instance.
|
|
436
|
+
|
|
437
|
+
### Task Lifecycle Events
|
|
438
|
+
|
|
439
|
+
`ProgressTask` is not the current task-handler transport. Handlers receive a
|
|
440
|
+
`TaskHandlerRequest`, read DB clients and cross-cutting context directly from that
|
|
441
|
+
request, and return `TaskHandlerResponse | None`.
|
|
442
|
+
|
|
443
|
+
Task lifecycle and domain progress are emitted through the shared
|
|
444
|
+
`AgenticEventEmitter` on `request.event_emitter`. The emitter is intentionally
|
|
445
|
+
detached: `aemit()` schedules callbacks and the task runtime drains before the
|
|
446
|
+
execution boundary returns.
|
|
447
|
+
|
|
448
|
+
## Schemas
|
|
449
|
+
|
|
450
|
+
The package provides several schemas for working with tasks:
|
|
451
|
+
|
|
452
|
+
### TaskStage
|
|
453
|
+
|
|
454
|
+
An enum representing the different stages of a task:
|
|
455
|
+
|
|
456
|
+
```python
|
|
457
|
+
class TaskStage(str, Enum):
|
|
458
|
+
PENDING = "PENDING" # Task is created but not yet started
|
|
459
|
+
PROCESSING = "PROCESSING" # Task is currently being processed
|
|
460
|
+
SUCCESS = "SUCCESS" # Task completed successfully
|
|
461
|
+
FAILURE = "FAILURE" # Task failed to complete
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Tasks in `PENDING` or `PROCESSING` stages can be started or restarted.
|
|
465
|
+
|
|
466
|
+
### SharingScope
|
|
467
|
+
|
|
468
|
+
An enum defining the visibility scope of a task:
|
|
469
|
+
|
|
470
|
+
```python
|
|
471
|
+
class SharingScope(str, Enum):
|
|
472
|
+
PRIVATE = "PRIVATE" # Only accessible by the owner
|
|
473
|
+
PUBLIC = "PUBLIC" # Accessible by anyone
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
### TaskBase
|
|
477
|
+
|
|
478
|
+
The base schema for task data:
|
|
479
|
+
|
|
480
|
+
```python
|
|
481
|
+
class TaskBase(SchemaBase):
|
|
482
|
+
# Error information
|
|
483
|
+
error: Optional[str] = None
|
|
484
|
+
exception_type: Optional[TaskExceptionType] = None
|
|
485
|
+
|
|
486
|
+
# Current task stage
|
|
487
|
+
stage: TaskStage
|
|
488
|
+
|
|
489
|
+
# Task type identifier
|
|
490
|
+
task_type: str
|
|
491
|
+
|
|
492
|
+
# Optional progress tracking key
|
|
493
|
+
progress_key: Optional[str]
|
|
494
|
+
|
|
495
|
+
# Display information
|
|
496
|
+
title: Optional[str]
|
|
497
|
+
subtitle: Optional[str]
|
|
498
|
+
image: Optional[str]
|
|
499
|
+
|
|
500
|
+
# Custom metadata for the task
|
|
501
|
+
metadata: Optional[dict]
|
|
502
|
+
|
|
503
|
+
# Access control
|
|
504
|
+
sharing_scope: SharingScope = SharingScope.PRIVATE
|
|
505
|
+
status: Status = Status.ACTIVE
|
|
506
|
+
owner: str
|
|
507
|
+
|
|
508
|
+
# Timestamp tracking
|
|
509
|
+
started_ts: Optional[Timestamp] = None
|
|
510
|
+
success_ts: Optional[Timestamp] = None
|
|
511
|
+
failure_ts: Optional[Timestamp] = None
|
|
512
|
+
|
|
513
|
+
# Token management
|
|
514
|
+
expected_token: int # Expected tokens to consume
|
|
515
|
+
consumed_token: int = 0 # Actual tokens consumed
|
|
516
|
+
|
|
517
|
+
# Retry and timeout handling
|
|
518
|
+
attempts: int = 0 # Number of attempts so far
|
|
519
|
+
timeout_mins: int = 120 # Timeout in minutes
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
The `TaskBase` provides two helpful properties:
|
|
523
|
+
|
|
524
|
+
- `unfinshed_timeout`: Returns `True` if the task has timed out but is still in `PENDING` or `PROCESSING` stage
|
|
525
|
+
- `allow_to_retry`: Returns `True` if the task has fewer than 3 attempts and is in a stage that allows starting
|
|
526
|
+
|
|
527
|
+
### TaskUpdate
|
|
528
|
+
|
|
529
|
+
A schema for the fields an owner may patch post-creation. Unset fields are not
|
|
530
|
+
written (`aowner_update` dumps with `exclude_unset`), so a partial patch never
|
|
531
|
+
clobbers other columns. `image` reuses the exact same URL validation as creation
|
|
532
|
+
(`TaskBase.validate_image_url`), so an edit can never store an image creation
|
|
533
|
+
would have rejected.
|
|
534
|
+
|
|
535
|
+
```python
|
|
536
|
+
class TaskUpdate(BaseModel):
|
|
537
|
+
sharing_scope: Optional[SharingScope] = None
|
|
538
|
+
title: Optional[str] = None
|
|
539
|
+
image: Optional[str] = None # owner thumbnail; full http(s) URL only
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
### TaskMongoObject
|
|
543
|
+
|
|
544
|
+
The MongoDB document representation of a task:
|
|
545
|
+
|
|
546
|
+
```python
|
|
547
|
+
class TaskMongoObject(MongoObjectBase, TaskBase):
|
|
548
|
+
pass
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
This combines `TaskBase` with MongoDB-specific functionality from `MongoObjectBase`.
|
|
552
|
+
|
|
553
|
+
### TaskRelationMongoObject
|
|
554
|
+
|
|
555
|
+
The MongoDB document representation of extra lineage between tasks in one workspace:
|
|
556
|
+
|
|
557
|
+
```python
|
|
558
|
+
class TaskRelationType(str, Enum):
|
|
559
|
+
MAPPED = "MAPPED"
|
|
560
|
+
REDUCED = "REDUCED"
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
class TaskRelationBase(SchemaBase):
|
|
564
|
+
root_task: ObjectId
|
|
565
|
+
from_task_id: ObjectId
|
|
566
|
+
to_task_id: ObjectId
|
|
567
|
+
relation_type: TaskRelationType
|
|
568
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class TaskRelationMongoObject(MongoObjectBase, TaskRelationBase):
|
|
572
|
+
pass
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
Relation rows do not modify `root_task`, `parent_task`, or task status. They are sidecar lineage edges for backend/frontend composition.
|
|
576
|
+
|
|
577
|
+
### Generic Task Actions
|
|
578
|
+
|
|
579
|
+
Use `TaskActions` when an agent or backend API needs task-owned mutations without feature-specific side effects.
|
|
580
|
+
|
|
581
|
+
```python
|
|
582
|
+
out = await task_client.aapply_actions(
|
|
583
|
+
amongo=amongo,
|
|
584
|
+
current_task=source_task,
|
|
585
|
+
payload=TaskActions(
|
|
586
|
+
message="Create a related child task.",
|
|
587
|
+
actions=[
|
|
588
|
+
CreateRelatedTaskAction(
|
|
589
|
+
relation_type=TaskRelationType.MAPPED,
|
|
590
|
+
task_type="POSITIONING",
|
|
591
|
+
title="Related workspace",
|
|
592
|
+
metadata={"source": "agent"},
|
|
593
|
+
)
|
|
594
|
+
],
|
|
595
|
+
),
|
|
596
|
+
extended_aredis=extended_aredis,
|
|
597
|
+
)
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
The task package creates the child task, validates root ownership, writes relation rows, and emits `TaskActionEvent` using `keble_helpers.AgenticActionEvent`. Positioning-specific mapped/reduced-child behavior belongs in backend-owned typed tools, not in `keble-task` hook registries.
|
|
601
|
+
|
|
602
|
+
Action results now expose slim DTOs instead of full Mongo rows:
|
|
603
|
+
|
|
604
|
+
1. `TaskActionCreatedTask` carries `task_id`, root/parent ids, task type, stage, title, and progress key.
|
|
605
|
+
2. `TaskActionCreatedRelation` carries relation id, root/source/target ids, and relation type.
|
|
606
|
+
3. Internal `TaskMongoObject` and `TaskRelationMongoObject` remain the persistence models, but tool/action payloads should not return them directly.
|
|
607
|
+
|
|
608
|
+
### Agent Tool Registration
|
|
609
|
+
|
|
610
|
+
Task owns the pydantic-ai registrar for generic task workspace mutations.
|
|
611
|
+
Backend should register this package tool instead of defining a manual duplicate
|
|
612
|
+
in backend chat code.
|
|
613
|
+
|
|
614
|
+
```python
|
|
615
|
+
from keble_task.agent import TaskAgentDeps, register_mutation_tools
|
|
616
|
+
|
|
617
|
+
agent = Agent[TaskAgentDeps, Any](...)
|
|
618
|
+
|
|
619
|
+
register_mutation_tools(
|
|
620
|
+
agent,
|
|
621
|
+
task_client=task_client,
|
|
622
|
+
)
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
Deps shape:
|
|
626
|
+
- `TaskAgentDeps` inherits `keble_db.AgentDbDeps`; Mongo/Redis are not separate tool args.
|
|
627
|
+
- Task runtime state is under `ctx.deps.task`.
|
|
628
|
+
- The tool delegates to `TaskClient.aapply_actions(...)`, so task parentage, generic relations, and task action events stay package-owned.
|
|
629
|
+
|
|
630
|
+
### Native Chat Tool Provider (query tools)
|
|
631
|
+
|
|
632
|
+
The package also owns the READ/QUERY registrar (`register_query_tools`: owner-scoped
|
|
633
|
+
`list_tasks` / `get_task`) and, since 2.7.0, ships it as a NATIVE
|
|
634
|
+
`keble_helpers.ChatToolProviderProtocol` provider so hosts no longer need a
|
|
635
|
+
generic adapter around the registrar:
|
|
636
|
+
|
|
637
|
+
```python
|
|
638
|
+
from keble_task.agent import TaskQueryChatToolProvider
|
|
639
|
+
|
|
640
|
+
provider = TaskQueryChatToolProvider(
|
|
641
|
+
task_client=task_client,
|
|
642
|
+
tools_config=None, # optional TaskAgentQueryToolsConfig | dict overrides
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
# A chat host composes providers declaratively; register attaches the tools.
|
|
646
|
+
provider.register(agent=agent, context=None)
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
Contract notes:
|
|
650
|
+
- `provider.provider_id` is EXACTLY `"task_query"` — it is recorded in room
|
|
651
|
+
diagnostics and mapped to a user-readable label by the frontend; do not change it.
|
|
652
|
+
- All deps are captured at construction; `register(*, agent, context)` ignores
|
|
653
|
+
`context`, which the cross-repo protocol explicitly permits.
|
|
654
|
+
|
|
655
|
+
### Task Handler Dependencies
|
|
656
|
+
|
|
657
|
+
The retired `TaskResources` bag is no longer part of the active handler API.
|
|
658
|
+
`TaskHandlerRequest` inherits `keble_db.AgentDbDeps`, so handlers read clients and
|
|
659
|
+
cross-cutting context directly from the request:
|
|
660
|
+
|
|
661
|
+
```python
|
|
662
|
+
request.amongo
|
|
663
|
+
request.extended_aredis
|
|
664
|
+
request.aneo4j
|
|
665
|
+
request.qdrant_client
|
|
666
|
+
request.event_emitter
|
|
667
|
+
request.usage_recorder
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
The `event_emitter` field is the canonical, single-source channel for task
|
|
671
|
+
lifecycle and package-owned domain events. Package handlers emit typed
|
|
672
|
+
`AgenticActionEvent` values through this emitter; the task runtime drains it
|
|
673
|
+
before returning from lifecycle boundaries so owner-list and room listeners see
|
|
674
|
+
deterministic terminal state.
|
|
675
|
+
|
|
676
|
+
### TaskHandlerRequest
|
|
677
|
+
|
|
678
|
+
A schema for task handling requests passed to task handlers:
|
|
679
|
+
|
|
680
|
+
```python
|
|
681
|
+
class TaskHandlerRequest(AgentDbDeps):
|
|
682
|
+
model_config = PydanticModelConfig.default(arbitrary_types_allowed=True)
|
|
683
|
+
task: TaskMongoObject # The task to be processed
|
|
684
|
+
metadata: TaskMetadata | None = None
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
Handlers return `TaskHandlerResponse` when the current process completed the
|
|
688
|
+
task. Handlers return `None` only when completion is intentionally delegated to a
|
|
689
|
+
separate process; in that case the runtime leaves the task in `PROCESSING` until
|
|
690
|
+
the delegate finalizes it.
|
|
691
|
+
|
|
692
|
+
### TaskHandlerResponse
|
|
693
|
+
|
|
694
|
+
A schema for responses returned from task handlers:
|
|
695
|
+
|
|
696
|
+
```python
|
|
697
|
+
class TaskHandlerResponse(BaseModel):
|
|
698
|
+
model_config = PydanticModelConfig.default()
|
|
699
|
+
task: TaskMongoObject # The processed task
|
|
700
|
+
success: bool # Whether the task was successful
|
|
701
|
+
consuming_token: int # Actual tokens consumed
|
|
702
|
+
exception_type: Optional[TaskExceptionType] = None # Type of exception if failed
|
|
703
|
+
error: Optional[str] = None # Error message if failed
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
### TokenConsumptionType
|
|
707
|
+
|
|
708
|
+
An enum representing token consumption actions:
|
|
709
|
+
|
|
710
|
+
```python
|
|
711
|
+
class TokenConsumptionType(str, Enum):
|
|
712
|
+
CONSUME = "CONSUME" # Consume tokens
|
|
713
|
+
RECOVER = "RECOVER" # Recover (return) tokens
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
### TokenConsumptionPayload
|
|
717
|
+
|
|
718
|
+
A schema for token consumption operations:
|
|
719
|
+
|
|
720
|
+
```python
|
|
721
|
+
class TokenConsumptionPayload(BaseModel):
|
|
722
|
+
model_config = PydanticModelConfig.default(arbitrary_types_allowed=True)
|
|
723
|
+
consumption_type: TokenConsumptionType # Whether to consume or recover tokens
|
|
724
|
+
owner: str # The owner of the tokens
|
|
725
|
+
token: int # The amount of tokens to consume or recover
|
|
726
|
+
task_id: ObjectId | None = None
|
|
727
|
+
amongo: AsyncIOMotorClient
|
|
728
|
+
extended_aredis: ExtendedAsyncRedis
|
|
729
|
+
aneo4j: Neo4jAsyncDriver | None = None
|
|
730
|
+
metadata: TaskMetadata | None = None
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
### TaskExceptionType
|
|
734
|
+
|
|
735
|
+
An enum representing different types of task exceptions:
|
|
736
|
+
|
|
737
|
+
```python
|
|
738
|
+
class TaskExceptionType(str, Enum):
|
|
739
|
+
UNKNOWN = "UNKNOWN" # Unknown error
|
|
740
|
+
FAILED_TO_START = "FAILED_TO_START" # Task failed to start
|
|
741
|
+
NO_SUFFICIENT_DATA = "NO_SUFFICIENT_DATA" # Insufficient data to process task
|
|
742
|
+
TIMEOUT = "TIMEOUT" # Task timed out
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
### TaskException
|
|
746
|
+
|
|
747
|
+
Base exception class for task-related errors:
|
|
748
|
+
|
|
749
|
+
```python
|
|
750
|
+
class TaskException(KebleException):
|
|
751
|
+
def __init__(
|
|
752
|
+
self,
|
|
753
|
+
*,
|
|
754
|
+
exception_type: TaskExceptionType = TaskExceptionType.UNKNOWN,
|
|
755
|
+
error: Optional[str] = None,
|
|
756
|
+
):
|
|
757
|
+
self.exception_type = exception_type
|
|
758
|
+
self.error = error
|
|
759
|
+
# Inherits from KebleException
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
Subclasses of TaskException:
|
|
763
|
+
|
|
764
|
+
- **TaskNoSufficientDataException**: Raised when there is insufficient data to process a task
|
|
765
|
+
- **TaskFailedToStartException**: Raised when a task fails to start
|
|
766
|
+
- **TaskTimeoutException**: Raised when a task times out
|
|
767
|
+
- **TaskUnknownException**: Raised for unknown errors
|
|
768
|
+
|
|
769
|
+
### Difficulty
|
|
770
|
+
|
|
771
|
+
A utility enum for representing task difficulty levels:
|
|
772
|
+
|
|
773
|
+
```python
|
|
774
|
+
class Difficulty(str, Enum):
|
|
775
|
+
EASY = "EASY"
|
|
776
|
+
MEDIUM = "MEDIUM"
|
|
777
|
+
HARD = "HARD"
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
## Examples
|
|
781
|
+
|
|
782
|
+
### Backend API: Creating a TaskClient
|
|
783
|
+
|
|
784
|
+
```python
|
|
785
|
+
import asyncio
|
|
786
|
+
from keble_task import (
|
|
787
|
+
TaskClient,
|
|
788
|
+
TaskHandlerRequest,
|
|
789
|
+
TaskHandlerResponse,
|
|
790
|
+
TokenConsumptionPayload,
|
|
791
|
+
TokenConsumptionType,
|
|
792
|
+
TaskExceptionType,
|
|
793
|
+
TaskStage,
|
|
794
|
+
)
|
|
795
|
+
from keble_helpers import AgenticEventEmitter, SharingScope
|
|
796
|
+
from motor.motor_asyncio import AsyncIOMotorClient
|
|
797
|
+
from keble_db import ExtendedAsyncRedis
|
|
798
|
+
|
|
799
|
+
# Define a token consumption handler (synchronous or asynchronous)
|
|
800
|
+
def token_consumption_handler(payload: TokenConsumptionPayload) -> bool:
|
|
801
|
+
# Access DB clients directly from the payload; there is no resources bag.
|
|
802
|
+
amongo = payload.amongo
|
|
803
|
+
extended_aredis = payload.extended_aredis
|
|
804
|
+
|
|
805
|
+
# Implement logic to handle token consumption or recovery
|
|
806
|
+
if payload.consumption_type == TokenConsumptionType.CONSUME:
|
|
807
|
+
print(f"Consuming {payload.token} tokens for {payload.owner}")
|
|
808
|
+
else: # TokenConsumptionType.RECOVER
|
|
809
|
+
print(f"Recovering {payload.token} tokens for {payload.owner}")
|
|
810
|
+
|
|
811
|
+
return True
|
|
812
|
+
|
|
813
|
+
# Alternatively, you can define an asynchronous token consumption handler
|
|
814
|
+
async def async_token_consumption_handler(payload: TokenConsumptionPayload) -> bool:
|
|
815
|
+
amongo = payload.amongo
|
|
816
|
+
extended_aredis = payload.extended_aredis
|
|
817
|
+
|
|
818
|
+
if payload.consumption_type == TokenConsumptionType.CONSUME:
|
|
819
|
+
print(f"Async consuming {payload.token} tokens for {payload.owner}")
|
|
820
|
+
else: # TokenConsumptionType.RECOVER
|
|
821
|
+
print(f"Async recovering {payload.token} tokens for {payload.owner}")
|
|
822
|
+
|
|
823
|
+
return True
|
|
824
|
+
|
|
825
|
+
# Define an async task handler
|
|
826
|
+
async def task_handler(request: TaskHandlerRequest) -> TaskHandlerResponse:
|
|
827
|
+
task = request.task
|
|
828
|
+
amongo = request.amongo
|
|
829
|
+
extended_aredis = request.extended_aredis
|
|
830
|
+
|
|
831
|
+
# Process the task with direct request clients and return the final response.
|
|
832
|
+
result = await process_task(task, amongo=amongo, extended_aredis=extended_aredis)
|
|
833
|
+
|
|
834
|
+
return TaskHandlerResponse(
|
|
835
|
+
task=task,
|
|
836
|
+
success=result.success,
|
|
837
|
+
consuming_token=result.tokens_used,
|
|
838
|
+
exception_type=None if result.success else TaskExceptionType.UNKNOWN,
|
|
839
|
+
error=None if result.success else result.error,
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
# Initialize MongoDB and Redis connections
|
|
843
|
+
async def setup():
|
|
844
|
+
# Initialize MongoDB and Redis connections
|
|
845
|
+
amongo_client = AsyncIOMotorClient("mongodb://localhost:27017")
|
|
846
|
+
extended_aredis = ExtendedAsyncRedis("redis://localhost:6379")
|
|
847
|
+
|
|
848
|
+
# Create TaskClient instance
|
|
849
|
+
task_client = TaskClient(
|
|
850
|
+
token_consumption_handler=token_consumption_handler, # Or async_token_consumption_handler
|
|
851
|
+
task_handler=task_handler,
|
|
852
|
+
mongo_database="my_database", # Optional, defaults to "__keble_task__"
|
|
853
|
+
task_collection="tasks" # Optional, defaults to "__keble_task__task__"
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
return task_client, amongo_client, extended_aredis
|
|
857
|
+
|
|
858
|
+
# Create a new task
|
|
859
|
+
async def create_task(task_client, amongo):
|
|
860
|
+
task = await task_client.acreate(
|
|
861
|
+
amongo=amongo,
|
|
862
|
+
expected_token=5, # Expected tokens to consume
|
|
863
|
+
owner="user123", # Owner ID
|
|
864
|
+
task_type="image_processing", # Type of task
|
|
865
|
+
title="Process Image", # Optional title
|
|
866
|
+
metadata={"image_url": "https://example.com/image.jpg"}, # Optional metadata
|
|
867
|
+
progress_key="process_image_123", # Optional progress key
|
|
868
|
+
image=None, # Optional image URL
|
|
869
|
+
subtitle=None, # Optional subtitle
|
|
870
|
+
timeout_mins=120, # Optional timeout in minutes (default 120)
|
|
871
|
+
sharing_scope=SharingScope.PRIVATE, # Optional sharing scope
|
|
872
|
+
stage=TaskStage.PENDING, # Optional initial stage
|
|
873
|
+
attempts=0, # Optional initial attempts
|
|
874
|
+
consumed_token=0 # Optional initial consumed tokens
|
|
875
|
+
)
|
|
876
|
+
return task
|
|
877
|
+
|
|
878
|
+
# Start a task and route lifecycle events through the shared emitter
|
|
879
|
+
async def start_task(task_client, amongo, extended_aredis, task_id):
|
|
880
|
+
event_emitter = AgenticEventEmitter()
|
|
881
|
+
|
|
882
|
+
await task_client.astart(
|
|
883
|
+
amongo=amongo,
|
|
884
|
+
extended_aredis=extended_aredis,
|
|
885
|
+
task_id=task_id,
|
|
886
|
+
task_lifecycle_event_emitter=event_emitter,
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
return event_emitter
|
|
890
|
+
|
|
891
|
+
# Get a task by ID
|
|
892
|
+
async def get_task(task_client, amongo, task_id):
|
|
893
|
+
task = await task_client.aget(
|
|
894
|
+
amongo=amongo,
|
|
895
|
+
task_id=task_id,
|
|
896
|
+
task_type=None, # Optional filter by task type
|
|
897
|
+
include_childs=True, # Optional: return TaskMongoObjectExtended with childs
|
|
898
|
+
)
|
|
899
|
+
return task
|
|
900
|
+
|
|
901
|
+
# Get a task by owner and ID
|
|
902
|
+
async def get_owner_task(task_client, amongo, owner, task_id):
|
|
903
|
+
task = await task_client.aowner_get(
|
|
904
|
+
amongo=amongo,
|
|
905
|
+
owner=owner,
|
|
906
|
+
task_id=task_id,
|
|
907
|
+
task_type=None, # Optional filter by task type
|
|
908
|
+
sharing_scope=None, # Optional filter by sharing scope
|
|
909
|
+
include_childs=True, # Optional: return TaskMongoObjectExtended with childs
|
|
910
|
+
)
|
|
911
|
+
return task
|
|
912
|
+
|
|
913
|
+
# Get multiple tasks
|
|
914
|
+
async def get_multiple_tasks(task_client, amongo, extended_aredis):
|
|
915
|
+
tasks = await task_client.aget_multi(
|
|
916
|
+
amongo=amongo,
|
|
917
|
+
extended_aredis=extended_aredis,
|
|
918
|
+
skip=0,
|
|
919
|
+
limit=10,
|
|
920
|
+
task_types=["image_processing", "text_processing"], # Optional filter by task types
|
|
921
|
+
include_childs=True, # Optional: return a tree (roots + childs)
|
|
922
|
+
root_task=None, # Optional: filter to a specific root tree
|
|
923
|
+
parent_task=None, # Optional: list direct childs of a parent task
|
|
924
|
+
)
|
|
925
|
+
return tasks
|
|
926
|
+
|
|
927
|
+
# Get multiple tasks for an owner
|
|
928
|
+
async def get_owner_multiple_tasks(task_client, amongo, extended_aredis, owner):
|
|
929
|
+
tasks = await task_client.aowner_get_multi(
|
|
930
|
+
amongo=amongo,
|
|
931
|
+
extended_aredis=extended_aredis,
|
|
932
|
+
owner=owner,
|
|
933
|
+
skip=0,
|
|
934
|
+
limit=10,
|
|
935
|
+
task_types=["image_processing"], # Optional filter by task types
|
|
936
|
+
sharing_scopes=[SharingScope.PRIVATE], # Optional filter by sharing scopes
|
|
937
|
+
include_childs=True, # Optional: return a tree (roots + childs)
|
|
938
|
+
root_task=None, # Optional: filter to a specific root tree
|
|
939
|
+
parent_task=None, # Optional: list direct childs of a parent task
|
|
940
|
+
)
|
|
941
|
+
return tasks
|
|
942
|
+
|
|
943
|
+
# Run example
|
|
944
|
+
async def main():
|
|
945
|
+
task_client, amongo, extended_aredis = await setup()
|
|
946
|
+
|
|
947
|
+
# Create a task
|
|
948
|
+
task = await create_task(task_client, amongo)
|
|
949
|
+
print(f"Created task with ID: {task.id}")
|
|
950
|
+
|
|
951
|
+
# Start task with lifecycle events
|
|
952
|
+
await start_task(task_client, amongo, extended_aredis, task.id)
|
|
953
|
+
print("Started task with lifecycle events")
|
|
954
|
+
|
|
955
|
+
# Get task by ID
|
|
956
|
+
retrieved_task = await get_task(task_client, amongo, task.id)
|
|
957
|
+
print(f"Retrieved task: {retrieved_task.stage}")
|
|
958
|
+
|
|
959
|
+
# Get task by owner
|
|
960
|
+
owner_task = await get_owner_task(task_client, amongo, "user123", task.id)
|
|
961
|
+
print(f"Retrieved owner task: {owner_task.stage}")
|
|
962
|
+
|
|
963
|
+
# Get multiple tasks
|
|
964
|
+
tasks = await get_multiple_tasks(task_client, amongo, extended_aredis)
|
|
965
|
+
print(f"Retrieved {len(tasks)} tasks")
|
|
966
|
+
|
|
967
|
+
# Get multiple owner tasks
|
|
968
|
+
owner_tasks = await get_owner_multiple_tasks(task_client, amongo, extended_aredis, "user123")
|
|
969
|
+
print(f"Retrieved {len(owner_tasks)} owner tasks")
|
|
970
|
+
|
|
971
|
+
if __name__ == "__main__":
|
|
972
|
+
asyncio.run(main())
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
### Package Supporting Keble Task: Implementing a Task Handler
|
|
976
|
+
|
|
977
|
+
If you're developing a package that wants to support keble-task, you only need to provide a task handler function:
|
|
978
|
+
|
|
979
|
+
```python
|
|
980
|
+
from keble_task import TaskHandlerRequest, TaskHandlerResponse, TaskExceptionType
|
|
981
|
+
|
|
982
|
+
async def my_package_task_handler(
|
|
983
|
+
request: TaskHandlerRequest,
|
|
984
|
+
) -> TaskHandlerResponse | None:
|
|
985
|
+
task = request.task
|
|
986
|
+
|
|
987
|
+
# Return None only when this handler intentionally delegates completion to
|
|
988
|
+
# another process. The task runtime leaves the task PROCESSING in that case.
|
|
989
|
+
if task.task_type != "my_package_task_type":
|
|
990
|
+
return None
|
|
991
|
+
|
|
992
|
+
result = await process_task(
|
|
993
|
+
task,
|
|
994
|
+
amongo=request.amongo,
|
|
995
|
+
extended_aredis=request.extended_aredis,
|
|
996
|
+
event_emitter=request.event_emitter,
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
return TaskHandlerResponse(
|
|
1000
|
+
task=task,
|
|
1001
|
+
success=result.success,
|
|
1002
|
+
consuming_token=result.tokens_used,
|
|
1003
|
+
exception_type=None if result.success else TaskExceptionType.UNKNOWN,
|
|
1004
|
+
error=None if result.success else result.error,
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
async def process_task(task, *, amongo, extended_aredis, event_emitter):
|
|
1008
|
+
# Implement your task processing logic here
|
|
1009
|
+
# ...
|
|
1010
|
+
return result
|
|
1011
|
+
```
|
|
1012
|
+
|
|
1013
|
+
## Error Handling
|
|
1014
|
+
|
|
1015
|
+
The package uses `keble_exceptions.KebleException` which will be thrown in various error scenarios. Specific task exceptions include:
|
|
1016
|
+
|
|
1017
|
+
- `TaskFailedToStartException`: When a task fails to start
|
|
1018
|
+
- `TaskNoSufficientDataException`: When there is insufficient data to process a task
|
|
1019
|
+
- `TaskTimeoutException`: When a task exceeds its timeout duration
|
|
1020
|
+
- `TaskUnknownException`: For general unexpected errors
|
|
1021
|
+
|
|
1022
|
+
Always handle these exceptions appropriately in your implementation.
|
|
1023
|
+
|
|
1024
|
+
## Task Lifecycle
|
|
1025
|
+
|
|
1026
|
+
1. **Creation**: Tasks are created with `acreate()` method
|
|
1027
|
+
2. **Starting**: Tasks are started with `astart()` method
|
|
1028
|
+
3. **Processing**: Tasks are processed by the task handler
|
|
1029
|
+
4. **Completion**: The handler returns `TaskHandlerResponse` and the runtime finalizes success or failure.
|
|
1030
|
+
|
|
1031
|
+
The package automatically handles retries, timeouts, and token consumption based on the configuration provided.
|
|
1032
|
+
|
|
1033
|
+
## Lifecycle And Progress Events
|
|
1034
|
+
|
|
1035
|
+
The active progress path is event-based:
|
|
1036
|
+
|
|
1037
|
+
1. **Lifecycle**: `TaskClient` emits `KEBLE_TASK / TASK_STAGE_CHANGED` after persisted stage transitions.
|
|
1038
|
+
2. **Domain progress**: Package handlers emit their own typed `AgenticActionEvent` payloads through `request.event_emitter`.
|
|
1039
|
+
3. **Drain boundary**: `aemit()` schedules callbacks; `adrain()` is the deterministic side-effect boundary.
|
|
1040
|
+
4. **Frontend delivery**: Backend bridges the same event envelope to owner-list and room websocket listeners.
|
|
1041
|
+
|
|
1042
|
+
`ProgressTask` may still appear in historical compatibility notes, but it is not
|
|
1043
|
+
the current task-handler progress transport.
|
|
1044
|
+
|
|
1045
|
+
## Breaking API Changes in Version 0.0.5
|
|
1046
|
+
|
|
1047
|
+
This version introduces significant breaking changes from the previous version:
|
|
1048
|
+
|
|
1049
|
+
1. **All Functions are Now Async**: All functions have been converted to asynchronous with an 'a' prefix (e.g., `acreate`, `astart`, `aget`)
|
|
1050
|
+
|
|
1051
|
+
2. **Direct Resource Parameters**: Functions no longer accept a `TaskResources` object. Handler requests and token payloads carry `amongo`, `extended_aredis`, and optional graph/vector clients directly.
|
|
1052
|
+
|
|
1053
|
+
3. **Async Handlers**: Task handlers should now be implemented as async functions. Token consumption handlers can be either synchronous or asynchronous.
|
|
1054
|
+
|
|
1055
|
+
4. **MongoDB and Redis Requirements**: The package now requires the async versions of MongoDB (AsyncIOMotorClient) and Redis clients.
|
|
1056
|
+
|
|
1057
|
+
5. **Progress Tracking**: Current progress uses `AgenticEventEmitter` and typed package events; older `ProgressTask` examples are historical only.
|
|
1058
|
+
|
|
1059
|
+
6. **Token Consumption Handler**: The parameter `token_consumption_handler` now supports both synchronous and asynchronous implementations.
|
|
1060
|
+
|
|
1061
|
+
If you're upgrading from a previous version, you'll need to update all your code to follow these new conventions.
|
|
1062
|
+
|
|
1063
|
+
## Dependency Documentation
|
|
1064
|
+
|
|
1065
|
+
This repo no longer stores copied dependency `README.md` snapshots under a local `readme/` directory.
|
|
1066
|
+
Read dependency documentation from the maintained source repos so local docs do not drift from released packages.
|
|
1067
|
+
|
|
1068
|
+
## Mongo Startup Indexes
|
|
1069
|
+
|
|
1070
|
+
Backend startup should call `TaskClient.ainit(amongo=...)`. The method delegates
|
|
1071
|
+
to the existing package-owned task, task-relation, and task-cost index setup so
|
|
1072
|
+
APIs and workers do not create indexes inside hot read/write paths.
|
|
1073
|
+
|
|
1074
|
+
## 2.4.17 Task-Cost Source Aggregation
|
|
1075
|
+
|
|
1076
|
+
`TaskCostCreate.from_task_usage(...)` is the canonical constructor for durable
|
|
1077
|
+
cost rows. It copies `owner`, `task_type`, and `root_task` from the stored task
|
|
1078
|
+
context and accepts first-class usage `source` without duplicating it as a tag.
|
|
1079
|
+
|
|
1080
|
+
`TaskCostAggregateResponse.build(...)` now groups by `TOTAL`, `TAG`, `SOURCE`,
|
|
1081
|
+
or `TASK_TYPE` through `TaskCostAggregateGroupBy`. List and aggregate reads
|
|
1082
|
+
continue to share `TaskCostFilterBase.to_mongo_filters()`, and runtime
|
|
1083
|
+
create/list/aggregate paths do not create indexes.
|