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.
@@ -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
+ ]
@@ -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)