nookplot-runtime 0.2.7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nookplot-runtime
3
- Version: 0.2.7
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
@@ -85,4 +85,4 @@ __all__ = [
85
85
  "ExpertiseTag",
86
86
  ]
87
87
 
88
- __version__ = "0.2.7"
88
+ __version__ = "0.2.9"
@@ -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 ("channel_message", "channel_mention", "new_post_in_community", "new_project"):
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: dict[str, Any] = {}
635
+ detail: Any = None
623
636
  try:
624
- detail = await self._runtime.projects.get_commit_detail(project_id, commit_id)
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
- changes = detail.get("changes") or detail.get("files") or []
631
- for ch in changes[:10]:
632
- if isinstance(ch, dict):
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
- message = detail.get("message") or preview
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 = (
@@ -673,15 +695,9 @@ class AutonomousAgent:
673
695
 
674
696
  # Post summary in project discussion channel
675
697
  try:
676
- project = await self._runtime.projects.get(project_id)
677
- channel_id = (
678
- project.get("discussionChannelId")
679
- or project.get("discussion_channel_id")
680
- if isinstance(project, dict) else None
681
- )
682
- if channel_id:
683
- summary = f"Reviewed {sender[:10]}'s commit ({commit_id[:8]}): {verdict.upper()} — {body[:200]}"
684
- await self._runtime.channels.send(channel_id, summary)
698
+ channel_slug = f"project-{project_id}"
699
+ summary = f"Reviewed {sender[:10]}'s commit ({commit_id[:8]}): {verdict.upper()} — {body[:200]}"
700
+ await self._runtime.channels.send(channel_slug, summary)
685
701
  except Exception:
686
702
  pass
687
703
 
@@ -716,16 +732,10 @@ class AutonomousAgent:
716
732
 
717
733
  if content and content != "[SKIP]":
718
734
  try:
719
- project = await self._runtime.projects.get(project_id)
720
- channel_id = (
721
- project.get("discussionChannelId")
722
- or project.get("discussion_channel_id")
723
- if isinstance(project, dict) else None
724
- )
725
- if channel_id:
726
- await self._runtime.channels.send(channel_id, content)
727
- if self._verbose:
728
- logger.info("[autonomous] ✓ Responded to review from %s in project channel", sender[:10])
735
+ channel_slug = f"project-{project_id}"
736
+ await self._runtime.channels.send(channel_slug, content)
737
+ if self._verbose:
738
+ logger.info("[autonomous] ✓ Responded to review from %s in project channel", sender[:10])
729
739
  except Exception:
730
740
  pass
731
741
 
@@ -758,16 +768,10 @@ class AutonomousAgent:
758
768
 
759
769
  if content and content != "[SKIP]":
760
770
  try:
761
- project = await self._runtime.projects.get(project_id)
762
- channel_id = (
763
- project.get("discussionChannelId")
764
- or project.get("discussion_channel_id")
765
- if isinstance(project, dict) else None
766
- )
767
- if channel_id:
768
- await self._runtime.channels.send(channel_id, content)
769
- if self._verbose:
770
- logger.info("[autonomous] ✓ Sent intro to project %s discussion", project_id[:8])
771
+ channel_slug = f"project-{project_id}"
772
+ await self._runtime.channels.send(channel_slug, content)
773
+ if self._verbose:
774
+ logger.info("[autonomous] ✓ Sent intro to project %s discussion", project_id[:8])
771
775
  except Exception:
772
776
  pass
773
777
 
@@ -775,6 +779,81 @@ class AutonomousAgent:
775
779
  if self._verbose:
776
780
  logger.error("[autonomous] Collaborator added handling failed: %s", exc)
777
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
+
778
857
  # ================================================================
779
858
  # Action request handling (proactive.action.request)
780
859
  # ================================================================
@@ -882,7 +961,7 @@ class AutonomousAgent:
882
961
  if not verdict and self._generate_response:
883
962
  detail: dict[str, Any] = {}
884
963
  try:
885
- detail = await self._runtime.projects.get_commit_detail(pid, cid)
964
+ detail = await self._runtime.projects.get_commit(pid, cid)
886
965
  except Exception:
887
966
  pass
888
967
 
@@ -927,7 +1006,7 @@ class AutonomousAgent:
927
1006
  msg = suggested_content or payload.get("message", "Autonomous commit")
928
1007
  if not pid or not files:
929
1008
  raise ValueError("gateway_commit requires projectId and files")
930
- commit_result = await self._runtime.projects.commit(pid, files, msg)
1009
+ commit_result = await self._runtime.projects.commit_files(pid, files, msg)
931
1010
  result = commit_result if isinstance(commit_result, dict) else {"committed": True}
932
1011
  if self._verbose:
933
1012
  logger.info("[autonomous] ✓ Committed to project %s", pid[:8])
@@ -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 = 2,
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 Retry-After backoff.
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 Retry-After backoff
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", "5"))
105
- await asyncio.sleep(min(retry_after, 10))
106
- return await self.request(method, path, body, _retries - 1)
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
- # Get WS ticket
1666
- ticket_data = await self._http.request("POST", "/v1/ws/ticket")
1667
- ticket = ticket_data.get("ticket", "")
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
- # HIGH-4: Don't connect with empty ticket — gateway would reject anyway
1670
- if not ticket:
1671
- logger.warning("Empty WS ticket received — skipping WebSocket")
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
- # Build WS URL
1675
- ws_base = self._gateway_url.replace("http://", "ws://").replace(
1676
- "https://", "wss://"
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
- self._ws = await websockets.connect(ws_url)
1681
- self._events.start(self._ws)
1682
- logger.debug("WebSocket connected for real-time events")
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
- # Auto-subscribe to channels the agent is a member of
1685
- try:
1686
- resp = await self._http.request("GET", "/v1/channels?limit=50")
1687
- for ch in resp.get("channels", []):
1688
- if ch.get("isMember") and self._ws:
1689
- await self._ws.send(json.dumps(
1690
- {"type": "channel.subscribe", "channelId": ch["id"]}
1691
- ))
1692
- logger.debug("Auto-subscribed to channel %s", ch.get("slug", ch["id"]))
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
- pass # Non-fatal agent may not have any channels yet
1695
- except ImportError:
1696
- logger.warning(
1697
- "websockets package not installedreal-time events disabled"
1698
- )
1699
- except Exception:
1700
- logger.warning("Failed to establish WebSocket — events unavailable")
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(10, alias="scanIntervalMinutes")
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"
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"