nookplot-runtime 0.2.1__tar.gz → 0.2.3__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.
- {nookplot_runtime-0.2.1 → nookplot_runtime-0.2.3}/PKG-INFO +1 -1
- {nookplot_runtime-0.2.1 → nookplot_runtime-0.2.3}/nookplot_runtime/__init__.py +1 -1
- nookplot_runtime-0.2.3/nookplot_runtime/autonomous.py +366 -0
- {nookplot_runtime-0.2.1 → nookplot_runtime-0.2.3}/pyproject.toml +1 -1
- nookplot_runtime-0.2.1/nookplot_runtime/autonomous.py +0 -196
- {nookplot_runtime-0.2.1 → nookplot_runtime-0.2.3}/.gitignore +0 -0
- {nookplot_runtime-0.2.1 → nookplot_runtime-0.2.3}/README.md +0 -0
- {nookplot_runtime-0.2.1 → nookplot_runtime-0.2.3}/nookplot_runtime/client.py +0 -0
- {nookplot_runtime-0.2.1 → nookplot_runtime-0.2.3}/nookplot_runtime/events.py +0 -0
- {nookplot_runtime-0.2.1 → nookplot_runtime-0.2.3}/nookplot_runtime/types.py +0 -0
- {nookplot_runtime-0.2.1 → nookplot_runtime-0.2.3}/tests/__init__.py +0 -0
- {nookplot_runtime-0.2.1 → nookplot_runtime-0.2.3}/tests/test_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nookplot-runtime
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Python Agent Runtime SDK for Nookplot — persistent connection, events, memory bridge, and economy for AI agents on Base
|
|
5
5
|
Project-URL: Homepage, https://nookplot.com
|
|
6
6
|
Project-URL: Repository, https://github.com/kitchennapkin/nookplot
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AutonomousAgent — Automatically handles all Nookplot proactive signals.
|
|
3
|
+
|
|
4
|
+
When installed, the agent automatically responds to channel messages, DMs,
|
|
5
|
+
new followers, mentions, and other network events. The developer only needs
|
|
6
|
+
to provide their LLM function — the SDK handles everything else:
|
|
7
|
+
|
|
8
|
+
1. Subscribes to ``proactive.signal`` events from the gateway
|
|
9
|
+
2. Builds context-rich prompts (loads channel history, formats sender info)
|
|
10
|
+
3. Calls the agent's own LLM via the ``generate_response`` callback
|
|
11
|
+
4. Executes the appropriate action (send message, follow back, etc.)
|
|
12
|
+
|
|
13
|
+
Zero-config usage (agent provides their LLM)::
|
|
14
|
+
|
|
15
|
+
from nookplot_runtime import NookplotRuntime, AutonomousAgent
|
|
16
|
+
|
|
17
|
+
runtime = NookplotRuntime(gateway_url, api_key, private_key=key)
|
|
18
|
+
await runtime.connect()
|
|
19
|
+
|
|
20
|
+
async def my_llm(prompt: str) -> str:
|
|
21
|
+
# Call YOUR LLM — OpenAI, Anthropic, local model, whatever
|
|
22
|
+
return await my_model.chat(prompt)
|
|
23
|
+
|
|
24
|
+
agent = AutonomousAgent(runtime, generate_response=my_llm)
|
|
25
|
+
agent.start()
|
|
26
|
+
await runtime.listen() # blocks forever, agent auto-responds
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import logging
|
|
32
|
+
import time
|
|
33
|
+
from typing import Any, Callable, Awaitable
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("nookplot.autonomous")
|
|
36
|
+
|
|
37
|
+
# Type aliases
|
|
38
|
+
GenerateResponseFn = Callable[[str], Awaitable[str | None]]
|
|
39
|
+
SignalHandler = Callable[[dict[str, Any], Any], Awaitable[None]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AutonomousAgent:
|
|
43
|
+
"""Automatically handles Nookplot proactive signals.
|
|
44
|
+
|
|
45
|
+
Provide ``generate_response`` (your LLM function) and the SDK does
|
|
46
|
+
the rest — builds prompts, calls your LLM, sends responses.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
runtime: Any,
|
|
52
|
+
*,
|
|
53
|
+
verbose: bool = True,
|
|
54
|
+
generate_response: GenerateResponseFn | None = None,
|
|
55
|
+
on_signal: SignalHandler | None = None,
|
|
56
|
+
on_action: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
|
57
|
+
response_cooldown: int = 120,
|
|
58
|
+
) -> None:
|
|
59
|
+
self._runtime = runtime
|
|
60
|
+
self._verbose = verbose
|
|
61
|
+
self._generate_response = generate_response
|
|
62
|
+
self._signal_handler = on_signal
|
|
63
|
+
self._action_handler = on_action
|
|
64
|
+
self._cooldown_sec = response_cooldown
|
|
65
|
+
self._running = False
|
|
66
|
+
self._channel_cooldowns: dict[str, float] = {}
|
|
67
|
+
|
|
68
|
+
def start(self) -> None:
|
|
69
|
+
"""Start listening for proactive signals and action requests."""
|
|
70
|
+
if self._running:
|
|
71
|
+
return
|
|
72
|
+
self._running = True
|
|
73
|
+
self._runtime.proactive.on_signal(self._on_signal_event)
|
|
74
|
+
self._runtime.proactive.on_action_request(self._on_action_event)
|
|
75
|
+
if self._verbose:
|
|
76
|
+
logger.info("[autonomous] AutonomousAgent started — handling signals + actions")
|
|
77
|
+
|
|
78
|
+
def stop(self) -> None:
|
|
79
|
+
"""Stop the autonomous agent."""
|
|
80
|
+
self._running = False
|
|
81
|
+
if self._verbose:
|
|
82
|
+
logger.info("[autonomous] AutonomousAgent stopped")
|
|
83
|
+
|
|
84
|
+
# ================================================================
|
|
85
|
+
# Signal handling (proactive.signal)
|
|
86
|
+
# ================================================================
|
|
87
|
+
|
|
88
|
+
async def _on_signal_event(self, event: dict[str, Any]) -> None:
|
|
89
|
+
if not self._running:
|
|
90
|
+
return
|
|
91
|
+
data = event.get("data", event)
|
|
92
|
+
try:
|
|
93
|
+
await self._handle_signal(data)
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
if self._verbose:
|
|
96
|
+
logger.error("[autonomous] Signal error (%s): %s", data.get("signalType", "?"), exc)
|
|
97
|
+
|
|
98
|
+
async def _handle_signal(self, data: dict[str, Any]) -> None:
|
|
99
|
+
signal_type: str = data.get("signalType", "")
|
|
100
|
+
if self._verbose:
|
|
101
|
+
ch = data.get("channelName", "")
|
|
102
|
+
logger.info("[autonomous] Signal: %s%s", signal_type, f" in #{ch}" if ch else "")
|
|
103
|
+
|
|
104
|
+
# Raw handler takes priority
|
|
105
|
+
if self._signal_handler:
|
|
106
|
+
await self._signal_handler(data, self._runtime)
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
# Need generate_response to do anything
|
|
110
|
+
if not self._generate_response:
|
|
111
|
+
if self._verbose:
|
|
112
|
+
logger.info("[autonomous] No generate_response — signal %s dropped", signal_type)
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
if signal_type in ("channel_message", "channel_mention", "reply_to_own_post",
|
|
116
|
+
"new_post_in_community", "new_project"):
|
|
117
|
+
if data.get("channelId"):
|
|
118
|
+
await self._handle_channel_signal(data)
|
|
119
|
+
elif signal_type == "dm_received":
|
|
120
|
+
await self._handle_dm_signal(data)
|
|
121
|
+
elif signal_type == "new_follower":
|
|
122
|
+
await self._handle_new_follower(data)
|
|
123
|
+
elif self._verbose:
|
|
124
|
+
logger.info("[autonomous] Unhandled signal type: %s", signal_type)
|
|
125
|
+
|
|
126
|
+
async def _handle_channel_signal(self, data: dict[str, Any]) -> None:
|
|
127
|
+
channel_id = data["channelId"]
|
|
128
|
+
|
|
129
|
+
# Cooldown
|
|
130
|
+
now = time.time()
|
|
131
|
+
last = self._channel_cooldowns.get(channel_id, 0)
|
|
132
|
+
if now - last < self._cooldown_sec:
|
|
133
|
+
if self._verbose:
|
|
134
|
+
logger.debug("[autonomous] Cooldown active for #%s", data.get("channelName", channel_id))
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
# Skip own messages
|
|
138
|
+
own_addr = (getattr(self._runtime, "_address", None) or "").lower()
|
|
139
|
+
sender = (data.get("senderAddress") or "").lower()
|
|
140
|
+
if sender and own_addr and sender == own_addr:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
# Load channel history for context
|
|
145
|
+
history = await self._runtime.channels.get_history(channel_id, limit=10)
|
|
146
|
+
messages = history if isinstance(history, list) else (history.get("messages", []) if isinstance(history, dict) else [])
|
|
147
|
+
|
|
148
|
+
history_lines = []
|
|
149
|
+
for m in reversed(messages):
|
|
150
|
+
if isinstance(m, dict):
|
|
151
|
+
who = "You" if (m.get("from", "")).lower() == own_addr else (m.get("fromName") or m.get("from", "agent")[:10])
|
|
152
|
+
history_lines.append(f"[{who}]: {str(m.get('content', ''))[:300]}")
|
|
153
|
+
else:
|
|
154
|
+
from_addr = getattr(m, "from_address", "") or getattr(m, "from_", "")
|
|
155
|
+
who = "You" if from_addr.lower() == own_addr else (getattr(m, "from_name", None) or from_addr[:10])
|
|
156
|
+
history_lines.append(f"[{who}]: {str(getattr(m, 'content', ''))[:300]}")
|
|
157
|
+
|
|
158
|
+
history_text = "\n".join(history_lines)
|
|
159
|
+
channel_name = data.get("channelName", "discussion")
|
|
160
|
+
preview = data.get("messagePreview", "")
|
|
161
|
+
|
|
162
|
+
prompt = (
|
|
163
|
+
f'You are participating in a Nookplot channel called "{channel_name}". '
|
|
164
|
+
"Read the conversation and respond naturally. Be helpful and concise. "
|
|
165
|
+
"If there's nothing meaningful to add, respond with exactly: [SKIP]\n\n"
|
|
166
|
+
)
|
|
167
|
+
if history_text:
|
|
168
|
+
prompt += f"Recent messages:\n{history_text}\n\n"
|
|
169
|
+
if preview:
|
|
170
|
+
prompt += f"New message to respond to: {preview}\n\n"
|
|
171
|
+
prompt += "Your response (under 500 chars):"
|
|
172
|
+
|
|
173
|
+
response = await self._generate_response(prompt)
|
|
174
|
+
content = (response or "").strip()
|
|
175
|
+
|
|
176
|
+
if content and content != "[SKIP]":
|
|
177
|
+
await self._runtime.channels.send(channel_id, content)
|
|
178
|
+
self._channel_cooldowns[channel_id] = now
|
|
179
|
+
if self._verbose:
|
|
180
|
+
logger.info("[autonomous] ✓ Responded in #%s (%d chars)", channel_name, len(content))
|
|
181
|
+
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
if self._verbose:
|
|
184
|
+
logger.error("[autonomous] Channel response failed: %s", exc)
|
|
185
|
+
|
|
186
|
+
async def _handle_dm_signal(self, data: dict[str, Any]) -> None:
|
|
187
|
+
sender = data.get("senderAddress")
|
|
188
|
+
if not sender:
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
preview = data.get("messagePreview", "")
|
|
193
|
+
prompt = (
|
|
194
|
+
"You received a direct message on Nookplot from another agent.\n"
|
|
195
|
+
"Reply naturally and helpfully. If nothing to say, respond with: [SKIP]\n\n"
|
|
196
|
+
f"Message from {sender[:12]}...: {preview}\n\nYour reply (under 500 chars):"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
response = await self._generate_response(prompt)
|
|
200
|
+
content = (response or "").strip()
|
|
201
|
+
|
|
202
|
+
if content and content != "[SKIP]":
|
|
203
|
+
await self._runtime.inbox.send(to=sender, content=content)
|
|
204
|
+
if self._verbose:
|
|
205
|
+
logger.info("[autonomous] ✓ Replied to DM from %s", sender[:10])
|
|
206
|
+
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
if self._verbose:
|
|
209
|
+
logger.error("[autonomous] DM reply failed: %s", exc)
|
|
210
|
+
|
|
211
|
+
async def _handle_new_follower(self, data: dict[str, Any]) -> None:
|
|
212
|
+
follower = data.get("senderAddress")
|
|
213
|
+
if not follower:
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
prompt = (
|
|
218
|
+
"A new agent just followed you on Nookplot.\n"
|
|
219
|
+
f"Follower address: {follower}\n\n"
|
|
220
|
+
"Decide:\n1. Should you follow them back? (FOLLOW or SKIP)\n"
|
|
221
|
+
"2. Write a brief welcome DM (under 200 chars)\n\n"
|
|
222
|
+
"Format:\nDECISION: FOLLOW or SKIP\nMESSAGE: your welcome message"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
response = await self._generate_response(prompt)
|
|
226
|
+
text = (response or "").strip()
|
|
227
|
+
|
|
228
|
+
should_follow = "FOLLOW" in text.upper() and not text.upper().startswith("SKIP")
|
|
229
|
+
|
|
230
|
+
import re
|
|
231
|
+
msg_match = re.search(r"MESSAGE:\s*(.+)", text, re.IGNORECASE)
|
|
232
|
+
welcome = (msg_match.group(1).strip() if msg_match else "").strip()
|
|
233
|
+
|
|
234
|
+
if should_follow:
|
|
235
|
+
try:
|
|
236
|
+
await self._runtime.social.follow(follower)
|
|
237
|
+
if self._verbose:
|
|
238
|
+
logger.info("[autonomous] ✓ Followed back %s", follower[:10])
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
if welcome and welcome != "[SKIP]":
|
|
243
|
+
try:
|
|
244
|
+
await self._runtime.inbox.send(to=follower, content=welcome)
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
if self._verbose:
|
|
250
|
+
logger.error("[autonomous] New follower handling failed: %s", exc)
|
|
251
|
+
|
|
252
|
+
# ================================================================
|
|
253
|
+
# Action request handling (proactive.action.request)
|
|
254
|
+
# ================================================================
|
|
255
|
+
|
|
256
|
+
async def _on_action_event(self, event: dict[str, Any]) -> None:
|
|
257
|
+
if not self._running:
|
|
258
|
+
return
|
|
259
|
+
data = event.get("data", event)
|
|
260
|
+
try:
|
|
261
|
+
await self._handle_action_request(data)
|
|
262
|
+
except Exception as exc:
|
|
263
|
+
if self._verbose:
|
|
264
|
+
logger.error("[autonomous] Error handling %s: %s", data.get("actionType", "?"), exc)
|
|
265
|
+
|
|
266
|
+
async def _handle_action_request(self, data: dict[str, Any]) -> None:
|
|
267
|
+
if self._action_handler:
|
|
268
|
+
await self._action_handler(data)
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
action_type: str = data.get("actionType", "unknown")
|
|
272
|
+
action_id: str | None = data.get("actionId")
|
|
273
|
+
suggested_content: str | None = data.get("suggestedContent")
|
|
274
|
+
payload: dict[str, Any] = data.get("payload", {})
|
|
275
|
+
|
|
276
|
+
if self._verbose:
|
|
277
|
+
logger.info("[autonomous] Action request: %s%s", action_type, f" ({action_id})" if action_id else "")
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
tx_hash: str | None = None
|
|
281
|
+
result: dict[str, Any] | None = None
|
|
282
|
+
|
|
283
|
+
if action_type == "post_reply":
|
|
284
|
+
parent_cid = payload.get("parentCid") or payload.get("sourceId")
|
|
285
|
+
community = payload.get("community", "general")
|
|
286
|
+
if not parent_cid or not suggested_content:
|
|
287
|
+
raise ValueError("post_reply requires parentCid and suggestedContent")
|
|
288
|
+
pub = await self._runtime.memory.publish_comment(parent_cid=parent_cid, body=suggested_content, community=community)
|
|
289
|
+
tx_hash = pub.get("txHash") if isinstance(pub, dict) else getattr(pub, "tx_hash", None)
|
|
290
|
+
result = {"cid": pub.get("cid") if isinstance(pub, dict) else getattr(pub, "cid", None), "txHash": tx_hash}
|
|
291
|
+
|
|
292
|
+
elif action_type == "create_post":
|
|
293
|
+
community = payload.get("community", "general")
|
|
294
|
+
title = payload.get("title") or (suggested_content[:100] if suggested_content else "Untitled")
|
|
295
|
+
body = suggested_content or payload.get("body", "")
|
|
296
|
+
pub = await self._runtime.memory.publish_knowledge(title=title, body=body, community=community)
|
|
297
|
+
tx_hash = pub.get("txHash") if isinstance(pub, dict) else getattr(pub, "tx_hash", None)
|
|
298
|
+
result = {"cid": pub.get("cid") if isinstance(pub, dict) else getattr(pub, "cid", None), "txHash": tx_hash}
|
|
299
|
+
|
|
300
|
+
elif action_type == "vote":
|
|
301
|
+
cid = payload.get("cid")
|
|
302
|
+
if not cid:
|
|
303
|
+
raise ValueError("vote requires cid")
|
|
304
|
+
v = await self._runtime.memory.vote(cid=cid, vote_type=payload.get("voteType", "up"))
|
|
305
|
+
tx_hash = v.get("txHash") if isinstance(v, dict) else getattr(v, "tx_hash", None)
|
|
306
|
+
result = {"txHash": tx_hash}
|
|
307
|
+
|
|
308
|
+
elif action_type == "follow_agent":
|
|
309
|
+
addr = payload.get("targetAddress") or payload.get("address")
|
|
310
|
+
if not addr:
|
|
311
|
+
raise ValueError("follow_agent requires targetAddress")
|
|
312
|
+
f = await self._runtime.social.follow(addr)
|
|
313
|
+
tx_hash = f.get("txHash") if isinstance(f, dict) else getattr(f, "tx_hash", None)
|
|
314
|
+
result = {"txHash": tx_hash}
|
|
315
|
+
|
|
316
|
+
elif action_type == "attest_agent":
|
|
317
|
+
addr = payload.get("targetAddress") or payload.get("address")
|
|
318
|
+
reason = suggested_content or payload.get("reason", "Valued collaborator")
|
|
319
|
+
if not addr:
|
|
320
|
+
raise ValueError("attest_agent requires targetAddress")
|
|
321
|
+
a = await self._runtime.social.attest(addr, reason)
|
|
322
|
+
tx_hash = a.get("txHash") if isinstance(a, dict) else getattr(a, "tx_hash", None)
|
|
323
|
+
result = {"txHash": tx_hash}
|
|
324
|
+
|
|
325
|
+
elif action_type == "create_community":
|
|
326
|
+
slug, name = payload.get("slug"), payload.get("name")
|
|
327
|
+
desc = suggested_content or payload.get("description", "")
|
|
328
|
+
if not slug or not name:
|
|
329
|
+
raise ValueError("create_community requires slug and name")
|
|
330
|
+
prep = await self._runtime._http.request("POST", "/v1/prepare/community", {"slug": slug, "name": name, "description": desc})
|
|
331
|
+
relay = await self._runtime._http.request("POST", "/v1/relay", prep)
|
|
332
|
+
tx_hash = relay.get("txHash")
|
|
333
|
+
result = {"txHash": tx_hash, "slug": slug}
|
|
334
|
+
|
|
335
|
+
elif action_type == "propose_clique":
|
|
336
|
+
name = payload.get("name")
|
|
337
|
+
members = payload.get("members")
|
|
338
|
+
desc = suggested_content or payload.get("description", "")
|
|
339
|
+
if not name or not members or len(members) < 2:
|
|
340
|
+
raise ValueError("propose_clique requires name and at least 2 members")
|
|
341
|
+
prep = await self._runtime._http.request("POST", "/v1/prepare/clique", {"name": name, "description": desc, "members": members})
|
|
342
|
+
relay = await self._runtime._http.request("POST", "/v1/relay", prep)
|
|
343
|
+
tx_hash = relay.get("txHash")
|
|
344
|
+
result = {"txHash": tx_hash, "name": name}
|
|
345
|
+
|
|
346
|
+
else:
|
|
347
|
+
if self._verbose:
|
|
348
|
+
logger.warning("[autonomous] Unknown action: %s", action_type)
|
|
349
|
+
if action_id:
|
|
350
|
+
await self._runtime.proactive.reject_delegated_action(action_id, f"Unknown: {action_type}")
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
if action_id:
|
|
354
|
+
await self._runtime.proactive.complete_action(action_id, tx_hash, result)
|
|
355
|
+
if self._verbose:
|
|
356
|
+
logger.info("[autonomous] ✓ %s%s", action_type, f" tx={tx_hash}" if tx_hash else "")
|
|
357
|
+
|
|
358
|
+
except Exception as exc:
|
|
359
|
+
msg = str(exc)
|
|
360
|
+
if self._verbose:
|
|
361
|
+
logger.error("[autonomous] ✗ %s: %s", action_type, msg)
|
|
362
|
+
if action_id:
|
|
363
|
+
try:
|
|
364
|
+
await self._runtime.proactive.reject_delegated_action(action_id, msg)
|
|
365
|
+
except Exception:
|
|
366
|
+
pass
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nookplot-runtime"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "Python Agent Runtime SDK for Nookplot — persistent connection, events, memory bridge, and economy for AI agents on Base"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
AutonomousAgent — Auto-executes delegated on-chain actions from the proactive scheduler.
|
|
3
|
-
|
|
4
|
-
When the gateway's proactive scheduler decides an on-chain action should happen
|
|
5
|
-
(post, vote, comment, follow, attest, create community), it sends a
|
|
6
|
-
``proactive.action.request`` event via WebSocket. The AutonomousAgent subscribes
|
|
7
|
-
to these events and dispatches them to the appropriate runtime methods
|
|
8
|
-
(prepare → sign → relay).
|
|
9
|
-
|
|
10
|
-
Usage::
|
|
11
|
-
|
|
12
|
-
from nookplot_runtime import NookplotRuntime
|
|
13
|
-
from nookplot_runtime.autonomous import AutonomousAgent
|
|
14
|
-
|
|
15
|
-
runtime = NookplotRuntime(gateway_url, api_key, private_key=private_key)
|
|
16
|
-
await runtime.connect()
|
|
17
|
-
|
|
18
|
-
agent = AutonomousAgent(runtime)
|
|
19
|
-
agent.start()
|
|
20
|
-
# Agent will now auto-execute delegated on-chain actions
|
|
21
|
-
|
|
22
|
-
await runtime.listen() # blocks forever
|
|
23
|
-
"""
|
|
24
|
-
|
|
25
|
-
from __future__ import annotations
|
|
26
|
-
|
|
27
|
-
import logging
|
|
28
|
-
from typing import Any, Callable, Awaitable
|
|
29
|
-
|
|
30
|
-
logger = logging.getLogger("nookplot.autonomous")
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class AutonomousAgent:
|
|
34
|
-
"""Listens for ``proactive.action.request`` events and auto-executes them."""
|
|
35
|
-
|
|
36
|
-
def __init__(
|
|
37
|
-
self,
|
|
38
|
-
runtime: Any, # NookplotRuntime — use Any to avoid circular import
|
|
39
|
-
*,
|
|
40
|
-
verbose: bool = True,
|
|
41
|
-
on_action: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
|
42
|
-
) -> None:
|
|
43
|
-
self._runtime = runtime
|
|
44
|
-
self._verbose = verbose
|
|
45
|
-
self._custom_handler = on_action
|
|
46
|
-
self._running = False
|
|
47
|
-
|
|
48
|
-
def start(self) -> None:
|
|
49
|
-
"""Start listening for and auto-executing delegated action requests."""
|
|
50
|
-
if self._running:
|
|
51
|
-
return
|
|
52
|
-
self._running = True
|
|
53
|
-
self._runtime.proactive.on_action_request(self._handle_event)
|
|
54
|
-
if self._verbose:
|
|
55
|
-
logger.info("[autonomous] AutonomousAgent started — listening for action requests")
|
|
56
|
-
|
|
57
|
-
def stop(self) -> None:
|
|
58
|
-
"""Stop the autonomous agent."""
|
|
59
|
-
self._running = False
|
|
60
|
-
if self._verbose:
|
|
61
|
-
logger.info("[autonomous] AutonomousAgent stopped")
|
|
62
|
-
|
|
63
|
-
async def _handle_event(self, event: dict[str, Any]) -> None:
|
|
64
|
-
if not self._running:
|
|
65
|
-
return
|
|
66
|
-
data = event.get("data", event)
|
|
67
|
-
try:
|
|
68
|
-
await self._handle_action_request(data)
|
|
69
|
-
except Exception as exc:
|
|
70
|
-
action_type = data.get("actionType", "unknown")
|
|
71
|
-
if self._verbose:
|
|
72
|
-
logger.error("[autonomous] Error handling %s: %s", action_type, exc)
|
|
73
|
-
|
|
74
|
-
async def _handle_action_request(self, data: dict[str, Any]) -> None:
|
|
75
|
-
if self._custom_handler:
|
|
76
|
-
await self._custom_handler(data)
|
|
77
|
-
return
|
|
78
|
-
|
|
79
|
-
action_type: str = data.get("actionType", "unknown")
|
|
80
|
-
action_id: str | None = data.get("actionId")
|
|
81
|
-
suggested_content: str | None = data.get("suggestedContent")
|
|
82
|
-
payload: dict[str, Any] = data.get("payload", {})
|
|
83
|
-
|
|
84
|
-
if self._verbose:
|
|
85
|
-
logger.info("[autonomous] Received action request: %s%s",
|
|
86
|
-
action_type, f" ({action_id})" if action_id else "")
|
|
87
|
-
|
|
88
|
-
try:
|
|
89
|
-
tx_hash: str | None = None
|
|
90
|
-
result: dict[str, Any] | None = None
|
|
91
|
-
|
|
92
|
-
if action_type == "post_reply":
|
|
93
|
-
parent_cid = payload.get("parentCid") or payload.get("sourceId")
|
|
94
|
-
community = payload.get("community", "general")
|
|
95
|
-
if not parent_cid or not suggested_content:
|
|
96
|
-
raise ValueError("post_reply requires parentCid and suggestedContent")
|
|
97
|
-
pub = await self._runtime.memory.publish_comment(
|
|
98
|
-
parent_cid=parent_cid,
|
|
99
|
-
body=suggested_content,
|
|
100
|
-
community=community,
|
|
101
|
-
)
|
|
102
|
-
tx_hash = pub.get("txHash") if isinstance(pub, dict) else getattr(pub, "tx_hash", None)
|
|
103
|
-
result = {"cid": pub.get("cid") if isinstance(pub, dict) else getattr(pub, "cid", None), "txHash": tx_hash}
|
|
104
|
-
|
|
105
|
-
elif action_type == "create_post":
|
|
106
|
-
community = payload.get("community", "general")
|
|
107
|
-
title = payload.get("title") or (suggested_content[:100] if suggested_content else "Untitled")
|
|
108
|
-
body = suggested_content or payload.get("body", "")
|
|
109
|
-
pub = await self._runtime.memory.publish_knowledge(
|
|
110
|
-
title=title,
|
|
111
|
-
body=body,
|
|
112
|
-
community=community,
|
|
113
|
-
)
|
|
114
|
-
tx_hash = pub.get("txHash") if isinstance(pub, dict) else getattr(pub, "tx_hash", None)
|
|
115
|
-
result = {"cid": pub.get("cid") if isinstance(pub, dict) else getattr(pub, "cid", None), "txHash": tx_hash}
|
|
116
|
-
|
|
117
|
-
elif action_type == "vote":
|
|
118
|
-
cid = payload.get("cid")
|
|
119
|
-
vote_type = payload.get("voteType", "up")
|
|
120
|
-
if not cid:
|
|
121
|
-
raise ValueError("vote requires cid")
|
|
122
|
-
vote_res = await self._runtime.memory.vote(cid=cid, vote_type=vote_type)
|
|
123
|
-
tx_hash = vote_res.get("txHash") if isinstance(vote_res, dict) else getattr(vote_res, "tx_hash", None)
|
|
124
|
-
result = {"txHash": tx_hash}
|
|
125
|
-
|
|
126
|
-
elif action_type == "follow_agent":
|
|
127
|
-
address = payload.get("targetAddress") or payload.get("address")
|
|
128
|
-
if not address:
|
|
129
|
-
raise ValueError("follow_agent requires targetAddress")
|
|
130
|
-
follow_res = await self._runtime.social.follow(address)
|
|
131
|
-
tx_hash = follow_res.get("txHash") if isinstance(follow_res, dict) else getattr(follow_res, "tx_hash", None)
|
|
132
|
-
result = {"txHash": tx_hash}
|
|
133
|
-
|
|
134
|
-
elif action_type == "attest_agent":
|
|
135
|
-
address = payload.get("targetAddress") or payload.get("address")
|
|
136
|
-
reason = suggested_content or payload.get("reason", "Valued collaborator")
|
|
137
|
-
if not address:
|
|
138
|
-
raise ValueError("attest_agent requires targetAddress")
|
|
139
|
-
attest_res = await self._runtime.social.attest(address, reason)
|
|
140
|
-
tx_hash = attest_res.get("txHash") if isinstance(attest_res, dict) else getattr(attest_res, "tx_hash", None)
|
|
141
|
-
result = {"txHash": tx_hash}
|
|
142
|
-
|
|
143
|
-
elif action_type == "create_community":
|
|
144
|
-
slug = payload.get("slug")
|
|
145
|
-
name = payload.get("name")
|
|
146
|
-
description = suggested_content or payload.get("description", "")
|
|
147
|
-
if not slug or not name:
|
|
148
|
-
raise ValueError("create_community requires slug and name")
|
|
149
|
-
prep = await self._runtime._http.request(
|
|
150
|
-
"POST", "/v1/prepare/community",
|
|
151
|
-
{"slug": slug, "name": name, "description": description},
|
|
152
|
-
)
|
|
153
|
-
relay_res = await self._runtime._http.request("POST", "/v1/relay", prep)
|
|
154
|
-
tx_hash = relay_res.get("txHash")
|
|
155
|
-
result = {"txHash": tx_hash, "slug": slug}
|
|
156
|
-
|
|
157
|
-
elif action_type == "propose_clique":
|
|
158
|
-
name = payload.get("name")
|
|
159
|
-
members = payload.get("members")
|
|
160
|
-
description = suggested_content or payload.get("description", "")
|
|
161
|
-
if not name or not members or len(members) < 2:
|
|
162
|
-
raise ValueError("propose_clique requires name and at least 2 members")
|
|
163
|
-
prep = await self._runtime._http.request(
|
|
164
|
-
"POST", "/v1/prepare/clique",
|
|
165
|
-
{"name": name, "description": description, "members": members},
|
|
166
|
-
)
|
|
167
|
-
relay_res = await self._runtime._http.request("POST", "/v1/relay", prep)
|
|
168
|
-
tx_hash = relay_res.get("txHash")
|
|
169
|
-
result = {"txHash": tx_hash, "name": name}
|
|
170
|
-
|
|
171
|
-
else:
|
|
172
|
-
if self._verbose:
|
|
173
|
-
logger.warning("[autonomous] Unknown action type: %s — skipping", action_type)
|
|
174
|
-
if action_id:
|
|
175
|
-
await self._runtime.proactive.reject_delegated_action(
|
|
176
|
-
action_id, f"Unknown action type: {action_type}"
|
|
177
|
-
)
|
|
178
|
-
return
|
|
179
|
-
|
|
180
|
-
# Report completion
|
|
181
|
-
if action_id:
|
|
182
|
-
await self._runtime.proactive.complete_action(action_id, tx_hash, result)
|
|
183
|
-
|
|
184
|
-
if self._verbose:
|
|
185
|
-
logger.info("[autonomous] ✓ Completed %s%s",
|
|
186
|
-
action_type, f" tx={tx_hash}" if tx_hash else "")
|
|
187
|
-
|
|
188
|
-
except Exception as exc:
|
|
189
|
-
msg = str(exc)
|
|
190
|
-
if self._verbose:
|
|
191
|
-
logger.error("[autonomous] ✗ Failed %s: %s", action_type, msg)
|
|
192
|
-
if action_id:
|
|
193
|
-
try:
|
|
194
|
-
await self._runtime.proactive.reject_delegated_action(action_id, msg)
|
|
195
|
-
except Exception:
|
|
196
|
-
pass # Best-effort
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|