memro 0.2.0__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.
- memro/__init__.py +39 -0
- memro/agent.py +449 -0
- memro/client.py +547 -0
- memro/crypto.py +110 -0
- memro/integrations/__init__.py +0 -0
- memro/integrations/langchain.py +84 -0
- memro/models.py +117 -0
- memro-0.2.0.dist-info/METADATA +174 -0
- memro-0.2.0.dist-info/RECORD +12 -0
- memro-0.2.0.dist-info/WHEEL +5 -0
- memro-0.2.0.dist-info/licenses/LICENSE +75 -0
- memro-0.2.0.dist-info/top_level.txt +1 -0
memro/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
memro — AI agent memory infrastructure SDK for Python.
|
|
3
|
+
|
|
4
|
+
Quickstart:
|
|
5
|
+
from memro import Agent
|
|
6
|
+
|
|
7
|
+
agent = Agent.from_env() # MEMRO_API_KEY=memro_sk_...
|
|
8
|
+
agent.remember("User prefers dark mode", type="profile")
|
|
9
|
+
results = agent.recall("user preferences")
|
|
10
|
+
"""
|
|
11
|
+
from .agent import Agent
|
|
12
|
+
from .client import MemroClient, MemroError, RateLimitError
|
|
13
|
+
from .models import (
|
|
14
|
+
Memory, SearchResult, ExportData, HealthStatus, TemporalSearchResult,
|
|
15
|
+
ReasoningResult, EvolutionResult, MemoryTimeline, MemoryChain, TemporalStats, UploadedFile,
|
|
16
|
+
)
|
|
17
|
+
from .crypto import generate_keypair, sign_request
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Agent",
|
|
21
|
+
"MemroClient",
|
|
22
|
+
"MemroError",
|
|
23
|
+
"RateLimitError",
|
|
24
|
+
"TemporalSearchResult",
|
|
25
|
+
"ReasoningResult",
|
|
26
|
+
"EvolutionResult",
|
|
27
|
+
"MemoryTimeline",
|
|
28
|
+
"MemoryChain",
|
|
29
|
+
"TemporalStats",
|
|
30
|
+
"UploadedFile",
|
|
31
|
+
"sign_request",
|
|
32
|
+
"Memory",
|
|
33
|
+
"SearchResult",
|
|
34
|
+
"ExportData",
|
|
35
|
+
"HealthStatus",
|
|
36
|
+
"generate_keypair",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
__version__ = "0.2.0"
|
memro/agent.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""
|
|
2
|
+
High-level Agent interface for the memro protocol.
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
import json
|
|
6
|
+
from typing import Optional, List, Dict, Any
|
|
7
|
+
|
|
8
|
+
from .client import MemroClient
|
|
9
|
+
from .crypto import generate_keypair
|
|
10
|
+
from .models import (
|
|
11
|
+
Memory, SearchResult, ExportData, TemporalSearchResult, ReasoningResult,
|
|
12
|
+
EvolutionResult, MemoryTimeline, MemoryChain, TemporalStats, UploadedFile,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Agent:
|
|
17
|
+
"""
|
|
18
|
+
An AI agent with persistent, self-owned memory.
|
|
19
|
+
|
|
20
|
+
Memro Cloud (API key from `npx memro-cli connect` or the dashboard):
|
|
21
|
+
agent = Agent.from_api_key("memro_sk_...")
|
|
22
|
+
agent = Agent.from_env() # MEMRO_API_KEY
|
|
23
|
+
|
|
24
|
+
Self-owned identity (ed25519 key generated on this machine):
|
|
25
|
+
agent = Agent.create()
|
|
26
|
+
agent.save("identity.json") # keep this file secret
|
|
27
|
+
agent = Agent.from_file("identity.json")
|
|
28
|
+
|
|
29
|
+
Then:
|
|
30
|
+
agent.remember("User prefers dark mode", type="profile")
|
|
31
|
+
results = agent.recall("user preferences")
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
agent_id: str,
|
|
37
|
+
private_key: Optional[str] = None,
|
|
38
|
+
client: Optional[MemroClient] = None,
|
|
39
|
+
):
|
|
40
|
+
self.agent_id = agent_id
|
|
41
|
+
self._private_key = private_key
|
|
42
|
+
self.client = client or MemroClient()
|
|
43
|
+
|
|
44
|
+
# ── Constructors ────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def create(cls, client: Optional[MemroClient] = None) -> "Agent":
|
|
48
|
+
"""Create a new agent identity on the memro server."""
|
|
49
|
+
c = client or MemroClient()
|
|
50
|
+
resp = c.create_identity()
|
|
51
|
+
return cls(agent_id=resp["agent_id"], private_key=resp["private_key"], client=c)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_file(cls, path: str, client: Optional[MemroClient] = None) -> "Agent":
|
|
55
|
+
"""Load agent identity from a JSON file."""
|
|
56
|
+
with open(path, "r") as f:
|
|
57
|
+
data = json.load(f)
|
|
58
|
+
return cls(
|
|
59
|
+
agent_id=data["agent_id"],
|
|
60
|
+
private_key=data["private_key"],
|
|
61
|
+
client=client,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_api_key(cls, api_key: str, client: Optional[MemroClient] = None) -> "Agent":
|
|
66
|
+
"""Use an API key (memro_sk_...) from the dashboard or `memro connect`."""
|
|
67
|
+
c = client or MemroClient(api_key=api_key)
|
|
68
|
+
c.api_key = api_key
|
|
69
|
+
return cls(agent_id=c.whoami()["agent_id"], client=c)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def connect(cls, api_key: Optional[str] = None, base_url: Optional[str] = None) -> "Agent":
|
|
73
|
+
"""Shortcut for Agent.from_api_key with an optional self-hosted base URL."""
|
|
74
|
+
key = api_key or os.environ.get("MEMRO_API_KEY")
|
|
75
|
+
if not key:
|
|
76
|
+
raise EnvironmentError("Pass an API key or set MEMRO_API_KEY")
|
|
77
|
+
return cls.from_api_key(key, client=MemroClient(base_url, api_key=key))
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_env(cls, client: Optional[MemroClient] = None) -> "Agent":
|
|
81
|
+
"""
|
|
82
|
+
Load agent from environment variables:
|
|
83
|
+
MEMRO_API_KEY, or MEMRO_AGENT_ID + MEMRO_PRIVATE_KEY.
|
|
84
|
+
"""
|
|
85
|
+
api_key = os.environ.get("MEMRO_API_KEY")
|
|
86
|
+
if api_key:
|
|
87
|
+
return cls.from_api_key(api_key, client=client)
|
|
88
|
+
agent_id = os.environ.get("MEMRO_AGENT_ID")
|
|
89
|
+
private_key = os.environ.get("MEMRO_PRIVATE_KEY")
|
|
90
|
+
if not agent_id or not private_key:
|
|
91
|
+
raise EnvironmentError(
|
|
92
|
+
"Set MEMRO_API_KEY, or MEMRO_AGENT_ID and MEMRO_PRIVATE_KEY"
|
|
93
|
+
)
|
|
94
|
+
return cls(agent_id=agent_id, private_key=private_key, client=client)
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def from_dict(cls, data: Dict[str, str], client: Optional[MemroClient] = None) -> "Agent":
|
|
98
|
+
"""Load agent from a dict with agent_id and private_key."""
|
|
99
|
+
return cls(
|
|
100
|
+
agent_id=data["agent_id"],
|
|
101
|
+
private_key=data["private_key"],
|
|
102
|
+
client=client,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# ── Persistence ─────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def save(self, path: str) -> None:
|
|
108
|
+
"""Save agent identity to a JSON file (mode 0600). Keep this file secret."""
|
|
109
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
110
|
+
fd = os.open(path, flags, 0o600)
|
|
111
|
+
with open(fd, "w") as f:
|
|
112
|
+
json.dump(
|
|
113
|
+
{"agent_id": self.agent_id, "private_key": self._private_key},
|
|
114
|
+
f,
|
|
115
|
+
indent=2,
|
|
116
|
+
)
|
|
117
|
+
f.write("\n")
|
|
118
|
+
|
|
119
|
+
def to_dict(self) -> Dict[str, str]:
|
|
120
|
+
return {"agent_id": self.agent_id, "private_key": self._private_key}
|
|
121
|
+
|
|
122
|
+
# ── Memory operations ───────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def remember(
|
|
125
|
+
self,
|
|
126
|
+
content: str,
|
|
127
|
+
type: Optional[str] = None,
|
|
128
|
+
visibility: str = "private",
|
|
129
|
+
event_date: Optional[str] = None,
|
|
130
|
+
session_id: Optional[str] = None,
|
|
131
|
+
is_atomic: bool = False,
|
|
132
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
133
|
+
) -> Memory:
|
|
134
|
+
"""
|
|
135
|
+
Store a new memory with optional temporal grounding.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
content: What to remember (max 10,000 chars)
|
|
139
|
+
type: episodic | semantic | procedural | profile, or None to
|
|
140
|
+
let the server classify it
|
|
141
|
+
visibility: private | shared | public
|
|
142
|
+
event_date: When the event occurred (ISO 8601)
|
|
143
|
+
session_id: Session identifier for multi-session reasoning
|
|
144
|
+
is_atomic: Whether this is an atomic fact vs raw chunk
|
|
145
|
+
metadata: Optional dict of extra context
|
|
146
|
+
"""
|
|
147
|
+
data = self.client.create_memory(
|
|
148
|
+
agent_id=self.agent_id,
|
|
149
|
+
private_key=self._private_key,
|
|
150
|
+
content=content,
|
|
151
|
+
memory_type=type,
|
|
152
|
+
visibility=visibility,
|
|
153
|
+
event_date=event_date,
|
|
154
|
+
session_id=session_id,
|
|
155
|
+
is_atomic=is_atomic,
|
|
156
|
+
metadata=metadata,
|
|
157
|
+
)
|
|
158
|
+
return Memory(**data)
|
|
159
|
+
|
|
160
|
+
def remember_batch(
|
|
161
|
+
self,
|
|
162
|
+
memories: List[Dict[str, Any]],
|
|
163
|
+
) -> List[str]:
|
|
164
|
+
"""
|
|
165
|
+
Store up to 500 memories in a single transaction.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
memories: List of memory dicts (each with content, optional type,
|
|
169
|
+
event_date, session_id, metadata, visibility, is_atomic).
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
List of created memory IDs in input order.
|
|
173
|
+
"""
|
|
174
|
+
data = self.client.create_memories_batch(
|
|
175
|
+
agent_id=self.agent_id,
|
|
176
|
+
private_key=self._private_key,
|
|
177
|
+
memories=memories,
|
|
178
|
+
)
|
|
179
|
+
return data.get("ids", [])
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def recall(
|
|
183
|
+
self,
|
|
184
|
+
query: str,
|
|
185
|
+
limit: int = 10,
|
|
186
|
+
type: Optional[str] = None,
|
|
187
|
+
min_score: Optional[float] = None,
|
|
188
|
+
mode: str = "hybrid",
|
|
189
|
+
) -> List[SearchResult]:
|
|
190
|
+
"""
|
|
191
|
+
Search memories semantically or via hybrid retrieval.
|
|
192
|
+
"""
|
|
193
|
+
raw = self.client.search(
|
|
194
|
+
agent_id=self.agent_id,
|
|
195
|
+
private_key=self._private_key,
|
|
196
|
+
query=query,
|
|
197
|
+
limit=limit,
|
|
198
|
+
memory_type=type,
|
|
199
|
+
mode=mode,
|
|
200
|
+
)
|
|
201
|
+
results = [SearchResult(**r) for r in raw]
|
|
202
|
+
if min_score is not None:
|
|
203
|
+
results = [r for r in results if r.score >= min_score]
|
|
204
|
+
return results
|
|
205
|
+
|
|
206
|
+
def recall_temporal(
|
|
207
|
+
self,
|
|
208
|
+
query: str,
|
|
209
|
+
limit: int = 10,
|
|
210
|
+
after_date: Optional[str] = None,
|
|
211
|
+
before_date: Optional[str] = None,
|
|
212
|
+
after_event: Optional[str] = None,
|
|
213
|
+
before_event: Optional[str] = None,
|
|
214
|
+
session_id: Optional[str] = None,
|
|
215
|
+
atomic_only: bool = False,
|
|
216
|
+
temporal_weight: float = 0.3,
|
|
217
|
+
) -> List[TemporalSearchResult]:
|
|
218
|
+
"""
|
|
219
|
+
Perform temporal search with date-based filtering and recency weighting.
|
|
220
|
+
"""
|
|
221
|
+
raw = self.client.search_temporal(
|
|
222
|
+
agent_id=self.agent_id,
|
|
223
|
+
private_key=self._private_key,
|
|
224
|
+
query=query,
|
|
225
|
+
limit=limit,
|
|
226
|
+
after_date=after_date,
|
|
227
|
+
before_date=before_date,
|
|
228
|
+
after_event=after_event,
|
|
229
|
+
before_event=before_event,
|
|
230
|
+
session_id=session_id,
|
|
231
|
+
atomic_only=atomic_only,
|
|
232
|
+
temporal_weight=temporal_weight,
|
|
233
|
+
)
|
|
234
|
+
return [TemporalSearchResult(**r) for r in raw.get("results", [])]
|
|
235
|
+
|
|
236
|
+
def get_recent(
|
|
237
|
+
self,
|
|
238
|
+
limit: int = 10,
|
|
239
|
+
type: Optional[str] = None,
|
|
240
|
+
visibility: Optional[str] = None,
|
|
241
|
+
) -> List[Memory]:
|
|
242
|
+
"""Get most recent memories without semantic search."""
|
|
243
|
+
raw = self.client.get_memories(
|
|
244
|
+
agent_id=self.agent_id,
|
|
245
|
+
private_key=self._private_key,
|
|
246
|
+
memory_type=type,
|
|
247
|
+
visibility=visibility,
|
|
248
|
+
limit=limit,
|
|
249
|
+
)
|
|
250
|
+
return [Memory(**m) for m in raw]
|
|
251
|
+
|
|
252
|
+
def delete_memory(self, memory_id: str) -> bool:
|
|
253
|
+
"""Delete a specific memory by ID. Returns True if deleted."""
|
|
254
|
+
return self.client.delete_memory(
|
|
255
|
+
agent_id=self.agent_id,
|
|
256
|
+
private_key=self._private_key,
|
|
257
|
+
memory_id=memory_id,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def export(self) -> ExportData:
|
|
261
|
+
"""Export all memories for this agent."""
|
|
262
|
+
data = self.client.export(self.agent_id, self._private_key)
|
|
263
|
+
return ExportData(**data)
|
|
264
|
+
|
|
265
|
+
# ── Memory engine ───────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
def reason(self, query: str) -> ReasoningResult:
|
|
268
|
+
"""Answer a question from memory, with the memories and paths it used."""
|
|
269
|
+
return ReasoningResult(**self.client.reason(self.agent_id, self._private_key, query))
|
|
270
|
+
|
|
271
|
+
def resolve_conflict(self, memory_a_id: str, memory_b_id: str) -> dict:
|
|
272
|
+
"""{"Resolved": id} or {"Ambiguous": [a, b, expires_at]}."""
|
|
273
|
+
return self.client.resolve_conflict(self.agent_id, self._private_key, memory_a_id, memory_b_id)
|
|
274
|
+
|
|
275
|
+
def evolve(self) -> EvolutionResult:
|
|
276
|
+
"""Consolidate repeated episodes into semantic memories."""
|
|
277
|
+
return EvolutionResult(**self.client.evolve(self.agent_id, self._private_key))
|
|
278
|
+
|
|
279
|
+
def timeline(self, memory_id: str) -> MemoryTimeline:
|
|
280
|
+
return MemoryTimeline(**self.client.timeline(self.agent_id, self._private_key, memory_id))
|
|
281
|
+
|
|
282
|
+
def memory_chain(self, memory_id: str) -> MemoryChain:
|
|
283
|
+
return MemoryChain(**self.client.memory_chain(self.agent_id, self._private_key, memory_id))
|
|
284
|
+
|
|
285
|
+
def stats(self) -> TemporalStats:
|
|
286
|
+
return TemporalStats(**self.client.stats(self.agent_id, self._private_key))
|
|
287
|
+
|
|
288
|
+
def usage(self) -> dict:
|
|
289
|
+
return self.client.usage(self.agent_id, self._private_key)
|
|
290
|
+
|
|
291
|
+
# ── Files ───────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
def upload_file(self, file, remember: bool = True, type: str = "semantic") -> UploadedFile:
|
|
294
|
+
"""Upload a file (path or (name, bytes[, content_type])) and, by default,
|
|
295
|
+
store its extracted text as a memory."""
|
|
296
|
+
up = UploadedFile(**self.client.upload_file(self.agent_id, self._private_key, file))
|
|
297
|
+
if remember:
|
|
298
|
+
res = self.client.memory_from_file(self.agent_id, self._private_key, up.file_id, memory_type=type)
|
|
299
|
+
up.memory_id = res.get("memory_id")
|
|
300
|
+
return up
|
|
301
|
+
|
|
302
|
+
def upload_image(self, file) -> Memory:
|
|
303
|
+
"""Describe an image with vision and store the description."""
|
|
304
|
+
return Memory(**self.client.upload_image(self.agent_id, self._private_key, file))
|
|
305
|
+
|
|
306
|
+
def upload_document(self, file) -> List[Memory]:
|
|
307
|
+
"""Store a PDF, Word, PowerPoint, Excel or text document as ~1,500-character memories."""
|
|
308
|
+
return [Memory(**m) for m in self.client.upload_document(self.agent_id, self._private_key, file)]
|
|
309
|
+
|
|
310
|
+
# ── Webhooks ────────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
def add_webhook(self, url: str, event_type: str = "memory_created") -> None:
|
|
313
|
+
"""POST each new memory of this agent to url (public http(s) only)."""
|
|
314
|
+
self.client.register_webhook(self.agent_id, self._private_key, url, event_type)
|
|
315
|
+
|
|
316
|
+
def remove_webhook(self, url: str, event_type: str = "memory_created") -> bool:
|
|
317
|
+
return self.client.delete_webhook(self.agent_id, self._private_key, url, event_type)
|
|
318
|
+
|
|
319
|
+
def webhooks(self, event_type: str = "memory_created") -> List[str]:
|
|
320
|
+
return self.client.list_webhooks(self.agent_id, self._private_key, event_type)
|
|
321
|
+
|
|
322
|
+
def delete_agent(self) -> None:
|
|
323
|
+
"""Permanently delete this agent and all its memories.
|
|
324
|
+
|
|
325
|
+
Needs the agent's private key; API keys cannot delete agents (403).
|
|
326
|
+
"""
|
|
327
|
+
self.client.delete_identity(self.agent_id, self._private_key)
|
|
328
|
+
|
|
329
|
+
# ── Coordination ────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
def claim(
|
|
332
|
+
self,
|
|
333
|
+
resource: str,
|
|
334
|
+
intent: str = "",
|
|
335
|
+
session_id: Optional[str] = None,
|
|
336
|
+
ttl_seconds: Optional[int] = None,
|
|
337
|
+
) -> "ClaimContext":
|
|
338
|
+
"""Claim a resource for multi-agent coordination.
|
|
339
|
+
Can be used as a context manager:
|
|
340
|
+
with agent.claim("src/auth.go", intent="refactor") as lease:
|
|
341
|
+
...
|
|
342
|
+
Or accessed as a dict:
|
|
343
|
+
res = agent.claim("src/auth.go")
|
|
344
|
+
if res["granted"]: ...
|
|
345
|
+
"""
|
|
346
|
+
raw = self.client.coordination_claim(
|
|
347
|
+
self.agent_id, self._private_key, resource, intent=intent, session_id=session_id, ttl_seconds=ttl_seconds
|
|
348
|
+
)
|
|
349
|
+
return ClaimContext(self, resource, raw, ttl_seconds)
|
|
350
|
+
|
|
351
|
+
def heartbeat(
|
|
352
|
+
self,
|
|
353
|
+
resource: Optional[str] = None,
|
|
354
|
+
lease_id: Optional[str] = None,
|
|
355
|
+
ttl_seconds: Optional[int] = None,
|
|
356
|
+
session_id: Optional[str] = None,
|
|
357
|
+
) -> dict:
|
|
358
|
+
return self.client.coordination_heartbeat(
|
|
359
|
+
self.agent_id, self._private_key, resource=resource, lease_id=lease_id, ttl_seconds=ttl_seconds,
|
|
360
|
+
session_id=session_id,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
def release(
|
|
364
|
+
self,
|
|
365
|
+
resource: Optional[str] = None,
|
|
366
|
+
lease_id: Optional[str] = None,
|
|
367
|
+
status: str = "completed",
|
|
368
|
+
session_id: Optional[str] = None,
|
|
369
|
+
) -> dict:
|
|
370
|
+
return self.client.coordination_release(
|
|
371
|
+
self.agent_id, self._private_key, resource=resource, lease_id=lease_id, status=status, session_id=session_id
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def handoff(
|
|
375
|
+
self,
|
|
376
|
+
resource: Optional[str] = None,
|
|
377
|
+
lease_id: Optional[str] = None,
|
|
378
|
+
summary: str = "",
|
|
379
|
+
next_agent_id: Optional[str] = None,
|
|
380
|
+
session_id: Optional[str] = None,
|
|
381
|
+
) -> dict:
|
|
382
|
+
return self.client.coordination_handoff(
|
|
383
|
+
self.agent_id, self._private_key, resource=resource, lease_id=lease_id, summary=summary,
|
|
384
|
+
next_agent_id=next_agent_id, session_id=session_id,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def check(self, resource: str, intent: str = "", session_id: Optional[str] = None) -> dict:
|
|
388
|
+
return self.client.coordination_check(
|
|
389
|
+
self.agent_id, self._private_key, resource=resource, intent=intent, session_id=session_id
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def active(self, prefix: Optional[str] = None) -> dict:
|
|
393
|
+
return self.client.coordination_active(self.agent_id, self._private_key, resource=prefix)
|
|
394
|
+
|
|
395
|
+
def log_intent(
|
|
396
|
+
self, resource: str, intent: str, ttl_seconds: Optional[int] = None, session_id: Optional[str] = None
|
|
397
|
+
) -> dict:
|
|
398
|
+
return self.client.coordination_intent(
|
|
399
|
+
self.agent_id, self._private_key, resource=resource, intent=intent, ttl_seconds=ttl_seconds,
|
|
400
|
+
session_id=session_id,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
def __repr__(self) -> str:
|
|
404
|
+
return f"Agent(id={self.agent_id[:16]}...)"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class ClaimContext(dict):
|
|
408
|
+
"""Result of agent.claim(...). Behaves as a dictionary, and supports context management:
|
|
409
|
+
with agent.claim('src/auth.go'):
|
|
410
|
+
# auto-heartbeats in background, auto-releases on exit
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
def __init__(self, agent: "Agent", resource: str, result: dict, ttl_seconds: Optional[int] = None):
|
|
414
|
+
super().__init__(result)
|
|
415
|
+
self.agent = agent
|
|
416
|
+
self.resource = resource
|
|
417
|
+
self.ttl_seconds = ttl_seconds or 900
|
|
418
|
+
self._stop_heartbeat = None
|
|
419
|
+
self._thread = None
|
|
420
|
+
|
|
421
|
+
def __enter__(self):
|
|
422
|
+
if not self.get("granted"):
|
|
423
|
+
raise RuntimeError(f"Resource '{self.resource}' could not be claimed: held by {self.get('holder')}")
|
|
424
|
+
import threading
|
|
425
|
+
|
|
426
|
+
self._stop_heartbeat = threading.Event()
|
|
427
|
+
|
|
428
|
+
def _beat():
|
|
429
|
+
interval = max(5, self.ttl_seconds // 3)
|
|
430
|
+
while not self._stop_heartbeat.wait(interval):
|
|
431
|
+
try:
|
|
432
|
+
self.agent.heartbeat(lease_id=self.get("lease_id"), resource=self.resource, ttl_seconds=self.ttl_seconds)
|
|
433
|
+
except Exception:
|
|
434
|
+
pass
|
|
435
|
+
|
|
436
|
+
self._thread = threading.Thread(target=_beat, daemon=True)
|
|
437
|
+
self._thread.start()
|
|
438
|
+
return self
|
|
439
|
+
|
|
440
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
441
|
+
if self._stop_heartbeat:
|
|
442
|
+
self._stop_heartbeat.set()
|
|
443
|
+
status = "abandoned" if exc_type is not None else "completed"
|
|
444
|
+
try:
|
|
445
|
+
self.agent.release(lease_id=self.get("lease_id"), resource=self.resource, status=status)
|
|
446
|
+
except Exception:
|
|
447
|
+
pass
|
|
448
|
+
return False
|
|
449
|
+
|