glaip-sdk 0.0.4__py3-none-any.whl → 0.0.5b1__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.
Files changed (48) hide show
  1. glaip_sdk/__init__.py +5 -5
  2. glaip_sdk/branding.py +18 -17
  3. glaip_sdk/cli/__init__.py +1 -1
  4. glaip_sdk/cli/agent_config.py +82 -0
  5. glaip_sdk/cli/commands/__init__.py +3 -3
  6. glaip_sdk/cli/commands/agents.py +570 -673
  7. glaip_sdk/cli/commands/configure.py +2 -2
  8. glaip_sdk/cli/commands/mcps.py +148 -143
  9. glaip_sdk/cli/commands/models.py +1 -1
  10. glaip_sdk/cli/commands/tools.py +250 -179
  11. glaip_sdk/cli/display.py +244 -0
  12. glaip_sdk/cli/io.py +106 -0
  13. glaip_sdk/cli/main.py +14 -18
  14. glaip_sdk/cli/resolution.py +59 -0
  15. glaip_sdk/cli/utils.py +305 -264
  16. glaip_sdk/cli/validators.py +235 -0
  17. glaip_sdk/client/__init__.py +3 -224
  18. glaip_sdk/client/agents.py +631 -191
  19. glaip_sdk/client/base.py +66 -4
  20. glaip_sdk/client/main.py +226 -0
  21. glaip_sdk/client/mcps.py +143 -18
  22. glaip_sdk/client/tools.py +146 -11
  23. glaip_sdk/config/constants.py +10 -1
  24. glaip_sdk/models.py +42 -2
  25. glaip_sdk/rich_components.py +29 -0
  26. glaip_sdk/utils/__init__.py +18 -171
  27. glaip_sdk/utils/agent_config.py +181 -0
  28. glaip_sdk/utils/client_utils.py +159 -79
  29. glaip_sdk/utils/display.py +100 -0
  30. glaip_sdk/utils/general.py +94 -0
  31. glaip_sdk/utils/import_export.py +140 -0
  32. glaip_sdk/utils/rendering/formatting.py +6 -1
  33. glaip_sdk/utils/rendering/renderer/__init__.py +67 -8
  34. glaip_sdk/utils/rendering/renderer/base.py +340 -247
  35. glaip_sdk/utils/rendering/renderer/debug.py +3 -2
  36. glaip_sdk/utils/rendering/renderer/panels.py +11 -10
  37. glaip_sdk/utils/rendering/steps.py +1 -1
  38. glaip_sdk/utils/resource_refs.py +192 -0
  39. glaip_sdk/utils/rich_utils.py +29 -0
  40. glaip_sdk/utils/serialization.py +285 -0
  41. glaip_sdk/utils/validation.py +273 -0
  42. {glaip_sdk-0.0.4.dist-info → glaip_sdk-0.0.5b1.dist-info}/METADATA +22 -21
  43. glaip_sdk-0.0.5b1.dist-info/RECORD +55 -0
  44. {glaip_sdk-0.0.4.dist-info → glaip_sdk-0.0.5b1.dist-info}/WHEEL +1 -1
  45. glaip_sdk-0.0.5b1.dist-info/entry_points.txt +3 -0
  46. glaip_sdk/cli/commands/init.py +0 -93
  47. glaip_sdk-0.0.4.dist-info/RECORD +0 -41
  48. glaip_sdk-0.0.4.dist-info/entry_points.txt +0 -2
@@ -5,11 +5,14 @@ Authors:
5
5
  Raymond Christopher (raymond.christopher@gdplabs.id)
6
6
  """
7
7
 
8
+ import io
8
9
  import json
9
10
  import logging
11
+ from collections.abc import AsyncGenerator
10
12
  from time import monotonic
11
13
  from typing import Any, BinaryIO
12
14
 
15
+ import httpx
13
16
  from rich.console import Console as _Console
14
17
 
15
18
  from glaip_sdk.client.base import BaseClient
@@ -21,8 +24,10 @@ from glaip_sdk.config.constants import (
21
24
  DEFAULT_AGENT_VERSION,
22
25
  DEFAULT_MODEL,
23
26
  )
27
+ from glaip_sdk.exceptions import NotFoundError
24
28
  from glaip_sdk.models import Agent
25
29
  from glaip_sdk.utils.client_utils import (
30
+ aiter_sse_events,
26
31
  create_model_instances,
27
32
  extract_ids,
28
33
  find_by_name,
@@ -32,6 +37,10 @@ from glaip_sdk.utils.client_utils import (
32
37
  from glaip_sdk.utils.rendering.models import RunStats
33
38
  from glaip_sdk.utils.rendering.renderer import RichStreamRenderer
34
39
  from glaip_sdk.utils.rendering.renderer.config import RendererConfig
40
+ from glaip_sdk.utils.validation import validate_agent_instruction
41
+
42
+ # API endpoints
43
+ AGENTS_ENDPOINT = "/agents/"
35
44
 
36
45
  # Set up module-level logger
37
46
  logger = logging.getLogger("glaip_sdk.agents")
@@ -49,14 +58,87 @@ class AgentClient(BaseClient):
49
58
  """
50
59
  super().__init__(parent_client=parent_client, **kwargs)
51
60
 
52
- def list_agents(self) -> list[Agent]:
53
- """List all agents."""
54
- data = self._request("GET", "/agents/")
61
+ def list_agents(
62
+ self,
63
+ agent_type: str | None = None,
64
+ framework: str | None = None,
65
+ name: str | None = None,
66
+ version: str | None = None,
67
+ sync_langflow_agents: bool = False,
68
+ ) -> list[Agent]:
69
+ """List agents with optional filtering.
70
+
71
+ Args:
72
+ agent_type: Filter by agent type (config, code, a2a)
73
+ framework: Filter by framework (langchain, langgraph, google_adk)
74
+ name: Filter by partial name match (case-insensitive)
75
+ version: Filter by exact version match
76
+ sync_langflow_agents: Sync with LangFlow server before listing (only applies when agent_type=langflow)
77
+
78
+ Returns:
79
+ List of agents matching the filters
80
+ """
81
+ params = {}
82
+ if agent_type is not None:
83
+ params["agent_type"] = agent_type
84
+ if framework is not None:
85
+ params["framework"] = framework
86
+ if name is not None:
87
+ params["name"] = name
88
+ if version is not None:
89
+ params["version"] = version
90
+ if sync_langflow_agents:
91
+ params["sync_langflow_agents"] = "true"
92
+
93
+ if params:
94
+ data = self._request("GET", AGENTS_ENDPOINT, params=params)
95
+ else:
96
+ data = self._request("GET", AGENTS_ENDPOINT)
55
97
  return create_model_instances(data, Agent, self)
56
98
 
99
+ def sync_langflow_agents(
100
+ self,
101
+ base_url: str | None = None,
102
+ api_key: str | None = None,
103
+ ) -> dict[str, Any]:
104
+ """Sync LangFlow agents by fetching flows from the LangFlow server.
105
+
106
+ This method synchronizes agents with LangFlow flows. It fetches all flows
107
+ from the configured LangFlow server and creates/updates corresponding agents.
108
+
109
+ Args:
110
+ base_url: Custom LangFlow server base URL. If not provided, uses LANGFLOW_BASE_URL env var.
111
+ api_key: Custom LangFlow API key. If not provided, uses LANGFLOW_API_KEY env var.
112
+
113
+ Returns:
114
+ Response containing sync results and statistics
115
+
116
+ Raises:
117
+ ValueError: If LangFlow server configuration is missing
118
+ """
119
+ payload = {}
120
+ if base_url is not None:
121
+ payload["base_url"] = base_url
122
+ if api_key is not None:
123
+ payload["api_key"] = api_key
124
+
125
+ return self._request("POST", "/agents/langflow/sync", json=payload)
126
+
57
127
  def get_agent_by_id(self, agent_id: str) -> Agent:
58
128
  """Get agent by ID."""
59
129
  data = self._request("GET", f"/agents/{agent_id}")
130
+
131
+ if isinstance(data, str):
132
+ # Some backends may respond with plain text for missing agents.
133
+ message = data.strip() or f"Agent '{agent_id}' not found"
134
+ raise NotFoundError(message, status_code=404)
135
+
136
+ if not isinstance(data, dict):
137
+ raise NotFoundError(
138
+ f"Agent '{agent_id}' not found (unexpected response type)",
139
+ status_code=404,
140
+ )
141
+
60
142
  return Agent(**data)._set_client(self)
61
143
 
62
144
  def find_agents(self, name: str | None = None) -> list[Agent]:
@@ -65,13 +147,13 @@ class AgentClient(BaseClient):
65
147
  if name:
66
148
  params["name"] = name
67
149
 
68
- data = self._request("GET", "/agents/", params=params)
150
+ data = self._request("GET", AGENTS_ENDPOINT, params=params)
69
151
  agents = create_model_instances(data, Agent, self)
70
152
  if name is None:
71
153
  return agents
72
154
  return find_by_name(agents, name, case_sensitive=False)
73
155
 
74
- def create_agent(
156
+ def _build_create_payload(
75
157
  self,
76
158
  name: str,
77
159
  instruction: str,
@@ -80,38 +162,53 @@ class AgentClient(BaseClient):
80
162
  agents: list[str | Any] | None = None,
81
163
  timeout: int = DEFAULT_AGENT_RUN_TIMEOUT,
82
164
  **kwargs,
83
- ) -> "Agent":
84
- """Create a new agent."""
85
- # Client-side validation
86
- if not name or not name.strip():
87
- raise ValueError("Agent name cannot be empty or whitespace")
88
-
89
- if not instruction or not instruction.strip():
90
- raise ValueError("Agent instruction cannot be empty or whitespace")
165
+ ) -> dict[str, Any]:
166
+ """Build payload for agent creation with proper LM selection and metadata handling.
91
167
 
92
- if len(instruction.strip()) < 10:
93
- raise ValueError("Agent instruction must be at least 10 characters long")
168
+ CENTRALIZED PAYLOAD BUILDING LOGIC:
169
+ - LM exclusivity: Uses language_model_id if provided, otherwise provider/model_name
170
+ - Always includes required backend metadata
171
+ - Preserves mem0 keys in agent_config
172
+ - Handles tool/agent ID extraction from objects
94
173
 
95
- # Prepare the creation payload
174
+ Args:
175
+ name: Agent name
176
+ instruction: Agent instruction
177
+ model: Language model name (used when language_model_id not provided)
178
+ tools: List of tools to attach
179
+ agents: List of sub-agents to attach
180
+ timeout: Agent execution timeout
181
+ **kwargs: Additional parameters (language_model_id, agent_config, etc.)
182
+
183
+ Returns:
184
+ Complete payload dictionary for agent creation
185
+ """
186
+ # Prepare the creation payload with required fields
96
187
  payload: dict[str, Any] = {
97
188
  "name": name.strip(),
98
189
  "instruction": instruction.strip(),
99
190
  "type": DEFAULT_AGENT_TYPE,
100
191
  "framework": DEFAULT_AGENT_FRAMEWORK,
101
192
  "version": DEFAULT_AGENT_VERSION,
102
- "provider": DEFAULT_AGENT_PROVIDER,
103
- "model_name": model or DEFAULT_MODEL, # Ensure model_name is never None
104
193
  }
105
194
 
106
- # Include default execution timeout if provided
195
+ # Language model selection with exclusivity:
196
+ # Priority: language_model_id (if provided) > provider/model_name (fallback)
197
+ if kwargs.get("language_model_id"):
198
+ # Use language_model_id - defer to kwargs update below
199
+ pass
200
+ else:
201
+ # Use provider/model_name fallback
202
+ payload["provider"] = DEFAULT_AGENT_PROVIDER
203
+ payload["model_name"] = model or DEFAULT_MODEL
204
+
205
+ # Include execution timeout if provided
107
206
  if timeout is not None:
108
207
  payload["timeout"] = str(timeout)
109
208
 
110
209
  # Ensure minimum required metadata for visibility
111
210
  if "metadata" not in kwargs:
112
211
  kwargs["metadata"] = {}
113
-
114
- # Always include the minimum required metadata for visibility
115
212
  if "type" not in kwargs["metadata"]:
116
213
  kwargs["metadata"]["type"] = "custom"
117
214
 
@@ -125,32 +222,16 @@ class AgentClient(BaseClient):
125
222
  if agent_ids:
126
223
  payload["agents"] = agent_ids
127
224
 
128
- # Add any additional kwargs
225
+ # Add any additional kwargs (including language_model_id, agent_config, etc.)
129
226
  payload.update(kwargs)
130
227
 
131
- # Create the agent and fetch full details
132
- full_agent_data = self._post_then_fetch(
133
- id_key="id",
134
- post_endpoint="/agents/",
135
- get_endpoint_fmt="/agents/{id}",
136
- json=payload,
137
- )
138
- return Agent(**full_agent_data)._set_client(self)
139
-
140
- def update_agent(
141
- self,
142
- agent_id: str,
143
- name: str | None = None,
144
- instruction: str | None = None,
145
- model: str | None = None,
146
- **kwargs,
147
- ) -> "Agent":
148
- """Update an existing agent."""
149
- # First, get the current agent data
150
- current_agent = self.get_agent_by_id(agent_id)
228
+ return payload
151
229
 
152
- # Prepare the update payload with current values as defaults
153
- update_data = {
230
+ def _build_basic_update_payload(
231
+ self, current_agent: "Agent", name: str | None, instruction: str | None
232
+ ) -> dict[str, Any]:
233
+ """Build the basic update payload with required fields."""
234
+ return {
154
235
  "name": name if name is not None else current_agent.name,
155
236
  "instruction": instruction
156
237
  if instruction is not None
@@ -160,48 +241,250 @@ class AgentClient(BaseClient):
160
241
  "version": DEFAULT_AGENT_VERSION, # Required by backend
161
242
  }
162
243
 
163
- # Handle model specification
164
- if model is not None:
165
- update_data["provider"] = DEFAULT_AGENT_PROVIDER # Default provider
244
+ def _handle_language_model_selection(
245
+ self,
246
+ update_data: dict[str, Any],
247
+ current_agent: "Agent",
248
+ model: str | None,
249
+ language_model_id: str | None,
250
+ ) -> None:
251
+ """Handle language model selection with proper priority and fallbacks."""
252
+ if language_model_id:
253
+ # Use language_model_id if provided
254
+ update_data["language_model_id"] = language_model_id
255
+ elif model is not None:
256
+ # Use explicit model parameter
257
+ update_data["provider"] = DEFAULT_AGENT_PROVIDER
166
258
  update_data["model_name"] = model
167
259
  else:
168
- # Use current model if available
169
- if hasattr(current_agent, "agent_config") and current_agent.agent_config:
170
- if "lm_provider" in current_agent.agent_config:
171
- update_data["provider"] = current_agent.agent_config["lm_provider"]
172
- if "lm_name" in current_agent.agent_config:
173
- update_data["model_name"] = current_agent.agent_config["lm_name"]
174
- else:
175
- # Default values
176
- update_data["provider"] = DEFAULT_AGENT_PROVIDER
177
- update_data["model_name"] = DEFAULT_MODEL
260
+ # Use current agent config or fallbacks
261
+ self._set_language_model_from_current_agent(update_data, current_agent)
262
+
263
+ def _set_language_model_from_current_agent(
264
+ self, update_data: dict[str, Any], current_agent: "Agent"
265
+ ) -> None:
266
+ """Set language model from current agent config or use defaults."""
267
+ if hasattr(current_agent, "agent_config") and current_agent.agent_config:
268
+ agent_config = current_agent.agent_config
269
+ if "lm_provider" in agent_config:
270
+ update_data["provider"] = agent_config["lm_provider"]
271
+ if "lm_name" in agent_config:
272
+ update_data["model_name"] = agent_config["lm_name"]
273
+ else:
274
+ # Default fallback values
275
+ update_data["provider"] = DEFAULT_AGENT_PROVIDER
276
+ update_data["model_name"] = DEFAULT_MODEL
178
277
 
179
- # Handle tools and agents
180
- if "tools" in kwargs:
181
- tool_ids = extract_ids(kwargs["tools"])
182
- if tool_ids:
183
- update_data["tools"] = tool_ids
184
- elif current_agent.tools:
185
- update_data["tools"] = [
278
+ def _handle_tools_and_agents(
279
+ self,
280
+ update_data: dict[str, Any],
281
+ current_agent: "Agent",
282
+ tools: list | None,
283
+ agents: list | None,
284
+ ) -> None:
285
+ """Handle tools and agents with proper ID extraction."""
286
+ # Handle tools
287
+ if tools is not None:
288
+ tool_ids = extract_ids(tools)
289
+ update_data["tools"] = tool_ids if tool_ids else []
290
+ else:
291
+ update_data["tools"] = self._extract_current_tool_ids(current_agent)
292
+
293
+ # Handle agents
294
+ if agents is not None:
295
+ agent_ids = extract_ids(agents)
296
+ update_data["agents"] = agent_ids if agent_ids else []
297
+ else:
298
+ update_data["agents"] = self._extract_current_agent_ids(current_agent)
299
+
300
+ def _extract_current_tool_ids(self, current_agent: "Agent") -> list[str]:
301
+ """Extract tool IDs from current agent."""
302
+ if current_agent.tools:
303
+ return [
186
304
  tool["id"] if isinstance(tool, dict) else tool
187
305
  for tool in current_agent.tools
188
306
  ]
307
+ return []
189
308
 
190
- if "agents" in kwargs:
191
- agent_ids = extract_ids(kwargs["agents"])
192
- if agent_ids:
193
- update_data["agents"] = agent_ids
194
- elif current_agent.agents:
195
- update_data["agents"] = [
309
+ def _extract_current_agent_ids(self, current_agent: "Agent") -> list[str]:
310
+ """Extract agent IDs from current agent."""
311
+ if current_agent.agents:
312
+ return [
196
313
  agent["id"] if isinstance(agent, dict) else agent
197
314
  for agent in current_agent.agents
198
315
  ]
316
+ return []
199
317
 
200
- # Add any other kwargs
318
+ def _handle_agent_config(
319
+ self,
320
+ update_data: dict[str, Any],
321
+ current_agent: "Agent",
322
+ agent_config: dict | None,
323
+ ) -> None:
324
+ """Handle agent_config with proper merging and cleanup."""
325
+ if agent_config is not None:
326
+ # Use provided agent_config, merging with current if needed
327
+ update_data["agent_config"] = self._merge_agent_configs(
328
+ current_agent, agent_config
329
+ )
330
+ elif hasattr(current_agent, "agent_config") and current_agent.agent_config:
331
+ # Preserve existing agent_config
332
+ update_data["agent_config"] = current_agent.agent_config.copy()
333
+ else:
334
+ # Default agent_config
335
+ update_data["agent_config"] = {
336
+ "lm_provider": DEFAULT_AGENT_PROVIDER,
337
+ "lm_name": DEFAULT_MODEL,
338
+ "lm_hyperparameters": {"temperature": 0.0},
339
+ }
340
+
341
+ # Clean LM keys from agent_config to prevent conflicts
342
+ self._clean_agent_config_lm_keys(update_data)
343
+
344
+ def _merge_agent_configs(self, current_agent: "Agent", new_config: dict) -> dict:
345
+ """Merge current agent config with new config."""
346
+ if hasattr(current_agent, "agent_config") and current_agent.agent_config:
347
+ merged_config = current_agent.agent_config.copy()
348
+ merged_config.update(new_config)
349
+ return merged_config
350
+ return new_config
351
+
352
+ def _clean_agent_config_lm_keys(self, update_data: dict[str, Any]) -> None:
353
+ """Remove LM keys from agent_config to prevent conflicts."""
354
+ if "agent_config" in update_data and isinstance(
355
+ update_data["agent_config"], dict
356
+ ):
357
+ agent_config = update_data["agent_config"]
358
+ lm_keys_to_remove = {
359
+ "lm_provider",
360
+ "lm_name",
361
+ "lm_base_url",
362
+ "lm_hyperparameters",
363
+ }
364
+ for key in lm_keys_to_remove:
365
+ agent_config.pop(key, None)
366
+
367
+ def _finalize_update_payload(
368
+ self, update_data: dict[str, Any], current_agent: "Agent", **kwargs
369
+ ) -> dict[str, Any]:
370
+ """Finalize the update payload with metadata and additional kwargs."""
371
+ # Handle metadata preservation
372
+ if hasattr(current_agent, "metadata") and current_agent.metadata:
373
+ update_data["metadata"] = current_agent.metadata.copy()
374
+
375
+ # Add any other kwargs (excluding already handled ones)
376
+ excluded_keys = {"tools", "agents", "agent_config", "language_model_id"}
201
377
  for key, value in kwargs.items():
202
- if key not in ["tools", "agents"]:
378
+ if key not in excluded_keys:
203
379
  update_data[key] = value
204
380
 
381
+ return update_data
382
+
383
+ def _build_update_payload(
384
+ self,
385
+ current_agent: "Agent",
386
+ name: str | None = None,
387
+ instruction: str | None = None,
388
+ model: str | None = None,
389
+ **kwargs,
390
+ ) -> dict[str, Any]:
391
+ """Build payload for agent update with proper LM selection and current state preservation.
392
+
393
+ Args:
394
+ current_agent: Current agent object to update
395
+ name: New agent name (None to keep current)
396
+ instruction: New instruction (None to keep current)
397
+ model: New language model name (None to use current or fallback)
398
+ **kwargs: Additional parameters including language_model_id, agent_config, etc.
399
+
400
+ Returns:
401
+ Complete payload dictionary for agent update
402
+
403
+ Notes:
404
+ - LM exclusivity: Uses language_model_id if provided, otherwise provider/model_name
405
+ - Preserves current values as defaults when new values not provided
406
+ - Handles tools/agents updates with proper ID extraction
407
+ """
408
+ # Build basic payload
409
+ update_data = self._build_basic_update_payload(current_agent, name, instruction)
410
+
411
+ # Handle language model selection
412
+ language_model_id = kwargs.get("language_model_id")
413
+ self._handle_language_model_selection(
414
+ update_data, current_agent, model, language_model_id
415
+ )
416
+
417
+ # Handle tools and agents
418
+ tools = kwargs.get("tools")
419
+ agents = kwargs.get("agents")
420
+ self._handle_tools_and_agents(update_data, current_agent, tools, agents)
421
+
422
+ # Handle agent config
423
+ agent_config = kwargs.get("agent_config")
424
+ self._handle_agent_config(update_data, current_agent, agent_config)
425
+
426
+ # Finalize payload
427
+ return self._finalize_update_payload(update_data, current_agent, **kwargs)
428
+
429
+ def create_agent(
430
+ self,
431
+ name: str,
432
+ instruction: str,
433
+ model: str = DEFAULT_MODEL,
434
+ tools: list[str | Any] | None = None,
435
+ agents: list[str | Any] | None = None,
436
+ timeout: int = DEFAULT_AGENT_RUN_TIMEOUT,
437
+ **kwargs,
438
+ ) -> "Agent":
439
+ """Create a new agent."""
440
+ # Client-side validation
441
+ if not name or not name.strip():
442
+ raise ValueError("Agent name cannot be empty or whitespace")
443
+
444
+ # Validate instruction using centralized validation
445
+ instruction = validate_agent_instruction(instruction)
446
+
447
+ # Build payload using centralized builder
448
+ payload = self._build_create_payload(
449
+ name=name,
450
+ instruction=instruction,
451
+ model=model,
452
+ tools=tools,
453
+ agents=agents,
454
+ timeout=timeout,
455
+ **kwargs,
456
+ )
457
+
458
+ # Create the agent and fetch full details
459
+ full_agent_data = self._post_then_fetch(
460
+ id_key="id",
461
+ post_endpoint=AGENTS_ENDPOINT,
462
+ get_endpoint_fmt=f"{AGENTS_ENDPOINT}{{id}}",
463
+ json=payload,
464
+ )
465
+ return Agent(**full_agent_data)._set_client(self)
466
+
467
+ def update_agent(
468
+ self,
469
+ agent_id: str,
470
+ name: str | None = None,
471
+ instruction: str | None = None,
472
+ model: str | None = None,
473
+ **kwargs,
474
+ ) -> "Agent":
475
+ """Update an existing agent."""
476
+ # First, get the current agent data
477
+ current_agent = self.get_agent_by_id(agent_id)
478
+
479
+ # Build payload using centralized builder
480
+ update_data = self._build_update_payload(
481
+ current_agent=current_agent,
482
+ name=name,
483
+ instruction=instruction,
484
+ model=model,
485
+ **kwargs,
486
+ )
487
+
205
488
  # Send the complete payload
206
489
  data = self._request("PUT", f"/agents/{agent_id}", json=update_data)
207
490
  return Agent(**data)._set_client(self)
@@ -210,20 +493,12 @@ class AgentClient(BaseClient):
210
493
  """Delete an agent."""
211
494
  self._request("DELETE", f"/agents/{agent_id}")
212
495
 
213
- def run_agent(
214
- self,
215
- agent_id: str,
216
- message: str,
217
- files: list[str | BinaryIO] | None = None,
218
- tty: bool = False,
219
- *,
220
- renderer: RichStreamRenderer | str | None = "auto",
221
- **kwargs,
222
- ) -> str:
223
- """Run an agent with a message, streaming via a renderer."""
224
- # Prepare multipart data if files are provided
496
+ def _prepare_payload_and_headers(
497
+ self, message: str, files: list[str | BinaryIO] | None, tty: bool, **kwargs
498
+ ):
499
+ """Prepare payload and headers for agent run request."""
225
500
  multipart_data = None
226
- headers = None # None means "don't override client defaults"
501
+ headers = None
227
502
 
228
503
  if files:
229
504
  multipart_data = prepare_multipart_data(message, files)
@@ -232,69 +507,195 @@ class AgentClient(BaseClient):
232
507
  multipart_data.data["chat_history"] = kwargs["chat_history"]
233
508
  if "pii_mapping" in kwargs and kwargs["pii_mapping"] is not None:
234
509
  multipart_data.data["pii_mapping"] = kwargs["pii_mapping"]
235
- headers = None # Let httpx set proper multipart boundaries
236
510
 
237
511
  # When streaming, explicitly prefer SSE
238
512
  headers = {**(headers or {}), "Accept": "text/event-stream"}
239
513
 
240
514
  if files:
241
515
  payload = None
242
- # Use multipart data
243
516
  data_payload = multipart_data.data
244
517
  files_payload = multipart_data.files
245
518
  else:
246
519
  payload = {"input": message, **kwargs}
247
520
  if tty:
248
521
  payload["tty"] = True
249
- # Explicitly send stream intent both ways
250
522
  payload["stream"] = True
251
523
  data_payload = None
252
524
  files_payload = None
253
525
 
254
- # Choose renderer: use provided instance or create a default
526
+ return payload, data_payload, files_payload, headers, multipart_data
527
+
528
+ def _create_renderer(self, renderer, **kwargs):
529
+ """Create appropriate renderer based on configuration."""
255
530
  if isinstance(renderer, RichStreamRenderer):
256
- r = renderer
257
- else:
258
- # Check if verbose mode is requested
259
- verbose = kwargs.get("verbose", False)
260
- if verbose:
261
- # Create a verbose renderer similar to CLI --verbose
262
- verbose_config = RendererConfig(
263
- theme="dark",
264
- style="debug", # CLI uses "debug" style for verbose
265
- live=False, # CLI disables live updates for verbose
266
- show_delegate_tool_panels=True, # CLI always shows tool panels
267
- append_finished_snapshots=False,
268
- )
269
- r = RichStreamRenderer(
270
- console=_Console(),
271
- cfg=verbose_config,
272
- verbose=True,
273
- )
531
+ return renderer
532
+
533
+ verbose = kwargs.get("verbose", False)
534
+
535
+ if isinstance(renderer, str):
536
+ if renderer == "silent":
537
+ return self._create_silent_renderer()
538
+ elif renderer == "minimal":
539
+ return self._create_minimal_renderer()
274
540
  else:
275
- # Default to a standard rich renderer with tool panels enabled
276
- default_config = RendererConfig(
277
- show_delegate_tool_panels=True, # Enable tool panels by default
278
- )
279
- r = RichStreamRenderer(console=_Console(), cfg=default_config)
541
+ return self._create_default_renderer(verbose)
542
+ elif verbose:
543
+ return self._create_verbose_renderer()
544
+ else:
545
+ return self._create_default_renderer(verbose)
546
+
547
+ def _create_silent_renderer(self):
548
+ """Create a silent renderer that suppresses all output."""
549
+ silent_config = RendererConfig(
550
+ live=False,
551
+ persist_live=False,
552
+ show_delegate_tool_panels=False,
553
+ render_thinking=False,
554
+ )
555
+ return RichStreamRenderer(
556
+ console=_Console(file=io.StringIO(), force_terminal=False),
557
+ cfg=silent_config,
558
+ verbose=False,
559
+ )
280
560
 
281
- # Try to set some meta early; refine as we receive events
282
- meta = {
283
- "agent_name": kwargs.get("agent_name", agent_id),
284
- "model": kwargs.get("model"),
285
- "run_id": None,
286
- "input_message": message, # Add the original query for context
287
- }
288
- r.on_start(meta)
561
+ def _create_minimal_renderer(self):
562
+ """Create a minimal renderer with basic output."""
563
+ minimal_config = RendererConfig(
564
+ live=False,
565
+ persist_live=False,
566
+ show_delegate_tool_panels=False,
567
+ render_thinking=False,
568
+ )
569
+ return RichStreamRenderer(
570
+ console=_Console(),
571
+ cfg=minimal_config,
572
+ verbose=False,
573
+ )
574
+
575
+ def _create_verbose_renderer(self):
576
+ """Create a verbose renderer for detailed output."""
577
+ verbose_config = RendererConfig(
578
+ theme="dark",
579
+ style="debug",
580
+ live=False,
581
+ show_delegate_tool_panels=True,
582
+ append_finished_snapshots=False,
583
+ )
584
+ return RichStreamRenderer(
585
+ console=_Console(),
586
+ cfg=verbose_config,
587
+ verbose=True,
588
+ )
589
+
590
+ def _create_default_renderer(self, verbose: bool):
591
+ """Create the default renderer."""
592
+ if verbose:
593
+ return self._create_verbose_renderer()
594
+ else:
595
+ default_config = RendererConfig(show_delegate_tool_panels=True)
596
+ return RichStreamRenderer(console=_Console(), cfg=default_config)
289
597
 
598
+ def _process_stream_events(
599
+ self, stream_response, renderer, timeout_seconds, agent_name, kwargs
600
+ ):
601
+ """Process streaming events and accumulate response."""
290
602
  final_text = ""
291
603
  stats_usage = {}
292
604
  started_monotonic = None
293
605
  finished_monotonic = None
606
+ meta = {
607
+ "agent_name": kwargs.get("agent_name", ""),
608
+ "model": kwargs.get("model"),
609
+ "run_id": None,
610
+ "input_message": "", # Will be set from kwargs if available
611
+ }
612
+
613
+ # Capture request id if provided
614
+ req_id = stream_response.headers.get(
615
+ "x-request-id"
616
+ ) or stream_response.headers.get("x-run-id")
617
+ if req_id:
618
+ meta["run_id"] = req_id
619
+ renderer.on_start(meta)
620
+
621
+ for event in iter_sse_events(stream_response, timeout_seconds, agent_name):
622
+ try:
623
+ ev = json.loads(event["data"])
624
+ except json.JSONDecodeError:
625
+ logger.debug("Non-JSON SSE fragment skipped")
626
+ continue
627
+
628
+ # Start timer at first meaningful event
629
+ if started_monotonic is None and (
630
+ "content" in ev or "status" in ev or ev.get("metadata")
631
+ ):
632
+ started_monotonic = monotonic()
633
+
634
+ kind = (ev.get("metadata") or {}).get("kind")
635
+ renderer.on_event(ev)
636
+
637
+ # Skip artifacts from content accumulation
638
+ if kind == "artifact":
639
+ continue
640
+
641
+ # Accumulate assistant content
642
+ if ev.get("content"):
643
+ if not ev["content"].startswith("Artifact received:"):
644
+ final_text = ev["content"]
645
+ continue
646
+
647
+ # Handle final response
648
+ if kind == "final_response" and ev.get("content"):
649
+ final_text = ev["content"]
650
+ continue
651
+
652
+ # Handle usage stats
653
+ if kind == "usage":
654
+ stats_usage.update(ev.get("usage") or {})
655
+ continue
656
+
657
+ # Handle run info updates
658
+ if kind == "run_info":
659
+ if ev.get("model"):
660
+ meta["model"] = ev["model"]
661
+ renderer.on_start(meta)
662
+ if ev.get("run_id"):
663
+ meta["run_id"] = ev["run_id"]
664
+ renderer.on_start(meta)
665
+
666
+ finished_monotonic = monotonic()
667
+ return final_text, stats_usage, started_monotonic, finished_monotonic
294
668
 
295
- # MultipartData handles file cleanup automatically
669
+ def run_agent(
670
+ self,
671
+ agent_id: str,
672
+ message: str,
673
+ files: list[str | BinaryIO] | None = None,
674
+ tty: bool = False,
675
+ *,
676
+ renderer: RichStreamRenderer | str | None = "auto",
677
+ **kwargs,
678
+ ) -> str:
679
+ """Run an agent with a message, streaming via a renderer."""
680
+ # Prepare request payload and headers
681
+ payload, data_payload, files_payload, headers, multipart_data = (
682
+ self._prepare_payload_and_headers(message, files, tty, **kwargs)
683
+ )
684
+
685
+ # Create renderer
686
+ r = self._create_renderer(renderer, **kwargs)
687
+
688
+ # Initialize renderer
689
+ meta = {
690
+ "agent_name": kwargs.get("agent_name", agent_id),
691
+ "model": kwargs.get("model"),
692
+ "run_id": None,
693
+ "input_message": message,
694
+ }
695
+ r.on_start(meta)
296
696
 
297
697
  try:
698
+ # Make streaming request
298
699
  response = self.http_client.stream(
299
700
  "POST",
300
701
  f"/agents/{agent_id}/run",
@@ -307,71 +708,16 @@ class AgentClient(BaseClient):
307
708
  with response as stream_response:
308
709
  stream_response.raise_for_status()
309
710
 
310
- # capture request id if provided
311
- req_id = stream_response.headers.get(
312
- "x-request-id"
313
- ) or stream_response.headers.get("x-run-id")
314
- if req_id:
315
- meta["run_id"] = req_id
316
- r.on_start(meta) # refresh header with run_id
317
-
318
- # Get agent run timeout for execution control
319
- # Prefer CLI-provided timeout, otherwise use default
711
+ # Process streaming events
320
712
  timeout_seconds = kwargs.get("timeout", DEFAULT_AGENT_RUN_TIMEOUT)
321
-
322
713
  agent_name = kwargs.get("agent_name")
323
714
 
324
- for event in iter_sse_events(
325
- stream_response, timeout_seconds, agent_name
326
- ):
327
- try:
328
- ev = json.loads(event["data"])
329
- except json.JSONDecodeError:
330
- logger.debug("Non-JSON SSE fragment skipped")
331
- continue
332
-
333
- # Start timer at first meaningful event
334
- if started_monotonic is None and (
335
- "content" in ev or "status" in ev or ev.get("metadata")
336
- ):
337
- started_monotonic = monotonic()
338
-
339
- kind = (ev.get("metadata") or {}).get("kind")
340
-
341
- # Pass event to the renderer (always, don't filter)
342
- r.on_event(ev)
343
-
344
- # Hide "artifact" chatter from content accumulation only
345
- if kind == "artifact":
346
- continue
347
-
348
- # Accumulate assistant content, but do not print here
349
- if "content" in ev and ev["content"]:
350
- # Filter weird backend text like "Artifact received: ..."
351
- if not ev["content"].startswith("Artifact received:"):
352
- final_text = ev["content"] # replace with latest
353
- continue
354
-
355
- # Also treat final_response like content for CLI return value
356
- if kind == "final_response" and ev.get("content"):
357
- final_text = ev["content"] # ensure CLI non-empty
358
- continue
359
-
360
- # Usage/cost event (if your backend emits it)
361
- if kind == "usage":
362
- stats_usage.update(ev.get("usage") or {})
363
- continue
364
-
365
- # Model/run info (if emitted mid-stream)
366
- if kind == "run_info":
367
- if ev.get("model"):
368
- meta["model"] = ev["model"]
369
- r.on_start(meta)
370
- if ev.get("run_id"):
371
- meta["run_id"] = ev["run_id"]
372
- r.on_start(meta)
373
-
374
- finished_monotonic = monotonic()
715
+ final_text, stats_usage, started_monotonic, finished_monotonic = (
716
+ self._process_stream_events(
717
+ stream_response, r, timeout_seconds, agent_name, kwargs
718
+ )
719
+ )
720
+
375
721
  except KeyboardInterrupt:
376
722
  try:
377
723
  r.close()
@@ -383,26 +729,120 @@ class AgentClient(BaseClient):
383
729
  finally:
384
730
  raise
385
731
  finally:
386
- # Ensure we close any opened file handles from multipart
732
+ # Ensure cleanup
387
733
  if multipart_data:
388
734
  multipart_data.close()
389
735
 
390
- # Finalize stats
736
+ # Finalize and return result
391
737
  st = RunStats()
392
- # Ensure monotonic order (avoid negative -0.0s)
393
- if started_monotonic is None:
394
- started_monotonic = finished_monotonic
395
-
396
738
  st.started_at = started_monotonic or st.started_at
397
739
  st.finished_at = finished_monotonic or st.started_at
398
740
  st.usage = stats_usage
399
741
 
400
- # Prefer explicit content, otherwise fall back to what the renderer saw
742
+ # Get final content
401
743
  if hasattr(r, "state") and hasattr(r.state, "buffer"):
402
744
  rendered_text = "".join(r.state.buffer)
403
745
  else:
404
746
  rendered_text = ""
405
- final_payload = final_text or rendered_text or "No response content received."
406
747
 
748
+ final_payload = final_text or rendered_text or "No response content received."
407
749
  r.on_complete(st)
408
750
  return final_payload
751
+
752
+ async def arun_agent(
753
+ self,
754
+ agent_id: str,
755
+ message: str,
756
+ files: list[str | BinaryIO] | None = None,
757
+ *,
758
+ timeout: float | None = None,
759
+ **kwargs,
760
+ ) -> AsyncGenerator[dict, None]:
761
+ """Async run an agent with a message, yielding streaming JSON chunks.
762
+
763
+ Args:
764
+ agent_id: ID of the agent to run
765
+ message: Message to send to the agent
766
+ files: Optional list of files to include
767
+ timeout: Request timeout in seconds
768
+ **kwargs: Additional arguments (chat_history, pii_mapping, etc.)
769
+
770
+ Yields:
771
+ Dictionary containing parsed JSON chunks from the streaming response
772
+
773
+ Raises:
774
+ AgentTimeoutError: When agent execution times out
775
+ httpx.TimeoutException: When general timeout occurs
776
+ Exception: For other unexpected errors
777
+ """
778
+ # Prepare multipart data if files are provided
779
+ multipart_data = None
780
+ headers = None # None means "don't override client defaults"
781
+
782
+ if files:
783
+ multipart_data = prepare_multipart_data(message, files)
784
+ # Inject optional multipart extras expected by backend
785
+ if "chat_history" in kwargs and kwargs["chat_history"] is not None:
786
+ multipart_data.data["chat_history"] = kwargs["chat_history"]
787
+ if "pii_mapping" in kwargs and kwargs["pii_mapping"] is not None:
788
+ multipart_data.data["pii_mapping"] = kwargs["pii_mapping"]
789
+ headers = None # Let httpx set proper multipart boundaries
790
+
791
+ # When streaming, explicitly prefer SSE
792
+ headers = {**(headers or {}), "Accept": "text/event-stream"}
793
+
794
+ if files:
795
+ payload = None
796
+ # Use multipart data
797
+ data_payload = multipart_data.data
798
+ files_payload = multipart_data.files
799
+ else:
800
+ payload = {"input": message, **kwargs}
801
+ # Explicitly send stream intent both ways
802
+ payload["stream"] = True
803
+ data_payload = None
804
+ files_payload = None
805
+
806
+ # Use timeout from parameter or instance default
807
+ request_timeout = timeout or self.timeout
808
+
809
+ try:
810
+ async_client_config = self._build_async_client(request_timeout)
811
+ if headers:
812
+ async_client_config["headers"] = {
813
+ **async_client_config["headers"],
814
+ **headers,
815
+ }
816
+
817
+ # Create async client for this request
818
+ async with httpx.AsyncClient(**async_client_config) as async_client:
819
+ async with async_client.stream(
820
+ "POST",
821
+ f"/agents/{agent_id}/run",
822
+ json=payload,
823
+ data=data_payload,
824
+ files=files_payload,
825
+ headers=headers,
826
+ ) as stream_response:
827
+ stream_response.raise_for_status()
828
+
829
+ # Get agent run timeout for execution control
830
+ # Prefer parameter timeout, otherwise use default
831
+ timeout_seconds = kwargs.get("timeout", DEFAULT_AGENT_RUN_TIMEOUT)
832
+
833
+ agent_name = kwargs.get("agent_name")
834
+
835
+ async for event in aiter_sse_events(
836
+ stream_response, timeout_seconds, agent_name
837
+ ):
838
+ try:
839
+ chunk = json.loads(event["data"])
840
+ yield chunk
841
+ except json.JSONDecodeError:
842
+ logger.debug("Non-JSON SSE fragment skipped")
843
+ continue
844
+
845
+ finally:
846
+ # Ensure we close any opened file handles from multipart
847
+ if multipart_data:
848
+ multipart_data.close()