soothe-client-python 0.9.4__py3-none-any.whl
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.
- soothe_client/__init__.py +171 -0
- soothe_client/appkit/__init__.py +138 -0
- soothe_client/appkit/attachments.py +118 -0
- soothe_client/appkit/broadcaster.py +116 -0
- soothe_client/appkit/chunk_filter.py +99 -0
- soothe_client/appkit/classifier.py +498 -0
- soothe_client/appkit/daemon_session.py +657 -0
- soothe_client/appkit/events.py +63 -0
- soothe_client/appkit/managed_client.py +163 -0
- soothe_client/appkit/observability.py +29 -0
- soothe_client/appkit/pool.py +258 -0
- soothe_client/appkit/query_gate.py +93 -0
- soothe_client/appkit/session_store.py +100 -0
- soothe_client/appkit/thinking_step.py +147 -0
- soothe_client/appkit/turn.py +362 -0
- soothe_client/appkit/turn_runner.py +565 -0
- soothe_client/errors.py +65 -0
- soothe_client/helpers.py +404 -0
- soothe_client/intent_hints.py +43 -0
- soothe_client/protocol_params.py +604 -0
- soothe_client/schemas.py +55 -0
- soothe_client/session.py +199 -0
- soothe_client/websocket.py +1626 -0
- soothe_client/ws_command_client.py +633 -0
- soothe_client_python-0.9.4.dist-info/METADATA +131 -0
- soothe_client_python-0.9.4.dist-info/RECORD +28 -0
- soothe_client_python-0.9.4.dist-info/WHEEL +4 -0
- soothe_client_python-0.9.4.dist-info/licenses/LICENSE +21 -0
soothe_client/helpers.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""WebSocket helper functions for daemon communication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from soothe_client.websocket import WebSocketClient
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def websocket_url_from_config(cfg: Any) -> str:
|
|
16
|
+
"""Construct WebSocket URL from a config-like object.
|
|
17
|
+
|
|
18
|
+
Duck-typed across the three workspace configs:
|
|
19
|
+
|
|
20
|
+
* ``CLIConfig`` (`soothe-cli`) exposes ``daemon_host`` / ``daemon_port``
|
|
21
|
+
and a ``websocket_url()`` helper.
|
|
22
|
+
* ``SootheDaemonConfig`` (`soothe-daemon`) exposes
|
|
23
|
+
``transports.websocket.host`` / ``.port``.
|
|
24
|
+
* Legacy callers may still pass an object with
|
|
25
|
+
``daemon.transports.websocket.host`` / ``.port``.
|
|
26
|
+
|
|
27
|
+
The SDK deliberately does not import any of those classes — keeping
|
|
28
|
+
`soothe_sdk` independent of `soothe`, `soothe_cli`, and `soothe_daemon`.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
cfg: Any object exposing one of the shapes above.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
WebSocket URL string (e.g., ``"ws://127.0.0.1:8765"``).
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
AttributeError: If ``cfg`` exposes none of the supported shapes.
|
|
38
|
+
"""
|
|
39
|
+
if hasattr(cfg, "websocket_url") and callable(cfg.websocket_url):
|
|
40
|
+
url = cfg.websocket_url()
|
|
41
|
+
if isinstance(url, str) and url:
|
|
42
|
+
return url
|
|
43
|
+
|
|
44
|
+
if hasattr(cfg, "daemon_host") and hasattr(cfg, "daemon_port"):
|
|
45
|
+
return f"ws://{cfg.daemon_host}:{cfg.daemon_port}"
|
|
46
|
+
|
|
47
|
+
transports = getattr(cfg, "transports", None)
|
|
48
|
+
if transports is None:
|
|
49
|
+
daemon = getattr(cfg, "daemon", None)
|
|
50
|
+
transports = getattr(daemon, "transports", None) if daemon is not None else None
|
|
51
|
+
|
|
52
|
+
if transports is not None:
|
|
53
|
+
websocket = getattr(transports, "websocket", None)
|
|
54
|
+
if websocket is not None:
|
|
55
|
+
return f"ws://{websocket.host}:{websocket.port}"
|
|
56
|
+
|
|
57
|
+
raise AttributeError(
|
|
58
|
+
"websocket_url_from_config: object does not expose websocket settings; "
|
|
59
|
+
"expected daemon_host/daemon_port, transports.websocket, or "
|
|
60
|
+
"daemon.transports.websocket"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _ensure_handshake(client: WebSocketClient, *, timeout: float) -> None:
|
|
65
|
+
"""Complete the protocol-1 readiness handshake when not already done (RFC-450 §8.2).
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
client: Connected WebSocketClient.
|
|
69
|
+
timeout: Maximum seconds to wait for ``connection_ack``.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ConnectionError: If the WebSocket is closed or protocol is incompatible.
|
|
73
|
+
RuntimeError: If the daemon reports a non-ready terminal state.
|
|
74
|
+
TimeoutError: If ``connection_ack`` does not arrive in time.
|
|
75
|
+
"""
|
|
76
|
+
if client._handshake_complete:
|
|
77
|
+
return
|
|
78
|
+
await client.request_connection_init()
|
|
79
|
+
await client.wait_for_connection_ack(ack_timeout_s=timeout)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def check_daemon_status(
|
|
83
|
+
client: WebSocketClient,
|
|
84
|
+
timeout: float = 5.0,
|
|
85
|
+
*,
|
|
86
|
+
min_interval_s: float = 1.0,
|
|
87
|
+
handshake_timeout: float | None = None,
|
|
88
|
+
) -> dict:
|
|
89
|
+
"""Check daemon status via RPC.
|
|
90
|
+
|
|
91
|
+
Performs ``connection_init`` / ``connection_ack`` when needed, then uses
|
|
92
|
+
``WebSocketClient.fetch_daemon_status`` so rapid or overlapping polls on the
|
|
93
|
+
same connection coalesce into one wire request per ``min_interval_s``.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
client: Connected WebSocketClient
|
|
97
|
+
timeout: Request timeout in seconds for ``daemon_status`` RPC
|
|
98
|
+
min_interval_s: Minimum seconds between real ``daemon_status`` RPCs; ``0``
|
|
99
|
+
always queries the daemon.
|
|
100
|
+
handshake_timeout: Seconds to wait for ``connection_ack``; defaults to
|
|
101
|
+
``timeout``.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Parsed `daemon_status_response` payload (typically includes `running`,
|
|
105
|
+
`port_live`, and a numeric count of in-flight client query work).
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
ConnectionError: If daemon not reachable
|
|
109
|
+
"""
|
|
110
|
+
ack_timeout = handshake_timeout if handshake_timeout is not None else timeout
|
|
111
|
+
await _ensure_handshake(client, timeout=ack_timeout)
|
|
112
|
+
return await client.fetch_daemon_status(timeout=timeout, min_interval_s=min_interval_s)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _daemon_status_indicates_live(status: dict) -> bool:
|
|
116
|
+
"""Infer liveness from a ``daemon_status`` response payload.
|
|
117
|
+
|
|
118
|
+
Uses ``readiness_state`` (RFC-450 §8.2): transitional states (``starting``,
|
|
119
|
+
``warming``) mean the daemon is not yet ready for loops; terminal states
|
|
120
|
+
(``error``, ``degraded``, ``stopped``) mean it cannot serve loops; only
|
|
121
|
+
``ready`` is live for loop operations.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
status: Daemon status response dict.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
True if daemon is live and ready for loop operations, False otherwise.
|
|
128
|
+
"""
|
|
129
|
+
readiness_state = status.get("readiness_state")
|
|
130
|
+
if readiness_state in {"starting", "warming"}:
|
|
131
|
+
return False
|
|
132
|
+
if readiness_state in {"error", "degraded", "stopped"}:
|
|
133
|
+
return False
|
|
134
|
+
return readiness_state == "ready"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def is_daemon_live(
|
|
138
|
+
ws_url: str,
|
|
139
|
+
timeout: float = 5.0,
|
|
140
|
+
wait_for_ready: bool = False,
|
|
141
|
+
ready_timeout: float = 30.0,
|
|
142
|
+
) -> bool:
|
|
143
|
+
"""Composite health check: connection + status RPC.
|
|
144
|
+
|
|
145
|
+
Optionally waits for daemon to reach "ready" state (IG-489), polling during
|
|
146
|
+
transitional states like "starting" and "warming".
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
ws_url: WebSocket URL to check
|
|
150
|
+
timeout: Per-request timeout for connection + RPC
|
|
151
|
+
wait_for_ready: If True, poll until daemon is "ready" (not transitional)
|
|
152
|
+
ready_timeout: Max seconds to wait for ready state when wait_for_ready=True
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
True if daemon is live (and ready if wait_for_ready=True), False otherwise
|
|
156
|
+
"""
|
|
157
|
+
attempts = 3
|
|
158
|
+
delay_s = 0.35
|
|
159
|
+
last_error: Exception | None = None
|
|
160
|
+
|
|
161
|
+
# When waiting for ready, we need to poll during transitional states
|
|
162
|
+
if wait_for_ready:
|
|
163
|
+
# Use monotonic time via asyncio for consistent timing
|
|
164
|
+
try:
|
|
165
|
+
loop = asyncio.get_running_loop()
|
|
166
|
+
start_time = loop.time()
|
|
167
|
+
except RuntimeError:
|
|
168
|
+
start_time = 0.0
|
|
169
|
+
|
|
170
|
+
while True:
|
|
171
|
+
for attempt in range(attempts):
|
|
172
|
+
client: WebSocketClient | None = None
|
|
173
|
+
try:
|
|
174
|
+
client = WebSocketClient(url=ws_url)
|
|
175
|
+
await client.connect()
|
|
176
|
+
try:
|
|
177
|
+
loop = asyncio.get_running_loop()
|
|
178
|
+
elapsed = loop.time() - start_time
|
|
179
|
+
except RuntimeError:
|
|
180
|
+
elapsed = 0.0
|
|
181
|
+
remaining = max(0.1, ready_timeout - elapsed)
|
|
182
|
+
status = await check_daemon_status(
|
|
183
|
+
client,
|
|
184
|
+
timeout=timeout,
|
|
185
|
+
handshake_timeout=min(timeout, remaining),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Check if daemon is ready
|
|
189
|
+
readiness_state = status.get("readiness_state")
|
|
190
|
+
if readiness_state == "ready":
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
# Check if transitional - continue polling
|
|
194
|
+
if readiness_state in {"starting", "warming"}:
|
|
195
|
+
# Calculate remaining time
|
|
196
|
+
try:
|
|
197
|
+
loop = asyncio.get_running_loop()
|
|
198
|
+
elapsed = loop.time() - start_time
|
|
199
|
+
except RuntimeError:
|
|
200
|
+
elapsed = 0.0
|
|
201
|
+
|
|
202
|
+
if elapsed >= ready_timeout:
|
|
203
|
+
logger.debug(
|
|
204
|
+
"Daemon not ready after %s seconds (state: %s)",
|
|
205
|
+
ready_timeout,
|
|
206
|
+
readiness_state,
|
|
207
|
+
)
|
|
208
|
+
return False
|
|
209
|
+
# Wait and retry
|
|
210
|
+
await asyncio.sleep(delay_s)
|
|
211
|
+
break # Exit attempt loop, continue polling
|
|
212
|
+
|
|
213
|
+
# Terminal state (error, degraded, stopped) or unknown
|
|
214
|
+
return _daemon_status_indicates_live(status)
|
|
215
|
+
except Exception as exc:
|
|
216
|
+
last_error = exc
|
|
217
|
+
if attempt < attempts - 1:
|
|
218
|
+
await asyncio.sleep(delay_s)
|
|
219
|
+
finally:
|
|
220
|
+
if client is not None:
|
|
221
|
+
with contextlib.suppress(Exception):
|
|
222
|
+
await client.close()
|
|
223
|
+
|
|
224
|
+
# Check timeout after exhausting attempts
|
|
225
|
+
try:
|
|
226
|
+
loop = asyncio.get_running_loop()
|
|
227
|
+
elapsed = loop.time() - start_time
|
|
228
|
+
except RuntimeError:
|
|
229
|
+
elapsed = 0.0
|
|
230
|
+
|
|
231
|
+
if elapsed >= ready_timeout:
|
|
232
|
+
break
|
|
233
|
+
|
|
234
|
+
if last_error is not None:
|
|
235
|
+
logger.debug("Daemon health check failed for %s: %s", ws_url, last_error)
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
# Standard liveness check without waiting
|
|
239
|
+
for attempt in range(attempts):
|
|
240
|
+
client: WebSocketClient | None = None
|
|
241
|
+
try:
|
|
242
|
+
client = WebSocketClient(url=ws_url)
|
|
243
|
+
await client.connect()
|
|
244
|
+
status = await check_daemon_status(client, timeout=timeout)
|
|
245
|
+
return _daemon_status_indicates_live(status)
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
last_error = exc
|
|
248
|
+
if attempt < attempts - 1:
|
|
249
|
+
await asyncio.sleep(delay_s)
|
|
250
|
+
finally:
|
|
251
|
+
if client is not None:
|
|
252
|
+
with contextlib.suppress(Exception):
|
|
253
|
+
await client.close()
|
|
254
|
+
|
|
255
|
+
if last_error is not None:
|
|
256
|
+
logger.debug("Daemon health check failed for %s: %s", ws_url, last_error)
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
async def request_daemon_shutdown(client: WebSocketClient, timeout: float = 10.0) -> None:
|
|
261
|
+
"""Request daemon shutdown via RPC.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
client: Connected WebSocketClient
|
|
265
|
+
timeout: Shutdown timeout in seconds
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
RuntimeError: If shutdown fails
|
|
269
|
+
"""
|
|
270
|
+
try:
|
|
271
|
+
response = await client.request("daemon_shutdown", {}, timeout=timeout)
|
|
272
|
+
if response.get("status") != "acknowledged":
|
|
273
|
+
raise RuntimeError(f"Shutdown failed: {response}")
|
|
274
|
+
except Exception as e:
|
|
275
|
+
raise RuntimeError(f"Shutdown failed: {e}") from e
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
async def request_daemon_config_reload(
|
|
279
|
+
client: WebSocketClient, timeout: float = 5.0
|
|
280
|
+
) -> dict[str, Any]:
|
|
281
|
+
"""Request daemon config reload via RPC.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
client: Connected WebSocketClient
|
|
285
|
+
timeout: Request timeout in seconds
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Response dict with success status and optional error message
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
ConnectionError: If daemon not reachable
|
|
292
|
+
RuntimeError: If reload request fails
|
|
293
|
+
"""
|
|
294
|
+
await _ensure_handshake(client, timeout=timeout)
|
|
295
|
+
response = await client.request("config_reload", {}, timeout=timeout)
|
|
296
|
+
return response
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def fetch_skills_catalog(client: WebSocketClient, timeout: float = 15.0) -> list[dict]:
|
|
300
|
+
"""Fetch skills catalog via RPC.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
client: Connected WebSocketClient
|
|
304
|
+
timeout: Request timeout in seconds
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
List of skill metadata dicts (wire-safe, no local parsing)
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
ConnectionError: If daemon not reachable
|
|
311
|
+
"""
|
|
312
|
+
response = await client.request("skills_list", {}, timeout=timeout)
|
|
313
|
+
return response.get("skills", [])
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
async def fetch_config_section(client: WebSocketClient, section: str, timeout: float = 5.0) -> dict:
|
|
317
|
+
"""Fetch daemon config section via RPC.
|
|
318
|
+
|
|
319
|
+
Performs ``connection_init`` / ``connection_ack`` handshake when needed
|
|
320
|
+
before sending the request (RFC-450 §8.2).
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
client: Connected WebSocketClient
|
|
324
|
+
section: Config section name (e.g., "providers", "defaults")
|
|
325
|
+
timeout: Request timeout in seconds
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Wire-safe config section dict
|
|
329
|
+
|
|
330
|
+
Raises:
|
|
331
|
+
ConnectionError: If daemon not reachable
|
|
332
|
+
"""
|
|
333
|
+
await _ensure_handshake(client, timeout=timeout)
|
|
334
|
+
response = await client.request("config_get", {"section": section}, timeout=timeout)
|
|
335
|
+
return response.get(section, {})
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
async def fetch_loop_history(
|
|
339
|
+
client: WebSocketClient,
|
|
340
|
+
loop_id: str,
|
|
341
|
+
*,
|
|
342
|
+
timeout: float = 30.0,
|
|
343
|
+
) -> dict[str, Any]:
|
|
344
|
+
"""Fetch goal display snapshots plus live card tail via RPC."""
|
|
345
|
+
lid = str(loop_id or "").strip()
|
|
346
|
+
if not lid:
|
|
347
|
+
raise ValueError("loop_id is required")
|
|
348
|
+
await _ensure_handshake(client, timeout=timeout)
|
|
349
|
+
return await client.loop_history_fetch(lid, timeout=timeout)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
async def fetch_loop_cards(
|
|
353
|
+
client: WebSocketClient,
|
|
354
|
+
loop_id: str,
|
|
355
|
+
*,
|
|
356
|
+
timeout: float = 30.0,
|
|
357
|
+
) -> dict[str, Any]:
|
|
358
|
+
"""Fetch the daemon's bound display-card snapshot for a loop."""
|
|
359
|
+
lid = str(loop_id or "").strip()
|
|
360
|
+
if not lid:
|
|
361
|
+
raise ValueError("loop_id is required")
|
|
362
|
+
await _ensure_handshake(client, timeout=timeout)
|
|
363
|
+
return await client.loop_cards_fetch(lid, timeout=timeout)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
async def fetch_loop_messages(
|
|
367
|
+
client: WebSocketClient,
|
|
368
|
+
loop_id: str,
|
|
369
|
+
*,
|
|
370
|
+
limit: int = 100,
|
|
371
|
+
offset: int = 0,
|
|
372
|
+
include_events: bool = False,
|
|
373
|
+
timeout: float = 10.0,
|
|
374
|
+
) -> list[dict[str, Any]]:
|
|
375
|
+
"""Load persisted conversation rows for a loop."""
|
|
376
|
+
lid = str(loop_id or "").strip()
|
|
377
|
+
if not lid:
|
|
378
|
+
return []
|
|
379
|
+
await _ensure_handshake(client, timeout=timeout)
|
|
380
|
+
resp = await client.loop_messages(
|
|
381
|
+
lid,
|
|
382
|
+
limit=limit,
|
|
383
|
+
offset=offset,
|
|
384
|
+
include_events=include_events,
|
|
385
|
+
timeout=timeout,
|
|
386
|
+
)
|
|
387
|
+
raw = resp.get("messages")
|
|
388
|
+
if not isinstance(raw, list):
|
|
389
|
+
return []
|
|
390
|
+
return [m for m in raw if isinstance(m, dict)]
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
__all__ = [
|
|
394
|
+
"websocket_url_from_config",
|
|
395
|
+
"check_daemon_status",
|
|
396
|
+
"is_daemon_live",
|
|
397
|
+
"request_daemon_shutdown",
|
|
398
|
+
"request_daemon_config_reload",
|
|
399
|
+
"fetch_skills_catalog",
|
|
400
|
+
"fetch_config_section",
|
|
401
|
+
"fetch_loop_history",
|
|
402
|
+
"fetch_loop_cards",
|
|
403
|
+
"fetch_loop_messages",
|
|
404
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""``loop_input.intent_hint`` constants and client-side validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
TEXT_COMPLETION: Final = "text_completion"
|
|
8
|
+
IMAGE_TO_TEXT: Final = "image_to_text"
|
|
9
|
+
OCR: Final = "ocr"
|
|
10
|
+
EMBED: Final = "embed"
|
|
11
|
+
|
|
12
|
+
REMOVED_INTENT_HINTS: frozenset[str] = frozenset({"direct_llm", "quiz"})
|
|
13
|
+
|
|
14
|
+
# Default deliverable phases for turn-ending replies (excludes plan_direct narration).
|
|
15
|
+
DEFAULT_DELIVERABLE_PHASES: frozenset[str] = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"quiz",
|
|
18
|
+
"goal_completion",
|
|
19
|
+
"direct_model",
|
|
20
|
+
"text_completion",
|
|
21
|
+
"image_to_text",
|
|
22
|
+
"ocr",
|
|
23
|
+
"embed",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_REMOVED_INTENT_HINT_MESSAGES: dict[str, str] = {
|
|
28
|
+
"direct_llm": (
|
|
29
|
+
"intent_hint direct_llm is removed; "
|
|
30
|
+
"use text_completion (text-only) or image_to_text (with attachments)"
|
|
31
|
+
),
|
|
32
|
+
"quiz": "intent_hint quiz is removed; omit intent_hint and let intake classify the turn",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_loop_input_intent_hint(hint: str) -> str | None:
|
|
37
|
+
"""Return an error message when ``hint`` is a removed legacy value.
|
|
38
|
+
|
|
39
|
+
Direct model hints and agent-path pass-through values (e.g.
|
|
40
|
+
``resume_clarification``, ``skill:foo``) are allowed.
|
|
41
|
+
"""
|
|
42
|
+
key = hint.strip().lower()
|
|
43
|
+
return _REMOVED_INTENT_HINT_MESSAGES.get(key)
|