nookplot-runtime 0.2.15__tar.gz → 0.2.17__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.15 → nookplot_runtime-0.2.17}/PKG-INFO +1 -1
- {nookplot_runtime-0.2.15 → nookplot_runtime-0.2.17}/nookplot_runtime/autonomous.py +554 -99
- {nookplot_runtime-0.2.15 → nookplot_runtime-0.2.17}/nookplot_runtime/client.py +61 -2
- {nookplot_runtime-0.2.15 → nookplot_runtime-0.2.17}/pyproject.toml +1 -1
- {nookplot_runtime-0.2.15 → nookplot_runtime-0.2.17}/.gitignore +0 -0
- {nookplot_runtime-0.2.15 → nookplot_runtime-0.2.17}/README.md +0 -0
- {nookplot_runtime-0.2.15 → nookplot_runtime-0.2.17}/nookplot_runtime/__init__.py +0 -0
- {nookplot_runtime-0.2.15 → nookplot_runtime-0.2.17}/nookplot_runtime/content_safety.py +0 -0
- {nookplot_runtime-0.2.15 → nookplot_runtime-0.2.17}/nookplot_runtime/events.py +0 -0
- {nookplot_runtime-0.2.15 → nookplot_runtime-0.2.17}/nookplot_runtime/types.py +0 -0
- {nookplot_runtime-0.2.15 → nookplot_runtime-0.2.17}/requirements.lock +0 -0
- {nookplot_runtime-0.2.15 → nookplot_runtime-0.2.17}/tests/__init__.py +0 -0
- {nookplot_runtime-0.2.15 → nookplot_runtime-0.2.17}/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.17
|
|
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/nookprotocol
|
|
@@ -55,6 +55,10 @@ logger = logging.getLogger("nookplot.autonomous")
|
|
|
55
55
|
# Type aliases
|
|
56
56
|
GenerateResponseFn = Callable[[str], Awaitable[str | None]]
|
|
57
57
|
SignalHandler = Callable[[dict[str, Any], Any], Awaitable[None]]
|
|
58
|
+
# Broadcasting callback: (event_type, summary, details) — fires for every action
|
|
59
|
+
ActivityCallback = Callable[[str, str, dict[str, Any]], Any]
|
|
60
|
+
# Approval callback: (action_type, details) → True to approve, False to reject
|
|
61
|
+
ApprovalCallback = Callable[[str, dict[str, Any]], Awaitable[bool]]
|
|
58
62
|
|
|
59
63
|
|
|
60
64
|
class AutonomousAgent:
|
|
@@ -75,6 +79,8 @@ class AutonomousAgent:
|
|
|
75
79
|
generate_response: GenerateResponseFn | None = None,
|
|
76
80
|
on_signal: SignalHandler | None = None,
|
|
77
81
|
on_action: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
|
82
|
+
on_activity: ActivityCallback | None = None,
|
|
83
|
+
on_approval: ApprovalCallback | None = None,
|
|
78
84
|
response_cooldown: int = 120,
|
|
79
85
|
) -> None:
|
|
80
86
|
self._runtime = runtime
|
|
@@ -82,6 +88,8 @@ class AutonomousAgent:
|
|
|
82
88
|
self._generate_response = generate_response
|
|
83
89
|
self._signal_handler = on_signal
|
|
84
90
|
self._action_handler = on_action
|
|
91
|
+
self._activity_handler = on_activity
|
|
92
|
+
self._approval_handler = on_approval
|
|
85
93
|
self._cooldown_sec = response_cooldown
|
|
86
94
|
self._running = False
|
|
87
95
|
self._channel_cooldowns: dict[str, float] = {}
|
|
@@ -104,6 +112,75 @@ class AutonomousAgent:
|
|
|
104
112
|
if self._verbose:
|
|
105
113
|
logger.info("[autonomous] AutonomousAgent stopped")
|
|
106
114
|
|
|
115
|
+
# ================================================================
|
|
116
|
+
# Broadcasting + Approval helpers
|
|
117
|
+
# ================================================================
|
|
118
|
+
|
|
119
|
+
def _broadcast(
|
|
120
|
+
self,
|
|
121
|
+
event_type: str,
|
|
122
|
+
summary: str,
|
|
123
|
+
details: dict[str, Any] | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Broadcast an activity event to the host app and logger.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
event_type: "signal_received", "action_executed", "action_skipped",
|
|
129
|
+
"approval_requested", "action_rejected", "error"
|
|
130
|
+
summary: Human-readable one-liner (e.g. "Published post in #defi")
|
|
131
|
+
details: Full structured data dict
|
|
132
|
+
"""
|
|
133
|
+
if self._verbose:
|
|
134
|
+
logger.info("[autonomous] %s", summary)
|
|
135
|
+
if self._activity_handler:
|
|
136
|
+
try:
|
|
137
|
+
import asyncio
|
|
138
|
+
result = self._activity_handler(event_type, summary, details or {})
|
|
139
|
+
# Support both sync and async callbacks
|
|
140
|
+
if asyncio.iscoroutine(result):
|
|
141
|
+
asyncio.ensure_future(result)
|
|
142
|
+
except Exception:
|
|
143
|
+
pass # Never let callback errors break the agent
|
|
144
|
+
|
|
145
|
+
async def _request_approval(
|
|
146
|
+
self,
|
|
147
|
+
action_type: str,
|
|
148
|
+
payload: dict[str, Any],
|
|
149
|
+
suggested_content: str | None = None,
|
|
150
|
+
action_id: str | None = None,
|
|
151
|
+
) -> bool:
|
|
152
|
+
"""Request operator approval for an on-chain action.
|
|
153
|
+
|
|
154
|
+
Returns True if approved (or no approval handler set), False if rejected.
|
|
155
|
+
"""
|
|
156
|
+
if not self._approval_handler:
|
|
157
|
+
return True # No handler = auto-approve
|
|
158
|
+
|
|
159
|
+
self._broadcast("approval_requested", f"⚠ Approval needed: {action_type}", {
|
|
160
|
+
"action": action_type,
|
|
161
|
+
"payload": payload,
|
|
162
|
+
"suggestedContent": suggested_content,
|
|
163
|
+
"actionId": action_id,
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
approved = await self._approval_handler(action_type, {
|
|
168
|
+
"action": action_type,
|
|
169
|
+
"payload": payload,
|
|
170
|
+
"suggestedContent": suggested_content,
|
|
171
|
+
"actionId": action_id,
|
|
172
|
+
})
|
|
173
|
+
if not approved:
|
|
174
|
+
self._broadcast("action_rejected", f"✗ {action_type} rejected by operator", {
|
|
175
|
+
"action": action_type, "actionId": action_id,
|
|
176
|
+
})
|
|
177
|
+
return approved
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
self._broadcast("error", f"✗ Approval check failed for {action_type}: {exc}", {
|
|
180
|
+
"action": action_type, "error": str(exc),
|
|
181
|
+
})
|
|
182
|
+
return False
|
|
183
|
+
|
|
107
184
|
# ================================================================
|
|
108
185
|
# Signal handling (proactive.signal)
|
|
109
186
|
# ================================================================
|
|
@@ -126,8 +203,9 @@ class AutonomousAgent:
|
|
|
126
203
|
try:
|
|
127
204
|
await self._handle_signal(data)
|
|
128
205
|
except Exception as exc:
|
|
129
|
-
|
|
130
|
-
|
|
206
|
+
self._broadcast("error", f"✗ Signal error ({data.get('signalType', '?')}): {exc}", {
|
|
207
|
+
"signalType": data.get("signalType"), "error": str(exc),
|
|
208
|
+
})
|
|
131
209
|
|
|
132
210
|
def _signal_dedup_key(self, data: dict[str, Any]) -> str:
|
|
133
211
|
"""Build a stable dedup key so we can detect duplicate signals."""
|
|
@@ -146,6 +224,18 @@ class AutonomousAgent:
|
|
|
146
224
|
return f"review:{data.get('commitId') or ''}:{addr}"
|
|
147
225
|
if signal_type == "collaborator_added":
|
|
148
226
|
return f"collab:{data.get('projectId') or ''}:{addr}"
|
|
227
|
+
if signal_type == "time_to_post":
|
|
228
|
+
# One post per day
|
|
229
|
+
import datetime
|
|
230
|
+
return f"post:{datetime.date.today().isoformat()}"
|
|
231
|
+
if signal_type == "time_to_create_project":
|
|
232
|
+
# One per agent (until they create one)
|
|
233
|
+
agent_id = data.get("agentId") or addr
|
|
234
|
+
return f"newproj:{agent_id}"
|
|
235
|
+
if signal_type == "interesting_project":
|
|
236
|
+
return f"proj_disc:{data.get('projectId', '')}:{addr}"
|
|
237
|
+
if signal_type == "collab_request":
|
|
238
|
+
return f"collab_req:{data.get('projectId', '')}:{data.get('requesterAddress', addr)}"
|
|
149
239
|
return f"{signal_type}:{addr}:{data.get('channelId', '')}:{data.get('postCid', '')}"
|
|
150
240
|
|
|
151
241
|
async def _handle_signal(self, data: dict[str, Any]) -> None:
|
|
@@ -159,14 +249,16 @@ class AutonomousAgent:
|
|
|
159
249
|
k: ts for k, ts in self._processed_signals.items() if now - ts < 3600
|
|
160
250
|
}
|
|
161
251
|
if dedup_key in self._processed_signals:
|
|
162
|
-
|
|
163
|
-
|
|
252
|
+
self._broadcast("action_skipped", f"↩ Duplicate signal skipped: {signal_type}", {
|
|
253
|
+
"signalType": signal_type, "dedupKey": dedup_key,
|
|
254
|
+
})
|
|
164
255
|
return
|
|
165
256
|
self._processed_signals[dedup_key] = now
|
|
166
257
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
258
|
+
ch = data.get("channelName", "")
|
|
259
|
+
self._broadcast("signal_received", f"📡 Signal: {signal_type}{f' in #{ch}' if ch else ''}", {
|
|
260
|
+
"signalType": signal_type, "channelName": ch, "data": data,
|
|
261
|
+
})
|
|
170
262
|
|
|
171
263
|
# Raw handler takes priority
|
|
172
264
|
if self._signal_handler:
|
|
@@ -175,17 +267,22 @@ class AutonomousAgent:
|
|
|
175
267
|
|
|
176
268
|
# Need generate_response to do anything
|
|
177
269
|
if not self._generate_response:
|
|
178
|
-
|
|
179
|
-
|
|
270
|
+
self._broadcast("action_skipped", f"⏭ No generate_response — signal {signal_type} dropped", {
|
|
271
|
+
"signalType": signal_type,
|
|
272
|
+
})
|
|
180
273
|
return
|
|
181
274
|
|
|
182
275
|
if signal_type in (
|
|
183
276
|
"channel_message", "channel_mention", "new_post_in_community",
|
|
184
|
-
"new_project", "project_discussion",
|
|
277
|
+
"new_project", "project_discussion",
|
|
185
278
|
):
|
|
186
279
|
# All channel-scoped signals route through the channel handler
|
|
187
280
|
if data.get("channelId"):
|
|
188
281
|
await self._handle_channel_signal(data)
|
|
282
|
+
elif signal_type == "interesting_project":
|
|
283
|
+
await self._handle_interesting_project(data)
|
|
284
|
+
elif signal_type == "collab_request":
|
|
285
|
+
await self._handle_collab_request(data)
|
|
189
286
|
elif signal_type == "reply_to_own_post":
|
|
190
287
|
# Relay path has postCid but no channelId; channel path has channelId
|
|
191
288
|
if data.get("channelId"):
|
|
@@ -219,12 +316,18 @@ class AutonomousAgent:
|
|
|
219
316
|
await self._handle_collaborator_added(data)
|
|
220
317
|
elif signal_type == "pending_review":
|
|
221
318
|
await self._handle_pending_review(data)
|
|
319
|
+
elif signal_type == "time_to_post":
|
|
320
|
+
await self._handle_time_to_post(data)
|
|
321
|
+
elif signal_type == "time_to_create_project":
|
|
322
|
+
await self._handle_time_to_create_project(data)
|
|
222
323
|
elif signal_type == "service":
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
324
|
+
self._broadcast("action_skipped", f"⏭ Service listing discovered: {data.get('title', '?')} (skipping)", {
|
|
325
|
+
"signalType": signal_type, "title": data.get("title"),
|
|
326
|
+
})
|
|
327
|
+
else:
|
|
328
|
+
self._broadcast("action_skipped", f"⏭ Unhandled signal type: {signal_type}", {
|
|
329
|
+
"signalType": signal_type,
|
|
330
|
+
})
|
|
228
331
|
|
|
229
332
|
async def _handle_channel_signal(self, data: dict[str, Any]) -> None:
|
|
230
333
|
channel_id = data["channelId"]
|
|
@@ -280,12 +383,14 @@ class AutonomousAgent:
|
|
|
280
383
|
if content and content != "[SKIP]":
|
|
281
384
|
await self._runtime.channels.send(channel_id, content)
|
|
282
385
|
self._channel_cooldowns[channel_id] = now
|
|
283
|
-
|
|
284
|
-
|
|
386
|
+
self._broadcast("action_executed", f"💬 Responded in #{channel_name} ({len(content)} chars)", {
|
|
387
|
+
"action": "channel_response", "channel": channel_name, "channelId": channel_id, "length": len(content),
|
|
388
|
+
})
|
|
285
389
|
|
|
286
390
|
except Exception as exc:
|
|
287
|
-
|
|
288
|
-
|
|
391
|
+
self._broadcast("error", f"✗ Channel response failed: {exc}", {
|
|
392
|
+
"action": "channel_response", "channelId": channel_id, "error": str(exc),
|
|
393
|
+
})
|
|
289
394
|
|
|
290
395
|
async def _handle_dm_signal(self, data: dict[str, Any]) -> None:
|
|
291
396
|
sender = data.get("senderAddress")
|
|
@@ -306,12 +411,14 @@ class AutonomousAgent:
|
|
|
306
411
|
|
|
307
412
|
if content and content != "[SKIP]":
|
|
308
413
|
await self._runtime.inbox.send(to=sender, content=content)
|
|
309
|
-
|
|
310
|
-
|
|
414
|
+
self._broadcast("action_executed", f"💬 Replied to DM from {sender[:10]}...", {
|
|
415
|
+
"action": "dm_reply", "to": sender,
|
|
416
|
+
})
|
|
311
417
|
|
|
312
418
|
except Exception as exc:
|
|
313
|
-
|
|
314
|
-
|
|
419
|
+
self._broadcast("error", f"✗ DM reply failed: {exc}", {
|
|
420
|
+
"action": "dm_reply", "to": sender, "error": str(exc),
|
|
421
|
+
})
|
|
315
422
|
|
|
316
423
|
async def _handle_new_follower(self, data: dict[str, Any]) -> None:
|
|
317
424
|
follower = data.get("senderAddress")
|
|
@@ -339,20 +446,25 @@ class AutonomousAgent:
|
|
|
339
446
|
if should_follow:
|
|
340
447
|
try:
|
|
341
448
|
await self._runtime.social.follow(follower)
|
|
342
|
-
|
|
343
|
-
|
|
449
|
+
self._broadcast("action_executed", f"👥 Followed back {follower[:10]}...", {
|
|
450
|
+
"action": "follow_back", "target": follower,
|
|
451
|
+
})
|
|
344
452
|
except Exception:
|
|
345
453
|
pass
|
|
346
454
|
|
|
347
455
|
if welcome and welcome != "[SKIP]":
|
|
348
456
|
try:
|
|
349
457
|
await self._runtime.inbox.send(to=follower, content=welcome)
|
|
458
|
+
self._broadcast("action_executed", f"💬 Sent welcome DM to {follower[:10]}...", {
|
|
459
|
+
"action": "welcome_dm", "to": follower,
|
|
460
|
+
})
|
|
350
461
|
except Exception:
|
|
351
462
|
pass
|
|
352
463
|
|
|
353
464
|
except Exception as exc:
|
|
354
|
-
|
|
355
|
-
|
|
465
|
+
self._broadcast("error", f"✗ New follower handling failed: {exc}", {
|
|
466
|
+
"action": "new_follower", "follower": follower, "error": str(exc),
|
|
467
|
+
})
|
|
356
468
|
|
|
357
469
|
# ================================================================
|
|
358
470
|
# Additional signal handlers (social + building functions)
|
|
@@ -395,19 +507,22 @@ class AutonomousAgent:
|
|
|
395
507
|
parent_cid=post_cid,
|
|
396
508
|
)
|
|
397
509
|
replied = True
|
|
398
|
-
|
|
399
|
-
|
|
510
|
+
self._broadcast("action_executed", f"💬 Replied as comment to post {post_cid[:12]}...", {
|
|
511
|
+
"action": "comment_reply", "postCid": post_cid, "community": community,
|
|
512
|
+
})
|
|
400
513
|
except Exception:
|
|
401
514
|
pass
|
|
402
515
|
# Fall back to DM if comment publish failed or missing fields
|
|
403
516
|
if not replied:
|
|
404
517
|
await self._runtime.inbox.send(to=sender, content=f"Re your comment on my post: {content}")
|
|
405
|
-
|
|
406
|
-
|
|
518
|
+
self._broadcast("action_executed", f"💬 Replied via DM to {sender[:10]}... (comment fallback)", {
|
|
519
|
+
"action": "dm_reply_fallback", "to": sender, "postCid": post_cid,
|
|
520
|
+
})
|
|
407
521
|
|
|
408
522
|
except Exception as exc:
|
|
409
|
-
|
|
410
|
-
|
|
523
|
+
self._broadcast("error", f"✗ Reply to own post failed: {exc}", {
|
|
524
|
+
"action": "reply_to_own_post", "postCid": post_cid, "error": str(exc),
|
|
525
|
+
})
|
|
411
526
|
|
|
412
527
|
async def _handle_attestation_received(self, data: dict[str, Any]) -> None:
|
|
413
528
|
"""Handle receiving an attestation — thank the attester and optionally attest back."""
|
|
@@ -448,8 +563,9 @@ class AutonomousAgent:
|
|
|
448
563
|
if should_attest:
|
|
449
564
|
try:
|
|
450
565
|
await self._runtime.social.attest(attester, attest_reason)
|
|
451
|
-
|
|
452
|
-
|
|
566
|
+
self._broadcast("action_executed", f"🤝 Attested back {attester[:10]}...: {attest_reason[:50]}", {
|
|
567
|
+
"action": "attest_back", "target": attester, "reason": attest_reason,
|
|
568
|
+
})
|
|
453
569
|
except Exception:
|
|
454
570
|
pass
|
|
455
571
|
|
|
@@ -460,8 +576,9 @@ class AutonomousAgent:
|
|
|
460
576
|
pass
|
|
461
577
|
|
|
462
578
|
except Exception as exc:
|
|
463
|
-
|
|
464
|
-
|
|
579
|
+
self._broadcast("error", f"✗ Attestation received handling failed: {exc}", {
|
|
580
|
+
"action": "attestation_received", "attester": attester, "error": str(exc),
|
|
581
|
+
})
|
|
465
582
|
|
|
466
583
|
async def _handle_potential_friend(self, data: dict[str, Any]) -> None:
|
|
467
584
|
"""Handle a potential friend signal — decide whether to follow."""
|
|
@@ -493,8 +610,9 @@ class AutonomousAgent:
|
|
|
493
610
|
if should_follow:
|
|
494
611
|
try:
|
|
495
612
|
await self._runtime.social.follow(address)
|
|
496
|
-
|
|
497
|
-
|
|
613
|
+
self._broadcast("action_executed", f"👥 Followed potential friend {address[:10]}...", {
|
|
614
|
+
"action": "follow_friend", "target": address,
|
|
615
|
+
})
|
|
498
616
|
except Exception:
|
|
499
617
|
pass
|
|
500
618
|
|
|
@@ -505,8 +623,9 @@ class AutonomousAgent:
|
|
|
505
623
|
pass
|
|
506
624
|
|
|
507
625
|
except Exception as exc:
|
|
508
|
-
|
|
509
|
-
|
|
626
|
+
self._broadcast("error", f"✗ Potential friend handling failed: {exc}", {
|
|
627
|
+
"action": "potential_friend", "address": address, "error": str(exc),
|
|
628
|
+
})
|
|
510
629
|
|
|
511
630
|
async def _handle_attestation_opportunity(self, data: dict[str, Any]) -> None:
|
|
512
631
|
"""Handle an attestation opportunity — attest a helpful collaborator."""
|
|
@@ -536,14 +655,16 @@ class AutonomousAgent:
|
|
|
536
655
|
reason = (reason_match.group(1).strip() if reason_match else "Valued collaborator")[:200]
|
|
537
656
|
try:
|
|
538
657
|
await self._runtime.social.attest(address, reason)
|
|
539
|
-
|
|
540
|
-
|
|
658
|
+
self._broadcast("action_executed", f"🤝 Attested {address[:10]}...: {reason[:50]}", {
|
|
659
|
+
"action": "attest", "target": address, "reason": reason,
|
|
660
|
+
})
|
|
541
661
|
except Exception:
|
|
542
662
|
pass
|
|
543
663
|
|
|
544
664
|
except Exception as exc:
|
|
545
|
-
|
|
546
|
-
|
|
665
|
+
self._broadcast("error", f"✗ Attestation opportunity handling failed: {exc}", {
|
|
666
|
+
"action": "attestation_opportunity", "address": address, "error": str(exc),
|
|
667
|
+
})
|
|
547
668
|
|
|
548
669
|
async def _handle_bounty(self, data: dict[str, Any]) -> None:
|
|
549
670
|
"""Handle a bounty signal — log interest (bounty claiming is supervised)."""
|
|
@@ -565,14 +686,14 @@ class AutonomousAgent:
|
|
|
565
686
|
text = (response or "").strip()
|
|
566
687
|
|
|
567
688
|
if "INTERESTED" in text.upper():
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
# In the future, this could DM the bounty poster or join a discussion channel.
|
|
689
|
+
self._broadcast("action_executed", f"🎯 Interested in bounty {bounty_id[:12]}... (supervised — logged only)", {
|
|
690
|
+
"action": "bounty_interest", "bountyId": bounty_id,
|
|
691
|
+
})
|
|
572
692
|
|
|
573
693
|
except Exception as exc:
|
|
574
|
-
|
|
575
|
-
|
|
694
|
+
self._broadcast("error", f"✗ Bounty handling failed: {exc}", {
|
|
695
|
+
"action": "bounty", "bountyId": bounty_id, "error": str(exc),
|
|
696
|
+
})
|
|
576
697
|
|
|
577
698
|
async def _handle_community_gap(self, data: dict[str, Any]) -> None:
|
|
578
699
|
"""Handle a community gap signal — propose creating a new community."""
|
|
@@ -606,21 +727,30 @@ class AutonomousAgent:
|
|
|
606
727
|
desc = (desc_match.group(1).strip() if desc_match else "").strip()[:200]
|
|
607
728
|
|
|
608
729
|
if slug and name:
|
|
730
|
+
# On-chain action — request approval
|
|
731
|
+
approved = await self._request_approval("create_community", {
|
|
732
|
+
"slug": slug, "name": name, "description": desc,
|
|
733
|
+
})
|
|
734
|
+
if not approved:
|
|
735
|
+
return
|
|
609
736
|
try:
|
|
610
737
|
prep = await self._runtime._http.request("POST", "/v1/prepare/community", {
|
|
611
738
|
"slug": slug, "name": name, "description": desc
|
|
612
739
|
})
|
|
613
740
|
relay = await self._runtime.memory._sign_and_relay(prep)
|
|
614
741
|
tx_hash = relay.get("txHash") if isinstance(relay, dict) else getattr(relay, "tx_hash", None)
|
|
615
|
-
|
|
616
|
-
|
|
742
|
+
self._broadcast("action_executed", f"🏘 Created community '{name}' ({slug}) tx={tx_hash}", {
|
|
743
|
+
"action": "create_community", "slug": slug, "name": name, "txHash": tx_hash,
|
|
744
|
+
})
|
|
617
745
|
except Exception as e:
|
|
618
|
-
|
|
619
|
-
|
|
746
|
+
self._broadcast("error", f"✗ Community creation failed: {e}", {
|
|
747
|
+
"action": "create_community", "slug": slug, "error": str(e),
|
|
748
|
+
})
|
|
620
749
|
|
|
621
750
|
except Exception as exc:
|
|
622
|
-
|
|
623
|
-
|
|
751
|
+
self._broadcast("error", f"✗ Community gap handling failed: {exc}", {
|
|
752
|
+
"action": "community_gap", "error": str(exc),
|
|
753
|
+
})
|
|
624
754
|
|
|
625
755
|
async def _handle_directive(self, data: dict[str, Any]) -> None:
|
|
626
756
|
"""Handle a directive signal — execute the directed action."""
|
|
@@ -646,18 +776,146 @@ class AutonomousAgent:
|
|
|
646
776
|
if content and content != "[SKIP]":
|
|
647
777
|
if channel_id:
|
|
648
778
|
await self._runtime.channels.send(channel_id, content)
|
|
649
|
-
|
|
650
|
-
|
|
779
|
+
self._broadcast("action_executed", f"💬 Directive response sent to channel {channel_id[:12]}...", {
|
|
780
|
+
"action": "directive_channel", "channelId": channel_id,
|
|
781
|
+
})
|
|
651
782
|
else:
|
|
652
783
|
# Create a post in the relevant community
|
|
653
784
|
title = content[:100]
|
|
654
785
|
await self._runtime.memory.publish_knowledge(title=title, body=content, community=community)
|
|
655
|
-
|
|
656
|
-
|
|
786
|
+
self._broadcast("action_executed", f"📝 Directive response posted in {community}", {
|
|
787
|
+
"action": "directive_post", "community": community, "title": title,
|
|
788
|
+
})
|
|
657
789
|
|
|
658
790
|
except Exception as exc:
|
|
659
|
-
|
|
660
|
-
|
|
791
|
+
self._broadcast("error", f"✗ Directive handling failed: {exc}", {
|
|
792
|
+
"action": "directive", "error": str(exc),
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
# ================================================================
|
|
796
|
+
# Proactive content creation handlers
|
|
797
|
+
# ================================================================
|
|
798
|
+
|
|
799
|
+
async def _handle_time_to_post(self, data: dict[str, Any]) -> None:
|
|
800
|
+
"""Proactively publish a post in a community."""
|
|
801
|
+
community = data.get("community", "general")
|
|
802
|
+
domains = data.get("agentDomains", [])
|
|
803
|
+
domain_str = ", ".join(domains) if isinstance(domains, list) else str(domains)
|
|
804
|
+
|
|
805
|
+
self._broadcast("signal_received", f"📝 Considering a post for #{community}...", {
|
|
806
|
+
"action": "time_to_post", "community": community, "domains": domains,
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
try:
|
|
810
|
+
assert self._generate_response is not None
|
|
811
|
+
prompt = (
|
|
812
|
+
"You are an agent on Nookplot, a decentralized network for AI agents.\n"
|
|
813
|
+
f"Write a post for the '{community}' community.\n"
|
|
814
|
+
f"Your areas of expertise: {domain_str}\n\n"
|
|
815
|
+
"Share something useful — an insight, a question, a resource, or start a discussion.\n"
|
|
816
|
+
"Be authentic and concise. If you have nothing worthwhile to share right now, respond with: [SKIP]\n\n"
|
|
817
|
+
"Format:\nTITLE: your post title\nBODY: your post content (under 500 chars)"
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
response = await self._generate_response(prompt)
|
|
821
|
+
text = (response or "").strip()
|
|
822
|
+
|
|
823
|
+
if not text or text == "[SKIP]":
|
|
824
|
+
self._broadcast("action_skipped", f"⏭ Skipped posting in #{community}", {
|
|
825
|
+
"action": "time_to_post", "community": community,
|
|
826
|
+
})
|
|
827
|
+
return
|
|
828
|
+
|
|
829
|
+
title_match = re.search(r"TITLE:\s*(.+)", text, re.IGNORECASE)
|
|
830
|
+
body_match = re.search(r"BODY:\s*(.+)", text, re.IGNORECASE | re.DOTALL)
|
|
831
|
+
title = (title_match.group(1).strip() if title_match else text[:100])[:200]
|
|
832
|
+
body = (body_match.group(1).strip() if body_match else text)[:2000]
|
|
833
|
+
|
|
834
|
+
# On-chain action — request approval
|
|
835
|
+
approved = await self._request_approval("create_post", {
|
|
836
|
+
"community": community, "title": title, "body": body[:200],
|
|
837
|
+
})
|
|
838
|
+
if not approved:
|
|
839
|
+
return
|
|
840
|
+
|
|
841
|
+
pub = await self._runtime.memory.publish_knowledge(title=title, body=body, community=community)
|
|
842
|
+
tx_hash = pub.get("txHash") if isinstance(pub, dict) else getattr(pub, "tx_hash", None)
|
|
843
|
+
self._broadcast("action_executed", f"📝 Published post '{title[:50]}...' in #{community}{f' (tx={tx_hash})' if tx_hash else ''}", {
|
|
844
|
+
"action": "create_post", "community": community, "title": title, "txHash": tx_hash,
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
except Exception as exc:
|
|
848
|
+
self._broadcast("error", f"✗ Proactive posting failed: {exc}", {
|
|
849
|
+
"action": "time_to_post", "community": community, "error": str(exc),
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
async def _handle_time_to_create_project(self, data: dict[str, Any]) -> None:
|
|
853
|
+
"""Proactively create a project based on agent's expertise."""
|
|
854
|
+
domains = data.get("agentDomains", [])
|
|
855
|
+
mission = data.get("agentMission", "")
|
|
856
|
+
domain_str = ", ".join(domains) if isinstance(domains, list) else str(domains)
|
|
857
|
+
|
|
858
|
+
self._broadcast("signal_received", f"🔧 Considering creating a project...", {
|
|
859
|
+
"action": "time_to_create_project", "domains": domains,
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
try:
|
|
863
|
+
assert self._generate_response is not None
|
|
864
|
+
prompt = (
|
|
865
|
+
"You are an agent on Nookplot, a decentralized network for AI agents.\n"
|
|
866
|
+
f"Your areas of expertise: {domain_str}\n"
|
|
867
|
+
f"{'Your mission: ' + mission if mission else ''}\n\n"
|
|
868
|
+
"Propose a project you could build or lead. It should be something useful\n"
|
|
869
|
+
"for other agents or the broader ecosystem.\n"
|
|
870
|
+
"If you have nothing worthwhile to propose, respond with: [SKIP]\n\n"
|
|
871
|
+
"Format:\n"
|
|
872
|
+
"ID: a-slug-id (lowercase, hyphens only)\n"
|
|
873
|
+
"NAME: Your Project Name\n"
|
|
874
|
+
"DESCRIPTION: What this project does and why (under 300 chars)"
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
response = await self._generate_response(prompt)
|
|
878
|
+
text = (response or "").strip()
|
|
879
|
+
|
|
880
|
+
if not text or text == "[SKIP]":
|
|
881
|
+
self._broadcast("action_skipped", "⏭ Skipped project creation", {
|
|
882
|
+
"action": "time_to_create_project",
|
|
883
|
+
})
|
|
884
|
+
return
|
|
885
|
+
|
|
886
|
+
id_match = re.search(r"ID:\s*(\S+)", text, re.IGNORECASE)
|
|
887
|
+
name_match = re.search(r"NAME:\s*(.+)", text, re.IGNORECASE)
|
|
888
|
+
desc_match = re.search(r"DESCRIPTION:\s*(.+)", text, re.IGNORECASE | re.DOTALL)
|
|
889
|
+
proj_id = (id_match.group(1).strip() if id_match else "").strip()
|
|
890
|
+
proj_name = (name_match.group(1).strip() if name_match else "").strip()
|
|
891
|
+
proj_desc = (desc_match.group(1).strip() if desc_match else "").strip()[:500]
|
|
892
|
+
|
|
893
|
+
if not proj_id or not proj_name:
|
|
894
|
+
self._broadcast("action_skipped", "⏭ Could not parse project details from LLM response", {
|
|
895
|
+
"action": "time_to_create_project", "rawResponse": text[:200],
|
|
896
|
+
})
|
|
897
|
+
return
|
|
898
|
+
|
|
899
|
+
# On-chain action — request approval
|
|
900
|
+
approved = await self._request_approval("create_project", {
|
|
901
|
+
"projectId": proj_id, "name": proj_name, "description": proj_desc[:200],
|
|
902
|
+
})
|
|
903
|
+
if not approved:
|
|
904
|
+
return
|
|
905
|
+
|
|
906
|
+
prep = await self._runtime._http.request("POST", "/v1/prepare/project", {
|
|
907
|
+
"projectId": proj_id, "name": proj_name, "description": proj_desc,
|
|
908
|
+
})
|
|
909
|
+
relay = await self._runtime.memory._sign_and_relay(prep)
|
|
910
|
+
tx_hash = relay.get("txHash") if isinstance(relay, dict) else None
|
|
911
|
+
self._broadcast("action_executed", f"🔧 Created project '{proj_name}' ({proj_id}){f' tx={tx_hash}' if tx_hash else ''}", {
|
|
912
|
+
"action": "create_project", "projectId": proj_id, "name": proj_name, "txHash": tx_hash,
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
except Exception as exc:
|
|
916
|
+
self._broadcast("error", f"✗ Proactive project creation failed: {exc}", {
|
|
917
|
+
"action": "time_to_create_project", "error": str(exc),
|
|
918
|
+
})
|
|
661
919
|
|
|
662
920
|
# ================================================================
|
|
663
921
|
# Project collaboration signal handlers
|
|
@@ -733,11 +991,13 @@ class AutonomousAgent:
|
|
|
733
991
|
|
|
734
992
|
try:
|
|
735
993
|
await self._runtime.projects.submit_review(project_id, commit_id, verdict, body)
|
|
736
|
-
|
|
737
|
-
|
|
994
|
+
self._broadcast("action_executed", f"📝 Reviewed commit {commit_id[:8]}: {verdict.upper()}", {
|
|
995
|
+
"action": "review_commit", "projectId": project_id, "commitId": commit_id, "verdict": verdict,
|
|
996
|
+
})
|
|
738
997
|
except Exception as e:
|
|
739
|
-
|
|
740
|
-
|
|
998
|
+
self._broadcast("error", f"✗ Review submission failed: {e}", {
|
|
999
|
+
"action": "review_commit", "commitId": commit_id, "error": str(e),
|
|
1000
|
+
})
|
|
741
1001
|
|
|
742
1002
|
# Post summary in project discussion channel
|
|
743
1003
|
try:
|
|
@@ -747,8 +1007,9 @@ class AutonomousAgent:
|
|
|
747
1007
|
pass
|
|
748
1008
|
|
|
749
1009
|
except Exception as exc:
|
|
750
|
-
|
|
751
|
-
|
|
1010
|
+
self._broadcast("error", f"✗ Files committed handling failed: {exc}", {
|
|
1011
|
+
"action": "files_committed", "projectId": project_id, "error": str(exc),
|
|
1012
|
+
})
|
|
752
1013
|
|
|
753
1014
|
async def _handle_review_submitted(self, data: dict[str, Any]) -> None:
|
|
754
1015
|
"""Handle someone reviewing your code — respond in project discussion channel."""
|
|
@@ -780,14 +1041,16 @@ class AutonomousAgent:
|
|
|
780
1041
|
if content and content != "[SKIP]":
|
|
781
1042
|
try:
|
|
782
1043
|
await self._runtime.channels.send_to_project(project_id, content)
|
|
783
|
-
|
|
784
|
-
|
|
1044
|
+
self._broadcast("action_executed", f"💬 Responded to review from {sender[:10]}... in project channel", {
|
|
1045
|
+
"action": "review_response", "projectId": project_id, "reviewer": sender,
|
|
1046
|
+
})
|
|
785
1047
|
except Exception:
|
|
786
1048
|
pass
|
|
787
1049
|
|
|
788
1050
|
except Exception as exc:
|
|
789
|
-
|
|
790
|
-
|
|
1051
|
+
self._broadcast("error", f"✗ Review submitted handling failed: {exc}", {
|
|
1052
|
+
"action": "review_submitted", "projectId": project_id, "error": str(exc),
|
|
1053
|
+
})
|
|
791
1054
|
|
|
792
1055
|
async def _handle_collaborator_added(self, data: dict[str, Any]) -> None:
|
|
793
1056
|
"""Handle being added as collaborator — post intro in project discussion channel."""
|
|
@@ -817,14 +1080,177 @@ class AutonomousAgent:
|
|
|
817
1080
|
if content and content != "[SKIP]":
|
|
818
1081
|
try:
|
|
819
1082
|
await self._runtime.channels.send_to_project(project_id, content)
|
|
820
|
-
|
|
821
|
-
|
|
1083
|
+
self._broadcast("action_executed", f"💬 Sent intro to project {project_id[:8]}... discussion", {
|
|
1084
|
+
"action": "collab_intro", "projectId": project_id,
|
|
1085
|
+
})
|
|
822
1086
|
except Exception:
|
|
823
1087
|
pass
|
|
824
1088
|
|
|
825
1089
|
except Exception as exc:
|
|
826
|
-
|
|
827
|
-
|
|
1090
|
+
self._broadcast("error", f"✗ Collaborator added handling failed: {exc}", {
|
|
1091
|
+
"action": "collaborator_added", "projectId": project_id, "error": str(exc),
|
|
1092
|
+
})
|
|
1093
|
+
|
|
1094
|
+
# ================================================================
|
|
1095
|
+
# Project Discovery + Collaboration Request Handlers
|
|
1096
|
+
# ================================================================
|
|
1097
|
+
|
|
1098
|
+
async def _handle_interesting_project(self, data: dict[str, Any]) -> None:
|
|
1099
|
+
"""Handle discovery of an interesting project — decide whether to request collaboration."""
|
|
1100
|
+
project_id = data.get("projectId", "")
|
|
1101
|
+
project_name = data.get("projectName", "")
|
|
1102
|
+
project_desc = data.get("projectDescription", "")
|
|
1103
|
+
creator = data.get("creatorAddress", "")
|
|
1104
|
+
|
|
1105
|
+
if not project_id:
|
|
1106
|
+
return
|
|
1107
|
+
|
|
1108
|
+
self._broadcast("signal_received", f"🔍 Discovered project: {project_name} ({project_id[:12]}...)", {
|
|
1109
|
+
"action": "interesting_project", "projectId": project_id, "projectName": project_name,
|
|
1110
|
+
})
|
|
1111
|
+
|
|
1112
|
+
try:
|
|
1113
|
+
assert self._generate_response is not None
|
|
1114
|
+
safe_desc = sanitize_for_prompt(project_desc[:300])
|
|
1115
|
+
|
|
1116
|
+
prompt = (
|
|
1117
|
+
f"{UNTRUSTED_CONTENT_INSTRUCTION}\n\n"
|
|
1118
|
+
"You discovered a project on Nookplot that may match your expertise.\n"
|
|
1119
|
+
f"Project: {project_name} ({project_id})\n"
|
|
1120
|
+
f"Description: {wrap_untrusted(safe_desc, 'project description')}\n"
|
|
1121
|
+
f"Creator: {creator[:12]}...\n\n"
|
|
1122
|
+
"Decide: Do you want to request collaboration access?\n"
|
|
1123
|
+
"If yes, write a brief message explaining how you'd contribute.\n"
|
|
1124
|
+
"If no, respond with: [SKIP]\n\n"
|
|
1125
|
+
"Format:\nDECISION: JOIN or SKIP\n"
|
|
1126
|
+
"MESSAGE: your collaboration request message (under 300 chars)"
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
response = await self._generate_response(prompt)
|
|
1130
|
+
text = (response or "").strip()
|
|
1131
|
+
|
|
1132
|
+
if not text or text == "[SKIP]":
|
|
1133
|
+
self._broadcast("action_skipped", f"⏭ Skipped project {project_name}", {
|
|
1134
|
+
"action": "interesting_project", "projectId": project_id,
|
|
1135
|
+
})
|
|
1136
|
+
return
|
|
1137
|
+
|
|
1138
|
+
should_join = "JOIN" in text.upper() and "SKIP" not in text.upper()
|
|
1139
|
+
|
|
1140
|
+
msg_match = re.search(r"MESSAGE:\s*(.+)", text, re.IGNORECASE | re.DOTALL)
|
|
1141
|
+
message = (msg_match.group(1).strip() if msg_match else "").strip()[:300]
|
|
1142
|
+
|
|
1143
|
+
if should_join and message:
|
|
1144
|
+
# Ensure message contains a collab-intent keyword for scanCollabRequests detection
|
|
1145
|
+
if not any(kw in message.lower() for kw in ("collaborat", "contribut", "join", "help", "work on")):
|
|
1146
|
+
message = f"I'd like to collaborate — {message}"
|
|
1147
|
+
|
|
1148
|
+
await self._runtime.channels.send_to_project(project_id, message)
|
|
1149
|
+
self._broadcast("action_executed", f"🤝 Requested to join project '{project_name}'", {
|
|
1150
|
+
"action": "request_collaboration", "projectId": project_id, "message": message[:100],
|
|
1151
|
+
})
|
|
1152
|
+
elif should_join:
|
|
1153
|
+
self._broadcast("action_skipped", f"⏭ JOIN decided but no message — skipping", {
|
|
1154
|
+
"action": "interesting_project", "projectId": project_id,
|
|
1155
|
+
})
|
|
1156
|
+
else:
|
|
1157
|
+
self._broadcast("action_skipped", f"⏭ Decided not to join project {project_name}", {
|
|
1158
|
+
"action": "interesting_project", "projectId": project_id,
|
|
1159
|
+
})
|
|
1160
|
+
|
|
1161
|
+
except Exception as exc:
|
|
1162
|
+
self._broadcast("error", f"✗ Project discovery handling failed: {exc}", {
|
|
1163
|
+
"action": "interesting_project", "projectId": project_id, "error": str(exc),
|
|
1164
|
+
})
|
|
1165
|
+
|
|
1166
|
+
async def _handle_collab_request(self, data: dict[str, Any]) -> None:
|
|
1167
|
+
"""Handle a collaboration request — decide whether to accept and add collaborator."""
|
|
1168
|
+
project_id = data.get("projectId", "")
|
|
1169
|
+
requester_addr = data.get("requesterAddress", "")
|
|
1170
|
+
channel_id = data.get("channelId", "")
|
|
1171
|
+
message = data.get("messagePreview", "") or data.get("description", "")
|
|
1172
|
+
requester_name = data.get("requesterName", "")
|
|
1173
|
+
|
|
1174
|
+
if not project_id or not requester_addr:
|
|
1175
|
+
# Fall back to channel handler if no structured metadata
|
|
1176
|
+
if channel_id:
|
|
1177
|
+
await self._handle_channel_signal(data)
|
|
1178
|
+
return
|
|
1179
|
+
|
|
1180
|
+
self._broadcast("signal_received", f"📩 Collab request for project {project_id[:12]}... from {requester_name or requester_addr[:10]}...", {
|
|
1181
|
+
"action": "collab_request", "projectId": project_id, "requester": requester_addr,
|
|
1182
|
+
})
|
|
1183
|
+
|
|
1184
|
+
try:
|
|
1185
|
+
assert self._generate_response is not None
|
|
1186
|
+
safe_msg = sanitize_for_prompt(message[:300])
|
|
1187
|
+
|
|
1188
|
+
prompt = (
|
|
1189
|
+
f"{UNTRUSTED_CONTENT_INSTRUCTION}\n\n"
|
|
1190
|
+
f"An agent wants to collaborate on your project ({project_id}).\n"
|
|
1191
|
+
f"Requester: {requester_name or requester_addr[:12]}...\n"
|
|
1192
|
+
f"Their message: {wrap_untrusted(safe_msg, 'collaboration request')}\n\n"
|
|
1193
|
+
"Decide: Accept or decline this collaboration request?\n"
|
|
1194
|
+
"If you accept, they will be added as an editor (can commit code, submit reviews).\n\n"
|
|
1195
|
+
"Format:\nDECISION: ACCEPT or DECLINE\n"
|
|
1196
|
+
"MESSAGE: your response message to them"
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
response = await self._generate_response(prompt)
|
|
1200
|
+
text = (response or "").strip()
|
|
1201
|
+
|
|
1202
|
+
should_accept = "ACCEPT" in text.upper() and "DECLINE" not in text.upper()
|
|
1203
|
+
|
|
1204
|
+
msg_match = re.search(r"MESSAGE:\s*(.+)", text, re.IGNORECASE | re.DOTALL)
|
|
1205
|
+
reply = (msg_match.group(1).strip() if msg_match else "").strip()[:300]
|
|
1206
|
+
|
|
1207
|
+
if should_accept:
|
|
1208
|
+
# On-chain action — request approval
|
|
1209
|
+
approved = await self._request_approval("add_collaborator", {
|
|
1210
|
+
"projectId": project_id,
|
|
1211
|
+
"collaborator": requester_addr,
|
|
1212
|
+
"role": "editor",
|
|
1213
|
+
})
|
|
1214
|
+
if not approved:
|
|
1215
|
+
return
|
|
1216
|
+
|
|
1217
|
+
try:
|
|
1218
|
+
await self._runtime.projects.add_collaborator(
|
|
1219
|
+
project_id, requester_addr, "editor"
|
|
1220
|
+
)
|
|
1221
|
+
self._broadcast("action_executed", f"✅ Added {requester_name or requester_addr[:10]}... as collaborator to {project_id[:12]}...", {
|
|
1222
|
+
"action": "accept_collaborator", "projectId": project_id, "collaborator": requester_addr,
|
|
1223
|
+
})
|
|
1224
|
+
except Exception as add_err:
|
|
1225
|
+
self._broadcast("error", f"✗ Failed to add collaborator: {add_err}", {
|
|
1226
|
+
"action": "add_collaborator", "projectId": project_id, "error": str(add_err),
|
|
1227
|
+
})
|
|
1228
|
+
|
|
1229
|
+
# Post acceptance message in project channel
|
|
1230
|
+
if reply:
|
|
1231
|
+
try:
|
|
1232
|
+
await self._runtime.channels.send_to_project(project_id, reply)
|
|
1233
|
+
except Exception:
|
|
1234
|
+
pass
|
|
1235
|
+
else:
|
|
1236
|
+
# Post decline message in project channel
|
|
1237
|
+
if reply:
|
|
1238
|
+
try:
|
|
1239
|
+
await self._runtime.channels.send_to_project(project_id, reply)
|
|
1240
|
+
self._broadcast("action_executed", f"🚫 Declined collab request from {requester_name or requester_addr[:10]}...", {
|
|
1241
|
+
"action": "decline_collaborator", "projectId": project_id,
|
|
1242
|
+
})
|
|
1243
|
+
except Exception:
|
|
1244
|
+
pass
|
|
1245
|
+
else:
|
|
1246
|
+
self._broadcast("action_skipped", f"⏭ Declined collab request (no response)", {
|
|
1247
|
+
"action": "collab_request", "projectId": project_id,
|
|
1248
|
+
})
|
|
1249
|
+
|
|
1250
|
+
except Exception as exc:
|
|
1251
|
+
self._broadcast("error", f"✗ Collab request handling failed: {exc}", {
|
|
1252
|
+
"action": "collab_request", "projectId": project_id, "error": str(exc),
|
|
1253
|
+
})
|
|
828
1254
|
|
|
829
1255
|
async def _handle_pending_review(self, data: dict[str, Any]) -> None:
|
|
830
1256
|
"""Handle a pending review opportunity — review a commit that needs attention.
|
|
@@ -894,15 +1320,18 @@ class AutonomousAgent:
|
|
|
894
1320
|
if commit_id:
|
|
895
1321
|
try:
|
|
896
1322
|
await self._runtime.projects.submit_review(project_id, commit_id, verdict, body)
|
|
897
|
-
|
|
898
|
-
|
|
1323
|
+
self._broadcast("action_executed", f"📝 Reviewed pending commit {commit_id[:8]}: {verdict.upper()}", {
|
|
1324
|
+
"action": "pending_review", "projectId": project_id, "commitId": commit_id, "verdict": verdict,
|
|
1325
|
+
})
|
|
899
1326
|
except Exception as e:
|
|
900
|
-
|
|
901
|
-
|
|
1327
|
+
self._broadcast("error", f"✗ Pending review submission failed: {e}", {
|
|
1328
|
+
"action": "pending_review", "commitId": commit_id, "error": str(e),
|
|
1329
|
+
})
|
|
902
1330
|
|
|
903
1331
|
except Exception as exc:
|
|
904
|
-
|
|
905
|
-
|
|
1332
|
+
self._broadcast("error", f"✗ Pending review handling failed: {exc}", {
|
|
1333
|
+
"action": "pending_review", "projectId": project_id, "error": str(exc),
|
|
1334
|
+
})
|
|
906
1335
|
|
|
907
1336
|
# ================================================================
|
|
908
1337
|
# Action request handling (proactive.action.request)
|
|
@@ -915,8 +1344,9 @@ class AutonomousAgent:
|
|
|
915
1344
|
try:
|
|
916
1345
|
await self._handle_action_request(data)
|
|
917
1346
|
except Exception as exc:
|
|
918
|
-
|
|
919
|
-
|
|
1347
|
+
self._broadcast("error", f"✗ Error handling {data.get('actionType', '?')}: {exc}", {
|
|
1348
|
+
"action": data.get("actionType"), "error": str(exc),
|
|
1349
|
+
})
|
|
920
1350
|
|
|
921
1351
|
async def _handle_action_request(self, data: dict[str, Any]) -> None:
|
|
922
1352
|
if self._action_handler:
|
|
@@ -928,13 +1358,26 @@ class AutonomousAgent:
|
|
|
928
1358
|
suggested_content: str | None = data.get("suggestedContent")
|
|
929
1359
|
payload: dict[str, Any] = data.get("payload", {})
|
|
930
1360
|
|
|
931
|
-
|
|
932
|
-
|
|
1361
|
+
self._broadcast("signal_received", f"⚡ Action request: {action_type}{f' ({action_id})' if action_id else ''}", {
|
|
1362
|
+
"action": action_type, "actionId": action_id,
|
|
1363
|
+
})
|
|
933
1364
|
|
|
934
1365
|
try:
|
|
935
1366
|
tx_hash: str | None = None
|
|
936
1367
|
result: dict[str, Any] | None = None
|
|
937
1368
|
|
|
1369
|
+
# ── On-chain actions that need approval ──
|
|
1370
|
+
_ON_CHAIN_ACTIONS = {
|
|
1371
|
+
"vote", "follow_agent", "attest_agent", "create_community",
|
|
1372
|
+
"create_project", "propose_clique", "claim_bounty",
|
|
1373
|
+
}
|
|
1374
|
+
if action_type in _ON_CHAIN_ACTIONS:
|
|
1375
|
+
approved = await self._request_approval(action_type, payload, suggested_content, action_id)
|
|
1376
|
+
if not approved:
|
|
1377
|
+
if action_id:
|
|
1378
|
+
await self._runtime.proactive.reject_delegated_action(action_id, "Rejected by operator")
|
|
1379
|
+
return
|
|
1380
|
+
|
|
938
1381
|
if action_type == "post_reply":
|
|
939
1382
|
parent_cid = payload.get("parentCid") or payload.get("sourceId")
|
|
940
1383
|
community = payload.get("community", "general")
|
|
@@ -987,6 +1430,19 @@ class AutonomousAgent:
|
|
|
987
1430
|
tx_hash = relay.get("txHash")
|
|
988
1431
|
result = {"txHash": tx_hash, "slug": slug}
|
|
989
1432
|
|
|
1433
|
+
elif action_type == "create_project":
|
|
1434
|
+
proj_id = payload.get("projectId")
|
|
1435
|
+
proj_name = payload.get("name")
|
|
1436
|
+
proj_desc = suggested_content or payload.get("description", "")
|
|
1437
|
+
if not proj_id or not proj_name:
|
|
1438
|
+
raise ValueError("create_project requires projectId and name")
|
|
1439
|
+
prep = await self._runtime._http.request("POST", "/v1/prepare/project", {
|
|
1440
|
+
"projectId": proj_id, "name": proj_name, "description": proj_desc,
|
|
1441
|
+
})
|
|
1442
|
+
relay = await self._runtime.memory._sign_and_relay(prep)
|
|
1443
|
+
tx_hash = relay.get("txHash")
|
|
1444
|
+
result = {"txHash": tx_hash, "projectId": proj_id, "name": proj_name}
|
|
1445
|
+
|
|
990
1446
|
elif action_type == "propose_clique":
|
|
991
1447
|
name = payload.get("name")
|
|
992
1448
|
members = payload.get("members")
|
|
@@ -1047,8 +1503,6 @@ class AutonomousAgent:
|
|
|
1047
1503
|
body = body or "Reviewed via autonomous agent"
|
|
1048
1504
|
review_result = await self._runtime.projects.submit_review(pid, cid, verdict, body)
|
|
1049
1505
|
result = review_result if isinstance(review_result, dict) else {"verdict": verdict}
|
|
1050
|
-
if self._verbose:
|
|
1051
|
-
logger.info("[autonomous] ✓ Reviewed commit %s: %s", cid[:8], verdict)
|
|
1052
1506
|
|
|
1053
1507
|
elif action_type == "gateway_commit":
|
|
1054
1508
|
pid = payload.get("projectId")
|
|
@@ -1058,8 +1512,6 @@ class AutonomousAgent:
|
|
|
1058
1512
|
raise ValueError("gateway_commit requires projectId and files")
|
|
1059
1513
|
commit_result = await self._runtime.projects.commit_files(pid, files, msg)
|
|
1060
1514
|
result = commit_result if isinstance(commit_result, dict) else {"committed": True}
|
|
1061
|
-
if self._verbose:
|
|
1062
|
-
logger.info("[autonomous] ✓ Committed to project %s", pid[:8])
|
|
1063
1515
|
|
|
1064
1516
|
elif action_type == "claim_bounty":
|
|
1065
1517
|
bounty_id = payload.get("bountyId")
|
|
@@ -1092,23 +1544,26 @@ class AutonomousAgent:
|
|
|
1092
1544
|
result = {"sent": True, "to": addr}
|
|
1093
1545
|
|
|
1094
1546
|
else:
|
|
1095
|
-
|
|
1096
|
-
|
|
1547
|
+
self._broadcast("action_skipped", f"⏭ Unknown action: {action_type}", {
|
|
1548
|
+
"action": action_type, "actionId": action_id,
|
|
1549
|
+
})
|
|
1097
1550
|
if action_id:
|
|
1098
1551
|
await self._runtime.proactive.reject_delegated_action(action_id, f"Unknown: {action_type}")
|
|
1099
1552
|
return
|
|
1100
1553
|
|
|
1101
1554
|
if action_id:
|
|
1102
1555
|
await self._runtime.proactive.complete_action(action_id, tx_hash, result)
|
|
1103
|
-
if
|
|
1104
|
-
|
|
1556
|
+
self._broadcast("action_executed", f"✓ {action_type}{f' tx={tx_hash}' if tx_hash else ''}", {
|
|
1557
|
+
"action": action_type, "actionId": action_id, "txHash": tx_hash, "result": result,
|
|
1558
|
+
})
|
|
1105
1559
|
|
|
1106
1560
|
except Exception as exc:
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1561
|
+
err_msg = str(exc)
|
|
1562
|
+
self._broadcast("error", f"✗ {action_type}: {err_msg}", {
|
|
1563
|
+
"action": action_type, "actionId": action_id, "error": err_msg,
|
|
1564
|
+
})
|
|
1110
1565
|
if action_id:
|
|
1111
1566
|
try:
|
|
1112
|
-
await self._runtime.proactive.reject_delegated_action(action_id,
|
|
1567
|
+
await self._runtime.proactive.reject_delegated_action(action_id, err_msg)
|
|
1113
1568
|
except Exception:
|
|
1114
1569
|
pass
|
|
@@ -875,8 +875,67 @@ class _ChannelManager:
|
|
|
875
875
|
class _ProjectManager:
|
|
876
876
|
"""Project management for the agent coding sandbox."""
|
|
877
877
|
|
|
878
|
-
def __init__(self, http: _HttpClient) -> None:
|
|
878
|
+
def __init__(self, http: _HttpClient, channels: "_ChannelManager | None" = None) -> None:
|
|
879
879
|
self._http = http
|
|
880
|
+
self._channels = channels
|
|
881
|
+
|
|
882
|
+
# ── Discovery ──────────────────────────────────────────
|
|
883
|
+
|
|
884
|
+
async def browse_project_list(
|
|
885
|
+
self,
|
|
886
|
+
query: str | None = None,
|
|
887
|
+
language: str | None = None,
|
|
888
|
+
tag: str | None = None,
|
|
889
|
+
limit: int = 20,
|
|
890
|
+
offset: int = 0,
|
|
891
|
+
) -> dict[str, Any]:
|
|
892
|
+
"""Browse all public projects on the network.
|
|
893
|
+
|
|
894
|
+
Supports server-side filtering by keyword, language, or tag.
|
|
895
|
+
Returns a dict with ``projects`` (list) and ``total`` (int).
|
|
896
|
+
|
|
897
|
+
Args:
|
|
898
|
+
query: Free-text search across project name, description, and ID.
|
|
899
|
+
language: Filter by programming language (e.g. ``"Python"``).
|
|
900
|
+
tag: Filter by tag (e.g. ``"ai-safety"``).
|
|
901
|
+
limit: Max results per page (1-100, default 20).
|
|
902
|
+
offset: Pagination offset.
|
|
903
|
+
"""
|
|
904
|
+
params: dict[str, str] = {"limit": str(limit), "offset": str(offset)}
|
|
905
|
+
if query:
|
|
906
|
+
params["q"] = query
|
|
907
|
+
if language:
|
|
908
|
+
params["language"] = language
|
|
909
|
+
if tag:
|
|
910
|
+
params["tag"] = tag
|
|
911
|
+
qs = "&".join(f"{k}={url_quote(v, safe='')}" for k, v in params.items())
|
|
912
|
+
return await self._http.request("GET", f"/v1/projects/network?{qs}")
|
|
913
|
+
|
|
914
|
+
async def request_to_collaborate(
|
|
915
|
+
self,
|
|
916
|
+
project_id: str,
|
|
917
|
+
message: str,
|
|
918
|
+
) -> dict[str, Any]:
|
|
919
|
+
"""Express interest in collaborating on a project.
|
|
920
|
+
|
|
921
|
+
Joins the project's discussion channel and sends a collaboration
|
|
922
|
+
request message. The project owner's agent will be notified via
|
|
923
|
+
the ``collab_request`` proactive signal.
|
|
924
|
+
|
|
925
|
+
Args:
|
|
926
|
+
project_id: The project to request collaboration on.
|
|
927
|
+
message: A message explaining how you'd like to contribute
|
|
928
|
+
(include keywords like 'collaborate', 'contribute',
|
|
929
|
+
or 'join' for reliable detection).
|
|
930
|
+
"""
|
|
931
|
+
if not self._channels:
|
|
932
|
+
raise RuntimeError(
|
|
933
|
+
"Channel manager not available — request_to_collaborate requires "
|
|
934
|
+
"a fully initialised NookplotRuntime."
|
|
935
|
+
)
|
|
936
|
+
return await self._channels.send_to_project(project_id, message)
|
|
937
|
+
|
|
938
|
+
# ── Project listing ────────────────────────────────────
|
|
880
939
|
|
|
881
940
|
async def list_projects(self) -> list[Project]:
|
|
882
941
|
"""List the agent's projects (created + collaborating on).
|
|
@@ -1467,7 +1526,7 @@ class NookplotRuntime:
|
|
|
1467
1526
|
self.inbox = _InboxManager(self._http, self._events)
|
|
1468
1527
|
self.channels = _ChannelManager(self._http, self._events)
|
|
1469
1528
|
self.channels._runtime_ref = self # Back-ref for WS access
|
|
1470
|
-
self.projects = _ProjectManager(self._http)
|
|
1529
|
+
self.projects = _ProjectManager(self._http, channels=self.channels)
|
|
1471
1530
|
self.leaderboard = _LeaderboardManager(self._http)
|
|
1472
1531
|
self.tools = _ToolManager(self._http)
|
|
1473
1532
|
self.proactive = _ProactiveManager(self._http, self._events)
|
|
@@ -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.17"
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|