macp-sdk-python 0.2.1__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.
- macp_sdk/__init__.py +207 -0
- macp_sdk/_logging.py +16 -0
- macp_sdk/agent/__init__.py +64 -0
- macp_sdk/agent/dispatcher.py +105 -0
- macp_sdk/agent/participant.py +404 -0
- macp_sdk/agent/runner.py +100 -0
- macp_sdk/agent/strategies.py +312 -0
- macp_sdk/agent/transports.py +153 -0
- macp_sdk/agent/types.py +64 -0
- macp_sdk/auth.py +74 -0
- macp_sdk/base_projection.py +52 -0
- macp_sdk/base_session.py +169 -0
- macp_sdk/client.py +625 -0
- macp_sdk/constants.py +20 -0
- macp_sdk/decision.py +154 -0
- macp_sdk/envelope.py +180 -0
- macp_sdk/errors.py +101 -0
- macp_sdk/handoff.py +241 -0
- macp_sdk/policy.py +292 -0
- macp_sdk/projections.py +183 -0
- macp_sdk/proposal.py +305 -0
- macp_sdk/proto_registry.py +150 -0
- macp_sdk/py.typed +0 -0
- macp_sdk/quorum.py +258 -0
- macp_sdk/retry.py +64 -0
- macp_sdk/task.py +368 -0
- macp_sdk/validation.py +117 -0
- macp_sdk/watchers.py +122 -0
- macp_sdk_python-0.2.1.dist-info/METADATA +165 -0
- macp_sdk_python-0.2.1.dist-info/RECORD +33 -0
- macp_sdk_python-0.2.1.dist-info/WHEEL +5 -0
- macp_sdk_python-0.2.1.dist-info/licenses/LICENSE +201 -0
- macp_sdk_python-0.2.1.dist-info/top_level.txt +1 -0
macp_sdk/quorum.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from macp.modes.quorum.v1 import quorum_pb2
|
|
6
|
+
from macp.v1 import envelope_pb2
|
|
7
|
+
|
|
8
|
+
from .auth import AuthConfig
|
|
9
|
+
from .base_projection import BaseProjection
|
|
10
|
+
from .base_session import BaseSession
|
|
11
|
+
from .constants import MODE_QUORUM
|
|
12
|
+
from .envelope import build_envelope, serialize_message
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Projection records
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class ApprovalRequestRecord:
|
|
21
|
+
request_id: str
|
|
22
|
+
action: str
|
|
23
|
+
summary: str
|
|
24
|
+
required_approvals: int
|
|
25
|
+
requester: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class BallotRecord:
|
|
30
|
+
request_id: str
|
|
31
|
+
vote: str # "approve" | "reject" | "abstain"
|
|
32
|
+
reason: str
|
|
33
|
+
sender: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Projection
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class QuorumProjection(BaseProjection):
|
|
42
|
+
"""In-process state tracking for Quorum mode sessions.
|
|
43
|
+
|
|
44
|
+
Supports multiple concurrent approval requests within a single session.
|
|
45
|
+
Query methods accept a ``request_id`` parameter to target a specific
|
|
46
|
+
request.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
MODE = MODE_QUORUM
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
super().__init__()
|
|
53
|
+
self.phase = "Pending"
|
|
54
|
+
self.requests: dict[str, ApprovalRequestRecord] = {}
|
|
55
|
+
self.ballots: dict[str, dict[str, BallotRecord]] = {} # request_id -> sender -> ballot
|
|
56
|
+
|
|
57
|
+
def _apply_mode_message(self, envelope: envelope_pb2.Envelope) -> None:
|
|
58
|
+
mt = envelope.message_type
|
|
59
|
+
|
|
60
|
+
if mt == "ApprovalRequest":
|
|
61
|
+
p = quorum_pb2.ApprovalRequestPayload()
|
|
62
|
+
p.ParseFromString(envelope.payload)
|
|
63
|
+
self.requests[p.request_id] = ApprovalRequestRecord(
|
|
64
|
+
request_id=p.request_id,
|
|
65
|
+
action=p.action,
|
|
66
|
+
summary=p.summary,
|
|
67
|
+
required_approvals=p.required_approvals,
|
|
68
|
+
requester=envelope.sender,
|
|
69
|
+
)
|
|
70
|
+
self.phase = "Voting"
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
if mt == "Approve":
|
|
74
|
+
p = quorum_pb2.ApprovePayload()
|
|
75
|
+
p.ParseFromString(envelope.payload)
|
|
76
|
+
self._set_ballot(p.request_id, envelope.sender, "approve", p.reason)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
if mt == "Reject":
|
|
80
|
+
p = quorum_pb2.RejectPayload()
|
|
81
|
+
p.ParseFromString(envelope.payload)
|
|
82
|
+
self._set_ballot(p.request_id, envelope.sender, "reject", p.reason)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
if mt == "Abstain":
|
|
86
|
+
p = quorum_pb2.AbstainPayload()
|
|
87
|
+
p.ParseFromString(envelope.payload)
|
|
88
|
+
self._set_ballot(p.request_id, envelope.sender, "abstain", p.reason)
|
|
89
|
+
|
|
90
|
+
def _set_ballot(self, request_id: str, sender: str, vote: str, reason: str) -> None:
|
|
91
|
+
sender_map = self.ballots.setdefault(request_id, {})
|
|
92
|
+
sender_map[sender] = BallotRecord(
|
|
93
|
+
request_id=request_id,
|
|
94
|
+
vote=vote,
|
|
95
|
+
reason=reason,
|
|
96
|
+
sender=sender,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _count_votes(self, request_id: str, vote: str) -> int:
|
|
100
|
+
sender_map = self.ballots.get(request_id)
|
|
101
|
+
if not sender_map:
|
|
102
|
+
return 0
|
|
103
|
+
return sum(1 for b in sender_map.values() if b.vote == vote)
|
|
104
|
+
|
|
105
|
+
# -- State query helpers --
|
|
106
|
+
|
|
107
|
+
def approval_count(self, request_id: str) -> int:
|
|
108
|
+
return self._count_votes(request_id, "approve")
|
|
109
|
+
|
|
110
|
+
def rejection_count(self, request_id: str) -> int:
|
|
111
|
+
return self._count_votes(request_id, "reject")
|
|
112
|
+
|
|
113
|
+
def abstention_count(self, request_id: str) -> int:
|
|
114
|
+
return self._count_votes(request_id, "abstain")
|
|
115
|
+
|
|
116
|
+
def is_threshold_reached(self, request_id: str) -> bool:
|
|
117
|
+
req = self.requests.get(request_id)
|
|
118
|
+
if req is None:
|
|
119
|
+
return False
|
|
120
|
+
return self.approval_count(request_id) >= req.required_approvals
|
|
121
|
+
|
|
122
|
+
def is_threshold_unreachable(self, request_id: str, total_eligible: int) -> bool:
|
|
123
|
+
"""True if remaining possible approvals cannot reach the threshold."""
|
|
124
|
+
req = self.requests.get(request_id)
|
|
125
|
+
if req is None:
|
|
126
|
+
return False
|
|
127
|
+
remaining = total_eligible - len(self.voted_senders(request_id))
|
|
128
|
+
return self.approval_count(request_id) + remaining < req.required_approvals
|
|
129
|
+
|
|
130
|
+
def commitment_ready(self, request_id: str) -> bool:
|
|
131
|
+
"""True if the threshold is reached."""
|
|
132
|
+
return self.is_threshold_reached(request_id)
|
|
133
|
+
|
|
134
|
+
def threshold(self, request_id: str) -> int:
|
|
135
|
+
"""Return the required approval count, or 0 if no request yet."""
|
|
136
|
+
req = self.requests.get(request_id)
|
|
137
|
+
return req.required_approvals if req else 0
|
|
138
|
+
|
|
139
|
+
def voted_senders(self, request_id: str) -> list[str]:
|
|
140
|
+
"""Return list of senders who have cast a ballot for this request."""
|
|
141
|
+
sender_map = self.ballots.get(request_id)
|
|
142
|
+
return list(sender_map.keys()) if sender_map else []
|
|
143
|
+
|
|
144
|
+
def remaining_votes_needed(self, request_id: str) -> int:
|
|
145
|
+
"""Return how many more approvals are needed to reach quorum."""
|
|
146
|
+
req = self.requests.get(request_id)
|
|
147
|
+
if req is None:
|
|
148
|
+
return 0
|
|
149
|
+
return max(0, req.required_approvals - self.approval_count(request_id))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Session helper
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class QuorumSession(BaseSession):
|
|
158
|
+
"""High-level helper for Quorum mode sessions."""
|
|
159
|
+
|
|
160
|
+
MODE = MODE_QUORUM
|
|
161
|
+
|
|
162
|
+
def _create_projection(self) -> BaseProjection:
|
|
163
|
+
return QuorumProjection()
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def quorum_projection(self) -> QuorumProjection:
|
|
167
|
+
assert isinstance(self.projection, QuorumProjection)
|
|
168
|
+
return self.projection
|
|
169
|
+
|
|
170
|
+
def request_approval(
|
|
171
|
+
self,
|
|
172
|
+
request_id: str,
|
|
173
|
+
action: str,
|
|
174
|
+
*,
|
|
175
|
+
summary: str = "",
|
|
176
|
+
details: bytes = b"",
|
|
177
|
+
required_approvals: int,
|
|
178
|
+
sender: str | None = None,
|
|
179
|
+
auth: AuthConfig | None = None,
|
|
180
|
+
) -> envelope_pb2.Ack:
|
|
181
|
+
payload = quorum_pb2.ApprovalRequestPayload(
|
|
182
|
+
request_id=request_id,
|
|
183
|
+
action=action,
|
|
184
|
+
summary=summary,
|
|
185
|
+
details=details,
|
|
186
|
+
required_approvals=required_approvals,
|
|
187
|
+
)
|
|
188
|
+
envelope = build_envelope(
|
|
189
|
+
mode=self.MODE,
|
|
190
|
+
message_type="ApprovalRequest",
|
|
191
|
+
session_id=self.session_id,
|
|
192
|
+
sender=self._sender_for(sender, auth=auth),
|
|
193
|
+
payload=serialize_message(payload),
|
|
194
|
+
)
|
|
195
|
+
return self._send_and_track(envelope, auth=auth)
|
|
196
|
+
|
|
197
|
+
def approve(
|
|
198
|
+
self,
|
|
199
|
+
request_id: str,
|
|
200
|
+
*,
|
|
201
|
+
reason: str = "",
|
|
202
|
+
sender: str | None = None,
|
|
203
|
+
auth: AuthConfig | None = None,
|
|
204
|
+
) -> envelope_pb2.Ack:
|
|
205
|
+
payload = quorum_pb2.ApprovePayload(
|
|
206
|
+
request_id=request_id,
|
|
207
|
+
reason=reason,
|
|
208
|
+
)
|
|
209
|
+
envelope = build_envelope(
|
|
210
|
+
mode=self.MODE,
|
|
211
|
+
message_type="Approve",
|
|
212
|
+
session_id=self.session_id,
|
|
213
|
+
sender=self._sender_for(sender, auth=auth),
|
|
214
|
+
payload=serialize_message(payload),
|
|
215
|
+
)
|
|
216
|
+
return self._send_and_track(envelope, auth=auth)
|
|
217
|
+
|
|
218
|
+
def reject(
|
|
219
|
+
self,
|
|
220
|
+
request_id: str,
|
|
221
|
+
*,
|
|
222
|
+
reason: str = "",
|
|
223
|
+
sender: str | None = None,
|
|
224
|
+
auth: AuthConfig | None = None,
|
|
225
|
+
) -> envelope_pb2.Ack:
|
|
226
|
+
payload = quorum_pb2.RejectPayload(
|
|
227
|
+
request_id=request_id,
|
|
228
|
+
reason=reason,
|
|
229
|
+
)
|
|
230
|
+
envelope = build_envelope(
|
|
231
|
+
mode=self.MODE,
|
|
232
|
+
message_type="Reject",
|
|
233
|
+
session_id=self.session_id,
|
|
234
|
+
sender=self._sender_for(sender, auth=auth),
|
|
235
|
+
payload=serialize_message(payload),
|
|
236
|
+
)
|
|
237
|
+
return self._send_and_track(envelope, auth=auth)
|
|
238
|
+
|
|
239
|
+
def abstain(
|
|
240
|
+
self,
|
|
241
|
+
request_id: str,
|
|
242
|
+
*,
|
|
243
|
+
reason: str = "",
|
|
244
|
+
sender: str | None = None,
|
|
245
|
+
auth: AuthConfig | None = None,
|
|
246
|
+
) -> envelope_pb2.Ack:
|
|
247
|
+
payload = quorum_pb2.AbstainPayload(
|
|
248
|
+
request_id=request_id,
|
|
249
|
+
reason=reason,
|
|
250
|
+
)
|
|
251
|
+
envelope = build_envelope(
|
|
252
|
+
mode=self.MODE,
|
|
253
|
+
message_type="Abstain",
|
|
254
|
+
session_id=self.session_id,
|
|
255
|
+
sender=self._sender_for(sender, auth=auth),
|
|
256
|
+
payload=serialize_message(payload),
|
|
257
|
+
)
|
|
258
|
+
return self._send_and_track(envelope, auth=auth)
|
macp_sdk/retry.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ._logging import logger
|
|
8
|
+
from .auth import AuthConfig
|
|
9
|
+
from .errors import MacpRetryError, MacpTransportError
|
|
10
|
+
|
|
11
|
+
# Re-export for convenience
|
|
12
|
+
__all__ = ["RetryPolicy", "retry_send"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class RetryPolicy:
|
|
17
|
+
"""Configuration for retry behaviour on transient failures."""
|
|
18
|
+
|
|
19
|
+
max_retries: int = 3
|
|
20
|
+
backoff_base: float = 0.1
|
|
21
|
+
backoff_max: float = 2.0
|
|
22
|
+
retryable_codes: frozenset[str] = field(
|
|
23
|
+
default_factory=lambda: frozenset({"RATE_LIMITED", "INTERNAL_ERROR"})
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def retry_send(
|
|
28
|
+
client: Any,
|
|
29
|
+
envelope: Any,
|
|
30
|
+
*,
|
|
31
|
+
policy: RetryPolicy | None = None,
|
|
32
|
+
auth: AuthConfig | None = None,
|
|
33
|
+
) -> Any:
|
|
34
|
+
"""Send an envelope with retries on transient failures.
|
|
35
|
+
|
|
36
|
+
Uses the client's ``send`` method. On ``MacpTransportError`` or retryable
|
|
37
|
+
NACK codes, backs off and retries up to ``policy.max_retries`` times.
|
|
38
|
+
"""
|
|
39
|
+
from .errors import MacpAckError # avoid circular
|
|
40
|
+
|
|
41
|
+
pol = policy or RetryPolicy()
|
|
42
|
+
last_error: Exception | None = None
|
|
43
|
+
|
|
44
|
+
for attempt in range(1 + pol.max_retries):
|
|
45
|
+
try:
|
|
46
|
+
return client.send(envelope, auth=auth)
|
|
47
|
+
except MacpTransportError as exc:
|
|
48
|
+
last_error = exc
|
|
49
|
+
except MacpAckError as exc:
|
|
50
|
+
if exc.failure.code not in pol.retryable_codes:
|
|
51
|
+
raise
|
|
52
|
+
last_error = exc
|
|
53
|
+
|
|
54
|
+
if attempt < pol.max_retries:
|
|
55
|
+
delay = min(pol.backoff_base * (2**attempt), pol.backoff_max)
|
|
56
|
+
logger.debug(
|
|
57
|
+
"retry attempt=%d delay=%.2fs error=%s",
|
|
58
|
+
attempt + 1,
|
|
59
|
+
delay,
|
|
60
|
+
last_error,
|
|
61
|
+
)
|
|
62
|
+
time.sleep(delay)
|
|
63
|
+
|
|
64
|
+
raise MacpRetryError(f"retries exhausted after {pol.max_retries} attempts") from last_error
|
macp_sdk/task.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from macp.modes.task.v1 import task_pb2
|
|
6
|
+
from macp.v1 import envelope_pb2
|
|
7
|
+
|
|
8
|
+
from .auth import AuthConfig
|
|
9
|
+
from .base_projection import BaseProjection
|
|
10
|
+
from .base_session import BaseSession
|
|
11
|
+
from .constants import MODE_TASK
|
|
12
|
+
from .envelope import build_envelope, serialize_message
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Projection records
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class TaskRequestRecord:
|
|
21
|
+
task_id: str
|
|
22
|
+
title: str
|
|
23
|
+
instructions: str
|
|
24
|
+
requested_assignee: str
|
|
25
|
+
requester: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class TaskRejectRecord:
|
|
30
|
+
task_id: str
|
|
31
|
+
assignee: str
|
|
32
|
+
reason: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class TaskUpdateRecord:
|
|
37
|
+
task_id: str
|
|
38
|
+
status: str
|
|
39
|
+
progress: float
|
|
40
|
+
message: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class TaskCompleteRecord:
|
|
45
|
+
task_id: str
|
|
46
|
+
assignee: str
|
|
47
|
+
summary: str
|
|
48
|
+
output: bytes
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class TaskFailRecord:
|
|
53
|
+
task_id: str
|
|
54
|
+
assignee: str
|
|
55
|
+
error_code: str
|
|
56
|
+
reason: str
|
|
57
|
+
retryable: bool
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Projection
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TaskProjection(BaseProjection):
|
|
66
|
+
"""In-process state tracking for Task mode sessions.
|
|
67
|
+
|
|
68
|
+
Supports multiple tasks within a single session. Each task is tracked
|
|
69
|
+
independently with its own status and progress.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
MODE = MODE_TASK
|
|
73
|
+
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
super().__init__()
|
|
76
|
+
self.phase = "Pending"
|
|
77
|
+
self.tasks: dict[str, TaskRequestRecord] = {}
|
|
78
|
+
self.updates: list[TaskUpdateRecord] = []
|
|
79
|
+
self.completions: list[TaskCompleteRecord] = []
|
|
80
|
+
self.failures: list[TaskFailRecord] = []
|
|
81
|
+
# Per-task mutable state
|
|
82
|
+
self._assignees: dict[str, str] = {} # task_id -> assignee
|
|
83
|
+
self._statuses: dict[str, str] = {} # task_id -> status
|
|
84
|
+
self._progress: dict[str, float] = {} # task_id -> progress
|
|
85
|
+
|
|
86
|
+
def _apply_mode_message(self, envelope: envelope_pb2.Envelope) -> None:
|
|
87
|
+
mt = envelope.message_type
|
|
88
|
+
|
|
89
|
+
if mt == "TaskRequest":
|
|
90
|
+
p = task_pb2.TaskRequestPayload()
|
|
91
|
+
p.ParseFromString(envelope.payload)
|
|
92
|
+
self.tasks[p.task_id] = TaskRequestRecord(
|
|
93
|
+
task_id=p.task_id,
|
|
94
|
+
title=p.title,
|
|
95
|
+
instructions=p.instructions,
|
|
96
|
+
requested_assignee=p.requested_assignee,
|
|
97
|
+
requester=envelope.sender,
|
|
98
|
+
)
|
|
99
|
+
self._statuses[p.task_id] = "requested"
|
|
100
|
+
self._progress[p.task_id] = 0.0
|
|
101
|
+
self.phase = "Requested"
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
if mt == "TaskAccept":
|
|
105
|
+
p = task_pb2.TaskAcceptPayload()
|
|
106
|
+
p.ParseFromString(envelope.payload)
|
|
107
|
+
assignee = p.assignee or envelope.sender
|
|
108
|
+
self._assignees[p.task_id] = assignee
|
|
109
|
+
self._statuses[p.task_id] = "accepted"
|
|
110
|
+
self.phase = "InProgress"
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
if mt == "TaskReject":
|
|
114
|
+
p = task_pb2.TaskRejectPayload()
|
|
115
|
+
p.ParseFromString(envelope.payload)
|
|
116
|
+
self._statuses[p.task_id] = "rejected"
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
if mt == "TaskUpdate":
|
|
120
|
+
p = task_pb2.TaskUpdatePayload()
|
|
121
|
+
p.ParseFromString(envelope.payload)
|
|
122
|
+
self.updates.append(
|
|
123
|
+
TaskUpdateRecord(
|
|
124
|
+
task_id=p.task_id,
|
|
125
|
+
status=p.status,
|
|
126
|
+
progress=p.progress,
|
|
127
|
+
message=p.message,
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
self._statuses[p.task_id] = "in_progress"
|
|
131
|
+
self._progress[p.task_id] = p.progress
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
if mt == "TaskComplete":
|
|
135
|
+
p = task_pb2.TaskCompletePayload()
|
|
136
|
+
p.ParseFromString(envelope.payload)
|
|
137
|
+
self.completions.append(
|
|
138
|
+
TaskCompleteRecord(
|
|
139
|
+
task_id=p.task_id,
|
|
140
|
+
assignee=p.assignee or envelope.sender,
|
|
141
|
+
summary=p.summary,
|
|
142
|
+
output=p.output,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
self._statuses[p.task_id] = "completed"
|
|
146
|
+
self._progress[p.task_id] = 1.0
|
|
147
|
+
self.phase = "Completed"
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
if mt == "TaskFail":
|
|
151
|
+
p = task_pb2.TaskFailPayload()
|
|
152
|
+
p.ParseFromString(envelope.payload)
|
|
153
|
+
self.failures.append(
|
|
154
|
+
TaskFailRecord(
|
|
155
|
+
task_id=p.task_id,
|
|
156
|
+
assignee=p.assignee or envelope.sender,
|
|
157
|
+
error_code=p.error_code,
|
|
158
|
+
reason=p.reason,
|
|
159
|
+
retryable=p.retryable,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
self._statuses[p.task_id] = "failed"
|
|
163
|
+
self.phase = "Failed"
|
|
164
|
+
|
|
165
|
+
# -- State query helpers --
|
|
166
|
+
|
|
167
|
+
def get_task(self, task_id: str) -> TaskRequestRecord | None:
|
|
168
|
+
"""Return the task request record for *task_id*, or None."""
|
|
169
|
+
return self.tasks.get(task_id)
|
|
170
|
+
|
|
171
|
+
def is_accepted(self, task_id: str) -> bool:
|
|
172
|
+
status = self._statuses.get(task_id)
|
|
173
|
+
return status == "accepted" or status == "in_progress"
|
|
174
|
+
|
|
175
|
+
def is_completed(self, task_id: str) -> bool:
|
|
176
|
+
return self._statuses.get(task_id) == "completed"
|
|
177
|
+
|
|
178
|
+
def is_failed(self, task_id: str) -> bool:
|
|
179
|
+
return self._statuses.get(task_id) == "failed"
|
|
180
|
+
|
|
181
|
+
def is_retryable(self, task_id: str) -> bool:
|
|
182
|
+
"""True if the task failed with ``retryable=True``."""
|
|
183
|
+
return any(f.task_id == task_id and f.retryable for f in self.failures)
|
|
184
|
+
|
|
185
|
+
def progress_of(self, task_id: str) -> float:
|
|
186
|
+
"""Return the latest progress value for *task_id*, or 0 if unknown."""
|
|
187
|
+
return self._progress.get(task_id, 0.0)
|
|
188
|
+
|
|
189
|
+
def latest_progress(self) -> float | None:
|
|
190
|
+
return self.updates[-1].progress if self.updates else None
|
|
191
|
+
|
|
192
|
+
def active_tasks(self) -> list[TaskRequestRecord]:
|
|
193
|
+
"""Return task records that are not in a terminal state."""
|
|
194
|
+
active_statuses = {"requested", "accepted", "in_progress"}
|
|
195
|
+
return [t for t in self.tasks.values() if self._statuses.get(t.task_id) in active_statuses]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# Session helper
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class TaskSession(BaseSession):
|
|
204
|
+
"""High-level helper for Task mode sessions."""
|
|
205
|
+
|
|
206
|
+
MODE = MODE_TASK
|
|
207
|
+
|
|
208
|
+
def _create_projection(self) -> BaseProjection:
|
|
209
|
+
return TaskProjection()
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def task_projection(self) -> TaskProjection:
|
|
213
|
+
assert isinstance(self.projection, TaskProjection)
|
|
214
|
+
return self.projection
|
|
215
|
+
|
|
216
|
+
def request(
|
|
217
|
+
self,
|
|
218
|
+
task_id: str,
|
|
219
|
+
title: str,
|
|
220
|
+
*,
|
|
221
|
+
instructions: str = "",
|
|
222
|
+
requested_assignee: str = "",
|
|
223
|
+
input_data: bytes = b"",
|
|
224
|
+
deadline_unix_ms: int = 0,
|
|
225
|
+
sender: str | None = None,
|
|
226
|
+
auth: AuthConfig | None = None,
|
|
227
|
+
) -> envelope_pb2.Ack:
|
|
228
|
+
payload = task_pb2.TaskRequestPayload(
|
|
229
|
+
task_id=task_id,
|
|
230
|
+
title=title,
|
|
231
|
+
instructions=instructions,
|
|
232
|
+
requested_assignee=requested_assignee,
|
|
233
|
+
input=input_data,
|
|
234
|
+
deadline_unix_ms=deadline_unix_ms,
|
|
235
|
+
)
|
|
236
|
+
envelope = build_envelope(
|
|
237
|
+
mode=self.MODE,
|
|
238
|
+
message_type="TaskRequest",
|
|
239
|
+
session_id=self.session_id,
|
|
240
|
+
sender=self._sender_for(sender, auth=auth),
|
|
241
|
+
payload=serialize_message(payload),
|
|
242
|
+
)
|
|
243
|
+
return self._send_and_track(envelope, auth=auth)
|
|
244
|
+
|
|
245
|
+
def accept_task(
|
|
246
|
+
self,
|
|
247
|
+
task_id: str,
|
|
248
|
+
*,
|
|
249
|
+
assignee: str = "",
|
|
250
|
+
reason: str = "",
|
|
251
|
+
sender: str | None = None,
|
|
252
|
+
auth: AuthConfig | None = None,
|
|
253
|
+
) -> envelope_pb2.Ack:
|
|
254
|
+
payload = task_pb2.TaskAcceptPayload(
|
|
255
|
+
task_id=task_id,
|
|
256
|
+
assignee=assignee or self._sender_for(sender, auth=auth),
|
|
257
|
+
reason=reason,
|
|
258
|
+
)
|
|
259
|
+
envelope = build_envelope(
|
|
260
|
+
mode=self.MODE,
|
|
261
|
+
message_type="TaskAccept",
|
|
262
|
+
session_id=self.session_id,
|
|
263
|
+
sender=self._sender_for(sender, auth=auth),
|
|
264
|
+
payload=serialize_message(payload),
|
|
265
|
+
)
|
|
266
|
+
return self._send_and_track(envelope, auth=auth)
|
|
267
|
+
|
|
268
|
+
def reject_task(
|
|
269
|
+
self,
|
|
270
|
+
task_id: str,
|
|
271
|
+
*,
|
|
272
|
+
assignee: str = "",
|
|
273
|
+
reason: str = "",
|
|
274
|
+
sender: str | None = None,
|
|
275
|
+
auth: AuthConfig | None = None,
|
|
276
|
+
) -> envelope_pb2.Ack:
|
|
277
|
+
payload = task_pb2.TaskRejectPayload(
|
|
278
|
+
task_id=task_id,
|
|
279
|
+
assignee=assignee or self._sender_for(sender, auth=auth),
|
|
280
|
+
reason=reason,
|
|
281
|
+
)
|
|
282
|
+
envelope = build_envelope(
|
|
283
|
+
mode=self.MODE,
|
|
284
|
+
message_type="TaskReject",
|
|
285
|
+
session_id=self.session_id,
|
|
286
|
+
sender=self._sender_for(sender, auth=auth),
|
|
287
|
+
payload=serialize_message(payload),
|
|
288
|
+
)
|
|
289
|
+
return self._send_and_track(envelope, auth=auth)
|
|
290
|
+
|
|
291
|
+
def update(
|
|
292
|
+
self,
|
|
293
|
+
task_id: str,
|
|
294
|
+
*,
|
|
295
|
+
status: str = "",
|
|
296
|
+
progress: float = 0.0,
|
|
297
|
+
message: str = "",
|
|
298
|
+
partial_output: bytes = b"",
|
|
299
|
+
sender: str | None = None,
|
|
300
|
+
auth: AuthConfig | None = None,
|
|
301
|
+
) -> envelope_pb2.Ack:
|
|
302
|
+
payload = task_pb2.TaskUpdatePayload(
|
|
303
|
+
task_id=task_id,
|
|
304
|
+
status=status,
|
|
305
|
+
progress=progress,
|
|
306
|
+
message=message,
|
|
307
|
+
partial_output=partial_output,
|
|
308
|
+
)
|
|
309
|
+
envelope = build_envelope(
|
|
310
|
+
mode=self.MODE,
|
|
311
|
+
message_type="TaskUpdate",
|
|
312
|
+
session_id=self.session_id,
|
|
313
|
+
sender=self._sender_for(sender, auth=auth),
|
|
314
|
+
payload=serialize_message(payload),
|
|
315
|
+
)
|
|
316
|
+
return self._send_and_track(envelope, auth=auth)
|
|
317
|
+
|
|
318
|
+
def complete(
|
|
319
|
+
self,
|
|
320
|
+
task_id: str,
|
|
321
|
+
*,
|
|
322
|
+
assignee: str = "",
|
|
323
|
+
output: bytes = b"",
|
|
324
|
+
summary: str = "",
|
|
325
|
+
sender: str | None = None,
|
|
326
|
+
auth: AuthConfig | None = None,
|
|
327
|
+
) -> envelope_pb2.Ack:
|
|
328
|
+
payload = task_pb2.TaskCompletePayload(
|
|
329
|
+
task_id=task_id,
|
|
330
|
+
assignee=assignee or self._sender_for(sender, auth=auth),
|
|
331
|
+
output=output,
|
|
332
|
+
summary=summary,
|
|
333
|
+
)
|
|
334
|
+
envelope = build_envelope(
|
|
335
|
+
mode=self.MODE,
|
|
336
|
+
message_type="TaskComplete",
|
|
337
|
+
session_id=self.session_id,
|
|
338
|
+
sender=self._sender_for(sender, auth=auth),
|
|
339
|
+
payload=serialize_message(payload),
|
|
340
|
+
)
|
|
341
|
+
return self._send_and_track(envelope, auth=auth)
|
|
342
|
+
|
|
343
|
+
def fail(
|
|
344
|
+
self,
|
|
345
|
+
task_id: str,
|
|
346
|
+
*,
|
|
347
|
+
assignee: str = "",
|
|
348
|
+
error_code: str = "",
|
|
349
|
+
reason: str = "",
|
|
350
|
+
retryable: bool = False,
|
|
351
|
+
sender: str | None = None,
|
|
352
|
+
auth: AuthConfig | None = None,
|
|
353
|
+
) -> envelope_pb2.Ack:
|
|
354
|
+
payload = task_pb2.TaskFailPayload(
|
|
355
|
+
task_id=task_id,
|
|
356
|
+
assignee=assignee or self._sender_for(sender, auth=auth),
|
|
357
|
+
error_code=error_code,
|
|
358
|
+
reason=reason,
|
|
359
|
+
retryable=retryable,
|
|
360
|
+
)
|
|
361
|
+
envelope = build_envelope(
|
|
362
|
+
mode=self.MODE,
|
|
363
|
+
message_type="TaskFail",
|
|
364
|
+
session_id=self.session_id,
|
|
365
|
+
sender=self._sender_for(sender, auth=auth),
|
|
366
|
+
payload=serialize_message(payload),
|
|
367
|
+
)
|
|
368
|
+
return self._send_and_track(envelope, auth=auth)
|