Skip to content

Commit b4df89f

Browse files
ryanmcmillanDelega Botclaude
authored
feat: 1.2.0 multi-agent coordination methods + usage() fix → 0.2.0 (#6)
Brings the Python SDK in line with @delega-dev/mcp 1.2.0. Agents using this SDK can now delegate, inspect chains, pass shared context, and dedup — the surfaces that turn Delega from a task tracker into an actual coordination layer. New methods (sync + async): - tasks.assign(task_id, agent_id) — PUT /tasks/:id with assigned_to_agent_id (None to unassign). For multi-agent handoffs use delegate() instead — assign() doesn't record a chain. - tasks.chain(task_id) — GET /tasks/:id/chain. Returns DelegationChain dataclass. Client normalizes hosted {root_id} vs self-hosted {root: Task} shape divergence so root_id is always populated. - tasks.update_context(task_id, context) — PATCH /tasks/:id/context. Deep-merges keys (not replace). Normalizes hosted bare-dict vs self-hosted full-task response; always returns the merged dict. - tasks.find_duplicates(content, threshold=None) — POST /tasks/dedup. Returns DedupResult with DuplicateMatch entries. Existing method updates: - tasks.delegate() gains project_id / labels / due_date / assigned_to_agent_id kwargs matching the delega-mcp tool signature. Existing callers unaffected (all additions are optional kwargs). - Delega.usage() BUG FIX: was hitting /stats, now correctly /usage. Raises DelegaError before any HTTP call on self-hosted backends (which don't expose /usage — mirrors the delega-mcp client gate). Async usage() also gets the gate. Model updates: - Task now surfaces parent_task_id, root_task_id, delegation_depth, status, assigned_to_agent_id, created_by_agent_id, completed_by_agent_id, context. from_dict() auto-parses the JSON-encoded context string that hosted returns (D1/SQLite text column) vs the dict self-hosted returns (SQLAlchemy JSON). - New: DelegationChain, DedupResult, DuplicateMatch dataclasses. All exposed from the package root. Test updates: - 10 new unit tests covering all new methods and both shape branches of chain() and update_context(). Existing test_usage split into _hosted + _self_hosted_raises_before_fetch (asserts mock never called). - HTTPClient.path_prefix property added to both sync and async transports so the usage gate can inspect the namespace. Version: 0.1.3 → 0.2.0 (minor — new methods, usage() endpoint change is a semantic fix not a signature change). 63/63 pytest tests passing. Out of scope (follow-up): async test coverage for the new methods. The async paths are line-by-line mirrors of sync; a later PR can add async-specific tests. Co-authored-by: Delega Bot <hello@delega.dev> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 30ad5a8 commit b4df89f

8 files changed

Lines changed: 463 additions & 16 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "delega"
7-
version = "0.1.3"
7+
version = "0.2.0"
88
description = "Official Python SDK for the Delega API"
99
readme = "README.md"
1010
license = "MIT"

src/delega/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,30 @@
99
DelegaNotFoundError,
1010
DelegaRateLimitError,
1111
)
12-
from .models import Agent, Comment, Project, Task
12+
from .models import (
13+
Agent,
14+
Comment,
15+
DedupResult,
16+
DelegationChain,
17+
DuplicateMatch,
18+
Project,
19+
Task,
20+
)
1321
from .webhooks import verify_webhook
1422

1523
__all__ = [
1624
"Agent",
1725
"AsyncDelega",
1826
"Comment",
27+
"DedupResult",
1928
"Delega",
2029
"DelegaAPIError",
2130
"DelegaAuthError",
2231
"DelegaError",
2332
"DelegaNotFoundError",
2433
"DelegaRateLimitError",
34+
"DelegationChain",
35+
"DuplicateMatch",
2536
"Project",
2637
"Task",
2738
"verify_webhook",

src/delega/_http.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ def __init__(self, base_url: str, api_key: str, timeout: int = _DEFAULT_TIMEOUT)
6060
self._api_key = api_key
6161
self._timeout = timeout
6262

63+
@property
64+
def path_prefix(self) -> str:
65+
"""Return the API namespace path ("/v1" for hosted, "/api" for self-hosted)."""
66+
return urllib.parse.urlparse(self._base_url).path or ""
67+
6368
def _headers(self) -> dict[str, str]:
6469
return {
6570
"X-Agent-Key": self._api_key,

src/delega/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""Package version metadata."""
22

3-
__version__ = "0.1.3"
3+
__version__ = "0.2.0"
44
USER_AGENT = f"delega-python/{__version__}"

src/delega/async_client.py

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
DelegaNotFoundError,
1414
DelegaRateLimitError,
1515
)
16-
from .models import Agent, Comment, Project, Task
16+
from .models import Agent, Comment, DedupResult, DelegationChain, Project, Task
1717
from ._version import USER_AGENT
1818

1919
_DEFAULT_BASE_URL = "https://api.delega.dev"
@@ -49,6 +49,12 @@ def __init__(self, base_url: str, api_key: str, timeout: int = 30) -> None:
4949
timeout=timeout,
5050
)
5151

52+
@property
53+
def path_prefix(self) -> str:
54+
"""Return the API namespace path ("/v1" for hosted, "/api" for self-hosted)."""
55+
import urllib.parse
56+
return urllib.parse.urlparse(self._base_url).path or ""
57+
5258
async def request(
5359
self,
5460
method: str,
@@ -201,16 +207,74 @@ async def delegate(
201207
*,
202208
description: Optional[str] = None,
203209
priority: Optional[int] = None,
210+
project_id: Optional[str] = None,
211+
labels: Optional[list[str]] = None,
212+
due_date: Optional[str] = None,
213+
assigned_to_agent_id: Optional[str] = None,
204214
) -> Task:
205-
"""Create a delegated sub-task under a parent task."""
215+
"""Create a delegated child task under a parent.
216+
217+
The parent's ``status`` flips to ``"delegated"``. Use this — not
218+
``assign()`` — for multi-agent handoffs so the parent/child
219+
accountability chain is recorded.
220+
"""
206221
body: dict[str, Any] = {"content": content}
207222
if description is not None:
208223
body["description"] = description
209224
if priority is not None:
210225
body["priority"] = priority
226+
if project_id is not None:
227+
body["project_id"] = project_id
228+
if labels is not None:
229+
body["labels"] = labels
230+
if due_date is not None:
231+
body["due_date"] = due_date
232+
if assigned_to_agent_id is not None:
233+
body["assigned_to_agent_id"] = assigned_to_agent_id
211234
data = await self._http.post(f"/tasks/{parent_task_id}/delegate", body=body)
212235
return Task.from_dict(data)
213236

237+
async def assign(self, task_id: str, agent_id: Optional[str]) -> Task:
238+
"""Assign a task to an agent (or ``None`` to unassign)."""
239+
data = await self._http.put(
240+
f"/tasks/{task_id}", body={"assigned_to_agent_id": agent_id}
241+
)
242+
return Task.from_dict(data)
243+
244+
async def chain(self, task_id: str) -> DelegationChain:
245+
"""Get the full parent/child delegation chain for a task."""
246+
data = await self._http.get(f"/tasks/{task_id}/chain")
247+
return DelegationChain.from_dict(data)
248+
249+
async def update_context(
250+
self, task_id: str, context: dict[str, Any]
251+
) -> dict[str, Any]:
252+
"""Deep-merge keys into a task's persistent context blob.
253+
254+
Existing keys are preserved; supplied keys are added or overwritten.
255+
"""
256+
data = await self._http.patch(f"/tasks/{task_id}/context", body=context)
257+
if isinstance(data, dict) and "content" in data and "id" in data:
258+
raw_ctx = data.get("context") or {}
259+
if isinstance(raw_ctx, str):
260+
import json as _json
261+
try:
262+
raw_ctx = _json.loads(raw_ctx) if raw_ctx.strip() else {}
263+
except Exception:
264+
raw_ctx = {}
265+
return raw_ctx if isinstance(raw_ctx, dict) else {}
266+
return data if isinstance(data, dict) else {}
267+
268+
async def find_duplicates(
269+
self, content: str, *, threshold: Optional[float] = None
270+
) -> DedupResult:
271+
"""Check whether content is similar to existing open tasks."""
272+
body: dict[str, Any] = {"content": content}
273+
if threshold is not None:
274+
body["threshold"] = threshold
275+
data = await self._http.post("/tasks/dedup", body=body)
276+
return DedupResult.from_dict(data)
277+
214278
async def add_comment(self, task_id: str, content: str) -> Comment:
215279
"""Add a comment to a task."""
216280
data = await self._http.post(f"/tasks/{task_id}/comments", body={"content": content})
@@ -368,7 +432,17 @@ async def me(self) -> dict[str, Any]:
368432
return await self._http.get("/agent/me") # type: ignore[no-any-return]
369433

370434
async def usage(self) -> dict[str, Any]:
371-
"""Get API usage information."""
435+
"""Get quota and rate-limit information for the current plan.
436+
437+
Hosted API only (``api.delega.dev``). Self-hosted deployments
438+
will raise :class:`DelegaError` before making a request.
439+
"""
440+
if self._http.path_prefix != "/v1":
441+
raise DelegaError(
442+
"usage() is only available on the hosted Delega API "
443+
"(api.delega.dev). Self-hosted deployments do not expose "
444+
"a usage endpoint."
445+
)
372446
return await self._http.get("/usage") # type: ignore[no-any-return]
373447

374448
async def aclose(self) -> None:

src/delega/client.py

Lines changed: 124 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@
77

88
from ._http import HTTPClient
99
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+
)
1118

1219
_DEFAULT_BASE_URL = "https://api.delega.dev"
1320

@@ -153,23 +160,121 @@ def delegate(
153160
*,
154161
description: Optional[str] = None,
155162
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,
156167
) -> 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()``).
158173
159174
Args:
160175
parent_task_id: The parent task identifier.
161-
content: The sub-task content/title.
176+
content: The child task content/title.
162177
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.
164183
"""
165184
body: dict[str, Any] = {"content": content}
166185
if description is not None:
167186
body["description"] = description
168187
if priority is not None:
169188
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
170197
data = self._http.post(f"/tasks/{parent_task_id}/delegate", body=body)
171198
return Task.from_dict(data)
172199

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+
173278
def add_comment(self, task_id: str, content: str) -> Comment:
174279
"""Add a comment to a task.
175280
@@ -386,9 +491,21 @@ def me(self) -> dict[str, Any]:
386491
return self._http.get("/agent/me") # type: ignore[no-any-return]
387492

388493
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.
390498
391499
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``.
393504
"""
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

Comments
 (0)