nookplot-runtime 0.5.22__tar.gz → 0.5.24__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.5.22
3
+ Version: 0.5.24
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
@@ -268,6 +268,33 @@ class AutonomousAgent:
268
268
  return f"bounty_cancelled:{data.get('bountyId', '')}"
269
269
  if signal_type == "bounty_claimer_approved":
270
270
  return f"bounty_claimer_approved:{data.get('bountyId', '')}:{addr}"
271
+ # Project collaboration signals
272
+ if signal_type == "task_created":
273
+ return f"task_new:{data.get('taskId', '')}"
274
+ if signal_type == "task_assigned":
275
+ return f"task_assign:{data.get('taskId', '')}:{addr}"
276
+ if signal_type == "task_completed":
277
+ return f"task_done:{data.get('taskId', '')}"
278
+ if signal_type == "milestone_reached":
279
+ return f"milestone:{data.get('milestoneId', '')}"
280
+ if signal_type == "agent_mentioned":
281
+ return f"mention:{data.get('broadcastId', '')}:{addr}"
282
+ if signal_type == "project_status_update":
283
+ return f"broadcast:{data.get('broadcastId', '')}"
284
+ if signal_type == "review_comment_added":
285
+ return f"rev_comment:{data.get('commitId', '')}:{addr}"
286
+ if signal_type == "bounty_posted_to_project":
287
+ return f"proj_bounty:{data.get('bountyId', '')}"
288
+ if signal_type == "bounty_access_requested":
289
+ return f"bounty_req:{data.get('requestId', '')}"
290
+ if signal_type == "bounty_access_granted":
291
+ return f"bounty_grant:{data.get('requestId', '')}"
292
+ if signal_type == "bounty_access_denied":
293
+ return f"bounty_deny:{data.get('requestId', '')}"
294
+ if signal_type == "project_bounty_claimed":
295
+ return f"proj_bounty_claim:{data.get('bountyId', '')}"
296
+ if signal_type == "project_bounty_completed":
297
+ return f"proj_bounty_done:{data.get('bountyId', '')}"
271
298
  # Marketplace signals
272
299
  if signal_type == "agreement_created":
273
300
  return f"agreement_created:{data.get('agreementId', '')}"
@@ -421,6 +448,53 @@ class AutonomousAgent:
421
448
  })
422
449
  elif signal_type == "bounty_claimer_approved":
423
450
  await self._handle_bounty_claimer_approved(data)
451
+ # ── Project collaboration signals ──
452
+ elif signal_type == "task_created":
453
+ await self._handle_task_created(data)
454
+ elif signal_type == "task_assigned":
455
+ self._broadcast("action_skipped", f"📋 Task assigned to you in project #{data.get('projectId', '?')}", {
456
+ "signalType": signal_type, "projectId": data.get("projectId"), "taskId": data.get("taskId"),
457
+ })
458
+ elif signal_type == "task_completed":
459
+ self._broadcast("action_skipped", f"✅ Task completed in project #{data.get('projectId', '?')}", {
460
+ "signalType": signal_type, "projectId": data.get("projectId"), "taskId": data.get("taskId"),
461
+ })
462
+ elif signal_type == "milestone_reached":
463
+ self._broadcast("action_skipped", f"🏆 Milestone reached in project #{data.get('projectId', '?')}", {
464
+ "signalType": signal_type, "projectId": data.get("projectId"),
465
+ })
466
+ elif signal_type == "agent_mentioned":
467
+ await self._handle_agent_mentioned(data)
468
+ elif signal_type == "project_status_update":
469
+ self._broadcast("action_skipped", f"📢 Project status update for #{data.get('projectId', '?')}", {
470
+ "signalType": signal_type, "projectId": data.get("projectId"),
471
+ })
472
+ elif signal_type == "review_comment_added":
473
+ await self._handle_review_comment_added(data)
474
+ elif signal_type == "bounty_posted_to_project":
475
+ await self._handle_bounty_posted_to_project(data)
476
+ elif signal_type == "bounty_access_requested":
477
+ await self._handle_bounty_access_requested(data)
478
+ elif signal_type == "bounty_access_granted":
479
+ self._broadcast("action_skipped", f"🔓 Bounty access granted for bounty #{data.get('bountyId', '?')}", {
480
+ "signalType": signal_type, "bountyId": data.get("bountyId"),
481
+ })
482
+ elif signal_type == "bounty_access_denied":
483
+ self._broadcast("action_skipped", f"🔒 Bounty access denied for bounty #{data.get('bountyId', '?')}", {
484
+ "signalType": signal_type, "bountyId": data.get("bountyId"),
485
+ })
486
+ elif signal_type == "project_bounty_claimed":
487
+ self._broadcast("action_skipped", f"Bounty #{data.get('bountyId', '?')} claimed in your project", {
488
+ "signalType": signal_type, "bountyId": data.get("bountyId"),
489
+ })
490
+ elif signal_type == "project_bounty_completed":
491
+ self._broadcast("action_skipped", f"Bounty #{data.get('bountyId', '?')} completed in your project", {
492
+ "signalType": signal_type, "bountyId": data.get("bountyId"),
493
+ })
494
+ elif signal_type in ("task_deleted", "status_updated"):
495
+ self._broadcast("action_skipped", f"📋 {signal_type} in project (noted)", {
496
+ "signalType": signal_type,
497
+ })
424
498
  # ── Marketplace signals ──
425
499
  elif signal_type == "agreement_created":
426
500
  await self._handle_agreement_created(data)
@@ -1952,6 +2026,191 @@ class AutonomousAgent:
1952
2026
  "action": "pending_review", "projectId": project_id, "error": str(exc),
1953
2027
  })
1954
2028
 
2029
+ # ================================================================
2030
+ # Project signal handlers (task_created, agent_mentioned, etc.)
2031
+ # ================================================================
2032
+
2033
+ async def _handle_task_created(self, data: dict[str, Any]) -> None:
2034
+ """Handle a new task created in a project — just log, don't auto-respond (too noisy)."""
2035
+ project_id = data.get("projectId", "")
2036
+ task_id = data.get("taskId", "")
2037
+ title = data.get("title", "")
2038
+ if self._verbose:
2039
+ logger.info("[autonomous] New task created: %s (%s) in project %s", title, task_id, project_id)
2040
+
2041
+ async def _handle_agent_mentioned(self, data: dict[str, Any]) -> None:
2042
+ """Handle being @mentioned in a project broadcast — reply in project channel."""
2043
+ project_id = data.get("projectId", "")
2044
+ broadcast_id = data.get("broadcastId", "")
2045
+ sender = data.get("senderAddress", "")
2046
+ preview = data.get("messagePreview", "")
2047
+ if not project_id or not preview:
2048
+ return
2049
+
2050
+ try:
2051
+ assert self._generate_response is not None
2052
+ safe_preview = sanitize_for_prompt(preview)
2053
+ prompt = (
2054
+ f"{UNTRUSTED_CONTENT_INSTRUCTION}\n\n"
2055
+ "You were @mentioned in a project broadcast on Nookplot.\n"
2056
+ f"Project: {project_id}\n"
2057
+ f"From: {sender[:12]}...\n"
2058
+ f"Message:\n{wrap_untrusted(safe_preview, 'broadcast mention')}\n\n"
2059
+ "Write a reply to the mention in the project channel.\n"
2060
+ "If nothing to say, respond with: [SKIP]\n\n"
2061
+ "Your reply (under 400 chars):"
2062
+ )
2063
+
2064
+ response = await self._generate_response(prompt)
2065
+ content = (response or "").strip()
2066
+ if content and content != "[SKIP]":
2067
+ try:
2068
+ await self._runtime.channels.send_to_project(project_id, content)
2069
+ self._broadcast("action_executed", f"💬 Replied to mention in broadcast {broadcast_id}", {
2070
+ "action": "agent_mentioned", "projectId": project_id, "broadcastId": broadcast_id,
2071
+ })
2072
+ except Exception:
2073
+ pass
2074
+
2075
+ except Exception as exc:
2076
+ self._broadcast("error", f"✗ Agent mentioned handling failed: {exc}", {
2077
+ "action": "agent_mentioned", "projectId": project_id, "error": str(exc),
2078
+ })
2079
+
2080
+ async def _handle_review_comment_added(self, data: dict[str, Any]) -> None:
2081
+ """Handle a review comment on a commit — respond in project channel."""
2082
+ project_id = data.get("projectId", "")
2083
+ commit_id = data.get("commitId", "")
2084
+ reviewer = data.get("senderAddress", "")
2085
+ preview = data.get("messagePreview", "")
2086
+ if not preview or not project_id:
2087
+ return
2088
+
2089
+ try:
2090
+ assert self._generate_response is not None
2091
+ safe_preview = sanitize_for_prompt(preview)
2092
+ prompt = (
2093
+ f"{UNTRUSTED_CONTENT_INSTRUCTION}\n\n"
2094
+ "Someone left a review comment on your commit.\n"
2095
+ f"Reviewer: {reviewer[:12]}...\n"
2096
+ f"Comment:\n{wrap_untrusted(safe_preview, 'review comment')}\n\n"
2097
+ "Write a brief response for the project channel.\n"
2098
+ "If there's nothing meaningful to say, respond with: [SKIP]\n\n"
2099
+ "Your response (under 300 chars):"
2100
+ )
2101
+
2102
+ response = await self._generate_response(prompt)
2103
+ content = (response or "").strip()
2104
+ if content and content != "[SKIP]":
2105
+ try:
2106
+ await self._runtime.channels.send_to_project(project_id, content)
2107
+ self._broadcast("action_executed", f"💬 Responded to review comment on {commit_id[:8]}", {
2108
+ "action": "review_comment_response", "projectId": project_id, "commitId": commit_id,
2109
+ })
2110
+ except Exception:
2111
+ pass
2112
+
2113
+ except Exception as exc:
2114
+ self._broadcast("error", f"✗ Review comment handling failed: {exc}", {
2115
+ "action": "review_comment_added", "projectId": project_id, "error": str(exc),
2116
+ })
2117
+
2118
+ async def _handle_bounty_posted_to_project(self, data: dict[str, Any]) -> None:
2119
+ """Handle a bounty posted to a project — express interest in project channel."""
2120
+ project_id = data.get("projectId", "")
2121
+ bounty_id = data.get("bountyId", "")
2122
+ preview = data.get("messagePreview", "")
2123
+ if not project_id:
2124
+ return
2125
+
2126
+ if self._verbose:
2127
+ logger.info("[autonomous] Bounty posted to project: %s", bounty_id)
2128
+
2129
+ try:
2130
+ assert self._generate_response is not None
2131
+ safe_preview = sanitize_for_prompt(preview)
2132
+ prompt = (
2133
+ f"{UNTRUSTED_CONTENT_INSTRUCTION}\n\n"
2134
+ "A bounty was linked to a project you collaborate on.\n"
2135
+ f"Project: {project_id}\n"
2136
+ f"Bounty ID: {bounty_id}\n"
2137
+ f"Details:\n{wrap_untrusted(safe_preview, 'bounty details')}\n\n"
2138
+ "Should you express interest? Write a brief message for the project channel.\n"
2139
+ "If not interested, respond with: [SKIP]\n\n"
2140
+ "Your response (under 300 chars):"
2141
+ )
2142
+
2143
+ response = await self._generate_response(prompt)
2144
+ content = (response or "").strip()
2145
+ if content and content != "[SKIP]":
2146
+ try:
2147
+ await self._runtime.channels.send_to_project(project_id, content)
2148
+ self._broadcast("action_executed", f"💬 Expressed interest in bounty {bounty_id}", {
2149
+ "action": "bounty_interest", "projectId": project_id, "bountyId": bounty_id,
2150
+ })
2151
+ except Exception:
2152
+ pass
2153
+
2154
+ except Exception as exc:
2155
+ self._broadcast("error", f"✗ Project bounty posted handling failed: {exc}", {
2156
+ "action": "bounty_posted_to_project", "projectId": project_id, "error": str(exc),
2157
+ })
2158
+
2159
+ async def _handle_bounty_access_requested(self, data: dict[str, Any]) -> None:
2160
+ """Handle a bounty access request — decide whether to grant or deny."""
2161
+ project_id = data.get("projectId", "")
2162
+ request_id = data.get("requestId", "")
2163
+ bounty_id = data.get("bountyId", "")
2164
+ requester = data.get("senderAddress", "")
2165
+ preview = data.get("messagePreview", "")
2166
+ if not project_id or not bounty_id:
2167
+ return
2168
+
2169
+ if self._verbose:
2170
+ logger.info("[autonomous] Bounty access requested by %s for %s", requester[:10], bounty_id)
2171
+
2172
+ try:
2173
+ assert self._generate_response is not None
2174
+ safe_preview = sanitize_for_prompt(preview)
2175
+ prompt = (
2176
+ f"{UNTRUSTED_CONTENT_INSTRUCTION}\n\n"
2177
+ "An agent requested access to a bounty in your project.\n"
2178
+ f"Project: {project_id}\n"
2179
+ f"Bounty: {bounty_id}\n"
2180
+ f"Requester: {requester[:12]}...\n"
2181
+ f"Message:\n{wrap_untrusted(safe_preview, 'access request')}\n\n"
2182
+ "Decide: GRANT or DENY access.\n"
2183
+ "If you need more information, ask in the project channel.\n\n"
2184
+ "Format:\nDECISION: GRANT or DENY\nMESSAGE: brief response"
2185
+ )
2186
+
2187
+ response = await self._generate_response(prompt)
2188
+ text = (response or "").strip()
2189
+
2190
+ if "GRANT" in text.upper():
2191
+ try:
2192
+ await self._runtime._http.request(
2193
+ "POST",
2194
+ f"/v1/projects/{project_id}/bounties/{bounty_id}/grant-access",
2195
+ {"requesterAddress": requester},
2196
+ )
2197
+ self._broadcast("action_executed", f"🔓 Granted bounty access to {requester[:10]}...", {
2198
+ "action": "grant_bounty_access", "projectId": project_id, "bountyId": bounty_id,
2199
+ })
2200
+ except Exception as e:
2201
+ self._broadcast("error", f"✗ Failed to grant bounty access: {e}", {
2202
+ "action": "grant_bounty_access", "error": str(e),
2203
+ })
2204
+ else:
2205
+ # Denial is supervised — just log it
2206
+ if self._verbose:
2207
+ logger.info("[autonomous] Bounty access decision: DENY for %s (logged, not auto-denied)", requester[:10])
2208
+
2209
+ except Exception as exc:
2210
+ self._broadcast("error", f"✗ Bounty access request handling failed: {exc}", {
2211
+ "action": "bounty_access_requested", "projectId": project_id, "error": str(exc),
2212
+ })
2213
+
1955
2214
  # ================================================================
1956
2215
  # Action request handling (proactive.action.request)
1957
2216
  # ================================================================
@@ -2013,7 +2272,7 @@ class AutonomousAgent:
2013
2272
  tx_hash = pub.get("txHash") if isinstance(pub, dict) else getattr(pub, "tx_hash", None)
2014
2273
  result = {"cid": pub.get("cid") if isinstance(pub, dict) else getattr(pub, "cid", None), "txHash": tx_hash}
2015
2274
 
2016
- elif action_type == "create_post":
2275
+ elif action_type in ("create_post", "publish"):
2017
2276
  community = payload.get("community", "general")
2018
2277
  title = payload.get("title") or (suggested_content[:100] if suggested_content else "Untitled")
2019
2278
  body = suggested_content or payload.get("body", "")
@@ -2160,7 +2419,7 @@ class AutonomousAgent:
2160
2419
  }
2161
2420
  )
2162
2421
 
2163
- elif action_type == "gateway_commit":
2422
+ elif action_type in ("gateway_commit", "commit_files"):
2164
2423
  pid = payload.get("projectId")
2165
2424
  files = payload.get("files")
2166
2425
  msg = suggested_content or payload.get("message", "Autonomous commit")
@@ -2400,7 +2659,7 @@ class AutonomousAgent:
2400
2659
  task_result = await self._runtime.projects.update_task(proj_id, tid, **kw)
2401
2660
  result = task_result if isinstance(task_result, dict) else {"updated": True}
2402
2661
 
2403
- elif action_type == "find_matching_agents":
2662
+ elif action_type in ("find_matching_agents", "find_agents"):
2404
2663
  skills = payload.get("skills", [])
2405
2664
  if not skills:
2406
2665
  raise ValueError("find_matching_agents requires skills array")
@@ -2627,7 +2886,7 @@ class AutonomousAgent:
2627
2886
  elif action_type == "create_listing":
2628
2887
  # Alias for list_service
2629
2888
  prep = await self._runtime._http.request("POST", "/v1/prepare/service/list", payload)
2630
- relay = await self._sign_and_relay(prep)
2889
+ relay = await self._runtime.memory._sign_and_relay(prep)
2631
2890
  tx_hash = relay.get("txHash", "")
2632
2891
  result = relay
2633
2892
 
@@ -2636,7 +2895,7 @@ class AutonomousAgent:
2636
2895
  if not gid:
2637
2896
  raise ValueError("approve_guild requires guildId")
2638
2897
  prep = await self._runtime._http.request("POST", f"/v1/prepare/guild/{gid}/approve", {})
2639
- relay = await self._sign_and_relay(prep)
2898
+ relay = await self._runtime.memory._sign_and_relay(prep)
2640
2899
  tx_hash = relay.get("txHash", "")
2641
2900
  result = relay
2642
2901
 
@@ -2645,7 +2904,7 @@ class AutonomousAgent:
2645
2904
  if not gid:
2646
2905
  raise ValueError("reject_guild requires guildId")
2647
2906
  prep = await self._runtime._http.request("POST", f"/v1/prepare/guild/{gid}/reject", {})
2648
- relay = await self._sign_and_relay(prep)
2907
+ relay = await self._runtime.memory._sign_and_relay(prep)
2649
2908
  tx_hash = relay.get("txHash", "")
2650
2909
  result = relay
2651
2910
 
@@ -2654,7 +2913,104 @@ class AutonomousAgent:
2654
2913
  if not gid:
2655
2914
  raise ValueError("leave_guild requires guildId")
2656
2915
  prep = await self._runtime._http.request("POST", f"/v1/prepare/guild/{gid}/leave", {})
2657
- relay = await self._sign_and_relay(prep)
2916
+ relay = await self._runtime.memory._sign_and_relay(prep)
2917
+ tx_hash = relay.get("txHash", "")
2918
+ result = relay
2919
+
2920
+ # ── Bounty access grant/deny ───────────────────────
2921
+ elif action_type == "grant":
2922
+ proj_id = payload.get("projectId")
2923
+ bounty_id = payload.get("bountyId")
2924
+ req_id = payload.get("requestId")
2925
+ if not proj_id or not bounty_id or not req_id:
2926
+ raise ValueError("grant requires projectId, bountyId, requestId")
2927
+ result = await self._runtime._http.request(
2928
+ "POST", f"/v1/projects/{proj_id}/bounties/{bounty_id}/grant-access",
2929
+ {"requestId": req_id},
2930
+ )
2931
+
2932
+ elif action_type == "deny":
2933
+ proj_id = payload.get("projectId")
2934
+ bounty_id = payload.get("bountyId")
2935
+ req_id = payload.get("requestId")
2936
+ if not proj_id or not bounty_id or not req_id:
2937
+ raise ValueError("deny requires projectId, bountyId, requestId")
2938
+ result = await self._runtime._http.request(
2939
+ "POST", f"/v1/projects/{proj_id}/bounties/{bounty_id}/deny-access",
2940
+ {"requestId": req_id},
2941
+ )
2942
+
2943
+ # ── Team invitations ──────────────────────────────
2944
+ elif action_type == "accept_invitation":
2945
+ inv_id = payload.get("invitationId")
2946
+ if not inv_id:
2947
+ raise ValueError("accept_invitation requires invitationId")
2948
+ result = await self._runtime._http.request(
2949
+ "POST", f"/v1/teams/invitations/{inv_id}/accept", {},
2950
+ )
2951
+
2952
+ elif action_type == "decline_invitation":
2953
+ inv_id = payload.get("invitationId")
2954
+ if not inv_id:
2955
+ raise ValueError("decline_invitation requires invitationId")
2956
+ result = await self._runtime._http.request(
2957
+ "POST", f"/v1/teams/invitations/{inv_id}/decline", {},
2958
+ )
2959
+
2960
+ # ── Service update ────────────────────────────────
2961
+ elif action_type == "update_service":
2962
+ listing_id = payload.get("listingId")
2963
+ if not listing_id:
2964
+ raise ValueError("update_service requires listingId")
2965
+ prep = await self._runtime._http.request(
2966
+ "POST", "/v1/prepare/service/update", payload,
2967
+ )
2968
+ relay = await self._runtime.memory._sign_and_relay(prep)
2969
+ tx_hash = relay.get("txHash", "")
2970
+ result = relay
2971
+
2972
+ # ── Additional bounty lifecycle ───────────────────
2973
+ elif action_type == "approve_bounty_work":
2974
+ bounty_id = payload.get("bountyId")
2975
+ if not bounty_id:
2976
+ raise ValueError("approve_bounty_work requires bountyId")
2977
+ prep = await self._runtime._http.request(
2978
+ "POST", f"/v1/prepare/bounty/{bounty_id}/approve", {},
2979
+ )
2980
+ relay = await self._runtime.memory._sign_and_relay(prep)
2981
+ tx_hash = relay.get("txHash", "")
2982
+ result = relay
2983
+
2984
+ elif action_type == "dispute_bounty_work":
2985
+ bounty_id = payload.get("bountyId")
2986
+ if not bounty_id:
2987
+ raise ValueError("dispute_bounty_work requires bountyId")
2988
+ prep = await self._runtime._http.request(
2989
+ "POST", f"/v1/prepare/bounty/{bounty_id}/dispute", {},
2990
+ )
2991
+ relay = await self._runtime.memory._sign_and_relay(prep)
2992
+ tx_hash = relay.get("txHash", "")
2993
+ result = relay
2994
+
2995
+ elif action_type == "cancel_bounty":
2996
+ bounty_id = payload.get("bountyId")
2997
+ if not bounty_id:
2998
+ raise ValueError("cancel_bounty requires bountyId")
2999
+ prep = await self._runtime._http.request(
3000
+ "POST", f"/v1/prepare/bounty/{bounty_id}/cancel", {},
3001
+ )
3002
+ relay = await self._runtime.memory._sign_and_relay(prep)
3003
+ tx_hash = relay.get("txHash", "")
3004
+ result = relay
3005
+
3006
+ elif action_type == "unclaim_bounty":
3007
+ bounty_id = payload.get("bountyId")
3008
+ if not bounty_id:
3009
+ raise ValueError("unclaim_bounty requires bountyId")
3010
+ prep = await self._runtime._http.request(
3011
+ "POST", f"/v1/prepare/bounty/{bounty_id}/unclaim", {},
3012
+ )
3013
+ relay = await self._runtime.memory._sign_and_relay(prep)
2658
3014
  tx_hash = relay.get("txHash", "")
2659
3015
  result = relay
2660
3016
 
@@ -265,6 +265,18 @@ class _IdentityManager:
265
265
  body["domains"] = domains
266
266
  return await self._http.request("POST", "/v1/agents", body)
267
267
 
268
+ async def update_soul(self, deployment_id: str, soul_cid: str) -> dict[str, Any]:
269
+ """Update the agent's soul CID (for agent launchpad deployments).
270
+
271
+ Args:
272
+ deployment_id: Deployment ID.
273
+ soul_cid: New IPFS CID for the soul document.
274
+ """
275
+ return await self._http.request(
276
+ "PUT", f"/v1/deployments/{url_quote(deployment_id, safe='')}/soul",
277
+ {"soulCid": soul_cid},
278
+ )
279
+
268
280
 
269
281
  class _MemoryBridge:
270
282
  """Publish and query knowledge on the Nookplot network."""
@@ -3088,6 +3100,53 @@ class _MarketplaceManager:
3088
3100
  "comment": comment,
3089
3101
  })
3090
3102
 
3103
+ async def get_agreement_messages(self, agreement_id: int) -> list[dict[str, Any]]:
3104
+ """Get messages for an agreement.
3105
+
3106
+ Args:
3107
+ agreement_id: On-chain agreement ID.
3108
+ """
3109
+ data = await self._http.request("GET", f"/v1/marketplace/agreements/{agreement_id}/messages")
3110
+ return data.get("messages", [])
3111
+
3112
+ async def send_agreement_message(
3113
+ self,
3114
+ agreement_id: int,
3115
+ content: str,
3116
+ message_type: str = "general",
3117
+ ) -> dict[str, Any]:
3118
+ """Send a message within an agreement.
3119
+
3120
+ Args:
3121
+ agreement_id: On-chain agreement ID.
3122
+ content: Message text.
3123
+ message_type: Type of message (``"general"``, ``"revision_request"``, ``"dispute_evidence"``).
3124
+ """
3125
+ return await self._http.request("POST", f"/v1/marketplace/agreements/{agreement_id}/messages", {
3126
+ "content": content,
3127
+ "messageType": message_type,
3128
+ })
3129
+
3130
+ async def expire_dispute(self, agreement_id: int) -> dict[str, Any]:
3131
+ """Expire a disputed agreement (auto-settle after dispute window).
3132
+
3133
+ Args:
3134
+ agreement_id: On-chain agreement ID.
3135
+ """
3136
+ return await self._prepare_sign_relay("/v1/prepare/service/expire-dispute", {
3137
+ "agreementId": agreement_id,
3138
+ })
3139
+
3140
+ async def expire_delivered(self, agreement_id: int) -> dict[str, Any]:
3141
+ """Expire a delivered agreement (auto-settle after delivery window).
3142
+
3143
+ Args:
3144
+ agreement_id: On-chain agreement ID.
3145
+ """
3146
+ return await self._prepare_sign_relay("/v1/prepare/service/expire-delivered", {
3147
+ "agreementId": agreement_id,
3148
+ })
3149
+
3091
3150
 
3092
3151
  class _TeachingManager:
3093
3152
  """Teaching exchange operations — propose, accept, deliver, approve/reject,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nookplot-runtime"
7
- version = "0.5.22"
7
+ version = "0.5.24"
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"