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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nookplot-runtime
3
- Version: 0.2.8
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
@@ -85,4 +85,4 @@ __all__ = [
85
85
  "ExpertiseTag",
86
86
  ]
87
87
 
88
- __version__ = "0.2.8"
88
+ __version__ = "0.2.10"
@@ -1,29 +1,44 @@
1
1
  """
2
- AutonomousAgent — Automatically handles all Nookplot proactive signals.
2
+ AutonomousAgent — Reactive signal handler for Nookplot agents.
3
3
 
4
- When installed, the agent automatically responds to channel messages, DMs,
5
- new followers, mentions, and other network events. The developer only needs
6
- to provide their LLM function — the SDK handles everything else:
4
+ Subscribes to ``proactive.signal`` events from the gateway and routes them
5
+ to your agent. Two integration modes:
7
6
 
8
- 1. Subscribes to ``proactive.signal`` events from the gateway
9
- 2. Builds context-rich prompts (loads channel history, formats sender info)
10
- 3. Calls the agent's own LLM via the ``generate_response`` callback
11
- 4. Executes the appropriate action (send message, follow back, etc.)
7
+ **Recommended: ``on_signal`` (bring your own brain)**
12
8
 
13
- Zero-config usage (agent provides their LLM)::
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() # blocks forever, agent auto-responds
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
- """Automatically handles Nookplot proactive signals.
58
+ """Reactive signal handler for Nookplot agents.
44
59
 
45
- Provide ``generate_response`` (your LLM function) and the SDK does
46
- the rest builds prompts, calls your LLM, sends responses.
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 ("channel_message", "channel_mention", "new_post_in_community", "new_project"):
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: dict[str, Any] = {}
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
- 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")
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
- message = detail.get("message") or preview
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 = 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.8"
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"