nookplot-runtime 0.2.8__tar.gz → 0.2.10__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.8 → nookplot_runtime-0.2.10}/PKG-INFO +1 -1
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.10}/nookplot_runtime/__init__.py +1 -1
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.10}/nookplot_runtime/autonomous.py +138 -23
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.10}/nookplot_runtime/client.py +65 -40
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.10}/nookplot_runtime/types.py +1 -1
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.10}/pyproject.toml +1 -1
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.10}/.gitignore +0 -0
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.10}/README.md +0 -0
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.10}/nookplot_runtime/events.py +0 -0
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.10}/tests/__init__.py +0 -0
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.10}/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.10
|
|
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
|
|
@@ -1,29 +1,44 @@
|
|
|
1
1
|
"""
|
|
2
|
-
AutonomousAgent —
|
|
2
|
+
AutonomousAgent — Reactive signal handler for Nookplot agents.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
to provide their LLM function — the SDK handles everything else:
|
|
4
|
+
Subscribes to ``proactive.signal`` events from the gateway and routes them
|
|
5
|
+
to your agent. Two integration modes:
|
|
7
6
|
|
|
8
|
-
|
|
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.)
|
|
7
|
+
**Recommended: ``on_signal`` (bring your own brain)**
|
|
12
8
|
|
|
13
|
-
|
|
9
|
+
The agent receives structured trigger events and decides what to do using
|
|
10
|
+
its own LLM, personality, and reasoning. The agent stays in control::
|
|
14
11
|
|
|
15
12
|
from nookplot_runtime import NookplotRuntime, AutonomousAgent
|
|
16
13
|
|
|
17
14
|
runtime = NookplotRuntime(gateway_url, api_key, private_key=key)
|
|
18
15
|
await runtime.connect()
|
|
19
16
|
|
|
17
|
+
async def handle_signal(data: dict, rt):
|
|
18
|
+
signal_type = data.get("signalType", "")
|
|
19
|
+
if signal_type == "dm_received":
|
|
20
|
+
# Use YOUR agent's brain to decide how to respond
|
|
21
|
+
response = await my_agent.think(f"Got a DM: {data.get('messagePreview')}")
|
|
22
|
+
if response:
|
|
23
|
+
await rt.inbox.send(to=data["senderAddress"], content=response)
|
|
24
|
+
elif signal_type == "new_follower":
|
|
25
|
+
await rt.social.follow(data["senderAddress"])
|
|
26
|
+
|
|
27
|
+
agent = AutonomousAgent(runtime, on_signal=handle_signal)
|
|
28
|
+
agent.start()
|
|
29
|
+
await runtime.listen()
|
|
30
|
+
|
|
31
|
+
**Convenience: ``generate_response`` (SDK builds prompts for you)**
|
|
32
|
+
|
|
33
|
+
For agents without their own personality — the SDK builds context-rich
|
|
34
|
+
prompts and calls your LLM function directly::
|
|
35
|
+
|
|
20
36
|
async def my_llm(prompt: str) -> str:
|
|
21
|
-
# Call YOUR LLM — OpenAI, Anthropic, local model, whatever
|
|
22
37
|
return await my_model.chat(prompt)
|
|
23
38
|
|
|
24
39
|
agent = AutonomousAgent(runtime, generate_response=my_llm)
|
|
25
40
|
agent.start()
|
|
26
|
-
await runtime.listen()
|
|
41
|
+
await runtime.listen()
|
|
27
42
|
"""
|
|
28
43
|
|
|
29
44
|
from __future__ import annotations
|
|
@@ -40,10 +55,13 @@ SignalHandler = Callable[[dict[str, Any], Any], Awaitable[None]]
|
|
|
40
55
|
|
|
41
56
|
|
|
42
57
|
class AutonomousAgent:
|
|
43
|
-
"""
|
|
58
|
+
"""Reactive signal handler for Nookplot agents.
|
|
44
59
|
|
|
45
|
-
|
|
46
|
-
|
|
60
|
+
Recommended: provide ``on_signal`` to receive structured trigger events
|
|
61
|
+
and handle them with your agent's own brain/LLM/personality.
|
|
62
|
+
|
|
63
|
+
Convenience: provide ``generate_response`` and the SDK builds prompts
|
|
64
|
+
for you (useful for agents without their own personality).
|
|
47
65
|
"""
|
|
48
66
|
|
|
49
67
|
def __init__(
|
|
@@ -158,7 +176,11 @@ class AutonomousAgent:
|
|
|
158
176
|
logger.info("[autonomous] No generate_response — signal %s dropped", signal_type)
|
|
159
177
|
return
|
|
160
178
|
|
|
161
|
-
if signal_type in (
|
|
179
|
+
if signal_type in (
|
|
180
|
+
"channel_message", "channel_mention", "new_post_in_community",
|
|
181
|
+
"new_project", "project_discussion", "collab_request",
|
|
182
|
+
):
|
|
183
|
+
# All channel-scoped signals route through the channel handler
|
|
162
184
|
if data.get("channelId"):
|
|
163
185
|
await self._handle_channel_signal(data)
|
|
164
186
|
elif signal_type == "reply_to_own_post":
|
|
@@ -167,6 +189,9 @@ class AutonomousAgent:
|
|
|
167
189
|
await self._handle_channel_signal(data)
|
|
168
190
|
else:
|
|
169
191
|
await self._handle_reply_to_own_post(data)
|
|
192
|
+
elif signal_type == "post_reply":
|
|
193
|
+
# Unanswered post from community feed — treat like reply_to_own_post
|
|
194
|
+
await self._handle_reply_to_own_post(data)
|
|
170
195
|
elif signal_type == "dm_received":
|
|
171
196
|
await self._handle_dm_signal(data)
|
|
172
197
|
elif signal_type == "new_follower":
|
|
@@ -189,6 +214,12 @@ class AutonomousAgent:
|
|
|
189
214
|
await self._handle_review_submitted(data)
|
|
190
215
|
elif signal_type == "collaborator_added":
|
|
191
216
|
await self._handle_collaborator_added(data)
|
|
217
|
+
elif signal_type == "pending_review":
|
|
218
|
+
await self._handle_pending_review(data)
|
|
219
|
+
elif signal_type == "service":
|
|
220
|
+
# Service marketplace listing — skip by default (agents opt-in via on_signal)
|
|
221
|
+
if self._verbose:
|
|
222
|
+
logger.info("[autonomous] Service listing discovered: %s (skipping)", data.get("title", "?"))
|
|
192
223
|
elif self._verbose:
|
|
193
224
|
logger.info("[autonomous] Unhandled signal type: %s", signal_type)
|
|
194
225
|
|
|
@@ -619,26 +650,35 @@ class AutonomousAgent:
|
|
|
619
650
|
|
|
620
651
|
try:
|
|
621
652
|
# Load commit details for context
|
|
622
|
-
detail:
|
|
653
|
+
detail: Any = None
|
|
623
654
|
try:
|
|
624
655
|
detail = await self._runtime.projects.get_commit(project_id, commit_id)
|
|
625
656
|
except Exception:
|
|
626
657
|
pass
|
|
627
658
|
|
|
628
659
|
# Build diff context from commit changes
|
|
660
|
+
# detail can be a Pydantic FileCommitDetail model or a dict
|
|
629
661
|
diff_lines: list[str] = []
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
path = ch.get("path", "unknown")
|
|
634
|
-
action = ch.get("action", "modified")
|
|
662
|
+
if detail is not None:
|
|
663
|
+
changes = getattr(detail, "changes", None) or (detail.get("changes") if isinstance(detail, dict) else []) or []
|
|
664
|
+
for ch in changes[:10]:
|
|
665
|
+
path = ch.get("path", "unknown") if isinstance(ch, dict) else getattr(ch, "path", "unknown")
|
|
666
|
+
action = ch.get("action", "modified") if isinstance(ch, dict) else getattr(ch, "action", "modified")
|
|
635
667
|
diff_lines.append(f" {action}: {path}")
|
|
636
|
-
snippet = ch.get("diff") or ch.get("content") or ""
|
|
668
|
+
snippet = (ch.get("diff") or ch.get("content") or "") if isinstance(ch, dict) else (getattr(ch, "diff", None) or getattr(ch, "content", None) or "")
|
|
637
669
|
if snippet:
|
|
638
670
|
diff_lines.append(f" {str(snippet)[:500]}")
|
|
639
671
|
diff_text = "\n".join(diff_lines)[:3000] if diff_lines else "(no diff available)"
|
|
640
672
|
|
|
641
|
-
|
|
673
|
+
# Extract commit message from detail
|
|
674
|
+
if detail is not None:
|
|
675
|
+
commit_obj = getattr(detail, "commit", None) or (detail.get("commit") if isinstance(detail, dict) else None)
|
|
676
|
+
if commit_obj:
|
|
677
|
+
message = getattr(commit_obj, "message", None) or (commit_obj.get("message") if isinstance(commit_obj, dict) else None) or preview
|
|
678
|
+
else:
|
|
679
|
+
message = preview
|
|
680
|
+
else:
|
|
681
|
+
message = preview
|
|
642
682
|
|
|
643
683
|
assert self._generate_response is not None
|
|
644
684
|
prompt = (
|
|
@@ -757,6 +797,81 @@ class AutonomousAgent:
|
|
|
757
797
|
if self._verbose:
|
|
758
798
|
logger.error("[autonomous] Collaborator added handling failed: %s", exc)
|
|
759
799
|
|
|
800
|
+
async def _handle_pending_review(self, data: dict[str, Any]) -> None:
|
|
801
|
+
"""Handle a pending review opportunity — review a commit that needs attention.
|
|
802
|
+
|
|
803
|
+
Discovered by the proactive opportunity scanner when commits in projects
|
|
804
|
+
the agent collaborates on have no reviews yet.
|
|
805
|
+
"""
|
|
806
|
+
project_id = data.get("projectId", "")
|
|
807
|
+
commit_id = data.get("commitId", "")
|
|
808
|
+
title = data.get("title", "")
|
|
809
|
+
preview = data.get("messagePreview", "")
|
|
810
|
+
|
|
811
|
+
if not project_id:
|
|
812
|
+
return
|
|
813
|
+
|
|
814
|
+
try:
|
|
815
|
+
# Try to get commit details if we have a commit ID
|
|
816
|
+
detail: Any = None
|
|
817
|
+
if commit_id:
|
|
818
|
+
try:
|
|
819
|
+
detail = await self._runtime.projects.get_commit(project_id, commit_id)
|
|
820
|
+
except Exception:
|
|
821
|
+
pass
|
|
822
|
+
|
|
823
|
+
diff_lines: list[str] = []
|
|
824
|
+
if detail is not None:
|
|
825
|
+
changes = getattr(detail, "changes", None) or (detail.get("changes") if isinstance(detail, dict) else []) or []
|
|
826
|
+
for ch in changes[:10]:
|
|
827
|
+
path = ch.get("path", "unknown") if isinstance(ch, dict) else getattr(ch, "path", "unknown")
|
|
828
|
+
action = ch.get("action", "modified") if isinstance(ch, dict) else getattr(ch, "action", "modified")
|
|
829
|
+
diff_lines.append(f" {action}: {path}")
|
|
830
|
+
snippet = (ch.get("diff") or ch.get("content") or "") if isinstance(ch, dict) else (getattr(ch, "diff", None) or getattr(ch, "content", None) or "")
|
|
831
|
+
if snippet:
|
|
832
|
+
diff_lines.append(f" {str(snippet)[:500]}")
|
|
833
|
+
diff_text = "\n".join(diff_lines)[:3000] if diff_lines else "(no diff available)"
|
|
834
|
+
|
|
835
|
+
assert self._generate_response is not None
|
|
836
|
+
prompt = (
|
|
837
|
+
"A commit in one of your projects needs a code review.\n"
|
|
838
|
+
f"Context: {title}\n"
|
|
839
|
+
f"Details: {preview}\n\n"
|
|
840
|
+
f"Changes:\n{diff_text}\n\n"
|
|
841
|
+
"Review the changes and decide:\n"
|
|
842
|
+
"VERDICT: APPROVE, REQUEST_CHANGES, or COMMENT\n"
|
|
843
|
+
"BODY: your review comments\n\n"
|
|
844
|
+
"If this doesn't need your review, respond with: [SKIP]\n\n"
|
|
845
|
+
"Format your response as:\n"
|
|
846
|
+
"VERDICT: <your verdict>\n"
|
|
847
|
+
"BODY: <your review comments>"
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
response = await self._generate_response(prompt)
|
|
851
|
+
text = (response or "").strip()
|
|
852
|
+
|
|
853
|
+
if text == "[SKIP]":
|
|
854
|
+
return
|
|
855
|
+
|
|
856
|
+
import re
|
|
857
|
+
verdict_match = re.search(r"VERDICT:\s*(APPROVE|REQUEST_CHANGES|COMMENT)", text, re.IGNORECASE)
|
|
858
|
+
verdict = verdict_match.group(1).lower() if verdict_match else "comment"
|
|
859
|
+
body_match = re.search(r"BODY:\s*(.+)", text, re.IGNORECASE | re.DOTALL)
|
|
860
|
+
body = (body_match.group(1).strip() if body_match else text)[:1000]
|
|
861
|
+
|
|
862
|
+
if commit_id:
|
|
863
|
+
try:
|
|
864
|
+
await self._runtime.projects.submit_review(project_id, commit_id, verdict, body)
|
|
865
|
+
if self._verbose:
|
|
866
|
+
logger.info("[autonomous] ✓ Reviewed pending commit %s: %s", commit_id[:8], verdict)
|
|
867
|
+
except Exception as e:
|
|
868
|
+
if self._verbose:
|
|
869
|
+
logger.error("[autonomous] Pending review submission failed: %s", e)
|
|
870
|
+
|
|
871
|
+
except Exception as exc:
|
|
872
|
+
if self._verbose:
|
|
873
|
+
logger.error("[autonomous] Pending review handling failed: %s", exc)
|
|
874
|
+
|
|
760
875
|
# ================================================================
|
|
761
876
|
# Action request handling (proactive.action.request)
|
|
762
877
|
# ================================================================
|
|
@@ -87,11 +87,13 @@ class _HttpClient:
|
|
|
87
87
|
method: str,
|
|
88
88
|
path: str,
|
|
89
89
|
body: dict[str, Any] | None = None,
|
|
90
|
-
_retries: int =
|
|
90
|
+
_retries: int = 4,
|
|
91
|
+
_attempt: int = 0,
|
|
91
92
|
) -> Any:
|
|
92
93
|
"""Make an authenticated request to the gateway.
|
|
93
94
|
|
|
94
|
-
Automatically retries on 429 (rate limited) with
|
|
95
|
+
Automatically retries on 429 (rate limited) with exponential backoff.
|
|
96
|
+
Default: up to 4 retries with 5s → 10s → 20s → 40s delays (jittered).
|
|
95
97
|
"""
|
|
96
98
|
response = await self._client.request(
|
|
97
99
|
method=method,
|
|
@@ -99,11 +101,19 @@ class _HttpClient:
|
|
|
99
101
|
json=body,
|
|
100
102
|
)
|
|
101
103
|
|
|
102
|
-
# Auto-retry on 429 with
|
|
104
|
+
# Auto-retry on 429 with exponential backoff + jitter
|
|
103
105
|
if response.status_code == 429 and _retries > 0:
|
|
104
|
-
retry_after = float(response.headers.get("retry-after", "
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
retry_after = float(response.headers.get("retry-after", "0"))
|
|
107
|
+
# Exponential backoff: 5s, 10s, 20s, 40s — capped at 60s
|
|
108
|
+
exp_delay = min(5 * (2 ** _attempt), 60)
|
|
109
|
+
# Use the larger of Retry-After header and exponential delay
|
|
110
|
+
delay = max(retry_after, exp_delay)
|
|
111
|
+
# Add jitter (±20%) to avoid thundering herd
|
|
112
|
+
import random
|
|
113
|
+
delay *= 0.8 + random.random() * 0.4
|
|
114
|
+
logger.info("Rate limited (429) — retrying in %.1fs (attempt %d/%d)", delay, _attempt + 1, _attempt + _retries)
|
|
115
|
+
await asyncio.sleep(delay)
|
|
116
|
+
return await self.request(method, path, body, _retries - 1, _attempt + 1)
|
|
107
117
|
|
|
108
118
|
# CRITICAL-2: Don't use raise_for_status() directly — it leaks
|
|
109
119
|
# the full response body (potentially including secrets) in the
|
|
@@ -1658,46 +1668,61 @@ class NookplotRuntime:
|
|
|
1658
1668
|
# ---- Internal ----
|
|
1659
1669
|
|
|
1660
1670
|
async def _start_ws(self) -> None:
|
|
1661
|
-
"""Open WebSocket connection for real-time events.
|
|
1662
|
-
try:
|
|
1663
|
-
import websockets
|
|
1671
|
+
"""Open WebSocket connection for real-time events.
|
|
1664
1672
|
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1673
|
+
Retries up to 3 times with exponential backoff if rate-limited.
|
|
1674
|
+
"""
|
|
1675
|
+
max_ws_retries = 3
|
|
1676
|
+
for attempt in range(max_ws_retries + 1):
|
|
1677
|
+
try:
|
|
1678
|
+
import websockets
|
|
1668
1679
|
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
return
|
|
1680
|
+
# Get WS ticket
|
|
1681
|
+
ticket_data = await self._http.request("POST", "/v1/ws/ticket")
|
|
1682
|
+
ticket = ticket_data.get("ticket", "")
|
|
1673
1683
|
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
ws_url = f"{ws_base}/ws/runtime?ticket={url_quote(ticket, safe='')}"
|
|
1684
|
+
# HIGH-4: Don't connect with empty ticket — gateway would reject anyway
|
|
1685
|
+
if not ticket:
|
|
1686
|
+
logger.warning("Empty WS ticket received — skipping WebSocket")
|
|
1687
|
+
return
|
|
1679
1688
|
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1689
|
+
# Build WS URL
|
|
1690
|
+
ws_base = self._gateway_url.replace("http://", "ws://").replace(
|
|
1691
|
+
"https://", "wss://"
|
|
1692
|
+
)
|
|
1693
|
+
ws_url = f"{ws_base}/ws/runtime?ticket={url_quote(ticket, safe='')}"
|
|
1683
1694
|
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1695
|
+
self._ws = await websockets.connect(ws_url)
|
|
1696
|
+
self._events.start(self._ws)
|
|
1697
|
+
logger.debug("WebSocket connected for real-time events")
|
|
1698
|
+
|
|
1699
|
+
# Auto-subscribe to channels the agent is a member of
|
|
1700
|
+
try:
|
|
1701
|
+
resp = await self._http.request("GET", "/v1/channels?limit=50")
|
|
1702
|
+
for ch in resp.get("channels", []):
|
|
1703
|
+
if ch.get("isMember") and self._ws:
|
|
1704
|
+
await self._ws.send(json.dumps(
|
|
1705
|
+
{"type": "channel.subscribe", "channelId": ch["id"]}
|
|
1706
|
+
))
|
|
1707
|
+
logger.debug("Auto-subscribed to channel %s", ch.get("slug", ch["id"]))
|
|
1708
|
+
except Exception:
|
|
1709
|
+
pass # Non-fatal — agent may not have any channels yet
|
|
1710
|
+
return # Success — exit retry loop
|
|
1711
|
+
except ImportError:
|
|
1712
|
+
logger.warning(
|
|
1713
|
+
"websockets package not installed — real-time events disabled"
|
|
1714
|
+
)
|
|
1715
|
+
return # No point retrying without the package
|
|
1693
1716
|
except Exception:
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1717
|
+
if attempt < max_ws_retries:
|
|
1718
|
+
delay = 5 * (2 ** attempt) # 5s, 10s, 20s
|
|
1719
|
+
logger.warning(
|
|
1720
|
+
"WebSocket connection failed (attempt %d/%d) — retrying in %ds",
|
|
1721
|
+
attempt + 1, max_ws_retries + 1, delay,
|
|
1722
|
+
)
|
|
1723
|
+
await asyncio.sleep(delay)
|
|
1724
|
+
else:
|
|
1725
|
+
logger.warning("Failed to establish WebSocket after %d attempts — events unavailable", max_ws_retries + 1)
|
|
1701
1726
|
|
|
1702
1727
|
async def _heartbeat_loop(self) -> None:
|
|
1703
1728
|
"""Send periodic heartbeats to keep the session alive."""
|
|
@@ -589,7 +589,7 @@ class ProactiveSettings(BaseModel):
|
|
|
589
589
|
|
|
590
590
|
agent_id: str = Field(alias="agentId")
|
|
591
591
|
enabled: bool = False
|
|
592
|
-
scan_interval_minutes: int = Field(
|
|
592
|
+
scan_interval_minutes: int = Field(60, alias="scanIntervalMinutes")
|
|
593
593
|
max_credits_per_cycle: int = Field(5000, alias="maxCreditsPerCycle")
|
|
594
594
|
max_actions_per_day: int = Field(10, alias="maxActionsPerDay")
|
|
595
595
|
paused_until: str | None = Field(None, alias="pausedUntil")
|
|
@@ -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.10"
|
|
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
|