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,177 @@
|
|
|
1
|
+
"""Agent-facing task schemas (pydantic-ai tool I/O contracts).
|
|
2
|
+
|
|
3
|
+
Every type here is part of the contract an AI agent sees: the typed RETURN
|
|
4
|
+
projection of the task READ/QUERY tools, plus the tool-registration configs for
|
|
5
|
+
the QUERY and MUTATION tools. They are named with the ``ForAgent`` suffix (and
|
|
6
|
+
``Agent`` infix on the configs) so a reader can tell them apart from:
|
|
7
|
+
|
|
8
|
+
- persisted/CRUD/event/domain models (``TaskBase``/``TaskUpdate``/
|
|
9
|
+
``TaskMongoObject``/``TaskActionEvent``);
|
|
10
|
+
- the external action module ``keble_task.actions`` (``TaskActions`` etc.),
|
|
11
|
+
which is the typed mutation payload, NOT a tool I/O projection.
|
|
12
|
+
|
|
13
|
+
Placement rule (enforced by ``tests/schemas/test_for_agent.py``): agent tool I/O
|
|
14
|
+
schemas live HERE in ``schemas/``, never inside the behavior registrars under
|
|
15
|
+
``agent/tools/``.
|
|
16
|
+
|
|
17
|
+
Side effect if changes:
|
|
18
|
+
- ``TaskSummaryForAgent`` is the return type of the query tools in
|
|
19
|
+
``agent/tools/query.py`` (``list_tasks``, ``get_task``). Field renames change
|
|
20
|
+
the agent-visible JSON.
|
|
21
|
+
- ``TaskAgentQueryToolsConfig`` / ``TaskAgentMutationToolsConfig`` are consumed
|
|
22
|
+
by ``agent/tools/query.py``, ``agent/tools/mutation.py``,
|
|
23
|
+
``agent/chat_provider.py``, and keble.backend chat composition.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from keble_helpers import AgentToolRegistrationConfig, PydanticModelConfig
|
|
31
|
+
from pydantic import BaseModel, Field
|
|
32
|
+
|
|
33
|
+
from keble_task.schemas import TaskMongoObject, TaskStage
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Query tool RETURN projection (compact, JSON-safe, never raw payloads).
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TaskSummaryForAgent(BaseModel):
|
|
42
|
+
"""Compact, JSON-safe projection of one task for agent reads.
|
|
43
|
+
|
|
44
|
+
Only safe, user-presentable identity/status fields are exposed; raw
|
|
45
|
+
``metadata`` is intentionally omitted so the agent never sees provider
|
|
46
|
+
payloads, stable keys, or internal dumps. Failure facts (``error`` /
|
|
47
|
+
``exception_type``) ARE exposed so the agent can explain WHY a task
|
|
48
|
+
failed instead of guessing.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
model_config = PydanticModelConfig.default()
|
|
52
|
+
|
|
53
|
+
id: str = Field(description="Task id (ObjectId as string).")
|
|
54
|
+
task_type: str
|
|
55
|
+
title: str | None = None
|
|
56
|
+
subtitle: str | None = None
|
|
57
|
+
stage: TaskStage = Field(description="Task lifecycle stage (PENDING/PROCESSING/SUCCESS/FAILURE).")
|
|
58
|
+
root_task_id: str | None = None
|
|
59
|
+
parent_task_id: str | None = None
|
|
60
|
+
created: str | None = None
|
|
61
|
+
error: str | None = Field(
|
|
62
|
+
default=None,
|
|
63
|
+
description="Failure message recorded by the worker (only set when the task failed).",
|
|
64
|
+
)
|
|
65
|
+
exception_type: str | None = Field(
|
|
66
|
+
default=None,
|
|
67
|
+
description=(
|
|
68
|
+
"Failure category, e.g. NO_SUFFICIENT_DATA / TIMEOUT / FAILED_TO_START / "
|
|
69
|
+
"UNKNOWN (only set when the task failed)."
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_task(cls, task: TaskMongoObject) -> "TaskSummaryForAgent":
|
|
75
|
+
"""Build the compact projection from a stored task.
|
|
76
|
+
|
|
77
|
+
Step by step:
|
|
78
|
+
1. stringify every ObjectId so the projection is JSON-safe for the model;
|
|
79
|
+
2. carry the lifecycle stage as its `TaskStage` enum (a `(str, Enum)` that
|
|
80
|
+
serializes to its value while staying typed);
|
|
81
|
+
3. carry only safe title/subtitle, dropping internal metadata;
|
|
82
|
+
4. carry failure facts (error + exception_type value) so a failed task
|
|
83
|
+
is explainable by the agent.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
return cls(
|
|
87
|
+
id=str(task.id),
|
|
88
|
+
task_type=task.task_type,
|
|
89
|
+
title=task.title,
|
|
90
|
+
subtitle=task.subtitle,
|
|
91
|
+
stage=task.stage,
|
|
92
|
+
root_task_id=str(task.root_task) if task.root_task is not None else None,
|
|
93
|
+
parent_task_id=str(task.parent_task) if task.parent_task is not None else None,
|
|
94
|
+
created=task.created.isoformat() if task.created is not None else None,
|
|
95
|
+
error=task.error,
|
|
96
|
+
exception_type=(
|
|
97
|
+
task.exception_type.value if task.exception_type is not None else None
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Tool-registration configs (query + mutation).
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class TaskAgentQueryToolsConfig(BaseModel):
|
|
108
|
+
"""Tool-registration config for the task-owned read/query tools.
|
|
109
|
+
|
|
110
|
+
Side effect if changes:
|
|
111
|
+
- `agent/tools/query.py::register_query_tools` resolves each field through
|
|
112
|
+
`AgentToolRegistrationConfig.build(...)`; changing field names changes
|
|
113
|
+
backend chat provider override payloads.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
model_config = PydanticModelConfig.default()
|
|
117
|
+
|
|
118
|
+
list_tasks: AgentToolRegistrationConfig = Field(
|
|
119
|
+
default_factory=AgentToolRegistrationConfig
|
|
120
|
+
)
|
|
121
|
+
get_task: AgentToolRegistrationConfig = Field(
|
|
122
|
+
default_factory=AgentToolRegistrationConfig
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def build(
|
|
127
|
+
cls,
|
|
128
|
+
value: "TaskAgentQueryToolsConfig | dict[str, Any] | None" = None,
|
|
129
|
+
) -> "TaskAgentQueryToolsConfig":
|
|
130
|
+
"""Build validated query-tool config from optional input."""
|
|
131
|
+
|
|
132
|
+
if value is None:
|
|
133
|
+
return cls()
|
|
134
|
+
if isinstance(value, cls):
|
|
135
|
+
return value
|
|
136
|
+
return cls.model_validate(value)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TaskAgentMutationToolsConfig(BaseModel):
|
|
140
|
+
"""Tool registration config for task-owned pydantic-ai mutation tools.
|
|
141
|
+
|
|
142
|
+
Side effect if changes:
|
|
143
|
+
- `agent/tools/mutation.py::register_mutation_tools` resolves this metadata
|
|
144
|
+
before registering the model-facing `mutate_task_workspace` tool.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
model_config = PydanticModelConfig.default()
|
|
148
|
+
|
|
149
|
+
mutate_task_workspace: AgentToolRegistrationConfig = Field(
|
|
150
|
+
default_factory=AgentToolRegistrationConfig
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def build(
|
|
155
|
+
cls,
|
|
156
|
+
value: "TaskAgentMutationToolsConfig | dict[str, Any] | None" = None,
|
|
157
|
+
) -> "TaskAgentMutationToolsConfig":
|
|
158
|
+
"""Build validated task tool config from optional input.
|
|
159
|
+
|
|
160
|
+
Step by step:
|
|
161
|
+
1. return the default config when callers pass no override;
|
|
162
|
+
2. preserve already validated config objects;
|
|
163
|
+
3. validate raw dict payloads at the package registrar boundary.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
if value is None:
|
|
167
|
+
return cls()
|
|
168
|
+
if isinstance(value, cls):
|
|
169
|
+
return value
|
|
170
|
+
return cls.model_validate(value)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
__all__ = [
|
|
174
|
+
"TaskSummaryForAgent",
|
|
175
|
+
"TaskAgentQueryToolsConfig",
|
|
176
|
+
"TaskAgentMutationToolsConfig",
|
|
177
|
+
]
|
keble_task/task_tree.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import keble_exceptions
|
|
6
|
+
from keble_helpers import ObjectId
|
|
7
|
+
|
|
8
|
+
from .schemas import TaskMongoObject, TaskMongoObjectExtended
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_task_tree(
|
|
12
|
+
*, roots: list[TaskMongoObject], tasks: list[TaskMongoObject]
|
|
13
|
+
) -> list[TaskMongoObjectExtended]:
|
|
14
|
+
"""Compose persisted task rows into nested root trees.
|
|
15
|
+
|
|
16
|
+
The given `roots` are the requested subtree tops, so a root's own `parent_task`
|
|
17
|
+
may legitimately point at an ancestor outside the loaded set (e.g. list filters
|
|
18
|
+
surface a descendant while excluding its parent). For every NON-root task,
|
|
19
|
+
however, the parent must be present: an absent parent, a missing requested root,
|
|
20
|
+
or a parent cycle is corrupted persisted data rather than a partial view, and per
|
|
21
|
+
the corrupted-reference rule these raise typed domain errors instead of silently
|
|
22
|
+
shrinking the tree.
|
|
23
|
+
|
|
24
|
+
Step by step:
|
|
25
|
+
1. index tasks by id and group children under their parent, recording any
|
|
26
|
+
non-root task whose parent is missing from the loaded set;
|
|
27
|
+
2. raise `ObjectNotFound` if a non-root parent or a requested root is absent;
|
|
28
|
+
3. sort each sibling group newest-first by persisted `created`;
|
|
29
|
+
4. build nodes depth-first, raising `DataIntegrityCompromised` on a parent cycle.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
tasks_by_id = {task.id: task for task in tasks}
|
|
33
|
+
root_ids = {root.id for root in roots}
|
|
34
|
+
children_by_parent: dict[ObjectId, list[ObjectId]] = {}
|
|
35
|
+
missing_parent_ids: list[ObjectId] = []
|
|
36
|
+
|
|
37
|
+
for task in tasks:
|
|
38
|
+
if task.parent_task is None:
|
|
39
|
+
continue
|
|
40
|
+
if task.parent_task not in tasks_by_id:
|
|
41
|
+
# A requested subtree root may point at an ancestor outside the set;
|
|
42
|
+
# any other orphan is corrupted lineage and must fail hard.
|
|
43
|
+
if task.id in root_ids:
|
|
44
|
+
continue
|
|
45
|
+
missing_parent_ids.append(task.parent_task)
|
|
46
|
+
continue
|
|
47
|
+
children_by_parent.setdefault(task.parent_task, []).append(task.id)
|
|
48
|
+
|
|
49
|
+
if missing_parent_ids:
|
|
50
|
+
raise keble_exceptions.ObjectNotFound(
|
|
51
|
+
object_name="Task",
|
|
52
|
+
id=missing_parent_ids,
|
|
53
|
+
alert_admin=True,
|
|
54
|
+
admin_note={
|
|
55
|
+
"reason": "child task references a parent absent from its root tree"
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def created_sort_key(task_id: ObjectId) -> float:
|
|
60
|
+
# `created` is a non-Optional persisted datetime for rows loaded from Mongo.
|
|
61
|
+
return tasks_by_id[task_id].created.timestamp()
|
|
62
|
+
|
|
63
|
+
for child_ids in children_by_parent.values():
|
|
64
|
+
child_ids.sort(key=created_sort_key, reverse=True)
|
|
65
|
+
|
|
66
|
+
def build_node(task_id: ObjectId, stack: set[ObjectId]) -> TaskMongoObjectExtended:
|
|
67
|
+
if task_id in stack:
|
|
68
|
+
raise keble_exceptions.DataIntegrityCompromised(
|
|
69
|
+
object_name="Task",
|
|
70
|
+
id=task_id,
|
|
71
|
+
alert_admin=True,
|
|
72
|
+
admin_note={"reason": "parent cycle detected while building task tree"},
|
|
73
|
+
)
|
|
74
|
+
task = tasks_by_id[task_id]
|
|
75
|
+
next_stack = set(stack)
|
|
76
|
+
next_stack.add(task_id)
|
|
77
|
+
childs = [
|
|
78
|
+
build_node(child_id, next_stack)
|
|
79
|
+
for child_id in children_by_parent.get(task_id, [])
|
|
80
|
+
]
|
|
81
|
+
return TaskMongoObjectExtended(**task.model_dump(), childs=childs)
|
|
82
|
+
|
|
83
|
+
missing_root_ids = [root.id for root in roots if root.id not in tasks_by_id]
|
|
84
|
+
if missing_root_ids:
|
|
85
|
+
raise keble_exceptions.ObjectNotFound(
|
|
86
|
+
object_name="Task",
|
|
87
|
+
id=missing_root_ids,
|
|
88
|
+
alert_admin=True,
|
|
89
|
+
admin_note={
|
|
90
|
+
"reason": "requested root task absent from the loaded task set"
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return [build_node(root.id, set()) for root in roots]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def find_task_in_tree(
|
|
98
|
+
*, nodes: list[TaskMongoObjectExtended], task_id: ObjectId
|
|
99
|
+
) -> Optional[TaskMongoObjectExtended]:
|
|
100
|
+
stack = list(nodes)
|
|
101
|
+
while len(stack) > 0:
|
|
102
|
+
node = stack.pop()
|
|
103
|
+
if node.id == task_id:
|
|
104
|
+
return node
|
|
105
|
+
stack.extend(node.childs)
|
|
106
|
+
return None
|
keble_task/utils.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
import keble_exceptions
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Difficulty(str, Enum):
|
|
8
|
+
"""A difficulty class for everythings"""
|
|
9
|
+
|
|
10
|
+
UNKNOWN = "UNKNOWN"
|
|
11
|
+
LOW = "LOW"
|
|
12
|
+
MEDIAN = "MEDIAN"
|
|
13
|
+
HIGH = "HIGH"
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_reason(
|
|
17
|
+
cls,
|
|
18
|
+
# reason can be any type of enum
|
|
19
|
+
# starting by Difficulty.value
|
|
20
|
+
# for example, UNKNOWN_DUE_TO_LACK_OF_DATA
|
|
21
|
+
# HIGH_RND
|
|
22
|
+
reason: Enum | str,
|
|
23
|
+
) -> "Difficulty":
|
|
24
|
+
if isinstance(reason, Enum):
|
|
25
|
+
reason_str = reason.value
|
|
26
|
+
else:
|
|
27
|
+
reason_str = reason
|
|
28
|
+
difficulties = list(Difficulty)
|
|
29
|
+
for d in difficulties:
|
|
30
|
+
if d.value in reason_str and d.value == reason_str[: len(d.value)]:
|
|
31
|
+
return d
|
|
32
|
+
raise keble_exceptions.UnhandledScenarioOrCase(
|
|
33
|
+
admin_note={
|
|
34
|
+
"reason": reason,
|
|
35
|
+
"error": "Failed to convert reason to Difficulty",
|
|
36
|
+
},
|
|
37
|
+
alert_admin=True,
|
|
38
|
+
unhandled_case=f"reason={reason}",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def merge(cls, difficulties: List["Difficulty"]) -> Optional["Difficulty"]:
|
|
43
|
+
difficulty_score = {"UNKNOWN": 0, "LOW": 1, "MEDIAN": 2, "HIGH": 3}
|
|
44
|
+
scores = []
|
|
45
|
+
for d in difficulties:
|
|
46
|
+
if d.value not in difficulty_score:
|
|
47
|
+
raise keble_exceptions.UnhandledScenarioOrCase(
|
|
48
|
+
admin_note={"difficulty": d.value},
|
|
49
|
+
alert_admin=True,
|
|
50
|
+
unhandled_case=f"difficulty={d.value}",
|
|
51
|
+
)
|
|
52
|
+
scores.append(difficulty_score[d.value])
|
|
53
|
+
if len(scores) == 0:
|
|
54
|
+
return None
|
|
55
|
+
max_s = max(scores)
|
|
56
|
+
for k, v in difficulty_score.items():
|
|
57
|
+
if v == max_s:
|
|
58
|
+
return Difficulty(k)
|