@agentsdock/server 1.0.7-beta.5
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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- package/server/workspace_git.py +545 -0
|
@@ -0,0 +1,1237 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Capability-scoped same-server chat contact CLI for AgentsDock agents."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import http.client
|
|
9
|
+
import ipaddress
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
# The legacy ``response_timeout_seconds`` wire field is now only a requested
|
|
22
|
+
# heartbeat interval. There is deliberately no client response-deadline
|
|
23
|
+
# constant. A provider tool call observes at most one bounded slice, then
|
|
24
|
+
# returns either the server's explicit pending receipt or an honest retryable
|
|
25
|
+
# transport receipt. The provider immediately invokes ``wait`` with those
|
|
26
|
+
# exact opaque IDs until the server lease reaches terminal state. Keeping
|
|
27
|
+
# every network observation at 30 seconds or less bounds the whole idempotent
|
|
28
|
+
# command safely below provider shell caps, instead of turning any
|
|
29
|
+
# provider-specific Bash limit into a cross-chat response deadline.
|
|
30
|
+
LIVE_RESPONSE_HEARTBEAT_SECONDS = 20
|
|
31
|
+
LIVE_RESPONSE_MAX_HEARTBEAT_SECONDS = 20
|
|
32
|
+
LIVE_RESPONSE_SOCKET_GRACE_SECONDS = 10
|
|
33
|
+
LIVE_RESPONSE_POST_SOCKET_SECONDS = 10
|
|
34
|
+
IDEMPOTENT_POST_RETRY_DELAYS_SECONDS = (0.1, 0.5)
|
|
35
|
+
IDEMPOTENT_GET_RETRY_DELAYS_SECONDS = (0.1, 0.5)
|
|
36
|
+
PROVIDER_RUNTIME_VALUE_MAX_BYTES = 4096
|
|
37
|
+
PROVIDER_RUNTIME_HANDLE_MAX_COUNT = 64
|
|
38
|
+
MESSAGE_STDIN_MAX_CHARS = 100_000
|
|
39
|
+
MESSAGE_STDIN_MAX_BYTES = 400 * 1024
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ChatsCLIError(RuntimeError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LiveWaitRetryable(ChatsCLIError):
|
|
47
|
+
"""One bounded live-wait slice lost transport, but its lease is intact."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
51
|
+
def redirect_request(self, *_args: Any, **_kwargs: Any) -> None:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def host_is_loopback(host: str) -> bool:
|
|
56
|
+
try:
|
|
57
|
+
address = ipaddress.ip_address(host)
|
|
58
|
+
except ValueError:
|
|
59
|
+
return host.lower() == "localhost"
|
|
60
|
+
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
|
|
61
|
+
address = address.ipv4_mapped
|
|
62
|
+
return address.is_loopback
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _canonical_http_origin(value: str, label: str) -> tuple[str, bool]:
|
|
66
|
+
raw = value.strip()
|
|
67
|
+
try:
|
|
68
|
+
parsed = urllib.parse.urlsplit(raw)
|
|
69
|
+
port = parsed.port or 80
|
|
70
|
+
except ValueError as exc:
|
|
71
|
+
raise ChatsCLIError(f"{label} must be an HTTP origin") from exc
|
|
72
|
+
if (
|
|
73
|
+
parsed.scheme.lower() != "http"
|
|
74
|
+
or not parsed.hostname
|
|
75
|
+
or parsed.username is not None
|
|
76
|
+
or parsed.password is not None
|
|
77
|
+
or parsed.path not in {"", "/"}
|
|
78
|
+
or parsed.query
|
|
79
|
+
or parsed.fragment
|
|
80
|
+
):
|
|
81
|
+
raise ChatsCLIError(f"{label} must be an HTTP origin")
|
|
82
|
+
host = parsed.hostname.lower()
|
|
83
|
+
try:
|
|
84
|
+
address = ipaddress.ip_address(host)
|
|
85
|
+
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
|
|
86
|
+
address = address.ipv4_mapped
|
|
87
|
+
host = address.compressed
|
|
88
|
+
loopback = address.is_loopback
|
|
89
|
+
url_host = f"[{host}]" if isinstance(address, ipaddress.IPv6Address) else host
|
|
90
|
+
except ValueError:
|
|
91
|
+
loopback = host == "localhost"
|
|
92
|
+
url_host = host
|
|
93
|
+
return f"http://{url_host}:{port}", loopback
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _authority_server_origin(path: str | None) -> str:
|
|
97
|
+
authority_path = _authority_path(path)
|
|
98
|
+
try:
|
|
99
|
+
if authority_path.stat().st_mode & 0o077:
|
|
100
|
+
raise ChatsCLIError("authority file permissions are unsafe")
|
|
101
|
+
payload = json.loads(authority_path.read_text(encoding="utf-8"))
|
|
102
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
103
|
+
raise ChatsCLIError(f"could not read authority file: {exc}") from exc
|
|
104
|
+
return _bounded_identity_value(
|
|
105
|
+
payload.get("provider_server_origin"),
|
|
106
|
+
"authority provider_server_origin",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def environment() -> str:
|
|
111
|
+
raw_server_url = os.environ.get("AGENTSDOCK_SERVER_URL", "").strip()
|
|
112
|
+
if not raw_server_url:
|
|
113
|
+
raise ChatsCLIError("missing AgentsDock agent environment")
|
|
114
|
+
server_origin, loopback = _canonical_http_origin(
|
|
115
|
+
raw_server_url,
|
|
116
|
+
"AGENTSDOCK_SERVER_URL",
|
|
117
|
+
)
|
|
118
|
+
runtime_origin = _bounded_runtime_value(
|
|
119
|
+
"AGENTSDOCK_PROVIDER_SERVER_ORIGIN"
|
|
120
|
+
)
|
|
121
|
+
if runtime_origin:
|
|
122
|
+
canonical_runtime, _runtime_loopback = _canonical_http_origin(
|
|
123
|
+
runtime_origin,
|
|
124
|
+
"AGENTSDOCK_PROVIDER_SERVER_ORIGIN",
|
|
125
|
+
)
|
|
126
|
+
if canonical_runtime != server_origin:
|
|
127
|
+
raise ChatsCLIError(
|
|
128
|
+
"AGENTSDOCK_SERVER_URL conflicts with the live provider origin"
|
|
129
|
+
)
|
|
130
|
+
if loopback:
|
|
131
|
+
return raw_server_url.rstrip("/")
|
|
132
|
+
authority_origin = _authority_server_origin(None)
|
|
133
|
+
if not authority_origin:
|
|
134
|
+
raise ChatsCLIError(
|
|
135
|
+
"non-loopback AGENTSDOCK_SERVER_URL must match the authority origin"
|
|
136
|
+
)
|
|
137
|
+
canonical_authority, _authority_loopback = _canonical_http_origin(
|
|
138
|
+
authority_origin,
|
|
139
|
+
"authority provider_server_origin",
|
|
140
|
+
)
|
|
141
|
+
if canonical_authority != server_origin:
|
|
142
|
+
raise ChatsCLIError(
|
|
143
|
+
"non-loopback AGENTSDOCK_SERVER_URL must match the authority origin"
|
|
144
|
+
)
|
|
145
|
+
return server_origin
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _bounded_identity_value(value: str | None, label: str) -> str:
|
|
149
|
+
clean = str(value or "").strip()
|
|
150
|
+
try:
|
|
151
|
+
encoded_size = len(clean.encode("utf-8"))
|
|
152
|
+
except UnicodeEncodeError as exc:
|
|
153
|
+
raise ChatsCLIError(f"{label} is not valid UTF-8") from exc
|
|
154
|
+
if encoded_size > PROVIDER_RUNTIME_VALUE_MAX_BYTES:
|
|
155
|
+
raise ChatsCLIError(f"{label} exceeds the provider runtime limit")
|
|
156
|
+
return clean
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _bounded_runtime_value(name: str) -> str:
|
|
160
|
+
return _bounded_identity_value(os.environ.get(name), name)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _authority_path(path: str | None) -> Path:
|
|
164
|
+
explicit = _bounded_identity_value(path, "--authority-file")
|
|
165
|
+
ambient = _bounded_runtime_value("AGENTSDOCK_PROVIDER_AUTHORITY_FILE")
|
|
166
|
+
if explicit and ambient:
|
|
167
|
+
explicit_key = os.path.abspath(os.path.expanduser(explicit))
|
|
168
|
+
ambient_key = os.path.abspath(os.path.expanduser(ambient))
|
|
169
|
+
if explicit_key != ambient_key:
|
|
170
|
+
raise ChatsCLIError(
|
|
171
|
+
"--authority-file conflicts with the live provider authority"
|
|
172
|
+
)
|
|
173
|
+
selected = explicit or ambient
|
|
174
|
+
if not selected:
|
|
175
|
+
raise ChatsCLIError("--authority-file is required")
|
|
176
|
+
return Path(selected).expanduser()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def authority(path: str | None) -> str:
|
|
180
|
+
authority_path = _authority_path(path)
|
|
181
|
+
try:
|
|
182
|
+
mode = authority_path.stat().st_mode & 0o777
|
|
183
|
+
if mode & 0o077:
|
|
184
|
+
raise ChatsCLIError("authority file permissions are unsafe")
|
|
185
|
+
payload = json.loads(authority_path.read_text(encoding="utf-8"))
|
|
186
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
187
|
+
raise ChatsCLIError(f"could not read authority file: {exc}") from exc
|
|
188
|
+
token = str(payload.get("provider_capability") or payload.get("capability") or "")
|
|
189
|
+
source_session_id = str(payload.get("source_session_id") or "").strip()
|
|
190
|
+
if not token or not source_session_id:
|
|
191
|
+
raise ChatsCLIError("authority file is invalid")
|
|
192
|
+
environment_chat_id = _bounded_runtime_value("AGENTSDOCK_CHAT_ID")
|
|
193
|
+
if environment_chat_id and environment_chat_id != source_session_id:
|
|
194
|
+
raise ChatsCLIError(
|
|
195
|
+
"AGENTSDOCK_CHAT_ID does not match the authority file"
|
|
196
|
+
)
|
|
197
|
+
return token
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def positive_target_index(value: str) -> int:
|
|
201
|
+
if len(value) > 2 or re.fullmatch(r"[1-9][0-9]*", value) is None:
|
|
202
|
+
raise argparse.ArgumentTypeError("--target-index must be a positive integer")
|
|
203
|
+
index = int(value)
|
|
204
|
+
if index > PROVIDER_RUNTIME_HANDLE_MAX_COUNT:
|
|
205
|
+
raise argparse.ArgumentTypeError(
|
|
206
|
+
f"--target-index must be at most {PROVIDER_RUNTIME_HANDLE_MAX_COUNT}"
|
|
207
|
+
)
|
|
208
|
+
return index
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def provider_handle(index: int, action: str) -> tuple[str, bool]:
|
|
212
|
+
count_text = _bounded_runtime_value("AGENTSDOCK_CROSS_CHAT_HANDLE_COUNT")
|
|
213
|
+
if re.fullmatch(r"0|[1-9][0-9]*", count_text) is None:
|
|
214
|
+
raise ChatsCLIError("the live @Chat handle count is unavailable")
|
|
215
|
+
count = int(count_text)
|
|
216
|
+
if count > PROVIDER_RUNTIME_HANDLE_MAX_COUNT or index > count:
|
|
217
|
+
raise ChatsCLIError("the requested @Chat handle is unavailable")
|
|
218
|
+
prefix = f"AGENTSDOCK_CROSS_CHAT_HANDLE_{index}"
|
|
219
|
+
handle = _bounded_runtime_value(prefix)
|
|
220
|
+
granted_action = _bounded_runtime_value(f"{prefix}_ACTION")
|
|
221
|
+
async_text = _bounded_runtime_value(f"{prefix}_ASYNC")
|
|
222
|
+
expected_action = "instruction" if action == "instruction" else "request_reply"
|
|
223
|
+
if not handle or granted_action != expected_action or async_text not in {"0", "1"}:
|
|
224
|
+
raise ChatsCLIError("the requested @Chat handle is unavailable")
|
|
225
|
+
if action == "instruction" and async_text != "0":
|
|
226
|
+
raise ChatsCLIError("the requested @Chat handle is malformed")
|
|
227
|
+
return handle, async_text == "1"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def respond_current(args: argparse.Namespace) -> dict[str, Any]:
|
|
231
|
+
if _bounded_runtime_value("AGENTSDOCK_CROSS_CHAT_RESPONSE_MODE") == "async_route_v1":
|
|
232
|
+
route_id = _bounded_runtime_value("AGENTSDOCK_CROSS_CHAT_RESPONSE_ROUTE_ID")
|
|
233
|
+
if re.fullmatch(r"route_[0-9a-f]{32}", route_id) is None:
|
|
234
|
+
raise ChatsCLIError("the current inbound conversation route is unavailable")
|
|
235
|
+
values = vars(args).copy()
|
|
236
|
+
values.update({"route": route_id, "target": None, "target_index": None,
|
|
237
|
+
"mode": "async_route_v1", "async_response": True})
|
|
238
|
+
return send_action(argparse.Namespace(**values), "instruction")
|
|
239
|
+
exchange_id = _bounded_runtime_value(
|
|
240
|
+
"AGENTSDOCK_CROSS_CHAT_RESPONSE_EXCHANGE_ID"
|
|
241
|
+
)
|
|
242
|
+
inbound_leg_id = _bounded_runtime_value(
|
|
243
|
+
"AGENTSDOCK_CROSS_CHAT_RESPONSE_INBOUND_LEG_ID"
|
|
244
|
+
)
|
|
245
|
+
followup = _bounded_runtime_value(
|
|
246
|
+
"AGENTSDOCK_CROSS_CHAT_RESPONSE_FOLLOWUP"
|
|
247
|
+
) or "none"
|
|
248
|
+
if (
|
|
249
|
+
re.fullmatch(r"exchange_[0-9a-f]{32}", exchange_id) is None
|
|
250
|
+
or re.fullmatch(r"leg_[0-9a-f]{32}", inbound_leg_id) is None
|
|
251
|
+
or followup not in {"none", "allowed", "allowed-async"}
|
|
252
|
+
):
|
|
253
|
+
raise ChatsCLIError("the current inbound reply grant is unavailable")
|
|
254
|
+
request_response = bool(args.request_response)
|
|
255
|
+
if request_response and followup == "none":
|
|
256
|
+
raise ChatsCLIError("the current inbound reply has no follow-up grant")
|
|
257
|
+
values = vars(args).copy()
|
|
258
|
+
values.update({
|
|
259
|
+
"exchange": exchange_id,
|
|
260
|
+
"inbound_leg": inbound_leg_id,
|
|
261
|
+
"async_response": request_response and followup == "allowed-async",
|
|
262
|
+
})
|
|
263
|
+
return respond(argparse.Namespace(**values))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def provider_headers(capability: str) -> dict[str, str]:
|
|
267
|
+
"""Return the one canonical header accepted by agent-helper routes.
|
|
268
|
+
|
|
269
|
+
The retired cross-chat-specific header is intentionally omitted. The
|
|
270
|
+
server rejects requests that mix legacy and current authority names so a
|
|
271
|
+
browser or stale helper cannot smuggle ambiguous credentials.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
"Accept": "application/json",
|
|
276
|
+
"X-AgentsDock-Provider-Capability": capability,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def post_json(
|
|
281
|
+
path: str,
|
|
282
|
+
payload: dict[str, Any],
|
|
283
|
+
capability: str,
|
|
284
|
+
) -> dict[str, Any]:
|
|
285
|
+
server_url = environment()
|
|
286
|
+
body = json.dumps(payload).encode("utf-8")
|
|
287
|
+
headers = {
|
|
288
|
+
**provider_headers(capability),
|
|
289
|
+
"Content-Type": "application/json",
|
|
290
|
+
}
|
|
291
|
+
request = urllib.request.Request(
|
|
292
|
+
f"{server_url}{path}",
|
|
293
|
+
data=body,
|
|
294
|
+
headers=headers,
|
|
295
|
+
method="POST",
|
|
296
|
+
)
|
|
297
|
+
opener = urllib.request.build_opener(
|
|
298
|
+
urllib.request.ProxyHandler({}),
|
|
299
|
+
NoRedirectHandler(),
|
|
300
|
+
)
|
|
301
|
+
promotion_deadline = time.monotonic() + 10.0
|
|
302
|
+
transport_retry = 0
|
|
303
|
+
while True:
|
|
304
|
+
try:
|
|
305
|
+
# The POST commits and returns a lease; response waiting happens
|
|
306
|
+
# through bounded GET heartbeats below.
|
|
307
|
+
socket_timeout = LIVE_RESPONSE_POST_SOCKET_SECONDS
|
|
308
|
+
with opener.open(request, timeout=socket_timeout) as response:
|
|
309
|
+
result = json.loads(response.read().decode("utf-8"))
|
|
310
|
+
break
|
|
311
|
+
except urllib.error.HTTPError as exc:
|
|
312
|
+
try:
|
|
313
|
+
raw = exc.read().decode("utf-8", errors="replace")
|
|
314
|
+
except (OSError, http.client.IncompleteRead) as read_exc:
|
|
315
|
+
retryable = bool(payload.get("idempotency_key"))
|
|
316
|
+
if (
|
|
317
|
+
retryable
|
|
318
|
+
and transport_retry
|
|
319
|
+
< len(IDEMPOTENT_POST_RETRY_DELAYS_SECONDS)
|
|
320
|
+
):
|
|
321
|
+
delay = IDEMPOTENT_POST_RETRY_DELAYS_SECONDS[
|
|
322
|
+
transport_retry
|
|
323
|
+
]
|
|
324
|
+
transport_retry += 1
|
|
325
|
+
time.sleep(delay)
|
|
326
|
+
continue
|
|
327
|
+
raise ChatsCLIError(
|
|
328
|
+
"could not confirm whether AgentsServer accepted the "
|
|
329
|
+
"request because its error response was truncated; do "
|
|
330
|
+
"not resend it with different wording"
|
|
331
|
+
) from read_exc
|
|
332
|
+
try:
|
|
333
|
+
detail = json.loads(raw).get("detail") or raw
|
|
334
|
+
except json.JSONDecodeError:
|
|
335
|
+
detail = raw
|
|
336
|
+
if (
|
|
337
|
+
exc.code == 409
|
|
338
|
+
and detail == "agent chat access is waiting for turn promotion"
|
|
339
|
+
and time.monotonic() < promotion_deadline
|
|
340
|
+
):
|
|
341
|
+
# The body and idempotency key are identical on every attempt.
|
|
342
|
+
# Promotion has made no durable target effect yet.
|
|
343
|
+
time.sleep(0.05)
|
|
344
|
+
continue
|
|
345
|
+
raise ChatsCLIError(
|
|
346
|
+
f"server rejected handoff ({exc.code}): {detail or exc.reason}"
|
|
347
|
+
) from exc
|
|
348
|
+
except (
|
|
349
|
+
urllib.error.URLError,
|
|
350
|
+
TimeoutError,
|
|
351
|
+
OSError,
|
|
352
|
+
http.client.IncompleteRead,
|
|
353
|
+
UnicodeDecodeError,
|
|
354
|
+
json.JSONDecodeError,
|
|
355
|
+
) as exc:
|
|
356
|
+
retryable = bool(payload.get("idempotency_key"))
|
|
357
|
+
if (
|
|
358
|
+
retryable
|
|
359
|
+
and transport_retry < len(IDEMPOTENT_POST_RETRY_DELAYS_SECONDS)
|
|
360
|
+
):
|
|
361
|
+
delay = IDEMPOTENT_POST_RETRY_DELAYS_SECONDS[transport_retry]
|
|
362
|
+
transport_retry += 1
|
|
363
|
+
# Reuse the byte-identical request and idempotency key. The
|
|
364
|
+
# prior server attempt may still commit after its socket dies.
|
|
365
|
+
time.sleep(delay)
|
|
366
|
+
continue
|
|
367
|
+
detail = getattr(exc, "reason", exc)
|
|
368
|
+
if retryable:
|
|
369
|
+
raise ChatsCLIError(
|
|
370
|
+
"could not confirm whether AgentsServer accepted the "
|
|
371
|
+
"request after retrying the same idempotency key; do not "
|
|
372
|
+
f"resend it with different wording: {detail}"
|
|
373
|
+
) from exc
|
|
374
|
+
raise ChatsCLIError(
|
|
375
|
+
f"could not reach AgentsServer: {detail}"
|
|
376
|
+
) from exc
|
|
377
|
+
if not isinstance(result, dict):
|
|
378
|
+
raise ChatsCLIError("AgentsServer returned an invalid response")
|
|
379
|
+
return result
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def get_json(
|
|
383
|
+
path: str,
|
|
384
|
+
capability: str,
|
|
385
|
+
*,
|
|
386
|
+
timeout: float = 30,
|
|
387
|
+
live_slice: bool = False,
|
|
388
|
+
) -> dict[str, Any]:
|
|
389
|
+
server_url = environment()
|
|
390
|
+
request = urllib.request.Request(
|
|
391
|
+
f"{server_url}{path}",
|
|
392
|
+
headers=provider_headers(capability),
|
|
393
|
+
method="GET",
|
|
394
|
+
)
|
|
395
|
+
opener = urllib.request.build_opener(
|
|
396
|
+
urllib.request.ProxyHandler({}),
|
|
397
|
+
NoRedirectHandler(),
|
|
398
|
+
)
|
|
399
|
+
transport_retry = 0
|
|
400
|
+
|
|
401
|
+
def retry_delay() -> float:
|
|
402
|
+
return IDEMPOTENT_GET_RETRY_DELAYS_SECONDS[transport_retry]
|
|
403
|
+
|
|
404
|
+
while True:
|
|
405
|
+
try:
|
|
406
|
+
with opener.open(request, timeout=timeout) as response:
|
|
407
|
+
result = json.loads(response.read().decode("utf-8"))
|
|
408
|
+
break
|
|
409
|
+
except urllib.error.HTTPError as exc:
|
|
410
|
+
try:
|
|
411
|
+
raw = exc.read().decode("utf-8", errors="replace")
|
|
412
|
+
except (OSError, http.client.IncompleteRead) as read_exc:
|
|
413
|
+
if live_slice:
|
|
414
|
+
raise LiveWaitRetryable(
|
|
415
|
+
"the live-response transport was interrupted"
|
|
416
|
+
) from read_exc
|
|
417
|
+
if (
|
|
418
|
+
transport_retry < len(IDEMPOTENT_GET_RETRY_DELAYS_SECONDS)
|
|
419
|
+
):
|
|
420
|
+
delay = retry_delay()
|
|
421
|
+
transport_retry += 1
|
|
422
|
+
time.sleep(delay)
|
|
423
|
+
continue
|
|
424
|
+
raise ChatsCLIError(
|
|
425
|
+
"AgentsServer returned a truncated error response after "
|
|
426
|
+
"retrying the exact live-response lease"
|
|
427
|
+
) from read_exc
|
|
428
|
+
try:
|
|
429
|
+
detail = json.loads(raw).get("detail") or raw
|
|
430
|
+
except json.JSONDecodeError:
|
|
431
|
+
detail = raw
|
|
432
|
+
if live_slice and (
|
|
433
|
+
exc.code in {408, 425, 429, 499}
|
|
434
|
+
or 500 <= exc.code <= 599
|
|
435
|
+
):
|
|
436
|
+
raise LiveWaitRetryable(
|
|
437
|
+
"the live-response transport is temporarily unavailable"
|
|
438
|
+
) from exc
|
|
439
|
+
raise ChatsCLIError(
|
|
440
|
+
f"server rejected request ({exc.code}): {detail or exc.reason}"
|
|
441
|
+
) from exc
|
|
442
|
+
except (
|
|
443
|
+
urllib.error.URLError,
|
|
444
|
+
TimeoutError,
|
|
445
|
+
OSError,
|
|
446
|
+
http.client.IncompleteRead,
|
|
447
|
+
) as exc:
|
|
448
|
+
if live_slice:
|
|
449
|
+
raise LiveWaitRetryable(
|
|
450
|
+
"the live-response transport is temporarily unavailable"
|
|
451
|
+
) from exc
|
|
452
|
+
if (
|
|
453
|
+
transport_retry < len(IDEMPOTENT_GET_RETRY_DELAYS_SECONDS)
|
|
454
|
+
):
|
|
455
|
+
delay = retry_delay()
|
|
456
|
+
transport_retry += 1
|
|
457
|
+
# GET is side-effect free and the live-response URL contains
|
|
458
|
+
# the same exact lease on every attempt. The server retains
|
|
459
|
+
# the result for this exact live provider-run owner.
|
|
460
|
+
time.sleep(delay)
|
|
461
|
+
continue
|
|
462
|
+
raise ChatsCLIError(
|
|
463
|
+
f"could not reach AgentsServer: {getattr(exc, 'reason', exc)}"
|
|
464
|
+
) from exc
|
|
465
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
466
|
+
# A malformed HTTP success body is a protocol error, not evidence
|
|
467
|
+
# that a busy peer still has work queued. A live slice must also
|
|
468
|
+
# remain one bounded provider-tool observation, so fail it
|
|
469
|
+
# immediately instead of multiplying its socket-timeout budget.
|
|
470
|
+
if live_slice:
|
|
471
|
+
raise ChatsCLIError(
|
|
472
|
+
"AgentsServer returned an invalid live-response body"
|
|
473
|
+
) from exc
|
|
474
|
+
# Other side-effect-free GETs retain the small ambiguity retry
|
|
475
|
+
# window, then fail instead of spinning forever on a corrupt or
|
|
476
|
+
# incompatible server.
|
|
477
|
+
if transport_retry < len(IDEMPOTENT_GET_RETRY_DELAYS_SECONDS):
|
|
478
|
+
delay = retry_delay()
|
|
479
|
+
transport_retry += 1
|
|
480
|
+
time.sleep(delay)
|
|
481
|
+
continue
|
|
482
|
+
raise ChatsCLIError(
|
|
483
|
+
"AgentsServer returned an invalid live-response body"
|
|
484
|
+
) from exc
|
|
485
|
+
if not isinstance(result, dict):
|
|
486
|
+
raise ChatsCLIError("AgentsServer returned an invalid response")
|
|
487
|
+
return result
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def live_response_heartbeat_seconds(value: int) -> int:
|
|
491
|
+
return max(1, min(int(value), LIVE_RESPONSE_MAX_HEARTBEAT_SECONDS))
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def await_live_response(
|
|
495
|
+
receipt: dict[str, Any],
|
|
496
|
+
capability: str,
|
|
497
|
+
timeout_seconds: int,
|
|
498
|
+
) -> dict[str, Any]:
|
|
499
|
+
exchange_id = str(receipt.get("exchange_id") or "")
|
|
500
|
+
inbound_leg_id = str(receipt.get("inbound_leg_id") or "")
|
|
501
|
+
lease_id = str(receipt.get("live_response_lease_id") or "")
|
|
502
|
+
if not re.fullmatch(r"exchange_[0-9a-f]{32}", exchange_id):
|
|
503
|
+
raise ChatsCLIError("live response exchange id is invalid")
|
|
504
|
+
if not re.fullmatch(r"leg_[0-9a-f]{32}", inbound_leg_id):
|
|
505
|
+
raise ChatsCLIError("live response inbound leg id is invalid")
|
|
506
|
+
if not re.fullmatch(r"lease_[0-9a-f]{32}", lease_id):
|
|
507
|
+
raise ChatsCLIError("live response lease id is invalid")
|
|
508
|
+
heartbeat_seconds = live_response_heartbeat_seconds(timeout_seconds)
|
|
509
|
+
query = urllib.parse.urlencode({
|
|
510
|
+
"lease_id": lease_id,
|
|
511
|
+
# Compatibility query name: the server treats it as a bounded
|
|
512
|
+
# transport heartbeat, never a total response deadline.
|
|
513
|
+
"timeout_seconds": heartbeat_seconds,
|
|
514
|
+
})
|
|
515
|
+
path = (
|
|
516
|
+
"/api/agent/cross-chat/exchanges/"
|
|
517
|
+
f"{urllib.parse.quote(exchange_id, safe='')}/legs/"
|
|
518
|
+
f"{urllib.parse.quote(inbound_leg_id, safe='')}/live-response?{query}"
|
|
519
|
+
)
|
|
520
|
+
answer_keys = {
|
|
521
|
+
"ok", "exchange_id", "inbound_leg_id", "body", "request_response",
|
|
522
|
+
}
|
|
523
|
+
deferred_keys = {
|
|
524
|
+
"ok", "exchange_id", "inbound_leg_id", "deferred", "delivery",
|
|
525
|
+
"message",
|
|
526
|
+
}
|
|
527
|
+
pending_keys = {"ok", "exchange_id", "inbound_leg_id", "pending"}
|
|
528
|
+
try:
|
|
529
|
+
result = get_json(
|
|
530
|
+
path,
|
|
531
|
+
capability,
|
|
532
|
+
timeout=(
|
|
533
|
+
heartbeat_seconds
|
|
534
|
+
+ LIVE_RESPONSE_SOCKET_GRACE_SECONDS
|
|
535
|
+
),
|
|
536
|
+
live_slice=True,
|
|
537
|
+
)
|
|
538
|
+
except LiveWaitRetryable:
|
|
539
|
+
# A transport failure says nothing about durable server state. In
|
|
540
|
+
# particular, the response may already be committed while the HTTP
|
|
541
|
+
# handler is still disconnecting. Never relabel that ambiguity as a
|
|
542
|
+
# genuine server-owned pending exchange. Return the same exact lease
|
|
543
|
+
# as a distinct retry receipt; replaying its GET is side-effect free
|
|
544
|
+
# and can recover a committed answer without resending the ask.
|
|
545
|
+
return {
|
|
546
|
+
"ok": False,
|
|
547
|
+
"exchange_id": exchange_id,
|
|
548
|
+
"inbound_leg_id": inbound_leg_id,
|
|
549
|
+
"live_response_lease_id": lease_id,
|
|
550
|
+
"transport_error": True,
|
|
551
|
+
"retryable": True,
|
|
552
|
+
"message": (
|
|
553
|
+
"AgentsServer did not confirm the live-response state because "
|
|
554
|
+
"the transport was interrupted. Retry the existing wait "
|
|
555
|
+
f"exactly with --exchange {exchange_id} "
|
|
556
|
+
f"--inbound-leg {inbound_leg_id} --lease {lease_id}; "
|
|
557
|
+
"do not resend the ask or change its wording."
|
|
558
|
+
),
|
|
559
|
+
}
|
|
560
|
+
valid_answer = (
|
|
561
|
+
set(result) == answer_keys
|
|
562
|
+
and isinstance(result.get("body"), str)
|
|
563
|
+
and isinstance(result.get("request_response"), bool)
|
|
564
|
+
)
|
|
565
|
+
valid_deferred = (
|
|
566
|
+
set(result) == deferred_keys
|
|
567
|
+
and result.get("deferred") is True
|
|
568
|
+
and result.get("delivery") == "asynchronous"
|
|
569
|
+
and isinstance(result.get("message"), str)
|
|
570
|
+
)
|
|
571
|
+
valid_pending = (
|
|
572
|
+
set(result) == pending_keys
|
|
573
|
+
and result.get("pending") is True
|
|
574
|
+
and result.get("inbound_leg_id") == inbound_leg_id
|
|
575
|
+
)
|
|
576
|
+
if (
|
|
577
|
+
not (valid_answer or valid_deferred or valid_pending)
|
|
578
|
+
or result.get("ok") is not True
|
|
579
|
+
or result.get("exchange_id") != exchange_id
|
|
580
|
+
or not isinstance(result.get("inbound_leg_id"), str)
|
|
581
|
+
):
|
|
582
|
+
raise ChatsCLIError("AgentsServer returned an invalid live response")
|
|
583
|
+
if valid_pending:
|
|
584
|
+
return {**result, "live_response_lease_id": lease_id}
|
|
585
|
+
return result
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def wait(args: argparse.Namespace) -> dict[str, Any]:
|
|
589
|
+
"""Observe one bounded slice of an already-committed live exchange."""
|
|
590
|
+
|
|
591
|
+
capability = authority(args.authority_file)
|
|
592
|
+
return await_live_response(
|
|
593
|
+
{
|
|
594
|
+
"exchange_id": args.exchange,
|
|
595
|
+
"inbound_leg_id": args.inbound_leg,
|
|
596
|
+
"live_response_lease_id": args.lease,
|
|
597
|
+
},
|
|
598
|
+
capability,
|
|
599
|
+
int(getattr(args, "timeout_seconds", LIVE_RESPONSE_HEARTBEAT_SECONDS)),
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def list_routes(args: argparse.Namespace) -> dict[str, Any]:
|
|
604
|
+
capability = authority(args.authority_file)
|
|
605
|
+
cursor = str(getattr(args, "cursor", None) or "")
|
|
606
|
+
if cursor and re.fullmatch(r"route_[0-9a-f]{32}", cursor) is None:
|
|
607
|
+
raise ChatsCLIError("--cursor must be the previous route page's next_cursor")
|
|
608
|
+
path = "/api/agent/cross-chat/routes"
|
|
609
|
+
if cursor:
|
|
610
|
+
path += "?" + urllib.parse.urlencode({"cursor": cursor})
|
|
611
|
+
result = get_json(path, capability)
|
|
612
|
+
routes = result.get("routes")
|
|
613
|
+
if not isinstance(routes, list) or any(
|
|
614
|
+
not isinstance(route, dict) for route in routes
|
|
615
|
+
):
|
|
616
|
+
raise ChatsCLIError("AgentsServer returned an invalid route list")
|
|
617
|
+
next_cursor = result.get("next_cursor")
|
|
618
|
+
if next_cursor is not None and (
|
|
619
|
+
not isinstance(next_cursor, str)
|
|
620
|
+
or re.fullmatch(r"route_[0-9a-f]{32}", next_cursor) is None
|
|
621
|
+
or next_cursor == cursor
|
|
622
|
+
):
|
|
623
|
+
raise ChatsCLIError("AgentsServer returned an invalid route cursor")
|
|
624
|
+
if cursor and "next_cursor" not in result:
|
|
625
|
+
raise ChatsCLIError("this AgentsServer does not support paginated route discovery")
|
|
626
|
+
return result
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def inbox(args: argparse.Namespace) -> dict[str, Any]:
|
|
630
|
+
"""List body-free pending senders; never claim or start their messages."""
|
|
631
|
+
capability = authority(args.authority_file)
|
|
632
|
+
path = "/api/agent/cross-chat/inbox"
|
|
633
|
+
cursor = getattr(args, "cursor", None)
|
|
634
|
+
if cursor is not None:
|
|
635
|
+
if not isinstance(cursor, str) or not 1 <= len(cursor) <= 128:
|
|
636
|
+
raise ChatsCLIError("--cursor must be the previous inbox page's next_cursor")
|
|
637
|
+
path += "?" + urllib.parse.urlencode({"cursor": cursor})
|
|
638
|
+
return get_json(path, capability)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def read_inbox(args: argparse.Namespace) -> dict[str, Any]:
|
|
642
|
+
"""Explicitly claim one sender page using a caller-stable durable receipt."""
|
|
643
|
+
sender = str(args.sender or "").strip()
|
|
644
|
+
request_id = str(args.request_id or "").strip()
|
|
645
|
+
if not 1 <= len(sender) <= 128:
|
|
646
|
+
raise ChatsCLIError("--sender must contain 1 to 128 characters")
|
|
647
|
+
if not 8 <= len(request_id) <= 128:
|
|
648
|
+
raise ChatsCLIError("--request-id must contain 8 to 128 characters and be reused for retries")
|
|
649
|
+
capability = authority(args.authority_file)
|
|
650
|
+
return post_json("/api/agent/cross-chat/inbox/read", {
|
|
651
|
+
"source_session_id": sender, "request_id": request_id,
|
|
652
|
+
"after_seq": args.cursor, "limit": args.limit,
|
|
653
|
+
}, capability)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def inbox_sequence(value: str) -> int:
|
|
657
|
+
if len(value) > 19 or re.fullmatch(r"0|[1-9][0-9]*", value) is None or int(value) > 2**63 - 1:
|
|
658
|
+
raise argparse.ArgumentTypeError("--cursor must be a nonnegative sequence from the previous response")
|
|
659
|
+
return int(value)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def negotiated_route_mode(capability: str, route_id: str, requested: str = "") -> str:
|
|
663
|
+
"""Discover mode through a read before sending any state-changing request."""
|
|
664
|
+
|
|
665
|
+
# Older servers ignore this additive query and return their complete route
|
|
666
|
+
# list; keep exact filtering for both contracts. New servers return only
|
|
667
|
+
# the requested live route, including routes beyond the first list page.
|
|
668
|
+
response = get_json(
|
|
669
|
+
"/api/agent/cross-chat/routes?" + urllib.parse.urlencode({"route_id": route_id}),
|
|
670
|
+
capability,
|
|
671
|
+
)
|
|
672
|
+
routes = response.get("routes")
|
|
673
|
+
if not isinstance(routes, list):
|
|
674
|
+
raise ChatsCLIError("AgentsServer returned an invalid route list")
|
|
675
|
+
matches = [route for route in routes if isinstance(route, dict)
|
|
676
|
+
and route.get("route_id") == route_id]
|
|
677
|
+
if len(matches) != 1 or matches[0].get("available") is not True:
|
|
678
|
+
raise ChatsCLIError("the requested route is unavailable")
|
|
679
|
+
mode = str(matches[0].get("mode") or "")
|
|
680
|
+
if mode not in {"", "async_route_v1"} or (requested and mode != requested):
|
|
681
|
+
raise ChatsCLIError("AgentsServer did not negotiate the requested conversation mode")
|
|
682
|
+
return mode
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def send_action(args: argparse.Namespace, action: str) -> dict[str, Any]:
|
|
686
|
+
capability = authority(args.authority_file)
|
|
687
|
+
message = str(args.message or "").strip()
|
|
688
|
+
if not message:
|
|
689
|
+
raise ChatsCLIError("--message must not be empty")
|
|
690
|
+
route = str(getattr(args, "route", None) or "")
|
|
691
|
+
target = str(getattr(args, "target", None) or "")
|
|
692
|
+
target_index = getattr(args, "target_index", None)
|
|
693
|
+
if sum((bool(route), bool(target), target_index is not None)) != 1:
|
|
694
|
+
raise ChatsCLIError(
|
|
695
|
+
"provide exactly one of --route, --target, or --target-index"
|
|
696
|
+
)
|
|
697
|
+
if target_index is not None:
|
|
698
|
+
if bool(getattr(args, "async_response", False)):
|
|
699
|
+
raise ChatsCLIError(
|
|
700
|
+
"--async-response is selected by the live @Chat grant"
|
|
701
|
+
)
|
|
702
|
+
target, grant_is_async = provider_handle(int(target_index), action)
|
|
703
|
+
if action == "request_reply":
|
|
704
|
+
args.async_response = grant_is_async
|
|
705
|
+
destination = route if route else target
|
|
706
|
+
requested_mode = str(getattr(args, "mode", None) or "")
|
|
707
|
+
reply_to = str(getattr(args, "reply_to", None) or "").strip()
|
|
708
|
+
if reply_to and (not route or re.fullmatch(r"handoff_[0-9a-f]{32}", reply_to) is None):
|
|
709
|
+
raise ChatsCLIError("--reply-to requires an exact asynchronous route and message ID from the inbox")
|
|
710
|
+
if requested_mode and not route:
|
|
711
|
+
raise ChatsCLIError("conversation mode requires an exact route")
|
|
712
|
+
discover_mode = bool(requested_mode or reply_to) or (
|
|
713
|
+
_bounded_runtime_value("AGENTSDOCK_CROSS_CHAT_MODE") == "async_route_v1"
|
|
714
|
+
)
|
|
715
|
+
mode = negotiated_route_mode(capability, route, requested_mode) if route and discover_mode else ""
|
|
716
|
+
if reply_to and mode != "async_route_v1":
|
|
717
|
+
raise ChatsCLIError("--reply-to is not supported by legacy exchanges")
|
|
718
|
+
if mode == "async_route_v1":
|
|
719
|
+
# Ask is an explicitly sent question in this mode. Any response is a
|
|
720
|
+
# separate message, so neither alias opens a legacy exchange or wait.
|
|
721
|
+
action = "instruction"
|
|
722
|
+
live_wait = (
|
|
723
|
+
action == "request_reply"
|
|
724
|
+
and mode != "async_route_v1"
|
|
725
|
+
and not bool(getattr(args, "async_response", False))
|
|
726
|
+
)
|
|
727
|
+
stable_key = "cli_" + hashlib.sha256(
|
|
728
|
+
(
|
|
729
|
+
f"{capability}\0{action}\0"
|
|
730
|
+
f"{'route' if route else 'target'}\0{destination}\0"
|
|
731
|
+
f"{int(live_wait)}\0{message}" + (f"\0reply:{reply_to}" if reply_to else "")
|
|
732
|
+
).encode("utf-8")
|
|
733
|
+
).hexdigest()
|
|
734
|
+
payload: dict[str, Any] = {
|
|
735
|
+
"action": action,
|
|
736
|
+
"body": message,
|
|
737
|
+
"idempotency_key": args.idempotency_key or stable_key,
|
|
738
|
+
"artifact_grants": [],
|
|
739
|
+
}
|
|
740
|
+
if mode:
|
|
741
|
+
payload["mode"] = mode
|
|
742
|
+
if reply_to:
|
|
743
|
+
payload["reply_to_message_id"] = reply_to
|
|
744
|
+
if live_wait:
|
|
745
|
+
heartbeat_seconds = live_response_heartbeat_seconds(
|
|
746
|
+
int(getattr(
|
|
747
|
+
args,
|
|
748
|
+
"timeout_seconds",
|
|
749
|
+
LIVE_RESPONSE_HEARTBEAT_SECONDS,
|
|
750
|
+
))
|
|
751
|
+
)
|
|
752
|
+
payload["wait_for_response"] = True
|
|
753
|
+
payload["response_timeout_seconds"] = heartbeat_seconds
|
|
754
|
+
if route:
|
|
755
|
+
path = (
|
|
756
|
+
"/api/agent/cross-chat/routes/"
|
|
757
|
+
f"{urllib.parse.quote(route, safe='')}/handoffs"
|
|
758
|
+
)
|
|
759
|
+
else:
|
|
760
|
+
path = "/api/agent/cross-chat/handoffs"
|
|
761
|
+
payload["target_session_id"] = target
|
|
762
|
+
result = post_json(path, payload, capability)
|
|
763
|
+
if mode == "async_route_v1":
|
|
764
|
+
receipt_fields = {"ok", "route_id", "action", "accepted", "mode", "message_id", "duplicate"}
|
|
765
|
+
mailbox_fields = {"delivery_mode", "state", "execution_started"}
|
|
766
|
+
if (set(result) not in (receipt_fields, receipt_fields | mailbox_fields,
|
|
767
|
+
receipt_fields | mailbox_fields | {"wake_policy"})
|
|
768
|
+
or result.get("ok") is not True or result.get("accepted") is not True
|
|
769
|
+
or result.get("route_id") != route or result.get("action") != "instruction"
|
|
770
|
+
or result.get("mode") != mode or not isinstance(result.get("duplicate"), bool)
|
|
771
|
+
or ("delivery_mode" in result and (result.get("delivery_mode") != "mailbox"
|
|
772
|
+
or result.get("state") not in {"unread", "read", "cancelled", "deleted"}
|
|
773
|
+
or result.get("execution_started") is not False))
|
|
774
|
+
or ("wake_policy" in result and result["wake_policy"] != "idle_only")
|
|
775
|
+
or re.fullmatch(r"handoff_[0-9a-f]{32}", str(result.get("message_id") or "")) is None):
|
|
776
|
+
raise ChatsCLIError(
|
|
777
|
+
"AgentsServer returned an invalid asynchronous message receipt. "
|
|
778
|
+
"Delivery may already be stored; do not resend with a new idempotency key."
|
|
779
|
+
)
|
|
780
|
+
return result
|
|
781
|
+
minimal_expected = {"ok", "action", "accepted"}
|
|
782
|
+
if route:
|
|
783
|
+
minimal_expected.add("route_id")
|
|
784
|
+
expected = set(minimal_expected)
|
|
785
|
+
deferred_expected = set(minimal_expected)
|
|
786
|
+
wait_expected = set(minimal_expected)
|
|
787
|
+
pending_expected = set(minimal_expected)
|
|
788
|
+
if live_wait:
|
|
789
|
+
expected.update({
|
|
790
|
+
"exchange_id",
|
|
791
|
+
"inbound_leg_id",
|
|
792
|
+
"body",
|
|
793
|
+
"request_response",
|
|
794
|
+
})
|
|
795
|
+
wait_expected.update({
|
|
796
|
+
"exchange_id",
|
|
797
|
+
"inbound_leg_id",
|
|
798
|
+
"live_response_lease_id",
|
|
799
|
+
})
|
|
800
|
+
pending_expected.update({
|
|
801
|
+
"exchange_id",
|
|
802
|
+
"inbound_leg_id",
|
|
803
|
+
"live_response_lease_id",
|
|
804
|
+
"pending",
|
|
805
|
+
})
|
|
806
|
+
deferred_expected.update({
|
|
807
|
+
"exchange_id",
|
|
808
|
+
"inbound_leg_id",
|
|
809
|
+
"deferred",
|
|
810
|
+
"delivery",
|
|
811
|
+
"message",
|
|
812
|
+
})
|
|
813
|
+
if frozenset(result) == frozenset(wait_expected):
|
|
814
|
+
result = {
|
|
815
|
+
**{key: result[key] for key in minimal_expected},
|
|
816
|
+
"exchange_id": result["exchange_id"],
|
|
817
|
+
"inbound_leg_id": result["inbound_leg_id"],
|
|
818
|
+
"live_response_lease_id": result["live_response_lease_id"],
|
|
819
|
+
"pending": True,
|
|
820
|
+
}
|
|
821
|
+
elif frozenset(result) == frozenset(minimal_expected):
|
|
822
|
+
raise ChatsCLIError(
|
|
823
|
+
"AgentsServer does not support a live response for this route"
|
|
824
|
+
)
|
|
825
|
+
has_live_response = live_wait and frozenset(result) == frozenset(expected)
|
|
826
|
+
has_deferred_response = (
|
|
827
|
+
live_wait and frozenset(result) == frozenset(deferred_expected)
|
|
828
|
+
)
|
|
829
|
+
has_pending_response = (
|
|
830
|
+
live_wait and frozenset(result) == frozenset(pending_expected)
|
|
831
|
+
)
|
|
832
|
+
if route:
|
|
833
|
+
if (
|
|
834
|
+
frozenset(result) not in {
|
|
835
|
+
frozenset(minimal_expected),
|
|
836
|
+
frozenset(expected),
|
|
837
|
+
frozenset(deferred_expected),
|
|
838
|
+
frozenset(pending_expected),
|
|
839
|
+
}
|
|
840
|
+
or result.get("ok") is not True
|
|
841
|
+
or result.get("route_id") != route
|
|
842
|
+
or result.get("action") != action
|
|
843
|
+
or result.get("accepted") is not True
|
|
844
|
+
or (has_live_response and not isinstance(result.get("body"), str))
|
|
845
|
+
or (
|
|
846
|
+
has_pending_response
|
|
847
|
+
and (
|
|
848
|
+
result.get("pending") is not True
|
|
849
|
+
or not isinstance(result.get("live_response_lease_id"), str)
|
|
850
|
+
)
|
|
851
|
+
)
|
|
852
|
+
or (
|
|
853
|
+
has_deferred_response
|
|
854
|
+
and (
|
|
855
|
+
result.get("deferred") is not True
|
|
856
|
+
or result.get("delivery") != "asynchronous"
|
|
857
|
+
)
|
|
858
|
+
)
|
|
859
|
+
):
|
|
860
|
+
raise ChatsCLIError("AgentsServer returned an invalid route handoff response")
|
|
861
|
+
else:
|
|
862
|
+
if (
|
|
863
|
+
frozenset(result) not in {
|
|
864
|
+
frozenset(minimal_expected),
|
|
865
|
+
frozenset(expected),
|
|
866
|
+
frozenset(deferred_expected),
|
|
867
|
+
frozenset(pending_expected),
|
|
868
|
+
}
|
|
869
|
+
or result.get("ok") is not True
|
|
870
|
+
or result.get("action") != action
|
|
871
|
+
or result.get("accepted") is not True
|
|
872
|
+
or (has_live_response and not isinstance(result.get("body"), str))
|
|
873
|
+
or (
|
|
874
|
+
has_pending_response
|
|
875
|
+
and (
|
|
876
|
+
result.get("pending") is not True
|
|
877
|
+
or not isinstance(result.get("live_response_lease_id"), str)
|
|
878
|
+
)
|
|
879
|
+
)
|
|
880
|
+
or (
|
|
881
|
+
has_deferred_response
|
|
882
|
+
and (
|
|
883
|
+
result.get("deferred") is not True
|
|
884
|
+
or result.get("delivery") != "asynchronous"
|
|
885
|
+
)
|
|
886
|
+
)
|
|
887
|
+
):
|
|
888
|
+
raise ChatsCLIError("AgentsServer returned an invalid direct handoff response")
|
|
889
|
+
return result
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def send(args: argparse.Namespace) -> dict[str, Any]:
|
|
893
|
+
return send_action(args, "instruction")
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def ask(args: argparse.Namespace) -> dict[str, Any]:
|
|
897
|
+
return send_action(args, "request_reply")
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def respond(args: argparse.Namespace) -> dict[str, Any]:
|
|
901
|
+
capability = authority(args.authority_file)
|
|
902
|
+
message = str(args.message or "").strip()
|
|
903
|
+
if not message:
|
|
904
|
+
raise ChatsCLIError("--message must not be empty")
|
|
905
|
+
request_response = bool(args.request_response)
|
|
906
|
+
async_response = bool(getattr(args, "async_response", False))
|
|
907
|
+
if async_response and not request_response:
|
|
908
|
+
raise ChatsCLIError("--async-response requires --request-response")
|
|
909
|
+
live_wait = request_response and not async_response
|
|
910
|
+
stable_key = "cli_" + hashlib.sha256(
|
|
911
|
+
(
|
|
912
|
+
f"{capability}\0respond\0{args.exchange}\0{args.inbound_leg}\0"
|
|
913
|
+
f"{int(request_response)}\0{int(live_wait)}\0{message}"
|
|
914
|
+
).encode("utf-8")
|
|
915
|
+
).hexdigest()
|
|
916
|
+
payload = {
|
|
917
|
+
"inbound_leg_id": args.inbound_leg,
|
|
918
|
+
"body": message,
|
|
919
|
+
"request_response": request_response,
|
|
920
|
+
"idempotency_key": args.idempotency_key or stable_key,
|
|
921
|
+
"artifact_grants": [],
|
|
922
|
+
}
|
|
923
|
+
if live_wait:
|
|
924
|
+
heartbeat_seconds = live_response_heartbeat_seconds(
|
|
925
|
+
int(getattr(
|
|
926
|
+
args,
|
|
927
|
+
"timeout_seconds",
|
|
928
|
+
LIVE_RESPONSE_HEARTBEAT_SECONDS,
|
|
929
|
+
))
|
|
930
|
+
)
|
|
931
|
+
payload["wait_for_response"] = True
|
|
932
|
+
payload["response_timeout_seconds"] = heartbeat_seconds
|
|
933
|
+
result = post_json(
|
|
934
|
+
f"/api/agent/cross-chat/exchanges/{urllib.parse.quote(args.exchange, safe='')}/responses",
|
|
935
|
+
payload,
|
|
936
|
+
capability,
|
|
937
|
+
)
|
|
938
|
+
minimal_expected = {"ok", "action", "accepted"}
|
|
939
|
+
expected = set(minimal_expected)
|
|
940
|
+
deferred_expected = set(minimal_expected)
|
|
941
|
+
wait_expected = set(minimal_expected)
|
|
942
|
+
pending_expected = set(minimal_expected)
|
|
943
|
+
if live_wait:
|
|
944
|
+
expected.update({
|
|
945
|
+
"exchange_id",
|
|
946
|
+
"inbound_leg_id",
|
|
947
|
+
"body",
|
|
948
|
+
"request_response",
|
|
949
|
+
})
|
|
950
|
+
wait_expected.update({
|
|
951
|
+
"exchange_id",
|
|
952
|
+
"inbound_leg_id",
|
|
953
|
+
"live_response_lease_id",
|
|
954
|
+
})
|
|
955
|
+
pending_expected.update({
|
|
956
|
+
"exchange_id",
|
|
957
|
+
"inbound_leg_id",
|
|
958
|
+
"live_response_lease_id",
|
|
959
|
+
"pending",
|
|
960
|
+
})
|
|
961
|
+
deferred_expected.update({
|
|
962
|
+
"exchange_id",
|
|
963
|
+
"inbound_leg_id",
|
|
964
|
+
"deferred",
|
|
965
|
+
"delivery",
|
|
966
|
+
"message",
|
|
967
|
+
})
|
|
968
|
+
if frozenset(result) == frozenset(wait_expected):
|
|
969
|
+
result = {
|
|
970
|
+
**{key: result[key] for key in minimal_expected},
|
|
971
|
+
"exchange_id": result["exchange_id"],
|
|
972
|
+
"inbound_leg_id": result["inbound_leg_id"],
|
|
973
|
+
"live_response_lease_id": result["live_response_lease_id"],
|
|
974
|
+
"pending": True,
|
|
975
|
+
}
|
|
976
|
+
elif frozenset(result) == frozenset(minimal_expected):
|
|
977
|
+
raise ChatsCLIError(
|
|
978
|
+
"AgentsServer does not support a live follow-up response"
|
|
979
|
+
)
|
|
980
|
+
has_live_response = live_wait and frozenset(result) == frozenset(expected)
|
|
981
|
+
has_deferred_response = (
|
|
982
|
+
live_wait and frozenset(result) == frozenset(deferred_expected)
|
|
983
|
+
)
|
|
984
|
+
has_pending_response = (
|
|
985
|
+
live_wait and frozenset(result) == frozenset(pending_expected)
|
|
986
|
+
)
|
|
987
|
+
if (
|
|
988
|
+
frozenset(result) not in {
|
|
989
|
+
frozenset(minimal_expected),
|
|
990
|
+
frozenset(expected),
|
|
991
|
+
frozenset(deferred_expected),
|
|
992
|
+
frozenset(pending_expected),
|
|
993
|
+
}
|
|
994
|
+
or result.get("ok") is not True
|
|
995
|
+
or result.get("action") != "response"
|
|
996
|
+
or result.get("accepted") is not True
|
|
997
|
+
or (has_live_response and not isinstance(result.get("body"), str))
|
|
998
|
+
or (
|
|
999
|
+
has_pending_response
|
|
1000
|
+
and (
|
|
1001
|
+
result.get("pending") is not True
|
|
1002
|
+
or not isinstance(result.get("live_response_lease_id"), str)
|
|
1003
|
+
)
|
|
1004
|
+
)
|
|
1005
|
+
or (
|
|
1006
|
+
has_deferred_response
|
|
1007
|
+
and (
|
|
1008
|
+
result.get("deferred") is not True
|
|
1009
|
+
or result.get("delivery") != "asynchronous"
|
|
1010
|
+
)
|
|
1011
|
+
)
|
|
1012
|
+
):
|
|
1013
|
+
raise ChatsCLIError(
|
|
1014
|
+
"AgentsServer returned an invalid cross-chat response"
|
|
1015
|
+
)
|
|
1016
|
+
return result
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def read_message_stdin() -> str:
|
|
1020
|
+
"""Read an explicitly selected body, bounded before any authority or I/O."""
|
|
1021
|
+
if sys.stdin.isatty():
|
|
1022
|
+
raise ChatsCLIError("--message-stdin requires piped text; no message was sent")
|
|
1023
|
+
stream = getattr(sys.stdin, "buffer", sys.stdin)
|
|
1024
|
+
raw = stream.read(MESSAGE_STDIN_MAX_BYTES + 1)
|
|
1025
|
+
try:
|
|
1026
|
+
message = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
|
1027
|
+
size = len(message.encode("utf-8"))
|
|
1028
|
+
except (UnicodeDecodeError, UnicodeEncodeError) as exc:
|
|
1029
|
+
raise ChatsCLIError("message stdin is not valid UTF-8; no message was sent") from exc
|
|
1030
|
+
if size > MESSAGE_STDIN_MAX_BYTES or len(message) > MESSAGE_STDIN_MAX_CHARS:
|
|
1031
|
+
raise ChatsCLIError("message stdin is too large; no message was sent")
|
|
1032
|
+
if "\x00" in message or not message.strip():
|
|
1033
|
+
raise ChatsCLIError("message stdin must contain nonempty text; no message was sent")
|
|
1034
|
+
return message
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def add_message_arguments(command: argparse.ArgumentParser) -> None:
|
|
1038
|
+
command.epilog = (
|
|
1039
|
+
"Preserve normal word spacing, punctuation, and paragraph breaks in message bodies; "
|
|
1040
|
+
"keep technical summaries concise without concatenating words or numbers."
|
|
1041
|
+
)
|
|
1042
|
+
body = command.add_mutually_exclusive_group(required=True)
|
|
1043
|
+
body.add_argument("--message")
|
|
1044
|
+
body.add_argument("--message-stdin", action="store_true", help="read the message body from stdin")
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def parser() -> argparse.ArgumentParser:
|
|
1048
|
+
root = argparse.ArgumentParser(
|
|
1049
|
+
description="Contact an eligible chat on this AgentsDock server.",
|
|
1050
|
+
allow_abbrev=False,
|
|
1051
|
+
)
|
|
1052
|
+
root.add_argument(
|
|
1053
|
+
"--authority-file",
|
|
1054
|
+
help=(
|
|
1055
|
+
"mode-0600 per-run authority file; defaults to the live provider "
|
|
1056
|
+
"environment"
|
|
1057
|
+
),
|
|
1058
|
+
)
|
|
1059
|
+
commands = root.add_subparsers(dest="command", required=True)
|
|
1060
|
+
list_command = commands.add_parser(
|
|
1061
|
+
"list",
|
|
1062
|
+
help="list one page of eligible same-server chats for this live run",
|
|
1063
|
+
allow_abbrev=False,
|
|
1064
|
+
)
|
|
1065
|
+
list_command.add_argument(
|
|
1066
|
+
"--cursor",
|
|
1067
|
+
help="continue listing with the previous response's non-null next_cursor",
|
|
1068
|
+
)
|
|
1069
|
+
list_command.set_defaults(handler=list_routes)
|
|
1070
|
+
inbox_command = commands.add_parser(
|
|
1071
|
+
"inbox", help="list pending senders without reading or running their messages", allow_abbrev=False,
|
|
1072
|
+
)
|
|
1073
|
+
inbox_command.add_argument("--cursor", help="opaque next_cursor from the previous sender page")
|
|
1074
|
+
inbox_command.set_defaults(handler=inbox)
|
|
1075
|
+
read_command = commands.add_parser(
|
|
1076
|
+
"read", help="explicitly read and claim one sender's pending messages; never automatically reply", allow_abbrev=False,
|
|
1077
|
+
)
|
|
1078
|
+
read_command.add_argument("--sender", required=True)
|
|
1079
|
+
read_command.add_argument("--request-id", required=True, help="stable 8 to 128 character receipt ID; reuse exactly on retry")
|
|
1080
|
+
read_command.add_argument("--cursor", type=inbox_sequence, default=0)
|
|
1081
|
+
read_command.add_argument("--limit", type=int, choices=range(1, 26), default=25)
|
|
1082
|
+
read_command.set_defaults(handler=read_inbox)
|
|
1083
|
+
command = commands.add_parser(
|
|
1084
|
+
"send",
|
|
1085
|
+
help="send one authorized instruction",
|
|
1086
|
+
allow_abbrev=False,
|
|
1087
|
+
)
|
|
1088
|
+
send_destination = command.add_mutually_exclusive_group(required=True)
|
|
1089
|
+
send_destination.add_argument("--route")
|
|
1090
|
+
send_destination.add_argument("--target")
|
|
1091
|
+
send_destination.add_argument("--target-index", type=positive_target_index)
|
|
1092
|
+
add_message_arguments(command)
|
|
1093
|
+
command.add_argument("--idempotency-key")
|
|
1094
|
+
command.add_argument("--mode", choices=["async_route_v1"])
|
|
1095
|
+
command.add_argument("--reply-to", help="exact received message ID; asynchronous routes only")
|
|
1096
|
+
command.set_defaults(handler=send)
|
|
1097
|
+
ask_command = commands.add_parser(
|
|
1098
|
+
"ask",
|
|
1099
|
+
help="ask a same-server agent and wait until it answers or is stopped",
|
|
1100
|
+
allow_abbrev=False,
|
|
1101
|
+
)
|
|
1102
|
+
ask_destination = ask_command.add_mutually_exclusive_group(required=True)
|
|
1103
|
+
ask_destination.add_argument("--route")
|
|
1104
|
+
ask_destination.add_argument("--target")
|
|
1105
|
+
ask_destination.add_argument("--target-index", type=positive_target_index)
|
|
1106
|
+
add_message_arguments(ask_command)
|
|
1107
|
+
ask_command.add_argument("--idempotency-key")
|
|
1108
|
+
ask_command.add_argument("--mode", choices=["async_route_v1"])
|
|
1109
|
+
ask_command.add_argument("--reply-to", help="exact received message ID; asynchronous routes only")
|
|
1110
|
+
ask_command.add_argument(
|
|
1111
|
+
"--async-response",
|
|
1112
|
+
action="store_true",
|
|
1113
|
+
help=(
|
|
1114
|
+
"return after durable send and receive the peer reply in a later "
|
|
1115
|
+
"turn (required for secure-peer routes)"
|
|
1116
|
+
),
|
|
1117
|
+
)
|
|
1118
|
+
ask_command.add_argument(
|
|
1119
|
+
"--timeout-seconds",
|
|
1120
|
+
type=int,
|
|
1121
|
+
choices=range(1, 3601),
|
|
1122
|
+
default=LIVE_RESPONSE_HEARTBEAT_SECONDS,
|
|
1123
|
+
help=(
|
|
1124
|
+
"deprecated compatibility value; live same-server waits have no "
|
|
1125
|
+
"response deadline"
|
|
1126
|
+
),
|
|
1127
|
+
)
|
|
1128
|
+
ask_command.set_defaults(handler=ask)
|
|
1129
|
+
response_command = commands.add_parser(
|
|
1130
|
+
"respond",
|
|
1131
|
+
help="respond to the exact inbound exchange leg",
|
|
1132
|
+
allow_abbrev=False,
|
|
1133
|
+
)
|
|
1134
|
+
response_command.add_argument("--exchange", required=True)
|
|
1135
|
+
response_command.add_argument("--inbound-leg", required=True)
|
|
1136
|
+
add_message_arguments(response_command)
|
|
1137
|
+
response_command.add_argument("--request-response", action="store_true")
|
|
1138
|
+
response_command.add_argument(
|
|
1139
|
+
"--async-response",
|
|
1140
|
+
action="store_true",
|
|
1141
|
+
help=(
|
|
1142
|
+
"with --request-response, receive the peer reply in a later turn "
|
|
1143
|
+
"instead of waiting on this provider call"
|
|
1144
|
+
),
|
|
1145
|
+
)
|
|
1146
|
+
response_command.add_argument("--idempotency-key")
|
|
1147
|
+
response_command.add_argument(
|
|
1148
|
+
"--timeout-seconds",
|
|
1149
|
+
type=int,
|
|
1150
|
+
choices=range(1, 3601),
|
|
1151
|
+
default=LIVE_RESPONSE_HEARTBEAT_SECONDS,
|
|
1152
|
+
help=(
|
|
1153
|
+
"deprecated compatibility value; live same-server waits have no "
|
|
1154
|
+
"response deadline"
|
|
1155
|
+
),
|
|
1156
|
+
)
|
|
1157
|
+
response_command.set_defaults(handler=respond)
|
|
1158
|
+
current_response_command = commands.add_parser(
|
|
1159
|
+
"respond-current",
|
|
1160
|
+
help="respond using this run's current inbound reply grant",
|
|
1161
|
+
allow_abbrev=False,
|
|
1162
|
+
)
|
|
1163
|
+
add_message_arguments(current_response_command)
|
|
1164
|
+
current_response_command.add_argument(
|
|
1165
|
+
"--request-response",
|
|
1166
|
+
action="store_true",
|
|
1167
|
+
)
|
|
1168
|
+
current_response_command.add_argument("--idempotency-key")
|
|
1169
|
+
current_response_command.add_argument(
|
|
1170
|
+
"--timeout-seconds",
|
|
1171
|
+
type=int,
|
|
1172
|
+
choices=range(1, 3601),
|
|
1173
|
+
default=LIVE_RESPONSE_HEARTBEAT_SECONDS,
|
|
1174
|
+
help=(
|
|
1175
|
+
"deprecated compatibility value; live same-server waits have no "
|
|
1176
|
+
"response deadline"
|
|
1177
|
+
),
|
|
1178
|
+
)
|
|
1179
|
+
current_response_command.set_defaults(handler=respond_current)
|
|
1180
|
+
wait_command = commands.add_parser(
|
|
1181
|
+
"wait",
|
|
1182
|
+
help=(
|
|
1183
|
+
"observe one bounded foreground slice of a pending same-server "
|
|
1184
|
+
"request"
|
|
1185
|
+
),
|
|
1186
|
+
allow_abbrev=False,
|
|
1187
|
+
)
|
|
1188
|
+
wait_command.add_argument("--exchange", required=True)
|
|
1189
|
+
wait_command.add_argument("--inbound-leg", required=True)
|
|
1190
|
+
wait_command.add_argument("--lease", required=True)
|
|
1191
|
+
wait_command.add_argument(
|
|
1192
|
+
"--timeout-seconds",
|
|
1193
|
+
type=int,
|
|
1194
|
+
choices=range(1, 3601),
|
|
1195
|
+
default=LIVE_RESPONSE_HEARTBEAT_SECONDS,
|
|
1196
|
+
help=(
|
|
1197
|
+
"bounded transport slice only; repeat wait after every pending "
|
|
1198
|
+
"receipt"
|
|
1199
|
+
),
|
|
1200
|
+
)
|
|
1201
|
+
wait_command.set_defaults(handler=wait)
|
|
1202
|
+
return root
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1206
|
+
previous_authority_file = os.environ.get(
|
|
1207
|
+
"AGENTSDOCK_PROVIDER_AUTHORITY_FILE"
|
|
1208
|
+
)
|
|
1209
|
+
try:
|
|
1210
|
+
args = parser().parse_args(argv)
|
|
1211
|
+
if getattr(args, "message_stdin", False):
|
|
1212
|
+
args.message = read_message_stdin()
|
|
1213
|
+
selected_authority = _authority_path(args.authority_file)
|
|
1214
|
+
os.environ["AGENTSDOCK_PROVIDER_AUTHORITY_FILE"] = str(
|
|
1215
|
+
selected_authority
|
|
1216
|
+
)
|
|
1217
|
+
result = args.handler(args)
|
|
1218
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
1219
|
+
# A retryable live-response transport failure is structured so the
|
|
1220
|
+
# caller retains its exact lease, but it is not a successful pending
|
|
1221
|
+
# observation. Exit nonzero after printing the receipt so automation
|
|
1222
|
+
# cannot silently treat network ambiguity as server-owned waiting.
|
|
1223
|
+
return 2 if result.get("transport_error") is True else 0
|
|
1224
|
+
except ChatsCLIError as exc:
|
|
1225
|
+
print(f"agentsdock-chats: {exc}", file=sys.stderr)
|
|
1226
|
+
return 2
|
|
1227
|
+
finally:
|
|
1228
|
+
if previous_authority_file is None:
|
|
1229
|
+
os.environ.pop("AGENTSDOCK_PROVIDER_AUTHORITY_FILE", None)
|
|
1230
|
+
else:
|
|
1231
|
+
os.environ[
|
|
1232
|
+
"AGENTSDOCK_PROVIDER_AUTHORITY_FILE"
|
|
1233
|
+
] = previous_authority_file
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
if __name__ == "__main__":
|
|
1237
|
+
raise SystemExit(main())
|