applied-cli 0.5.7__tar.gz → 0.5.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: applied-cli
3
- Version: 0.5.7
3
+ Version: 0.5.9
4
4
  Summary: CLI and shared client library for Applied Labs AI support agents
5
5
  Author: Applied Labs
6
6
  License-Expression: MIT
@@ -4,6 +4,6 @@ from applied_cli import tools
4
4
  from applied_cli.client import AppliedClient
5
5
  from applied_cli.formatters import to_csv, to_json
6
6
 
7
- __version__ = "0.5.5"
7
+ __version__ = "0.5.9"
8
8
 
9
9
  __all__ = ["AppliedClient", "tools", "to_csv", "to_json", "__version__"]
@@ -1,5 +1,8 @@
1
1
  """Applied Labs API client."""
2
2
 
3
+ import asyncio
4
+ import json
5
+ import uuid
3
6
  from typing import Any
4
7
 
5
8
  import httpx
@@ -250,6 +253,19 @@ class AppliedClient:
250
253
  # Flow Nodes
251
254
  # -------------------------------------------------------------------------
252
255
 
256
+ async def get_node(self, flow_id: str, node_id: str) -> dict | None:
257
+ """Get a single node with full configuration.
258
+
259
+ Returns None if node not found.
260
+ """
261
+ flow = await self.get_flow(flow_id)
262
+ graph = flow.get("graph", {})
263
+ nodes = graph.get("nodes", [])
264
+ for node in nodes:
265
+ if node.get("id") == node_id:
266
+ return node
267
+ return None
268
+
253
269
  async def create_node(
254
270
  self,
255
271
  flow_id: str,
@@ -341,10 +357,44 @@ class AppliedClient:
341
357
  self,
342
358
  flow_id: str,
343
359
  trigger_data: dict[str, Any] | None = None,
360
+ wait: bool = False,
361
+ wait_timeout: float = 60.0,
362
+ poll_interval: float = 1.0,
344
363
  ) -> dict:
345
- """Execute a flow with trigger data."""
364
+ """Execute a flow with trigger data.
365
+
366
+ Args:
367
+ flow_id: The flow UUID
368
+ trigger_data: Trigger input data
369
+ wait: If True, poll until the run completes and include spans
370
+ wait_timeout: Max seconds to wait for completion (default 60)
371
+ poll_interval: Seconds between polls (default 1)
372
+
373
+ Returns:
374
+ Flow run with spans if wait=True
375
+ """
346
376
  body = {"trigger": trigger_data or {}}
347
- return await self._request("POST", f"/v1/flows/{flow_id}/run/", body=body)
377
+ run = await self._request("POST", f"/v1/flows/{flow_id}/run/", body=body)
378
+
379
+ if not wait:
380
+ return run
381
+
382
+ # Poll until completion
383
+ run_id = run.get("id")
384
+ if not run_id:
385
+ return run
386
+
387
+ elapsed = 0.0
388
+ while elapsed < wait_timeout:
389
+ run = await self.get_flow_run(run_id)
390
+ status = run.get("status", "")
391
+ if status in ("Success", "Failed", "Cancelled"):
392
+ return run
393
+ await asyncio.sleep(poll_interval)
394
+ elapsed += poll_interval
395
+
396
+ # Timeout - return current state
397
+ return run
348
398
 
349
399
  async def list_flow_runs(
350
400
  self,
@@ -395,6 +445,31 @@ class AppliedClient:
395
445
  # Conversational Testing (via streaming /complete endpoint)
396
446
  # -------------------------------------------------------------------------
397
447
 
448
+ async def create_conversation(
449
+ self,
450
+ agent_id: str,
451
+ is_test: bool = True,
452
+ metadata: dict[str, Any] | None = None,
453
+ ) -> dict[str, Any]:
454
+ """Create a new conversation for an agent.
455
+
456
+ Args:
457
+ agent_id: The agent UUID
458
+ is_test: Whether this is a test conversation (default True)
459
+ metadata: Optional metadata for the conversation
460
+
461
+ Returns:
462
+ Dict with conversation id
463
+ """
464
+ body: dict[str, Any] = {
465
+ "agent_id": agent_id,
466
+ "is_test": is_test,
467
+ }
468
+ if metadata:
469
+ body["metadata"] = metadata
470
+
471
+ return await self._request("POST", "/v1/c/", body=body)
472
+
398
473
  async def send_message(
399
474
  self,
400
475
  agent_id: str,
@@ -410,6 +485,8 @@ class AppliedClient:
410
485
  This calls the /complete endpoint (streaming) and consumes the full
411
486
  response. Use this for testing conversational flows.
412
487
 
488
+ If no conversation_id is provided, a new conversation is created first.
489
+
413
490
  Args:
414
491
  agent_id: The agent UUID
415
492
  message: The user message to send
@@ -420,46 +497,51 @@ class AppliedClient:
420
497
  metadata: Optional metadata dict for the conversation
421
498
 
422
499
  Returns:
423
- Dict with conversation_id, response_text, and status
500
+ Dict with conversation_id, response, and status
424
501
  """
425
- headers = {
426
- "Authorization": f"Bearer {self.token}",
502
+ # Build metadata with contact info
503
+ meta = metadata.copy() if metadata else {}
504
+ if contact_email:
505
+ meta["email"] = contact_email
506
+ if contact_name:
507
+ meta["name"] = contact_name
508
+ if contact_phone:
509
+ meta["phone"] = contact_phone
510
+
511
+ # Create conversation if needed
512
+ result_conversation_id = conversation_id
513
+ if not result_conversation_id:
514
+ conv = await self.create_conversation(
515
+ agent_id=agent_id,
516
+ is_test=True,
517
+ metadata=meta if meta else None,
518
+ )
519
+ result_conversation_id = conv["id"]
520
+
521
+ # /complete endpoint uses AllowAny - no auth header needed
522
+ headers: dict[str, str] = {
427
523
  "Content-Type": "application/json",
428
- "Accept": "text/event-stream",
429
524
  }
430
- if self.shop_id:
431
- headers["X-Shop-Id"] = self.shop_id
432
525
 
433
526
  body: dict[str, Any] = {
434
- "input": message,
527
+ "conversation_id": result_conversation_id,
435
528
  "transcript": [
436
529
  {
530
+ "id": str(uuid.uuid4()),
437
531
  "role": "user",
438
532
  "content": message,
439
533
  "text": message,
440
- "format": "markdown",
534
+ "format": "MARKDOWN",
441
535
  }
442
536
  ],
443
537
  }
444
538
 
445
- if conversation_id:
446
- body["conversation_id"] = conversation_id
447
-
448
- # Build metadata with contact info
449
- meta = metadata or {}
450
- if contact_email:
451
- meta["email"] = contact_email
452
- if contact_name:
453
- meta["name"] = contact_name
454
- if contact_phone:
455
- meta["phone"] = contact_phone
456
539
  if meta:
457
540
  body["metadata"] = meta
458
541
 
459
542
  url = f"{self.base_url}/v1/agents/{agent_id}/complete/"
460
543
 
461
544
  response_text = ""
462
- result_conversation_id = conversation_id
463
545
 
464
546
  async with (
465
547
  httpx.AsyncClient(timeout=120.0) as client,
@@ -467,32 +549,19 @@ class AppliedClient:
467
549
  ):
468
550
  response.raise_for_status()
469
551
 
552
+ # v1 streaming format: JSON objects on each line
470
553
  async for line in response.aiter_lines():
471
554
  if not line:
472
555
  continue
473
556
 
474
- # Parse SSE format: "data: {...}"
475
- if line.startswith("data: "):
476
- data_str = line[6:] # Remove "data: " prefix
477
- if data_str == "[DONE]":
478
- break
479
-
480
- try:
481
- import json
482
-
483
- data = json.loads(data_str)
484
-
485
- # Extract conversation_id from first chunk
486
- if not result_conversation_id:
487
- result_conversation_id = data.get("conversation_id")
488
-
489
- # Accumulate response content
490
- if "content" in data:
491
- response_text += data["content"]
492
-
493
- except (json.JSONDecodeError, KeyError):
494
- # Skip malformed chunks
495
- pass
557
+ # Try to parse as JSON directly (v1 format)
558
+ try:
559
+ data = json.loads(line)
560
+ if "content" in data:
561
+ response_text += data["content"]
562
+ except json.JSONDecodeError:
563
+ # Skip non-JSON lines
564
+ pass
496
565
 
497
566
  return {
498
567
  "conversation_id": result_conversation_id,
@@ -256,6 +256,7 @@ async def flow_list(
256
256
  async def flow_get(
257
257
  client: AppliedClient,
258
258
  flow_id: str,
259
+ output_format: str = "summary",
259
260
  ) -> str:
260
261
  """
261
262
  Get a flow with its full graph (nodes and edges).
@@ -263,15 +264,20 @@ async def flow_get(
263
264
  Args:
264
265
  client: Authenticated AppliedClient
265
266
  flow_id: The flow UUID
267
+ output_format: 'summary' (default), 'full' (includes node metadata/prompts),
268
+ or 'json' (raw JSON)
266
269
 
267
270
  Returns:
268
- Flow metadata, nodes table, and edges table
271
+ Flow metadata, nodes, and edges. Use 'full' to see node configurations.
269
272
  """
270
273
  flow = await client.get_flow(flow_id)
271
274
  graph = flow.get("graph", {})
272
275
  nodes = graph.get("nodes", [])
273
276
  edges = graph.get("edges", [])
274
277
 
278
+ if output_format == "json":
279
+ return to_json(flow)
280
+
275
281
  # Format header
276
282
  result = f"# Flow: {flow.get('name')}\n"
277
283
  result += f"id: {flow.get('id')}\n"
@@ -285,15 +291,48 @@ async def flow_get(
285
291
 
286
292
  # Format nodes
287
293
  result += f"\n## Nodes ({len(nodes)})\n"
288
- node_rows = [
289
- {
290
- "id": n.get("id"),
291
- "type": n.get("type", n.get("data", {}).get("name", "")),
292
- "description": n.get("data", {}).get("description", "")[:50],
293
- }
294
- for n in nodes
295
- ]
296
- result += to_csv(node_rows, ["id", "type", "description"])
294
+
295
+ if output_format == "full":
296
+ # Full output: include metadata, prompt, schemas for each node
297
+ for n in nodes:
298
+ data = n.get("data", {})
299
+ node_name = data.get("name", n.get("id", ""))
300
+ node_type = n.get("type", data.get("executor_type", ""))
301
+
302
+ result += f"\n### {node_name}"
303
+ if node_type and node_type != node_name:
304
+ result += f" ({node_type})"
305
+ result += "\n"
306
+ result += f"id: {n.get('id')}\n"
307
+
308
+ if data.get("description"):
309
+ result += f"description: {data.get('description')}\n"
310
+
311
+ if data.get("prompt"):
312
+ prompt_preview = data.get("prompt", "")
313
+ if len(prompt_preview) > 300:
314
+ prompt_preview = prompt_preview[:300] + "..."
315
+ result += f"prompt: {prompt_preview}\n"
316
+
317
+ metadata = data.get("metadata", {})
318
+ if metadata:
319
+ result += f"metadata: {json.dumps(metadata)}\n"
320
+
321
+ outputs = data.get("outputs", {})
322
+ if outputs and outputs.get("properties"):
323
+ props = list(outputs.get("properties", {}).keys())
324
+ result += f"output_fields: {', '.join(props)}\n"
325
+ else:
326
+ # Summary output: table format
327
+ node_rows = [
328
+ {
329
+ "id": n.get("id"),
330
+ "type": n.get("type", n.get("data", {}).get("name", "")),
331
+ "description": n.get("data", {}).get("description", "")[:50],
332
+ }
333
+ for n in nodes
334
+ ]
335
+ result += to_csv(node_rows, ["id", "type", "description"])
297
336
 
298
337
  # Format edges
299
338
  result += f"\n## Edges ({len(edges)})\n"
@@ -415,6 +454,60 @@ async def flow_delete(
415
454
  # -----------------------------------------------------------------------------
416
455
 
417
456
 
457
+ async def flow_node_get(
458
+ client: AppliedClient,
459
+ flow_id: str,
460
+ node_id: str,
461
+ ) -> str:
462
+ """
463
+ Get a single node's full configuration.
464
+
465
+ Args:
466
+ client: Authenticated AppliedClient
467
+ flow_id: The flow UUID
468
+ node_id: The node UUID
469
+
470
+ Returns:
471
+ Full node config including metadata, prompt, executor type, and schemas
472
+ """
473
+ node = await client.get_node(flow_id, node_id)
474
+
475
+ if not node:
476
+ return f"Node {node_id} not found in flow {flow_id}"
477
+
478
+ data = node.get("data", {})
479
+
480
+ result = f"# Node: {data.get('name', node_id)}\n"
481
+ result += f"id: {node.get('id')}\n"
482
+ result += f"type: {node.get('type', data.get('executor_type', ''))}\n"
483
+
484
+ if data.get("description"):
485
+ result += f"description: {data.get('description')}\n"
486
+
487
+ if data.get("prompt"):
488
+ result += f"\n## Prompt\n{data.get('prompt')}\n"
489
+
490
+ # Full metadata
491
+ metadata = data.get("metadata", {})
492
+ if metadata:
493
+ result += "\n## Metadata\n"
494
+ result += json.dumps(metadata, indent=2) + "\n"
495
+
496
+ # Input schema
497
+ inputs = data.get("inputs", {})
498
+ if inputs:
499
+ result += "\n## Input Schema\n"
500
+ result += json.dumps(inputs, indent=2) + "\n"
501
+
502
+ # Output schema
503
+ outputs = data.get("outputs", {})
504
+ if outputs:
505
+ result += "\n## Output Schema\n"
506
+ result += json.dumps(outputs, indent=2) + "\n"
507
+
508
+ return result
509
+
510
+
418
511
  async def flow_node_create(
419
512
  client: AppliedClient,
420
513
  flow_id: str,
@@ -616,6 +709,8 @@ async def flow_run(
616
709
  client: AppliedClient,
617
710
  flow_id: str,
618
711
  trigger_data: dict | None = None,
712
+ wait: bool = True,
713
+ wait_timeout: float = 60.0,
619
714
  ) -> str:
620
715
  """
621
716
  Execute a flow with test input.
@@ -627,17 +722,76 @@ async def flow_run(
627
722
  {"message": "Hello"}
628
723
  {"message": "Help me", "contact": {"email": "user@example.com"}}
629
724
  {"conversation_id": "uuid-of-existing-conversation"}
725
+ wait: If True (default), wait for completion and return full trace.
726
+ If False, return immediately with just the run ID.
727
+ wait_timeout: Max seconds to wait (default 60)
630
728
 
631
729
  Returns:
632
- Flow run ID, status, and actions summary
730
+ Flow run with execution trace (if wait=True) or just run ID
633
731
  """
634
- run = await client.run_flow(flow_id, trigger_data)
732
+ run = await client.run_flow(
733
+ flow_id,
734
+ trigger_data,
735
+ wait=wait,
736
+ wait_timeout=wait_timeout,
737
+ )
635
738
 
636
739
  result = f"# Flow Run: {run.get('id')}\n"
637
740
  result += f"status: {run.get('status')}\n"
638
741
  result += f"started_at: {run.get('started_at')}\n"
639
- result += f"ended_at: {run.get('ended_at')}\n"
640
742
 
743
+ if run.get("ended_at"):
744
+ result += f"ended_at: {run.get('ended_at')}\n"
745
+ if run.get("duration"):
746
+ result += f"duration: {run.get('duration')}s\n"
747
+
748
+ # Execution trace from spans
749
+ spans = run.get("spans", [])
750
+ if spans:
751
+ result += f"\n## Execution Trace ({len(spans)} spans)\n"
752
+ for i, span in enumerate(spans, 1):
753
+ name = span.get("span_name", "")
754
+ duration = span.get("duration", 0)
755
+ status = span.get("status_code", "")
756
+ status_msg = span.get("status_message", "")
757
+ attrs = span.get("span_attributes", {})
758
+
759
+ status_str = "OK" if "OK" in str(status) else "ERROR"
760
+ result += f"{i}. [{name}] {duration:.2f}s - {status_str}"
761
+ if status_msg:
762
+ result += f" - {status_msg}"
763
+ result += "\n"
764
+
765
+ # Show output for executor_run spans
766
+ if name == "executor_run" and attrs.get("output"):
767
+ try:
768
+ output_data = json.loads(attrs["output"])
769
+ for k, v in output_data.items():
770
+ if k == "success":
771
+ continue
772
+ result += f" output.{k}: {str(v)[:300]}\n"
773
+ except (json.JSONDecodeError, TypeError):
774
+ result += f" output: {str(attrs['output'])[:500]}\n"
775
+
776
+ # completion: show the LLM text output
777
+ elif name == "completion" and attrs.get("content"):
778
+ result += f" completion: {str(attrs['content'])[:500]}\n"
779
+
780
+ # structured_completion: show parsed output
781
+ elif name == "structured_completion" and attrs.get("output"):
782
+ try:
783
+ out = json.loads(attrs["output"])
784
+ result += f" output: {json.dumps(out, indent=None)[:400]}\n"
785
+ except (json.JSONDecodeError, TypeError):
786
+ result += f" output: {str(attrs['output'])[:400]}\n"
787
+
788
+ # branch: show routing decision
789
+ elif name == "branch":
790
+ selected = attrs.get("selected.path", "")
791
+ if selected:
792
+ result += f" selected: {selected}\n"
793
+
794
+ # Actions summary
641
795
  actions = run.get("actions", [])
642
796
  if actions:
643
797
  result += f"\n## Actions ({len(actions)})\n"
@@ -650,6 +804,21 @@ async def flow_run(
650
804
  ]
651
805
  result += to_csv(action_rows, ["node", "status"])
652
806
 
807
+ # Errors
808
+ errors = [s for s in spans if "ERROR" in str(s.get("status_code", ""))]
809
+ if errors:
810
+ result += "\n## Errors\n"
811
+ for err in errors:
812
+ attrs = err.get("span_attributes", {})
813
+ node_name = attrs.get("tool_name") or err.get("span_name") or "unknown"
814
+ error_msg = err.get("status_message", "")
815
+ result += f"**Node:** {node_name}\n"
816
+ if error_msg:
817
+ result += f"**Error:** {error_msg}\n"
818
+ if attrs.get("output"):
819
+ result += f"**Output:** {str(attrs.get('output'))[:500]}\n"
820
+ result += "\n"
821
+
653
822
  return result
654
823
 
655
824
 
@@ -949,82 +1118,415 @@ async def send_message(
949
1118
 
950
1119
  async def executor_list(
951
1120
  client: AppliedClient,
952
- output_format: str = "csv",
1121
+ output_format: str = "full",
953
1122
  ) -> str:
954
1123
  """
955
- List available node types (executors).
1124
+ List available node types (executors) with their metadata schemas.
956
1125
 
957
1126
  Args:
958
1127
  client: Authenticated AppliedClient (not used, list is hardcoded)
959
- output_format: 'csv' or 'json'
1128
+ output_format: 'full' (default, includes schemas), 'csv' (names only), 'json'
960
1129
 
961
1130
  Returns:
962
- Available executor types with descriptions
1131
+ Available executor types with descriptions and metadata field schemas
963
1132
  """
964
- # Hardcoded list of common executors
1133
+ # Hardcoded list of executors with metadata schemas
965
1134
  executors = [
966
1135
  {
967
1136
  "name": "completion",
968
- "description": "LLM completion node - generates free-form text response",
969
- "use_case": "Generate responses, summaries, or any text output",
1137
+ "description": "LLM completion - generates free-form text",
1138
+ "use_case": "Responses, summaries, any text output",
1139
+ "metadata_fields": {
1140
+ "model": "string - LLM model (default: agent's model)",
1141
+ "temperature": "float - 0.0-1.0 (default: 0.7)",
1142
+ "max_tokens": "int - max output tokens",
1143
+ },
1144
+ "output_fields": ["completion"],
970
1145
  },
971
1146
  {
972
1147
  "name": "structured_completion",
973
1148
  "description": "LLM completion with structured JSON output",
974
- "use_case": "Extract data, classify, or generate typed responses",
1149
+ "use_case": "Extract data, classify, typed responses",
1150
+ "metadata_fields": {
1151
+ "model": "string - LLM model",
1152
+ "output_schema": "object - JSON Schema for output structure",
1153
+ },
1154
+ "output_fields": ["(defined by output_schema)"],
975
1155
  },
976
1156
  {
977
1157
  "name": "branch",
978
- "description": "Conditional branching based on LLM decision",
979
- "use_case": "Route flow based on user intent or conditions",
1158
+ "description": "Conditional branching based on comparison or LLM",
1159
+ "use_case": "Route flow based on conditions",
1160
+ "metadata_fields": {
1161
+ "branch_type": "'comparison' or 'llm'",
1162
+ "branches": "array - branch definitions with conditions",
1163
+ },
1164
+ "output_fields": ["selected_branch"],
980
1165
  },
981
1166
  {
982
- "name": "loop",
983
- "description": "Loop over a list and execute nodes for each item",
984
- "use_case": "Process multiple items, batch operations",
1167
+ "name": "code",
1168
+ "description": "Execute Python code in sandbox",
1169
+ "use_case": "Data transformation, calculations, API calls",
1170
+ "metadata_fields": {
1171
+ "language": "string - 'python' (required)",
1172
+ "code": "string - Python code to execute (required)",
1173
+ "packages": "array - pip packages to install, e.g. ['requests']",
1174
+ },
1175
+ "output_fields": ["result", "stdout", "stderr"],
985
1176
  },
986
1177
  {
987
1178
  "name": "http_request",
988
1179
  "description": "Make HTTP API calls",
989
1180
  "use_case": "Integrate with external APIs",
1181
+ "metadata_fields": {
1182
+ "method": "string - GET, POST, PUT, DELETE",
1183
+ "url": "string - endpoint URL (supports {var} interpolation)",
1184
+ "headers": "object - request headers",
1185
+ "body": "object - request body for POST/PUT",
1186
+ },
1187
+ "output_fields": ["status_code", "data", "headers"],
990
1188
  },
991
1189
  {
992
- "name": "code",
993
- "description": "Execute Python code",
994
- "use_case": "Data transformation, calculations",
1190
+ "name": "loop",
1191
+ "description": "Loop over a list and execute subflow",
1192
+ "use_case": "Process multiple items, batch operations",
1193
+ "metadata_fields": {
1194
+ "items_path": "string - variable path to iterate, e.g. {trigger.items}",
1195
+ "max_iterations": "int - limit iterations",
1196
+ },
1197
+ "output_fields": ["results", "count"],
995
1198
  },
996
1199
  {
997
1200
  "name": "memory",
998
1201
  "description": "Store/retrieve data in conversation memory",
999
- "use_case": "Remember user preferences, context",
1202
+ "use_case": "Remember user preferences, context across turns",
1203
+ "metadata_fields": {
1204
+ "operation": "'get' or 'set'",
1205
+ "key": "string - memory key",
1206
+ "value": "any - value to store (for set)",
1207
+ },
1208
+ "output_fields": ["value"],
1000
1209
  },
1001
1210
  {
1002
- "name": "global",
1003
- "description": "Global handler for escalation or failure",
1004
- "use_case": "Catch-all for errors or escalation intents",
1211
+ "name": "search",
1212
+ "description": "Search knowledge base",
1213
+ "use_case": "Find relevant documentation or Q&A",
1214
+ "metadata_fields": {
1215
+ "query": "string - search query (supports {var} interpolation)",
1216
+ "limit": "int - max results",
1217
+ },
1218
+ "output_fields": ["results", "count"],
1005
1219
  },
1006
1220
  {
1007
- "name": "end_flow",
1008
- "description": "Explicitly end the flow",
1009
- "use_case": "Terminate flow execution",
1221
+ "name": "global",
1222
+ "description": "Global handler for escalation or failure",
1223
+ "use_case": "Catch-all for errors, default handler",
1224
+ "metadata_fields": {},
1225
+ "output_fields": [],
1010
1226
  },
1011
1227
  {
1012
1228
  "name": "_escalate_conversation",
1013
1229
  "description": "Escalate conversation to human agent",
1014
1230
  "use_case": "Hand off to support team",
1231
+ "metadata_fields": {
1232
+ "reason": "string - escalation reason",
1233
+ },
1234
+ "output_fields": [],
1015
1235
  },
1016
1236
  {
1017
1237
  "name": "handoff",
1018
1238
  "description": "Hand off to another flow or agent",
1019
1239
  "use_case": "Transfer to specialized flow",
1240
+ "metadata_fields": {
1241
+ "target_flow_id": "string - flow to hand off to",
1242
+ },
1243
+ "output_fields": [],
1020
1244
  },
1021
1245
  {
1022
- "name": "search",
1023
- "description": "Search knowledge base",
1024
- "use_case": "Find relevant documentation or Q&A",
1246
+ "name": "end_flow",
1247
+ "description": "Explicitly end the flow",
1248
+ "use_case": "Terminate flow execution early",
1249
+ "metadata_fields": {},
1250
+ "output_fields": [],
1025
1251
  },
1026
1252
  ]
1027
1253
 
1028
1254
  if output_format == "csv":
1029
- return to_csv(executors, ["name", "description", "use_case"])
1030
- return to_json(executors)
1255
+ simple = [
1256
+ {"name": e["name"], "description": e["description"]} for e in executors
1257
+ ]
1258
+ return to_csv(simple, ["name", "description"])
1259
+
1260
+ if output_format == "json":
1261
+ return to_json(executors)
1262
+
1263
+ # Full output with schemas
1264
+ result = "# Available Node Types (Executors)\n\n"
1265
+ result += "Use these with flow_node_create(executor_type=...)\n\n"
1266
+
1267
+ for ex in executors:
1268
+ result += f"## {ex['name']}\n"
1269
+ result += f"{ex['description']}\n"
1270
+ result += f"Use case: {ex['use_case']}\n"
1271
+
1272
+ if ex.get("metadata_fields"):
1273
+ result += "\n**Metadata fields:**\n"
1274
+ for field, desc in ex["metadata_fields"].items():
1275
+ result += f" - `{field}`: {desc}\n"
1276
+
1277
+ if ex.get("output_fields"):
1278
+ result += f"\n**Output fields:** {', '.join(ex['output_fields'])}\n"
1279
+
1280
+ result += "\n"
1281
+
1282
+ result += "## Variable Reference Syntax\n"
1283
+ result += "Reference previous node outputs in prompts and metadata:\n"
1284
+ result += "- `{trigger.message}` - user's input message\n"
1285
+ result += "- `{trigger.contact.email}` - contact email\n"
1286
+ result += "- `{node_name.field}` - output from previous node\n"
1287
+ result += "- `{code-abc123.result}` - code node output\n"
1288
+ result += "- `{http_request-xyz.data.items}` - nested API response\n"
1289
+
1290
+ return result
1291
+
1292
+
1293
+ # -----------------------------------------------------------------------------
1294
+ # Flow Variables
1295
+ # -----------------------------------------------------------------------------
1296
+
1297
+
1298
+ def _extract_schema_paths(
1299
+ schema: dict,
1300
+ prefix: str = "",
1301
+ defs: dict | None = None,
1302
+ max_depth: int = 4,
1303
+ ) -> list[dict]:
1304
+ """
1305
+ Recursively extract variable paths from a JSON Schema.
1306
+
1307
+ Returns list of {"path": "field.subfield", "type": "string", "description": "..."}
1308
+ """
1309
+ if max_depth <= 0:
1310
+ return []
1311
+
1312
+ defs = defs or schema.get("$defs", {})
1313
+ paths = []
1314
+
1315
+ # Handle $ref
1316
+ if "$ref" in schema:
1317
+ ref_name = schema["$ref"].split("/")[-1]
1318
+ if ref_name in defs:
1319
+ return _extract_schema_paths(defs[ref_name], prefix, defs, max_depth)
1320
+ return []
1321
+
1322
+ # Handle anyOf/oneOf (take first option)
1323
+ if "anyOf" in schema:
1324
+ for option in schema["anyOf"]:
1325
+ if option.get("type") != "null":
1326
+ return _extract_schema_paths(option, prefix, defs, max_depth)
1327
+ return []
1328
+
1329
+ schema_type = schema.get("type", "")
1330
+
1331
+ if schema_type == "object":
1332
+ props = schema.get("properties", {})
1333
+ for prop_name, prop_schema in props.items():
1334
+ path = f"{prefix}.{prop_name}" if prefix else prop_name
1335
+ prop_type = prop_schema.get("type", "any")
1336
+ desc = prop_schema.get("description", "")
1337
+
1338
+ # Add this path
1339
+ paths.append({"path": path, "type": prop_type, "description": desc[:60]})
1340
+
1341
+ # Recurse for nested objects/arrays
1342
+ if prop_type == "object":
1343
+ paths.extend(
1344
+ _extract_schema_paths(prop_schema, path, defs, max_depth - 1)
1345
+ )
1346
+ elif prop_type == "array":
1347
+ items = prop_schema.get("items", {})
1348
+ if items:
1349
+ # Add [0] and [*] access patterns
1350
+ paths.append(
1351
+ {
1352
+ "path": f"{path}[0]",
1353
+ "type": "item",
1354
+ "description": "First item",
1355
+ }
1356
+ )
1357
+ paths.append(
1358
+ {
1359
+ "path": f"{path}[*]",
1360
+ "type": "list",
1361
+ "description": "Map over all",
1362
+ }
1363
+ )
1364
+ # Recurse into array items
1365
+ paths.extend(
1366
+ _extract_schema_paths(items, f"{path}[0]", defs, max_depth - 1)
1367
+ )
1368
+
1369
+ elif schema_type == "array":
1370
+ items = schema.get("items", {})
1371
+ if items and prefix:
1372
+ paths.append(
1373
+ {"path": f"{prefix}[0]", "type": "item", "description": "First item"}
1374
+ )
1375
+ paths.extend(
1376
+ _extract_schema_paths(items, f"{prefix}[0]", defs, max_depth - 1)
1377
+ )
1378
+
1379
+ return paths
1380
+
1381
+
1382
+ def _topological_sort(nodes: list[dict], edges: list[dict]) -> list[dict]:
1383
+ """Sort nodes in topological order based on edges."""
1384
+ # Build adjacency and in-degree
1385
+ node_map = {n["id"]: n for n in nodes}
1386
+ in_degree = {n["id"]: 0 for n in nodes}
1387
+ children = {n["id"]: [] for n in nodes}
1388
+
1389
+ for e in edges:
1390
+ src, tgt = e.get("source"), e.get("target")
1391
+ if src in node_map and tgt in node_map:
1392
+ children[src].append(tgt)
1393
+ in_degree[tgt] = in_degree.get(tgt, 0) + 1
1394
+
1395
+ # Kahn's algorithm
1396
+ queue = [nid for nid, deg in in_degree.items() if deg == 0]
1397
+ result = []
1398
+
1399
+ while queue:
1400
+ nid = queue.pop(0)
1401
+ result.append(node_map[nid])
1402
+ for child in children.get(nid, []):
1403
+ in_degree[child] -= 1
1404
+ if in_degree[child] == 0:
1405
+ queue.append(child)
1406
+
1407
+ return result
1408
+
1409
+
1410
+ async def flow_variables(
1411
+ client: AppliedClient,
1412
+ flow_id: str,
1413
+ node_id: str | None = None,
1414
+ ) -> str:
1415
+ """
1416
+ List available variables for a flow, showing what can be referenced in prompts
1417
+ and node configurations using {node_name.field} syntax.
1418
+
1419
+ Args:
1420
+ client: Authenticated AppliedClient
1421
+ flow_id: The flow UUID
1422
+ node_id: Optional - show only variables available to this specific node
1423
+ (predecessors in the graph). If omitted, shows all variables.
1424
+
1425
+ Returns:
1426
+ Formatted list of available variables grouped by source node,
1427
+ with paths, types, and descriptions.
1428
+
1429
+ Example output:
1430
+ ## 1. trigger
1431
+ {trigger.message} string User's message
1432
+ {trigger.contact.email} string Contact email
1433
+ {trigger.shop.is_open} boolean Business hours check
1434
+
1435
+ ## 2. http_request-abc123
1436
+ {http_request-abc123.data} object Response data
1437
+ {http_request-abc123.status_code} integer HTTP status
1438
+ """
1439
+ flow = await client.get_flow(flow_id)
1440
+ graph = flow.get("graph", {})
1441
+ nodes = graph.get("nodes", [])
1442
+ edges = graph.get("edges", [])
1443
+
1444
+ # Sort nodes topologically
1445
+ sorted_nodes = _topological_sort(nodes, edges)
1446
+
1447
+ # If node_id specified, find its predecessors
1448
+ if node_id:
1449
+ # Build predecessor set via BFS backwards
1450
+ parents = {n["id"]: set() for n in nodes}
1451
+ for e in edges:
1452
+ tgt = e.get("target")
1453
+ src = e.get("source")
1454
+ if tgt and src:
1455
+ parents[tgt].add(src)
1456
+
1457
+ # BFS to find all ancestors
1458
+ ancestors = set()
1459
+ queue = list(parents.get(node_id, set()))
1460
+ while queue:
1461
+ p = queue.pop(0)
1462
+ if p not in ancestors:
1463
+ ancestors.add(p)
1464
+ queue.extend(parents.get(p, set()))
1465
+
1466
+ # Filter to only ancestor nodes
1467
+ sorted_nodes = [n for n in sorted_nodes if n["id"] in ancestors]
1468
+
1469
+ result = "# Available Variables for Flow\n"
1470
+ result += "Use {node_name.field} syntax in prompts and metadata.\n\n"
1471
+
1472
+ for idx, node in enumerate(sorted_nodes, 1):
1473
+ node_data = node.get("data", {})
1474
+ node_name = node_data.get("name", node.get("id", ""))
1475
+ node_type = node.get("type", node_data.get("executor_type", ""))
1476
+ outputs = node_data.get("outputs", {})
1477
+
1478
+ # Skip nodes without output schema
1479
+ if not outputs or not outputs.get("properties"):
1480
+ continue
1481
+
1482
+ result += f"## {idx}. {node_name}"
1483
+ if node_type and node_type != node_name:
1484
+ result += f" ({node_type})"
1485
+ result += "\n"
1486
+
1487
+ # Extract paths from schema
1488
+ paths = _extract_schema_paths(outputs)
1489
+
1490
+ for p in paths[:20]: # Limit to avoid overwhelming output
1491
+ var_path = f"{{{node_name}.{p['path']}}}"
1492
+ type_str = p["type"][:10].ljust(10)
1493
+ desc = p["description"][:40] if p["description"] else ""
1494
+ result += f" {var_path:<45} {type_str} {desc}\n"
1495
+
1496
+ if len(paths) > 20:
1497
+ result += f" ... and {len(paths) - 20} more fields\n"
1498
+
1499
+ result += "\n"
1500
+
1501
+ if not result.strip().endswith("\n\n"):
1502
+ result += "\n"
1503
+
1504
+ result += "## Built-in Trigger Context (llm.call trigger)\n"
1505
+ result += "The trigger node always provides these fields:\n\n"
1506
+ result += "**{trigger.shop}** - Workspace/shop object:\n"
1507
+ result += " - {trigger.shop.is_open} (boolean) - whether shop is currently open\n"
1508
+ result += " - {trigger.shop.name} (string)\n"
1509
+ result += " - {trigger.shop.timezone} (string)\n"
1510
+ result += " - {trigger.shop.address} (string)\n"
1511
+ result += "\n"
1512
+ result += "**{trigger.contact}** - Customer contact:\n"
1513
+ result += " - {trigger.contact.email} (string)\n"
1514
+ result += " - {trigger.contact.name} (string)\n"
1515
+ result += " - {trigger.contact.phone} (string)\n"
1516
+ result += "\n"
1517
+ result += "**{trigger.conversation}** - Current conversation:\n"
1518
+ result += " - {trigger.conversation.id} (string)\n"
1519
+ result += " - {trigger.conversation.title} (string)\n"
1520
+ result += " - {trigger.conversation.resolution} (string)\n"
1521
+ result += "\n"
1522
+ result += "**{trigger.messages}** - Message history (array)\n"
1523
+ result += "**{trigger.attachments}** - Uploaded files (array)\n"
1524
+ result += "**{trigger.current_time}** - ISO timestamp (string)\n"
1525
+ result += "\n"
1526
+ result += "## Tips\n"
1527
+ result += "- Use {trigger.shop.is_open} directly in branch conditions\n"
1528
+ result += "- Reference previous nodes by full name: {completion-abc.completion}\n"
1529
+ result += "- Access nested fields with dots: {trigger.contact.email}\n"
1530
+ result += "- Access array items: {trigger.messages[0].text}\n"
1531
+
1532
+ return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: applied-cli
3
- Version: 0.5.7
3
+ Version: 0.5.9
4
4
  Summary: CLI and shared client library for Applied Labs AI support agents
5
5
  Author: Applied Labs
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "applied-cli"
3
- version = "0.5.7"
3
+ version = "0.5.9"
4
4
  description = "CLI and shared client library for Applied Labs AI support agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
File without changes
File without changes