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.
@@ -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,7 @@
1
+ httpx>=0.27
2
+ pydantic>=2.0
3
+
4
+ [dev]
5
+ pytest>=8.0
6
+ pytest-asyncio>=0.23
7
+ respx>=0.21
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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")