|
7 | 7 |
|
8 | 8 | from ._http import HTTPClient |
9 | 9 | from .exceptions import DelegaError |
10 | | -from .models import Agent, Comment, Project, Task |
| 10 | +from .models import ( |
| 11 | + Agent, |
| 12 | + Comment, |
| 13 | + DedupResult, |
| 14 | + DelegationChain, |
| 15 | + Project, |
| 16 | + Task, |
| 17 | +) |
11 | 18 |
|
12 | 19 | _DEFAULT_BASE_URL = "https://api.delega.dev" |
13 | 20 |
|
@@ -153,23 +160,121 @@ def delegate( |
153 | 160 | *, |
154 | 161 | description: Optional[str] = None, |
155 | 162 | priority: Optional[int] = None, |
| 163 | + project_id: Optional[str] = None, |
| 164 | + labels: Optional[list[str]] = None, |
| 165 | + due_date: Optional[str] = None, |
| 166 | + assigned_to_agent_id: Optional[str] = None, |
156 | 167 | ) -> Task: |
157 | | - """Create a delegated sub-task under a parent task. |
| 168 | + """Create a delegated child task under a parent. |
| 169 | +
|
| 170 | + The parent's ``status`` flips to ``"delegated"``. Use this — not |
| 171 | + ``assign()`` — for multi-agent handoffs so the parent/child |
| 172 | + accountability chain is recorded (inspectable via ``chain()``). |
158 | 173 |
|
159 | 174 | Args: |
160 | 175 | parent_task_id: The parent task identifier. |
161 | | - content: The sub-task content/title. |
| 176 | + content: The child task content/title. |
162 | 177 | description: Optional longer description. |
163 | | - priority: Optional priority level. |
| 178 | + priority: Optional priority level (1-4). |
| 179 | + project_id: Optional project to attach the child to. |
| 180 | + labels: Optional labels for the child. |
| 181 | + due_date: Optional due date (YYYY-MM-DD). |
| 182 | + assigned_to_agent_id: Optional agent to assign the child to. |
164 | 183 | """ |
165 | 184 | body: dict[str, Any] = {"content": content} |
166 | 185 | if description is not None: |
167 | 186 | body["description"] = description |
168 | 187 | if priority is not None: |
169 | 188 | body["priority"] = priority |
| 189 | + if project_id is not None: |
| 190 | + body["project_id"] = project_id |
| 191 | + if labels is not None: |
| 192 | + body["labels"] = labels |
| 193 | + if due_date is not None: |
| 194 | + body["due_date"] = due_date |
| 195 | + if assigned_to_agent_id is not None: |
| 196 | + body["assigned_to_agent_id"] = assigned_to_agent_id |
170 | 197 | data = self._http.post(f"/tasks/{parent_task_id}/delegate", body=body) |
171 | 198 | return Task.from_dict(data) |
172 | 199 |
|
| 200 | + def assign(self, task_id: str, agent_id: Optional[str]) -> Task: |
| 201 | + """Assign a task to an agent (or ``None`` to unassign). |
| 202 | +
|
| 203 | + For multi-agent handoffs where you want the parent/child chain |
| 204 | + recorded, use ``delegate()`` instead — ``assign()`` only updates |
| 205 | + the assignee on an existing task. |
| 206 | +
|
| 207 | + Args: |
| 208 | + task_id: The task identifier. |
| 209 | + agent_id: The agent identifier, or ``None`` to unassign. |
| 210 | + """ |
| 211 | + data = self._http.put( |
| 212 | + f"/tasks/{task_id}", body={"assigned_to_agent_id": agent_id} |
| 213 | + ) |
| 214 | + return Task.from_dict(data) |
| 215 | + |
| 216 | + def chain(self, task_id: str) -> DelegationChain: |
| 217 | + """Get the full parent/child delegation chain for a task. |
| 218 | +
|
| 219 | + Normalizes hosted (``{root_id}``) vs self-hosted (``{root: Task}``) |
| 220 | + response shapes so ``DelegationChain.root_id`` is always populated. |
| 221 | +
|
| 222 | + Args: |
| 223 | + task_id: Any task identifier in the chain. |
| 224 | + """ |
| 225 | + data = self._http.get(f"/tasks/{task_id}/chain") |
| 226 | + return DelegationChain.from_dict(data) |
| 227 | + |
| 228 | + def update_context( |
| 229 | + self, task_id: str, context: dict[str, Any] |
| 230 | + ) -> dict[str, Any]: |
| 231 | + """Deep-merge keys into a task's persistent context blob. |
| 232 | +
|
| 233 | + Existing keys are preserved; supplied keys are added or overwritten. |
| 234 | + Use this to pass shared state between delegated agents instead of |
| 235 | + re-describing context in task descriptions. |
| 236 | +
|
| 237 | + Args: |
| 238 | + task_id: The task identifier. |
| 239 | + context: Keys to merge into the existing context. |
| 240 | +
|
| 241 | + Returns: |
| 242 | + The merged context dict. |
| 243 | + """ |
| 244 | + # Hosted returns the bare merged context dict; self-hosted returns |
| 245 | + # the full Task with a ``context`` field. Normalize to always |
| 246 | + # return the merged context. |
| 247 | + data = self._http.patch(f"/tasks/{task_id}/context", body=context) |
| 248 | + if isinstance(data, dict) and "content" in data and "id" in data: |
| 249 | + # Looks like a full Task. |
| 250 | + raw_ctx = data.get("context") or {} |
| 251 | + if isinstance(raw_ctx, str): |
| 252 | + import json as _json |
| 253 | + try: |
| 254 | + raw_ctx = _json.loads(raw_ctx) if raw_ctx.strip() else {} |
| 255 | + except Exception: |
| 256 | + raw_ctx = {} |
| 257 | + return raw_ctx if isinstance(raw_ctx, dict) else {} |
| 258 | + return data if isinstance(data, dict) else {} |
| 259 | + |
| 260 | + def find_duplicates( |
| 261 | + self, content: str, *, threshold: Optional[float] = None |
| 262 | + ) -> DedupResult: |
| 263 | + """Check whether content is similar to existing open tasks. |
| 264 | +
|
| 265 | + Call before ``create()`` to avoid redundant work. Uses Jaccard |
| 266 | + similarity against open tasks. |
| 267 | +
|
| 268 | + Args: |
| 269 | + content: The proposed task content to check. |
| 270 | + threshold: Similarity threshold 0-1 (default 0.6 server-side). |
| 271 | + """ |
| 272 | + body: dict[str, Any] = {"content": content} |
| 273 | + if threshold is not None: |
| 274 | + body["threshold"] = threshold |
| 275 | + data = self._http.post("/tasks/dedup", body=body) |
| 276 | + return DedupResult.from_dict(data) |
| 277 | + |
173 | 278 | def add_comment(self, task_id: str, content: str) -> Comment: |
174 | 279 | """Add a comment to a task. |
175 | 280 |
|
@@ -386,9 +491,21 @@ def me(self) -> dict[str, Any]: |
386 | 491 | return self._http.get("/agent/me") # type: ignore[no-any-return] |
387 | 492 |
|
388 | 493 | def usage(self) -> dict[str, Any]: |
389 | | - """Get API usage information. |
| 494 | + """Get quota and rate-limit information for the current plan. |
| 495 | +
|
| 496 | + Hosted API only (``api.delega.dev``). Self-hosted deployments |
| 497 | + will raise :class:`DelegaError` before making a request. |
390 | 498 |
|
391 | 499 | Returns: |
392 | | - Dictionary with usage statistics. |
| 500 | + Dict with ``plan``, ``task_count_month``, ``task_limit``, |
| 501 | + ``reset_date``, ``agent_count``, ``agent_limit``, |
| 502 | + ``webhook_count``, ``webhook_limit``, ``project_count``, |
| 503 | + ``project_limit``, ``rate_limit_rpm``, ``max_content_chars``. |
393 | 504 | """ |
394 | | - return self._http.get("/stats") # type: ignore[no-any-return] |
| 505 | + if self._http.path_prefix != "/v1": |
| 506 | + raise DelegaError( |
| 507 | + "usage() is only available on the hosted Delega API " |
| 508 | + "(api.delega.dev). Self-hosted deployments do not expose " |
| 509 | + "a usage endpoint." |
| 510 | + ) |
| 511 | + return self._http.get("/usage") # type: ignore[no-any-return] |
0 commit comments