janus-broker 1.2.1__tar.gz
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.
- janus_broker-1.2.1/PKG-INFO +11 -0
- janus_broker-1.2.1/janus_broker/__init__.py +76 -0
- janus_broker-1.2.1/janus_broker/client.py +263 -0
- janus_broker-1.2.1/janus_broker/models.py +271 -0
- janus_broker-1.2.1/janus_broker/worker.py +125 -0
- janus_broker-1.2.1/janus_broker.egg-info/PKG-INFO +11 -0
- janus_broker-1.2.1/janus_broker.egg-info/SOURCES.txt +11 -0
- janus_broker-1.2.1/janus_broker.egg-info/dependency_links.txt +1 -0
- janus_broker-1.2.1/janus_broker.egg-info/requires.txt +7 -0
- janus_broker-1.2.1/janus_broker.egg-info/top_level.txt +1 -0
- janus_broker-1.2.1/pyproject.toml +23 -0
- janus_broker-1.2.1/setup.cfg +4 -0
- janus_broker-1.2.1/tests/test_client.py +161 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: janus-broker
|
|
3
|
+
Version: 1.2.1
|
|
4
|
+
Summary: Python SDK for Janus — A2A-native Durable Agent Broker
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: httpx>=0.27
|
|
7
|
+
Requires-Dist: pydantic>=2.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
10
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
11
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from .client import JanusAPIError, JanusClient
|
|
2
|
+
from .worker import JanusWorker, WorkerError, WorkerResult
|
|
3
|
+
from .models import (
|
|
4
|
+
APIKey,
|
|
5
|
+
Agent,
|
|
6
|
+
AgentCapability,
|
|
7
|
+
Budget,
|
|
8
|
+
BudgetRequest,
|
|
9
|
+
BudgetSpec,
|
|
10
|
+
ContextRef,
|
|
11
|
+
CreateAPIKey,
|
|
12
|
+
CreateAPIKeyRequest,
|
|
13
|
+
CreateMailboxRequest,
|
|
14
|
+
Mailbox,
|
|
15
|
+
MailboxActionResponse,
|
|
16
|
+
PublishTaskRequest,
|
|
17
|
+
PullResult,
|
|
18
|
+
AckRequest,
|
|
19
|
+
NackRequest,
|
|
20
|
+
PolicyRule,
|
|
21
|
+
PolicyContext,
|
|
22
|
+
PolicyRuleRequest,
|
|
23
|
+
PolicyRuleTemplateRequest,
|
|
24
|
+
RetryPolicy,
|
|
25
|
+
TaskError,
|
|
26
|
+
RegisterAgentRequest,
|
|
27
|
+
RegisterAgentCapability,
|
|
28
|
+
Tenant,
|
|
29
|
+
TaskEnvelope,
|
|
30
|
+
Target,
|
|
31
|
+
Payload,
|
|
32
|
+
ToolInvocation,
|
|
33
|
+
TraceContext,
|
|
34
|
+
Task,
|
|
35
|
+
UpdateMailboxRequest,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"JanusClient",
|
|
40
|
+
"JanusAPIError",
|
|
41
|
+
"JanusWorker",
|
|
42
|
+
"WorkerError",
|
|
43
|
+
"WorkerResult",
|
|
44
|
+
"APIKey",
|
|
45
|
+
"Agent",
|
|
46
|
+
"AgentCapability",
|
|
47
|
+
"Budget",
|
|
48
|
+
"BudgetRequest",
|
|
49
|
+
"BudgetSpec",
|
|
50
|
+
"ContextRef",
|
|
51
|
+
"CreateAPIKey",
|
|
52
|
+
"CreateAPIKeyRequest",
|
|
53
|
+
"CreateMailboxRequest",
|
|
54
|
+
"Mailbox",
|
|
55
|
+
"MailboxActionResponse",
|
|
56
|
+
"PublishTaskRequest",
|
|
57
|
+
"PullResult",
|
|
58
|
+
"AckRequest",
|
|
59
|
+
"NackRequest",
|
|
60
|
+
"PolicyRule",
|
|
61
|
+
"PolicyContext",
|
|
62
|
+
"PolicyRuleRequest",
|
|
63
|
+
"PolicyRuleTemplateRequest",
|
|
64
|
+
"RetryPolicy",
|
|
65
|
+
"TaskError",
|
|
66
|
+
"RegisterAgentRequest",
|
|
67
|
+
"RegisterAgentCapability",
|
|
68
|
+
"Tenant",
|
|
69
|
+
"TaskEnvelope",
|
|
70
|
+
"Target",
|
|
71
|
+
"Payload",
|
|
72
|
+
"ToolInvocation",
|
|
73
|
+
"TraceContext",
|
|
74
|
+
"Task",
|
|
75
|
+
"UpdateMailboxRequest",
|
|
76
|
+
]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from .models import (
|
|
5
|
+
RegisterAgentRequest,
|
|
6
|
+
PublishTaskRequest,
|
|
7
|
+
PullResult,
|
|
8
|
+
AckRequest,
|
|
9
|
+
NackRequest,
|
|
10
|
+
Task,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JanusAPIError(httpx.HTTPStatusError):
|
|
15
|
+
"""Typed error raised when the Janus API returns a non-2xx response.
|
|
16
|
+
|
|
17
|
+
Compatible with httpx.HTTPStatusError (so existing try/except still works)
|
|
18
|
+
but also exposes the structured fields from the Janus error envelope:
|
|
19
|
+
code, message, and status.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
code: Canonical error code (e.g. "NOT_FOUND", "INVALID_ARGUMENT").
|
|
23
|
+
message: Human-readable error message.
|
|
24
|
+
status: HTTP status code (int).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
code: str
|
|
28
|
+
message: str
|
|
29
|
+
status: int
|
|
30
|
+
|
|
31
|
+
def __init__(self, response: httpx.Response):
|
|
32
|
+
super().__init__(response.text, request=response.request, response=response)
|
|
33
|
+
self.status = response.status_code
|
|
34
|
+
try:
|
|
35
|
+
body = response.json()
|
|
36
|
+
self.code = body.get("code", "UNKNOWN")
|
|
37
|
+
self.message = body.get("message", body.get("error", response.text))
|
|
38
|
+
except Exception:
|
|
39
|
+
self.code = "UNKNOWN"
|
|
40
|
+
self.message = response.text
|
|
41
|
+
|
|
42
|
+
def __str__(self) -> str:
|
|
43
|
+
return f"{self.code} ({self.status}): {self.message}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class JanusClient:
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
base_url: str = "http://localhost:8080",
|
|
50
|
+
tenant_id: str = "default",
|
|
51
|
+
api_key: Optional[str] = None,
|
|
52
|
+
timeout: float = 30.0,
|
|
53
|
+
):
|
|
54
|
+
self._base_url = base_url.rstrip("/")
|
|
55
|
+
self._tenant_id = tenant_id
|
|
56
|
+
headers = {}
|
|
57
|
+
if api_key:
|
|
58
|
+
headers["X-API-Key"] = api_key
|
|
59
|
+
self._client = httpx.Client(timeout=timeout, headers=headers)
|
|
60
|
+
|
|
61
|
+
def _check(self, resp: httpx.Response) -> None:
|
|
62
|
+
"""Raise JanusAPIError on non-2xx responses."""
|
|
63
|
+
if resp.is_success:
|
|
64
|
+
return
|
|
65
|
+
raise JanusAPIError(resp)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def _prefix(self) -> str:
|
|
69
|
+
return f"/v1/tenants/{self._tenant_id}"
|
|
70
|
+
|
|
71
|
+
def _url(self, path: str) -> str:
|
|
72
|
+
return f"{self._base_url}{self._prefix}{path}"
|
|
73
|
+
|
|
74
|
+
def register_agent(self, req: RegisterAgentRequest) -> None:
|
|
75
|
+
resp = self._client.post(self._url("/agents"), json=req.model_dump(exclude_none=True))
|
|
76
|
+
self._check(resp)
|
|
77
|
+
|
|
78
|
+
def publish_task(self, req: PublishTaskRequest) -> dict:
|
|
79
|
+
resp = self._client.post(self._url("/tasks"), json=req.model_dump(exclude_none=True))
|
|
80
|
+
self._check(resp)
|
|
81
|
+
return resp.json()
|
|
82
|
+
|
|
83
|
+
def get_task(self, task_id: str) -> Task:
|
|
84
|
+
resp = self._client.get(self._url(f"/tasks/{task_id}"))
|
|
85
|
+
self._check(resp)
|
|
86
|
+
return Task.model_validate(resp.json())
|
|
87
|
+
|
|
88
|
+
def pull_task(self, mailbox_id: str, agent_id: str = "default") -> Optional[PullResult]:
|
|
89
|
+
resp = self._client.post(
|
|
90
|
+
self._url(f"/mailboxes/{mailbox_id}/pull"),
|
|
91
|
+
json={"agent_id": agent_id},
|
|
92
|
+
)
|
|
93
|
+
self._check(resp)
|
|
94
|
+
if resp.status_code == 204 or not resp.content:
|
|
95
|
+
return None
|
|
96
|
+
return PullResult.model_validate(resp.json())
|
|
97
|
+
|
|
98
|
+
def start_task(self, task_id: str, lease_id: str) -> None:
|
|
99
|
+
resp = self._client.post(
|
|
100
|
+
self._url(f"/tasks/{task_id}/start"),
|
|
101
|
+
json={"lease_id": lease_id},
|
|
102
|
+
)
|
|
103
|
+
self._check(resp)
|
|
104
|
+
|
|
105
|
+
def heartbeat(self, task_id: str, lease_id: str) -> None:
|
|
106
|
+
resp = self._client.post(
|
|
107
|
+
self._url(f"/tasks/{task_id}/heartbeat"),
|
|
108
|
+
json={"lease_id": lease_id},
|
|
109
|
+
)
|
|
110
|
+
self._check(resp)
|
|
111
|
+
|
|
112
|
+
def ack_task(self, task_id: str, req: AckRequest) -> None:
|
|
113
|
+
resp = self._client.post(
|
|
114
|
+
self._url(f"/tasks/{task_id}/ack"),
|
|
115
|
+
json=req.model_dump(exclude_none=True),
|
|
116
|
+
)
|
|
117
|
+
self._check(resp)
|
|
118
|
+
|
|
119
|
+
def nack_task(self, task_id: str, req: NackRequest) -> None:
|
|
120
|
+
resp = self._client.post(
|
|
121
|
+
self._url(f"/tasks/{task_id}/nack"),
|
|
122
|
+
json=req.model_dump(exclude_none=True),
|
|
123
|
+
)
|
|
124
|
+
self._check(resp)
|
|
125
|
+
|
|
126
|
+
def cancel_task(self, task_id: str) -> None:
|
|
127
|
+
resp = self._client.post(self._url(f"/tasks/{task_id}/cancel"))
|
|
128
|
+
self._check(resp)
|
|
129
|
+
|
|
130
|
+
def get_task_events(self, task_id: str) -> list[dict]:
|
|
131
|
+
resp = self._client.get(self._url(f"/tasks/{task_id}/events"))
|
|
132
|
+
self._check(resp)
|
|
133
|
+
data = resp.json()
|
|
134
|
+
return data.get("events", data) if isinstance(data, dict) else data
|
|
135
|
+
|
|
136
|
+
def create_tenant(self, tenant_id: str, name: str) -> dict:
|
|
137
|
+
resp = self._client.post(
|
|
138
|
+
f"{self._base_url}/v1/tenants",
|
|
139
|
+
json={"id": tenant_id, "name": name},
|
|
140
|
+
)
|
|
141
|
+
self._check(resp)
|
|
142
|
+
return resp.json()
|
|
143
|
+
|
|
144
|
+
def get_tenant(self, tenant_id: str) -> dict:
|
|
145
|
+
resp = self._client.get(f"{self._base_url}/v1/tenants/{tenant_id}")
|
|
146
|
+
self._check(resp)
|
|
147
|
+
return resp.json()
|
|
148
|
+
|
|
149
|
+
def create_mailbox(self, req) -> dict:
|
|
150
|
+
resp = self._client.post(self._url("/mailboxes"), json=req.model_dump(exclude_none=True))
|
|
151
|
+
self._check(resp)
|
|
152
|
+
return resp.json()
|
|
153
|
+
|
|
154
|
+
def get_mailbox(self, mailbox_id: str) -> dict:
|
|
155
|
+
resp = self._client.get(self._url(f"/mailboxes/{mailbox_id}"))
|
|
156
|
+
self._check(resp)
|
|
157
|
+
return resp.json()
|
|
158
|
+
|
|
159
|
+
def update_mailbox(self, mailbox_id: str, req) -> dict:
|
|
160
|
+
resp = self._client.patch(self._url(f"/mailboxes/{mailbox_id}"), json=req.model_dump(exclude_none=True))
|
|
161
|
+
self._check(resp)
|
|
162
|
+
return resp.json()
|
|
163
|
+
|
|
164
|
+
def pause_mailbox(self, mailbox_id: str) -> dict:
|
|
165
|
+
resp = self._client.post(self._url(f"/mailboxes/{mailbox_id}/pause"))
|
|
166
|
+
self._check(resp)
|
|
167
|
+
return resp.json()
|
|
168
|
+
|
|
169
|
+
def resume_mailbox(self, mailbox_id: str) -> dict:
|
|
170
|
+
resp = self._client.post(self._url(f"/mailboxes/{mailbox_id}/resume"))
|
|
171
|
+
self._check(resp)
|
|
172
|
+
return resp.json()
|
|
173
|
+
|
|
174
|
+
def heartbeat_agent(self, agent_id: str) -> dict:
|
|
175
|
+
resp = self._client.post(self._url(f"/agents/{agent_id}/heartbeat"))
|
|
176
|
+
self._check(resp)
|
|
177
|
+
return resp.json()
|
|
178
|
+
|
|
179
|
+
def list_agents(self) -> list[dict]:
|
|
180
|
+
resp = self._client.get(self._url("/agents"))
|
|
181
|
+
self._check(resp)
|
|
182
|
+
return resp.json().get("agents", [])
|
|
183
|
+
|
|
184
|
+
def get_agent(self, agent_id: str) -> dict:
|
|
185
|
+
resp = self._client.get(self._url(f"/agents/{agent_id}"))
|
|
186
|
+
self._check(resp)
|
|
187
|
+
return resp.json()
|
|
188
|
+
|
|
189
|
+
def query_dlq(self, mailbox_id: str = "", limit: int = 50) -> list[dict]:
|
|
190
|
+
params = {"limit": str(limit)}
|
|
191
|
+
if mailbox_id:
|
|
192
|
+
params["mailbox"] = mailbox_id
|
|
193
|
+
resp = self._client.get(self._url("/dlq"), params=params)
|
|
194
|
+
self._check(resp)
|
|
195
|
+
return resp.json().get("tasks", [])
|
|
196
|
+
|
|
197
|
+
def replay_dlq(self, task_id: str) -> dict:
|
|
198
|
+
resp = self._client.post(self._url(f"/dlq/{task_id}/replay"))
|
|
199
|
+
self._check(resp)
|
|
200
|
+
return resp.json()
|
|
201
|
+
|
|
202
|
+
def discard_dlq(self, task_id: str) -> None:
|
|
203
|
+
resp = self._client.post(self._url(f"/dlq/{task_id}/discard"))
|
|
204
|
+
self._check(resp)
|
|
205
|
+
|
|
206
|
+
def create_api_key(self, name: str) -> dict:
|
|
207
|
+
resp = self._client.post(self._url("/api-keys"), json={"name": name})
|
|
208
|
+
self._check(resp)
|
|
209
|
+
return resp.json()
|
|
210
|
+
|
|
211
|
+
def list_api_keys(self) -> list[dict]:
|
|
212
|
+
resp = self._client.get(self._url("/api-keys"))
|
|
213
|
+
self._check(resp)
|
|
214
|
+
return resp.json().get("api_keys", [])
|
|
215
|
+
|
|
216
|
+
def revoke_api_key(self, key_id: str) -> dict:
|
|
217
|
+
resp = self._client.post(self._url(f"/api-keys/{key_id}/revoke"))
|
|
218
|
+
self._check(resp)
|
|
219
|
+
return resp.json()
|
|
220
|
+
|
|
221
|
+
def create_policy_rule(self, req) -> dict:
|
|
222
|
+
resp = self._client.post(self._url("/policy-rules"), json=req.model_dump(exclude_none=True))
|
|
223
|
+
self._check(resp)
|
|
224
|
+
return resp.json()
|
|
225
|
+
|
|
226
|
+
def create_policy_rule_from_template(self, req) -> dict:
|
|
227
|
+
resp = self._client.post(self._url("/policy-rules/templates"), json=req.model_dump(exclude_none=True))
|
|
228
|
+
self._check(resp)
|
|
229
|
+
return resp.json()
|
|
230
|
+
|
|
231
|
+
def list_policy_rules(self) -> list[dict]:
|
|
232
|
+
resp = self._client.get(self._url("/policy-rules"))
|
|
233
|
+
self._check(resp)
|
|
234
|
+
return resp.json().get("policy_rules", [])
|
|
235
|
+
|
|
236
|
+
def upsert_budget(self, req) -> dict:
|
|
237
|
+
resp = self._client.post(self._url("/budgets"), json=req.model_dump(exclude_none=True))
|
|
238
|
+
self._check(resp)
|
|
239
|
+
return resp.json()
|
|
240
|
+
|
|
241
|
+
def get_budget(self, scope_type: str, scope_id: str) -> dict:
|
|
242
|
+
resp = self._client.get(self._url(f"/budgets/{scope_type}/{scope_id}"))
|
|
243
|
+
self._check(resp)
|
|
244
|
+
return resp.json()
|
|
245
|
+
|
|
246
|
+
def list_budgets(self) -> list[dict]:
|
|
247
|
+
resp = self._client.get(self._url("/budgets"))
|
|
248
|
+
self._check(resp)
|
|
249
|
+
return resp.json().get("budgets", [])
|
|
250
|
+
|
|
251
|
+
def replay_task(self, task_id: str) -> dict:
|
|
252
|
+
resp = self._client.post(self._url(f"/tasks/{task_id}/replay"))
|
|
253
|
+
self._check(resp)
|
|
254
|
+
return resp.json()
|
|
255
|
+
|
|
256
|
+
def close(self) -> None:
|
|
257
|
+
self._client.close()
|
|
258
|
+
|
|
259
|
+
def __enter__(self):
|
|
260
|
+
return self
|
|
261
|
+
|
|
262
|
+
def __exit__(self, *args):
|
|
263
|
+
self.close()
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TargetType(str, Enum):
|
|
7
|
+
AGENT = "agent"
|
|
8
|
+
MAILBOX = "mailbox"
|
|
9
|
+
SEMANTIC = "semantic"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Target(BaseModel):
|
|
13
|
+
type: TargetType = TargetType.AGENT
|
|
14
|
+
value: str = ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Payload(BaseModel):
|
|
18
|
+
type: str = "json"
|
|
19
|
+
content: str = ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TraceContext(BaseModel):
|
|
23
|
+
trace_id: str = ""
|
|
24
|
+
span_id: str = ""
|
|
25
|
+
parent_span_id: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TaskEnvelope(BaseModel):
|
|
29
|
+
janus_version: str = "0.1"
|
|
30
|
+
task_id: str = ""
|
|
31
|
+
tenant_id: str = ""
|
|
32
|
+
source_agent: str = ""
|
|
33
|
+
target: Target = Field(default_factory=Target)
|
|
34
|
+
priority: str = "normal"
|
|
35
|
+
payload: Payload = Field(default_factory=Payload)
|
|
36
|
+
trace: TraceContext = Field(default_factory=TraceContext)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Task(BaseModel):
|
|
40
|
+
id: str = ""
|
|
41
|
+
tenant_id: str = ""
|
|
42
|
+
source_agent: str = ""
|
|
43
|
+
target_type: str = ""
|
|
44
|
+
target_value: str = ""
|
|
45
|
+
mailbox_id: str = ""
|
|
46
|
+
status: str = ""
|
|
47
|
+
priority: str = ""
|
|
48
|
+
envelope: Optional[TaskEnvelope] = None
|
|
49
|
+
attempt_count: int = 0
|
|
50
|
+
created_at: str = ""
|
|
51
|
+
updated_at: str = ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TaskError(BaseModel):
|
|
55
|
+
code: str = ""
|
|
56
|
+
message: str = ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RegisterAgentRequest(BaseModel):
|
|
60
|
+
id: str
|
|
61
|
+
display_name: str = ""
|
|
62
|
+
protocol: str = "a2a"
|
|
63
|
+
capabilities: list[str] = Field(default_factory=list)
|
|
64
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PublishTaskRequest(BaseModel):
|
|
68
|
+
id: str = ""
|
|
69
|
+
source_agent: str = ""
|
|
70
|
+
target_type: str = "agent"
|
|
71
|
+
target_value: str = ""
|
|
72
|
+
mailbox_id: str = ""
|
|
73
|
+
priority: str = "normal"
|
|
74
|
+
envelope: Optional[TaskEnvelope] = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class LeaseInfo(BaseModel):
|
|
78
|
+
lease_id: str = ""
|
|
79
|
+
expires_at: Any = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class PullResult(BaseModel):
|
|
83
|
+
task: Optional[Task] = None
|
|
84
|
+
lease: LeaseInfo = Field(default_factory=LeaseInfo)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AckRequest(BaseModel):
|
|
88
|
+
lease_id: str = ""
|
|
89
|
+
attempt: int = 0
|
|
90
|
+
result_ref: str = ""
|
|
91
|
+
token_usage: Optional[dict[str, int]] = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class NackRequest(BaseModel):
|
|
95
|
+
lease_id: str = ""
|
|
96
|
+
attempt: int = 0
|
|
97
|
+
retriable: bool = False
|
|
98
|
+
error: Optional[TaskError] = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# --- Governance / management models ---
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Tenant(BaseModel):
|
|
105
|
+
id: str = ""
|
|
106
|
+
name: str = ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AgentCapability(BaseModel):
|
|
110
|
+
capability: str = ""
|
|
111
|
+
description: str = ""
|
|
112
|
+
capability_schema: Optional[str] = Field(None, alias="schema")
|
|
113
|
+
|
|
114
|
+
model_config = {"populate_by_name": True}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Agent(BaseModel):
|
|
118
|
+
id: str = ""
|
|
119
|
+
tenant_id: str = ""
|
|
120
|
+
team_id: str = ""
|
|
121
|
+
display_name: str = ""
|
|
122
|
+
protocol: str = ""
|
|
123
|
+
endpoint: str = ""
|
|
124
|
+
status: str = ""
|
|
125
|
+
description: str = ""
|
|
126
|
+
capabilities: list[AgentCapability] = []
|
|
127
|
+
max_concurrency: int = 0
|
|
128
|
+
rpm: int = 0
|
|
129
|
+
tpm: int = 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class RegisterAgentCapability(BaseModel):
|
|
133
|
+
capability: str = ""
|
|
134
|
+
description: str = ""
|
|
135
|
+
capability_schema: str = Field("", alias="schema")
|
|
136
|
+
|
|
137
|
+
model_config = {"populate_by_name": True}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Budget(BaseModel):
|
|
141
|
+
max_tokens: int = 0
|
|
142
|
+
max_cost_usd: float = 0.0
|
|
143
|
+
model_classes: list[str] = []
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class BudgetSpec(BaseModel):
|
|
147
|
+
tenant_id: str = ""
|
|
148
|
+
scope_type: str = ""
|
|
149
|
+
scope_id: str = ""
|
|
150
|
+
max_concurrency: int = 0
|
|
151
|
+
rpm: int = 0
|
|
152
|
+
tpm: int = 0
|
|
153
|
+
daily_cost_usd: float = 0.0
|
|
154
|
+
monthly_cost_usd: float = 0.0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class BudgetRequest(BaseModel):
|
|
158
|
+
scope_type: str = ""
|
|
159
|
+
scope_id: str = ""
|
|
160
|
+
rpm: int = 0
|
|
161
|
+
tpm: int = 0
|
|
162
|
+
max_concurrency: int = 0
|
|
163
|
+
daily_cost_usd: float = 0.0
|
|
164
|
+
monthly_cost_usd: float = 0.0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class ContextRef(BaseModel):
|
|
168
|
+
type: str = ""
|
|
169
|
+
uri: str = ""
|
|
170
|
+
hash: str = ""
|
|
171
|
+
classification: str = ""
|
|
172
|
+
access_scope: list[str] = []
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class RetryPolicy(BaseModel):
|
|
176
|
+
max_attempts: int = 0
|
|
177
|
+
backoff_type: str = ""
|
|
178
|
+
initial_seconds: int = 0
|
|
179
|
+
max_seconds: int = 0
|
|
180
|
+
jitter: bool = False
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class Mailbox(BaseModel):
|
|
184
|
+
id: str = ""
|
|
185
|
+
tenant_id: str = ""
|
|
186
|
+
agent_id: str = ""
|
|
187
|
+
status: str = ""
|
|
188
|
+
priority: str = ""
|
|
189
|
+
max_deliver: int = 0
|
|
190
|
+
retry_policy: Optional[RetryPolicy] = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class CreateMailboxRequest(BaseModel):
|
|
194
|
+
id: str = ""
|
|
195
|
+
agent_id: str = ""
|
|
196
|
+
max_concurrency: int = 0
|
|
197
|
+
ack_wait_seconds: int = 0
|
|
198
|
+
max_deliver: int = 0
|
|
199
|
+
retention_seconds: int = 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class UpdateMailboxRequest(BaseModel):
|
|
203
|
+
max_concurrency: Optional[int] = None
|
|
204
|
+
ack_wait_seconds: Optional[int] = None
|
|
205
|
+
max_deliver: Optional[int] = None
|
|
206
|
+
retention_seconds: Optional[int] = None
|
|
207
|
+
status: Optional[str] = None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class MailboxActionResponse(BaseModel):
|
|
211
|
+
status: str = ""
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class PolicyContext(BaseModel):
|
|
215
|
+
data_classification: str = ""
|
|
216
|
+
requires_human_approval: bool = False
|
|
217
|
+
allowed_tools: list[str] = []
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class PolicyRule(BaseModel):
|
|
221
|
+
tenant_id: str = ""
|
|
222
|
+
id: str = ""
|
|
223
|
+
name: str = ""
|
|
224
|
+
status: str = ""
|
|
225
|
+
priority: int = 0
|
|
226
|
+
condition: Optional[dict] = None
|
|
227
|
+
action: Optional[dict] = None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class PolicyRuleRequest(BaseModel):
|
|
231
|
+
name: str = ""
|
|
232
|
+
status: str = ""
|
|
233
|
+
priority: int = 0
|
|
234
|
+
condition: Optional[dict] = None
|
|
235
|
+
action: Optional[dict] = None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class PolicyRuleTemplateRequest(BaseModel):
|
|
239
|
+
template: str = ""
|
|
240
|
+
agent_id: str = ""
|
|
241
|
+
team_id: str = ""
|
|
242
|
+
capability: str = ""
|
|
243
|
+
tool: str = ""
|
|
244
|
+
data_classification: str = ""
|
|
245
|
+
name: str = ""
|
|
246
|
+
status: str = ""
|
|
247
|
+
priority: int = 0
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class ToolInvocation(BaseModel):
|
|
251
|
+
id: str = ""
|
|
252
|
+
name: str = ""
|
|
253
|
+
namespace: str = ""
|
|
254
|
+
source_protocol: str = ""
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class CreateAPIKey(BaseModel):
|
|
258
|
+
id: str = ""
|
|
259
|
+
key: str = ""
|
|
260
|
+
name: str = ""
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class CreateAPIKeyRequest(BaseModel):
|
|
264
|
+
name: str = ""
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class APIKey(BaseModel):
|
|
268
|
+
id: str = ""
|
|
269
|
+
name: str = ""
|
|
270
|
+
prefix: str = ""
|
|
271
|
+
status: str = ""
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""JanusWorker: a high-level poll-process-ack loop for Python agents."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
|
|
8
|
+
from .client import JanusClient, JanusAPIError
|
|
9
|
+
from .models import Task, AckRequest, NackRequest, TaskError
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("janus_broker.worker")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WorkerResult:
|
|
15
|
+
"""Result of processing a single task."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, task_id: str, success: bool, error: Optional[str] = None):
|
|
18
|
+
self.task_id = task_id
|
|
19
|
+
self.success = success
|
|
20
|
+
self.error = error
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WorkerError(Exception):
|
|
24
|
+
"""Raised when the worker encounters an unrecoverable error."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
TaskHandler = Callable[[Task], "tuple[str, Optional[dict]]"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class JanusWorker:
|
|
31
|
+
"""A high-level helper that runs a poll-process-ack loop.
|
|
32
|
+
|
|
33
|
+
The application provides a handler function that receives a Task and returns
|
|
34
|
+
(result_ref, token_usage). The Worker handles polling, task start/heartbeat,
|
|
35
|
+
and ACK/NACK.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
client: A JanusClient instance.
|
|
39
|
+
agent_id: The agent ID this worker pulls for.
|
|
40
|
+
mailbox_id: The mailbox to poll.
|
|
41
|
+
poll_interval: Seconds between polls when queue is empty (default 2).
|
|
42
|
+
heartbeat_interval: Seconds between task heartbeats (default 30).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
client: JanusClient,
|
|
48
|
+
agent_id: str,
|
|
49
|
+
mailbox_id: str,
|
|
50
|
+
poll_interval: float = 2.0,
|
|
51
|
+
heartbeat_interval: float = 30.0,
|
|
52
|
+
):
|
|
53
|
+
self._client = client
|
|
54
|
+
self._agent_id = agent_id
|
|
55
|
+
self._mailbox_id = mailbox_id
|
|
56
|
+
self._poll_interval = poll_interval
|
|
57
|
+
self._heartbeat_interval = heartbeat_interval
|
|
58
|
+
self._stop = threading.Event()
|
|
59
|
+
|
|
60
|
+
def run(self, handler: TaskHandler) -> None:
|
|
61
|
+
"""Run the worker loop until stop() is called."""
|
|
62
|
+
while not self._stop.is_set():
|
|
63
|
+
try:
|
|
64
|
+
self._process_one(handler)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.error("janus worker: %s", e)
|
|
67
|
+
|
|
68
|
+
def stop(self) -> None:
|
|
69
|
+
"""Signal the worker to stop."""
|
|
70
|
+
self._stop.set()
|
|
71
|
+
|
|
72
|
+
def _process_one(self, handler: TaskHandler) -> None:
|
|
73
|
+
result = self._client.pull_task(self._mailbox_id, self._agent_id)
|
|
74
|
+
if result is None:
|
|
75
|
+
self._stop.wait(self._poll_interval)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
task = result.task
|
|
79
|
+
lease_id = result.lease.lease_id
|
|
80
|
+
attempt = result.lease.attempt
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
self._client.start_task(task.id, lease_id)
|
|
84
|
+
except JanusAPIError as e:
|
|
85
|
+
raise WorkerError(f"start task {task.id}: {e}") from e
|
|
86
|
+
|
|
87
|
+
# Heartbeat in a background thread.
|
|
88
|
+
hb_stop = threading.Event()
|
|
89
|
+
|
|
90
|
+
def _heartbeat():
|
|
91
|
+
while not hb_stop.is_set() and not self._stop.is_set():
|
|
92
|
+
try:
|
|
93
|
+
self._client.heartbeat(task.id, lease_id)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
hb_stop.wait(self._heartbeat_interval)
|
|
97
|
+
|
|
98
|
+
hb_thread = threading.Thread(target=_heartbeat, daemon=True)
|
|
99
|
+
hb_thread.start()
|
|
100
|
+
|
|
101
|
+
success = False
|
|
102
|
+
error_msg = None
|
|
103
|
+
try:
|
|
104
|
+
result_ref, usage = handler(task)
|
|
105
|
+
success = True
|
|
106
|
+
except Exception as e:
|
|
107
|
+
error_msg = str(e)
|
|
108
|
+
|
|
109
|
+
hb_stop.set()
|
|
110
|
+
|
|
111
|
+
if success:
|
|
112
|
+
ack = AckRequest(lease_id=lease_id, attempt=attempt, result_ref=result_ref)
|
|
113
|
+
if usage:
|
|
114
|
+
ack.token_usage = usage
|
|
115
|
+
self._client.ack_task(task.id, ack)
|
|
116
|
+
else:
|
|
117
|
+
self._client.nack_task(
|
|
118
|
+
task.id,
|
|
119
|
+
NackRequest(
|
|
120
|
+
lease_id=lease_id,
|
|
121
|
+
attempt=attempt,
|
|
122
|
+
retriable=True,
|
|
123
|
+
error=TaskError(code="HANDLER_ERROR", message=error_msg or "unknown"),
|
|
124
|
+
),
|
|
125
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: janus-broker
|
|
3
|
+
Version: 1.2.1
|
|
4
|
+
Summary: Python SDK for Janus — A2A-native Durable Agent Broker
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: httpx>=0.27
|
|
7
|
+
Requires-Dist: pydantic>=2.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
10
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
11
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
janus_broker/__init__.py
|
|
3
|
+
janus_broker/client.py
|
|
4
|
+
janus_broker/models.py
|
|
5
|
+
janus_broker/worker.py
|
|
6
|
+
janus_broker.egg-info/PKG-INFO
|
|
7
|
+
janus_broker.egg-info/SOURCES.txt
|
|
8
|
+
janus_broker.egg-info/dependency_links.txt
|
|
9
|
+
janus_broker.egg-info/requires.txt
|
|
10
|
+
janus_broker.egg-info/top_level.txt
|
|
11
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
janus_broker
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "janus-broker"
|
|
7
|
+
version = "1.2.1"
|
|
8
|
+
description = "Python SDK for Janus — A2A-native Durable Agent Broker"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"httpx>=0.27",
|
|
12
|
+
"pydantic>=2.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[tool.setuptools.packages.find]
|
|
16
|
+
include = ["janus_broker*"]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=8.0",
|
|
21
|
+
"pytest-asyncio>=0.23",
|
|
22
|
+
"respx>=0.21",
|
|
23
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import httpx
|
|
3
|
+
import respx
|
|
4
|
+
|
|
5
|
+
from janus_broker import (
|
|
6
|
+
JanusClient,
|
|
7
|
+
RegisterAgentRequest,
|
|
8
|
+
PublishTaskRequest,
|
|
9
|
+
PullResult,
|
|
10
|
+
AckRequest,
|
|
11
|
+
NackRequest,
|
|
12
|
+
TaskError,
|
|
13
|
+
TaskEnvelope,
|
|
14
|
+
Target,
|
|
15
|
+
Payload,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
BASE = "http://localhost:8080"
|
|
20
|
+
TENANT = "test-tenant"
|
|
21
|
+
PREFIX = f"/v1/tenants/{TENANT}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def client():
|
|
26
|
+
with JanusClient(BASE, TENANT) as c:
|
|
27
|
+
yield c
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestRegisterAgent:
|
|
31
|
+
def test_success(self, client):
|
|
32
|
+
with respx.mock:
|
|
33
|
+
respx.post(f"{BASE}{PREFIX}/agents").mock(return_value=httpx.Response(201))
|
|
34
|
+
client.register_agent(RegisterAgentRequest(id="a1", display_name="Agent 1"))
|
|
35
|
+
|
|
36
|
+
def test_server_error(self, client):
|
|
37
|
+
with respx.mock:
|
|
38
|
+
respx.post(f"{BASE}{PREFIX}/agents").mock(return_value=httpx.Response(500))
|
|
39
|
+
with pytest.raises(httpx.HTTPStatusError):
|
|
40
|
+
client.register_agent(RegisterAgentRequest(id="a1", display_name="Agent 1"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestPublishTask:
|
|
44
|
+
def test_success(self, client):
|
|
45
|
+
with respx.mock:
|
|
46
|
+
respx.post(f"{BASE}{PREFIX}/tasks").mock(
|
|
47
|
+
return_value=httpx.Response(202, json={"id": "t1", "status": "queued"})
|
|
48
|
+
)
|
|
49
|
+
result = client.publish_task(PublishTaskRequest(
|
|
50
|
+
id="t1", source_agent="a1", target_value="a2",
|
|
51
|
+
envelope=TaskEnvelope(
|
|
52
|
+
task_id="t1", tenant_id=TENANT, source_agent="a1",
|
|
53
|
+
target=Target(value="a2"), payload=Payload(content='{"key":"val"}'),
|
|
54
|
+
),
|
|
55
|
+
))
|
|
56
|
+
assert result["id"] == "t1"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestGetTask:
|
|
60
|
+
def test_success(self, client):
|
|
61
|
+
with respx.mock:
|
|
62
|
+
respx.get(f"{BASE}{PREFIX}/tasks/t1").mock(
|
|
63
|
+
return_value=httpx.Response(200, json={"id": "t1", "status": "running"})
|
|
64
|
+
)
|
|
65
|
+
task = client.get_task("t1")
|
|
66
|
+
assert task.id == "t1"
|
|
67
|
+
assert task.status == "running"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestPullTask:
|
|
71
|
+
def test_with_task(self, client):
|
|
72
|
+
with respx.mock:
|
|
73
|
+
respx.post(f"{BASE}{PREFIX}/mailboxes/mb1/pull").mock(
|
|
74
|
+
return_value=httpx.Response(200, json={
|
|
75
|
+
"task": {"id": "t1", "status": "pending"},
|
|
76
|
+
"lease": {"lease_id": "l1", "expires_at": "2026-01-01T00:00:00Z"},
|
|
77
|
+
})
|
|
78
|
+
)
|
|
79
|
+
result = client.pull_task("mb1", "agent-1")
|
|
80
|
+
assert result is not None
|
|
81
|
+
assert result.task.id == "t1"
|
|
82
|
+
assert result.lease.lease_id == "l1"
|
|
83
|
+
|
|
84
|
+
def test_empty(self, client):
|
|
85
|
+
with respx.mock:
|
|
86
|
+
respx.post(f"{BASE}{PREFIX}/mailboxes/mb1/pull").mock(
|
|
87
|
+
return_value=httpx.Response(204)
|
|
88
|
+
)
|
|
89
|
+
result = client.pull_task("mb1")
|
|
90
|
+
assert result is None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestAckTask:
|
|
94
|
+
def test_success(self, client):
|
|
95
|
+
with respx.mock:
|
|
96
|
+
respx.post(f"{BASE}{PREFIX}/tasks/t1/ack").mock(
|
|
97
|
+
return_value=httpx.Response(200)
|
|
98
|
+
)
|
|
99
|
+
client.ack_task("t1", AckRequest(lease_id="l1", result_ref="result://a1/t1"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestNackTask:
|
|
103
|
+
def test_with_error(self, client):
|
|
104
|
+
with respx.mock:
|
|
105
|
+
respx.post(f"{BASE}{PREFIX}/tasks/t1/nack").mock(
|
|
106
|
+
return_value=httpx.Response(200)
|
|
107
|
+
)
|
|
108
|
+
client.nack_task("t1", NackRequest(
|
|
109
|
+
lease_id="l1", retriable=True,
|
|
110
|
+
error=TaskError(code="TIMEOUT", message="timed out"),
|
|
111
|
+
))
|
|
112
|
+
|
|
113
|
+
def test_without_error(self, client):
|
|
114
|
+
with respx.mock:
|
|
115
|
+
respx.post(f"{BASE}{PREFIX}/tasks/t1/nack").mock(
|
|
116
|
+
return_value=httpx.Response(200)
|
|
117
|
+
)
|
|
118
|
+
client.nack_task("t1", NackRequest(lease_id="l1"))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class TestCancelTask:
|
|
122
|
+
def test_success(self, client):
|
|
123
|
+
with respx.mock:
|
|
124
|
+
respx.post(f"{BASE}{PREFIX}/tasks/t1/cancel").mock(
|
|
125
|
+
return_value=httpx.Response(200)
|
|
126
|
+
)
|
|
127
|
+
client.cancel_task("t1")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TestGetTaskEvents:
|
|
131
|
+
def test_success(self, client):
|
|
132
|
+
with respx.mock:
|
|
133
|
+
respx.get(f"{BASE}{PREFIX}/tasks/t1/events").mock(
|
|
134
|
+
return_value=httpx.Response(200, json={
|
|
135
|
+
"events": [
|
|
136
|
+
{"event_type": "task.created", "task_id": "t1"},
|
|
137
|
+
{"event_type": "task.started", "task_id": "t1"},
|
|
138
|
+
]
|
|
139
|
+
})
|
|
140
|
+
)
|
|
141
|
+
events = client.get_task_events("t1")
|
|
142
|
+
assert len(events) == 2
|
|
143
|
+
assert events[0]["event_type"] == "task.created"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TestHeartbeat:
|
|
147
|
+
def test_success(self, client):
|
|
148
|
+
with respx.mock:
|
|
149
|
+
respx.post(f"{BASE}{PREFIX}/tasks/t1/heartbeat").mock(
|
|
150
|
+
return_value=httpx.Response(200)
|
|
151
|
+
)
|
|
152
|
+
client.heartbeat("t1", "l1")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TestStartTask:
|
|
156
|
+
def test_success(self, client):
|
|
157
|
+
with respx.mock:
|
|
158
|
+
respx.post(f"{BASE}{PREFIX}/tasks/t1/start").mock(
|
|
159
|
+
return_value=httpx.Response(200)
|
|
160
|
+
)
|
|
161
|
+
client.start_task("t1", "l1")
|