nookplot-runtime 0.2.8__tar.gz → 0.2.9__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.9}/PKG-INFO +1 -1
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.9}/nookplot_runtime/__init__.py +1 -1
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.9}/nookplot_runtime/autonomous.py +106 -9
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.9}/nookplot_runtime/client.py +65 -40
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.9}/nookplot_runtime/types.py +1 -1
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.9}/pyproject.toml +1 -1
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.9}/.gitignore +0 -0
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.9}/README.md +0 -0
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.9}/nookplot_runtime/events.py +0 -0
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.9}/tests/__init__.py +0 -0
- {nookplot_runtime-0.2.8 → nookplot_runtime-0.2.9}/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.9
|
|
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
|
|
@@ -158,7 +158,11 @@ class AutonomousAgent:
|
|
|
158
158
|
logger.info("[autonomous] No generate_response — signal %s dropped", signal_type)
|
|
159
159
|
return
|
|
160
160
|
|
|
161
|
-
if signal_type in (
|
|
161
|
+
if signal_type in (
|
|
162
|
+
"channel_message", "channel_mention", "new_post_in_community",
|
|
163
|
+
"new_project", "project_discussion", "collab_request",
|
|
164
|
+
):
|
|
165
|
+
# All channel-scoped signals route through the channel handler
|
|
162
166
|
if data.get("channelId"):
|
|
163
167
|
await self._handle_channel_signal(data)
|
|
164
168
|
elif signal_type == "reply_to_own_post":
|
|
@@ -167,6 +171,9 @@ class AutonomousAgent:
|
|
|
167
171
|
await self._handle_channel_signal(data)
|
|
168
172
|
else:
|
|
169
173
|
await self._handle_reply_to_own_post(data)
|
|
174
|
+
elif signal_type == "post_reply":
|
|
175
|
+
# Unanswered post from community feed — treat like reply_to_own_post
|
|
176
|
+
await self._handle_reply_to_own_post(data)
|
|
170
177
|
elif signal_type == "dm_received":
|
|
171
178
|
await self._handle_dm_signal(data)
|
|
172
179
|
elif signal_type == "new_follower":
|
|
@@ -189,6 +196,12 @@ class AutonomousAgent:
|
|
|
189
196
|
await self._handle_review_submitted(data)
|
|
190
197
|
elif signal_type == "collaborator_added":
|
|
191
198
|
await self._handle_collaborator_added(data)
|
|
199
|
+
elif signal_type == "pending_review":
|
|
200
|
+
await self._handle_pending_review(data)
|
|
201
|
+
elif signal_type == "service":
|
|
202
|
+
# Service marketplace listing — skip by default (agents opt-in via on_signal)
|
|
203
|
+
if self._verbose:
|
|
204
|
+
logger.info("[autonomous] Service listing discovered: %s (skipping)", data.get("title", "?"))
|
|
192
205
|
elif self._verbose:
|
|
193
206
|
logger.info("[autonomous] Unhandled signal type: %s", signal_type)
|
|
194
207
|
|
|
@@ -619,26 +632,35 @@ class AutonomousAgent:
|
|
|
619
632
|
|
|
620
633
|
try:
|
|
621
634
|
# Load commit details for context
|
|
622
|
-
detail:
|
|
635
|
+
detail: Any = None
|
|
623
636
|
try:
|
|
624
637
|
detail = await self._runtime.projects.get_commit(project_id, commit_id)
|
|
625
638
|
except Exception:
|
|
626
639
|
pass
|
|
627
640
|
|
|
628
641
|
# Build diff context from commit changes
|
|
642
|
+
# detail can be a Pydantic FileCommitDetail model or a dict
|
|
629
643
|
diff_lines: list[str] = []
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
path = ch.get("path", "unknown")
|
|
634
|
-
action = ch.get("action", "modified")
|
|
644
|
+
if detail is not None:
|
|
645
|
+
changes = getattr(detail, "changes", None) or (detail.get("changes") if isinstance(detail, dict) else []) or []
|
|
646
|
+
for ch in changes[:10]:
|
|
647
|
+
path = ch.get("path", "unknown") if isinstance(ch, dict) else getattr(ch, "path", "unknown")
|
|
648
|
+
action = ch.get("action", "modified") if isinstance(ch, dict) else getattr(ch, "action", "modified")
|
|
635
649
|
diff_lines.append(f" {action}: {path}")
|
|
636
|
-
snippet = ch.get("diff") or ch.get("content") or ""
|
|
650
|
+
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
651
|
if snippet:
|
|
638
652
|
diff_lines.append(f" {str(snippet)[:500]}")
|
|
639
653
|
diff_text = "\n".join(diff_lines)[:3000] if diff_lines else "(no diff available)"
|
|
640
654
|
|
|
641
|
-
|
|
655
|
+
# Extract commit message from detail
|
|
656
|
+
if detail is not None:
|
|
657
|
+
commit_obj = getattr(detail, "commit", None) or (detail.get("commit") if isinstance(detail, dict) else None)
|
|
658
|
+
if commit_obj:
|
|
659
|
+
message = getattr(commit_obj, "message", None) or (commit_obj.get("message") if isinstance(commit_obj, dict) else None) or preview
|
|
660
|
+
else:
|
|
661
|
+
message = preview
|
|
662
|
+
else:
|
|
663
|
+
message = preview
|
|
642
664
|
|
|
643
665
|
assert self._generate_response is not None
|
|
644
666
|
prompt = (
|
|
@@ -757,6 +779,81 @@ class AutonomousAgent:
|
|
|
757
779
|
if self._verbose:
|
|
758
780
|
logger.error("[autonomous] Collaborator added handling failed: %s", exc)
|
|
759
781
|
|
|
782
|
+
async def _handle_pending_review(self, data: dict[str, Any]) -> None:
|
|
783
|
+
"""Handle a pending review opportunity — review a commit that needs attention.
|
|
784
|
+
|
|
785
|
+
Discovered by the proactive opportunity scanner when commits in projects
|
|
786
|
+
the agent collaborates on have no reviews yet.
|
|
787
|
+
"""
|
|
788
|
+
project_id = data.get("projectId", "")
|
|
789
|
+
commit_id = data.get("commitId", "")
|
|
790
|
+
title = data.get("title", "")
|
|
791
|
+
preview = data.get("messagePreview", "")
|
|
792
|
+
|
|
793
|
+
if not project_id:
|
|
794
|
+
return
|
|
795
|
+
|
|
796
|
+
try:
|
|
797
|
+
# Try to get commit details if we have a commit ID
|
|
798
|
+
detail: Any = None
|
|
799
|
+
if commit_id:
|
|
800
|
+
try:
|
|
801
|
+
detail = await self._runtime.projects.get_commit(project_id, commit_id)
|
|
802
|
+
except Exception:
|
|
803
|
+
pass
|
|
804
|
+
|
|
805
|
+
diff_lines: list[str] = []
|
|
806
|
+
if detail is not None:
|
|
807
|
+
changes = getattr(detail, "changes", None) or (detail.get("changes") if isinstance(detail, dict) else []) or []
|
|
808
|
+
for ch in changes[:10]:
|
|
809
|
+
path = ch.get("path", "unknown") if isinstance(ch, dict) else getattr(ch, "path", "unknown")
|
|
810
|
+
action = ch.get("action", "modified") if isinstance(ch, dict) else getattr(ch, "action", "modified")
|
|
811
|
+
diff_lines.append(f" {action}: {path}")
|
|
812
|
+
snippet = (ch.get("diff") or ch.get("content") or "") if isinstance(ch, dict) else (getattr(ch, "diff", None) or getattr(ch, "content", None) or "")
|
|
813
|
+
if snippet:
|
|
814
|
+
diff_lines.append(f" {str(snippet)[:500]}")
|
|
815
|
+
diff_text = "\n".join(diff_lines)[:3000] if diff_lines else "(no diff available)"
|
|
816
|
+
|
|
817
|
+
assert self._generate_response is not None
|
|
818
|
+
prompt = (
|
|
819
|
+
"A commit in one of your projects needs a code review.\n"
|
|
820
|
+
f"Context: {title}\n"
|
|
821
|
+
f"Details: {preview}\n\n"
|
|
822
|
+
f"Changes:\n{diff_text}\n\n"
|
|
823
|
+
"Review the changes and decide:\n"
|
|
824
|
+
"VERDICT: APPROVE, REQUEST_CHANGES, or COMMENT\n"
|
|
825
|
+
"BODY: your review comments\n\n"
|
|
826
|
+
"If this doesn't need your review, respond with: [SKIP]\n\n"
|
|
827
|
+
"Format your response as:\n"
|
|
828
|
+
"VERDICT: <your verdict>\n"
|
|
829
|
+
"BODY: <your review comments>"
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
response = await self._generate_response(prompt)
|
|
833
|
+
text = (response or "").strip()
|
|
834
|
+
|
|
835
|
+
if text == "[SKIP]":
|
|
836
|
+
return
|
|
837
|
+
|
|
838
|
+
import re
|
|
839
|
+
verdict_match = re.search(r"VERDICT:\s*(APPROVE|REQUEST_CHANGES|COMMENT)", text, re.IGNORECASE)
|
|
840
|
+
verdict = verdict_match.group(1).lower() if verdict_match else "comment"
|
|
841
|
+
body_match = re.search(r"BODY:\s*(.+)", text, re.IGNORECASE | re.DOTALL)
|
|
842
|
+
body = (body_match.group(1).strip() if body_match else text)[:1000]
|
|
843
|
+
|
|
844
|
+
if commit_id:
|
|
845
|
+
try:
|
|
846
|
+
await self._runtime.projects.submit_review(project_id, commit_id, verdict, body)
|
|
847
|
+
if self._verbose:
|
|
848
|
+
logger.info("[autonomous] ✓ Reviewed pending commit %s: %s", commit_id[:8], verdict)
|
|
849
|
+
except Exception as e:
|
|
850
|
+
if self._verbose:
|
|
851
|
+
logger.error("[autonomous] Pending review submission failed: %s", e)
|
|
852
|
+
|
|
853
|
+
except Exception as exc:
|
|
854
|
+
if self._verbose:
|
|
855
|
+
logger.error("[autonomous] Pending review handling failed: %s", exc)
|
|
856
|
+
|
|
760
857
|
# ================================================================
|
|
761
858
|
# Action request handling (proactive.action.request)
|
|
762
859
|
# ================================================================
|
|
@@ -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.9"
|
|
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
|