applied-cli 0.1.0__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.
@@ -0,0 +1,379 @@
1
+ import json
2
+ import re
3
+ import uuid
4
+ from typing import Optional
5
+
6
+ import httpx
7
+ import typer
8
+
9
+ from applied_cli.commands._hints import suggest_value
10
+ from applied_cli.commands._ui import confirm_or_exit, emit_success, show_target
11
+ from applied_cli.error_reporting import render_api_error
12
+ from applied_cli.http import APIError
13
+ from applied_cli.runtime import resolve_runtime
14
+
15
+ app = typer.Typer(help="Chat with an agent using v1 completion.")
16
+
17
+ MESSAGE_REGEX = re.compile(
18
+ r"(\{(?:(\{(?:(\{(?:(\{(?:(\{(?:(\{(?:(\{(?:(\{(?:(\{[^}{]*\})|[^}{])*\})|[^}{])*\})|[^}{])*\})|[^}{])*\})|[^}{])*\})|[^}{])*\})|[^}{])*\})|[^}{])*\})"
19
+ )
20
+
21
+
22
+ def _headers(shop_id: str, api_token: str) -> dict[str, str]:
23
+ return {
24
+ "Authorization": f"Bearer {api_token}",
25
+ "X-Shop-Id": shop_id,
26
+ "Content-Type": "application/json",
27
+ }
28
+
29
+
30
+ def _verify_agent_scope(
31
+ client: httpx.Client,
32
+ *,
33
+ base_url: str,
34
+ shop_id: str,
35
+ api_token: str,
36
+ agent_id: str,
37
+ ) -> dict[str, object]:
38
+ try:
39
+ response = client.get(
40
+ f"{base_url}/v1/agents/{agent_id}/",
41
+ headers=_headers(shop_id, api_token),
42
+ timeout=10.0,
43
+ )
44
+ except httpx.HTTPError as exc:
45
+ raise APIError(
46
+ f"Agent scope check failed to reach server: {exc}",
47
+ code="NETWORK_ERROR",
48
+ hint="Check APPLIED_BASE_URL and confirm the server is reachable.",
49
+ retryable=True,
50
+ method="GET",
51
+ url=f"{base_url}/v1/agents/{agent_id}/",
52
+ suggestions=[
53
+ "Verify `applied-cli auth status` succeeds.",
54
+ "If running locally, confirm server is up on http://localhost:8000.",
55
+ ],
56
+ ) from exc
57
+ if response.status_code >= 400:
58
+ try:
59
+ detail = response.json()
60
+ except Exception:
61
+ detail = response.text
62
+ code = "AGENT_SCOPE_CHECK_FAILED"
63
+ hint = "Verify --agent-id, --shop-id, and token permissions."
64
+ suggestions = [
65
+ "Run `applied-cli auth status` to verify token validity.",
66
+ "Confirm the agent belongs to the selected shop.",
67
+ ]
68
+ if response.status_code == 401:
69
+ code = "AUTH_INVALID_TOKEN"
70
+ hint = "Token rejected while reading agent scope."
71
+ suggestions.insert(0, "Run `applied-cli auth login` with a fresh token.")
72
+ elif response.status_code == 403:
73
+ code = "SHOP_SCOPE_FORBIDDEN"
74
+ hint = "Token does not have permission for this shop or agent."
75
+ elif response.status_code == 404:
76
+ code = "AGENT_NOT_FOUND"
77
+ hint = "Agent was not found in the selected shop scope."
78
+ raise APIError(
79
+ f"Agent/shop scope check failed ({response.status_code}).",
80
+ status_code=response.status_code,
81
+ code=code,
82
+ hint=hint,
83
+ retryable=response.status_code >= 500,
84
+ method="GET",
85
+ url=f"{base_url}/v1/agents/{agent_id}/",
86
+ detail=detail,
87
+ suggestions=suggestions,
88
+ )
89
+ try:
90
+ data = response.json()
91
+ except Exception:
92
+ return {}
93
+ return data if isinstance(data, dict) else {}
94
+
95
+
96
+ def _create_conversation(
97
+ client: httpx.Client,
98
+ *,
99
+ base_url: str,
100
+ agent_id: str,
101
+ channel: str,
102
+ ) -> str:
103
+ payload: dict[str, object] = {
104
+ "agent_id": agent_id,
105
+ "is_test": True,
106
+ "metadata": {
107
+ "isTest": True,
108
+ "source": "applied-cli",
109
+ "context": {"channel": channel},
110
+ },
111
+ }
112
+ if channel == "email":
113
+ payload["type"] = "email"
114
+ elif channel == "sms":
115
+ payload["type"] = "sms"
116
+
117
+ try:
118
+ response = client.post(
119
+ f"{base_url}/v1/c/",
120
+ json=payload,
121
+ headers={"Content-Type": "application/json"},
122
+ timeout=10.0,
123
+ )
124
+ except httpx.HTTPError as exc:
125
+ raise APIError(
126
+ f"Conversation init failed to reach server: {exc}",
127
+ code="NETWORK_ERROR",
128
+ hint="Check APPLIED_BASE_URL and confirm the server is reachable.",
129
+ retryable=True,
130
+ method="POST",
131
+ url=f"{base_url}/v1/c/",
132
+ suggestions=[
133
+ "Verify local server availability.",
134
+ "Retry after confirming token/shop scope.",
135
+ ],
136
+ ) from exc
137
+ if response.status_code >= 400:
138
+ try:
139
+ detail = response.json()
140
+ except Exception:
141
+ detail = response.text
142
+ raise APIError(
143
+ f"Conversation init failed ({response.status_code}).",
144
+ status_code=response.status_code,
145
+ code="CONVERSATION_CREATE_FAILED",
146
+ hint="Conversation bootstrap failed before completion call.",
147
+ retryable=response.status_code >= 500,
148
+ method="POST",
149
+ url=f"{base_url}/v1/c/",
150
+ detail=detail,
151
+ suggestions=[
152
+ "Verify agent modality and requested --channel are compatible.",
153
+ "Check token/shop scope can create test conversations.",
154
+ ],
155
+ )
156
+
157
+ data = response.json()
158
+ conversation_id = data.get("id")
159
+ if not conversation_id:
160
+ raise APIError("Conversation init succeeded but no conversation id returned.")
161
+ return str(conversation_id)
162
+
163
+
164
+ def _stream_v1_completion(
165
+ client: httpx.Client,
166
+ *,
167
+ base_url: str,
168
+ shop_id: str,
169
+ api_token: str,
170
+ agent_id: str,
171
+ payload: dict[str, object],
172
+ ) -> str:
173
+ generated_text = ""
174
+ buffer = ""
175
+ content_complete_seen = False
176
+ read_after_complete = False
177
+ with client.stream(
178
+ "POST",
179
+ f"{base_url}/v1/agents/{agent_id}/complete/",
180
+ headers=_headers(shop_id, api_token),
181
+ json=payload,
182
+ timeout=60.0,
183
+ ) as response:
184
+ if response.status_code >= 400:
185
+ try:
186
+ detail = response.json()
187
+ except Exception:
188
+ detail = response.text
189
+ raise APIError(
190
+ f"Completion failed ({response.status_code}).",
191
+ status_code=response.status_code,
192
+ code="COMPLETION_REQUEST_FAILED",
193
+ hint="Completion endpoint returned an error for this payload.",
194
+ retryable=response.status_code >= 500,
195
+ method="POST",
196
+ url=f"{base_url}/v1/agents/{agent_id}/complete/",
197
+ detail=detail,
198
+ suggestions=[
199
+ "Verify `context` and transcript payload shape.",
200
+ "Confirm agent is active and has a valid completion configuration.",
201
+ ],
202
+ )
203
+
204
+ for chunk in response.iter_text():
205
+ if not chunk:
206
+ continue
207
+ buffer += chunk
208
+ last_consumed = 0
209
+ for match in MESSAGE_REGEX.finditer(buffer):
210
+ raw = match.group(1)
211
+ if not raw:
212
+ continue
213
+ try:
214
+ data = json.loads(raw)
215
+ except json.JSONDecodeError:
216
+ continue
217
+ last_consumed = match.end()
218
+ content = data.get("content")
219
+ if isinstance(content, str) and content:
220
+ generated_text += content
221
+ typer.echo(content, nl=False)
222
+ if bool(data.get("content_complete")):
223
+ content_complete_seen = True
224
+ if last_consumed > 0:
225
+ buffer = buffer[last_consumed:]
226
+ if content_complete_seen:
227
+ if read_after_complete:
228
+ break
229
+ # Read one additional chunk to allow server-side post-stream save hooks.
230
+ read_after_complete = True
231
+
232
+ return generated_text
233
+
234
+
235
+ @app.command(
236
+ "send",
237
+ help=(
238
+ "Send one message to an agent. Example: applied-cli chat send --agent-id <uuid> "
239
+ "--channel chat --message 'How much does a Ripple cost?'"
240
+ ),
241
+ )
242
+ def send(
243
+ agent_id: str = typer.Option(
244
+ ..., "--agent-id", "--agent", "--id", help="Target agent UUID."
245
+ ),
246
+ message: Optional[str] = typer.Option(
247
+ None, "--message", "-m", help="User message. If omitted, prompt interactively."
248
+ ),
249
+ channel: str = typer.Option(
250
+ "chat", help="Channel mode: chat, email, or sms.", case_sensitive=False
251
+ ),
252
+ context: str = typer.Option(
253
+ "EVALUATE", help="Completion context (e.g., EVALUATE, LIVE, DEVELOPMENT)."
254
+ ),
255
+ conversation_id: Optional[str] = typer.Option(
256
+ None,
257
+ "--conversation-id",
258
+ "--conversation",
259
+ help="Existing conversation UUID. If omitted, create a test conversation.",
260
+ ),
261
+ draft: bool = typer.Option(False, help="Do not persist completion when possible."),
262
+ base_url: Optional[str] = typer.Option(None, help="Applied base URL."),
263
+ shop_id: Optional[str] = typer.Option(None, help="Target shop UUID."),
264
+ api_token: Optional[str] = typer.Option(None, help="Applied API token."),
265
+ yes: bool = typer.Option(
266
+ False,
267
+ "--yes",
268
+ "-y",
269
+ help="Skip safety confirmation prompt.",
270
+ ),
271
+ ) -> None:
272
+ try:
273
+ uuid.UUID(agent_id)
274
+ except ValueError as exc:
275
+ raise typer.BadParameter(
276
+ "agent-id must be a valid UUID (example: 7ef9c71d-6ea8-42a9-9878-b496ed5f830d)"
277
+ ) from exc
278
+
279
+ normalized_channel = channel.lower().strip()
280
+ if normalized_channel not in {"chat", "email", "sms"}:
281
+ suggestion = suggest_value(normalized_channel, ["chat", "email", "sms"])
282
+ hint = f" Did you mean '{suggestion}'?" if suggestion else ""
283
+ raise typer.BadParameter(f"channel must be one of: chat, email, sms.{hint}")
284
+
285
+ prompt_message = (message or "").strip()
286
+ if not prompt_message:
287
+ prompt_message = typer.prompt("Message").strip()
288
+ if not prompt_message:
289
+ raise typer.BadParameter("message cannot be empty")
290
+
291
+ try:
292
+ resolved_base_url, resolved_shop_id, resolved_token = resolve_runtime(
293
+ base_url=base_url,
294
+ shop_id=shop_id,
295
+ api_token=api_token,
296
+ )
297
+ except APIError as exc:
298
+ typer.echo(render_api_error(exc, action="resolve runtime for chat"), err=True)
299
+ raise typer.Exit(code=1) from exc
300
+
301
+ show_target(
302
+ {
303
+ "base_url": resolved_base_url,
304
+ "shop_id": resolved_shop_id,
305
+ "agent_id": agent_id,
306
+ "channel": normalized_channel,
307
+ }
308
+ )
309
+ confirm_or_exit(yes=yes)
310
+
311
+ with httpx.Client() as client:
312
+ try:
313
+ agent_payload = _verify_agent_scope(
314
+ client,
315
+ base_url=resolved_base_url,
316
+ shop_id=resolved_shop_id,
317
+ api_token=resolved_token,
318
+ agent_id=agent_id,
319
+ )
320
+ if agent_payload.get("auto_reply") is False:
321
+ typer.echo(
322
+ "Warning: target agent has auto_reply disabled. "
323
+ "Behavior tests may fail until `agent settings reply --auto-reply` is set."
324
+ )
325
+ active_conversation_id = conversation_id or _create_conversation(
326
+ client,
327
+ base_url=resolved_base_url,
328
+ agent_id=agent_id,
329
+ channel=normalized_channel,
330
+ )
331
+ except APIError as exc:
332
+ typer.echo(render_api_error(exc, action="prepare chat conversation"), err=True)
333
+ raise typer.Exit(code=1) from exc
334
+
335
+ transcript = [
336
+ {
337
+ "id": str(uuid.uuid4()),
338
+ "role": "user",
339
+ "content": prompt_message,
340
+ "text": prompt_message,
341
+ "format": "TEXT",
342
+ "entity": {"type": "user"},
343
+ }
344
+ ]
345
+
346
+ payload: dict[str, object] = {
347
+ "conversation_id": active_conversation_id,
348
+ "context": context,
349
+ "transcript": transcript,
350
+ "metadata": {
351
+ "source": "applied-cli",
352
+ "isTest": True,
353
+ "channel": normalized_channel,
354
+ },
355
+ "draft": draft,
356
+ }
357
+
358
+ typer.echo("\nAssistant:")
359
+ try:
360
+ text = _stream_v1_completion(
361
+ client,
362
+ base_url=resolved_base_url,
363
+ shop_id=resolved_shop_id,
364
+ api_token=resolved_token,
365
+ agent_id=agent_id,
366
+ payload=payload,
367
+ )
368
+ except APIError as exc:
369
+ typer.echo(f"\n{render_api_error(exc, action='chat completion')}", err=True)
370
+ raise typer.Exit(code=1) from exc
371
+
372
+ emit_success(
373
+ output_json=False,
374
+ payload={},
375
+ fields={
376
+ "conversation_id": active_conversation_id,
377
+ "chars": len(text),
378
+ },
379
+ )