@agentsdock/server 1.0.11 → 1.0.12-beta.1

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.
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """Send a run-scoped notification whose level is chosen by the agent."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import sys
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ import uuid
14
+ from typing import Any
15
+
16
+ from agentsdock_emergency import (
17
+ EmergencyCLIError,
18
+ NoRedirectHandler,
19
+ clean_emergency_message,
20
+ nonempty_chat_id,
21
+ required_environment,
22
+ )
23
+
24
+
25
+ def send_notification(
26
+ level: str,
27
+ message: str,
28
+ *,
29
+ authority_file: str | None = None,
30
+ chat_id: str | None = None,
31
+ request_id: str | None = None,
32
+ ) -> dict[str, Any]:
33
+ clean_message = clean_emergency_message(message)
34
+ if not clean_message or len(clean_message) > 500:
35
+ raise EmergencyCLIError("--message must contain 1 to 500 characters")
36
+ if level not in {"normal", "important", "emergency"}:
37
+ raise EmergencyCLIError("--level must be normal, important, or emergency")
38
+ server_url, authority_chat_id, token = required_environment(authority_file, chat_id)
39
+ request_id = request_id or f"note_{uuid.uuid4().hex}"
40
+ if not re.fullmatch(r"[A-Za-z0-9._:-]{8,128}", request_id):
41
+ raise EmergencyCLIError("--request-id is invalid")
42
+ body = json.dumps({"request_id": request_id, "level": level, "message": clean_message},
43
+ separators=(",", ":")).encode("utf-8")
44
+ url = f"{server_url}/api/agent/sessions/{urllib.parse.quote(authority_chat_id, safe='')}/notifications"
45
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirectHandler())
46
+ decoded: Any = None
47
+ ambiguous_error: BaseException | None = None
48
+ for attempt in range(2):
49
+ request = urllib.request.Request(
50
+ url, data=body,
51
+ headers={
52
+ "Accept": "application/json",
53
+ "Content-Type": "application/json",
54
+ "X-AgentsDock-Provider-Capability": token,
55
+ **({"X-AgentsDock-Notification-Retry": "1"} if attempt else {}),
56
+ },
57
+ method="POST",
58
+ )
59
+ try:
60
+ with opener.open(request, timeout=30) as response:
61
+ decoded = json.loads(response.read().decode("utf-8"))
62
+ ambiguous_error = None
63
+ break
64
+ except urllib.error.HTTPError as exc:
65
+ raw = exc.read().decode("utf-8", errors="replace")
66
+ try:
67
+ detail = json.loads(raw).get("detail") or raw
68
+ except json.JSONDecodeError:
69
+ detail = raw
70
+ raise EmergencyCLIError(
71
+ f"server rejected notification ({exc.code}): {detail or exc.reason}"
72
+ ) from exc
73
+ except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
74
+ ambiguous_error = exc
75
+ if ambiguous_error is not None:
76
+ reason = getattr(ambiguous_error, "reason", ambiguous_error)
77
+ raise EmergencyCLIError(f"could not confirm whether the notification was sent: {reason}") from ambiguous_error
78
+ if (
79
+ not isinstance(decoded, dict)
80
+ or decoded.get("ok") is not True
81
+ or decoded.get("chat_id") != authority_chat_id
82
+ or decoded.get("level") != level
83
+ or not isinstance(decoded.get("event_id"), str)
84
+ or not decoded["event_id"]
85
+ or not isinstance(decoded.get("event_seq"), int)
86
+ or isinstance(decoded["event_seq"], bool)
87
+ or decoded["event_seq"] <= 0
88
+ or not isinstance(decoded.get("duplicate"), bool)
89
+ ):
90
+ raise EmergencyCLIError("AgentsServer returned an invalid notification receipt")
91
+ return decoded
92
+
93
+
94
+ def build_parser() -> argparse.ArgumentParser:
95
+ parser = argparse.ArgumentParser(description="Notify the user from the current AgentsDock turn", allow_abbrev=False)
96
+ parser.add_argument("--authority-file")
97
+ parser.add_argument("--chat-id", type=nonempty_chat_id)
98
+ commands = parser.add_subparsers(dest="command", required=True)
99
+ send = commands.add_parser("send", allow_abbrev=False)
100
+ send.add_argument("--level", required=True, choices=("normal", "important", "emergency"))
101
+ send.add_argument("--message", required=True)
102
+ send.add_argument("--request-id")
103
+ return parser
104
+
105
+
106
+ def main(argv: list[str] | None = None) -> int:
107
+ args = build_parser().parse_args(argv)
108
+ try:
109
+ result = send_notification(
110
+ args.level, args.message,
111
+ authority_file=args.authority_file,
112
+ chat_id=args.chat_id,
113
+ request_id=args.request_id,
114
+ )
115
+ except EmergencyCLIError as exc:
116
+ print(f"error: {exc}", file=sys.stderr)
117
+ return 1
118
+ print(json.dumps(result, indent=2, sort_keys=True))
119
+ return 0
120
+
121
+
122
+ if __name__ == "__main__":
123
+ raise SystemExit(main())
@@ -136,6 +136,17 @@ def _message(row: sqlite3.Row) -> dict:
136
136
  return message
137
137
 
138
138
 
139
+ def validate_message_response_size(message: dict, *, now: str) -> None:
140
+ """Require the complete readable message to fit with response metadata."""
141
+ readable = {**message, "read_id": "mailread_" + "0" * 32, "read_at": now}
142
+ try:
143
+ size = len(json.dumps(readable, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
144
+ except UnicodeEncodeError as exc:
145
+ raise MailboxConflict("Message and user delegation must be valid UTF-8") from exc
146
+ if 8192 + size > MAX_PAGE_BYTES:
147
+ raise MailboxConflict("Message and source user instruction exceed the bounded mailbox response")
148
+
149
+
139
150
  def store_message(connection: sqlite3.Connection, message_id: str, *, now: str,
140
151
  in_reply_to_message_id: str | None = None) -> dict:
141
152
  """Register one already-authorized stored envelope in the same transaction."""
@@ -173,13 +184,7 @@ def store_message(connection: sqlite3.Connection, message_id: str, *, now: str,
173
184
  raise MailboxConflict("Stored mailbox identity changed")
174
185
  message = _message(row)
175
186
  # Include all provenance in the same bounded response before accepting.
176
- readable = {**message, "read_id": "mailread_" + "0" * 32, "read_at": now}
177
- try:
178
- size = len(json.dumps(readable, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
179
- except UnicodeEncodeError as exc:
180
- raise MailboxConflict("Message and user delegation must be valid UTF-8") from exc
181
- if 8192 + size > MAX_PAGE_BYTES:
182
- raise MailboxConflict("Message and source user instruction exceed the bounded mailbox response")
187
+ validate_message_response_size(message, now=now)
183
188
  return message
184
189
 
185
190