gumloop 0.3.3__tar.gz → 0.3.4__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.
Files changed (82) hide show
  1. {gumloop-0.3.3 → gumloop-0.3.4}/PKG-INFO +2 -1
  2. {gumloop-0.3.3 → gumloop-0.3.4}/pyproject.toml +2 -1
  3. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/_http.py +6 -0
  4. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/agents.py +147 -2
  5. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/agents.py +82 -0
  6. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/types.py +29 -0
  7. {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_agents.py +67 -0
  8. {gumloop-0.3.3 → gumloop-0.3.4}/tests/integration/conftest.py +35 -0
  9. gumloop-0.3.4/tests/integration/test_live.py +170 -0
  10. {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_agents.py +126 -0
  11. {gumloop-0.3.3 → gumloop-0.3.4}/uv.lock +3 -1
  12. gumloop-0.3.3/tests/integration/test_live.py +0 -64
  13. {gumloop-0.3.3 → gumloop-0.3.4}/.env.example +0 -0
  14. {gumloop-0.3.3 → gumloop-0.3.4}/.gitignore +0 -0
  15. {gumloop-0.3.3 → gumloop-0.3.4}/.pre-commit-config.yaml +0 -0
  16. {gumloop-0.3.3 → gumloop-0.3.4}/.python-version +0 -0
  17. {gumloop-0.3.3 → gumloop-0.3.4}/LICENSE +0 -0
  18. {gumloop-0.3.3 → gumloop-0.3.4}/README.md +0 -0
  19. {gumloop-0.3.3 → gumloop-0.3.4}/scripts/check +0 -0
  20. {gumloop-0.3.3 → gumloop-0.3.4}/scripts/fmt +0 -0
  21. {gumloop-0.3.3 → gumloop-0.3.4}/scripts/sdk_examples.py +0 -0
  22. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/__init__.py +0 -0
  23. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/_client.py +0 -0
  24. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/__init__.py +0 -0
  25. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/__init__.py +0 -0
  26. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/_args_input.py +0 -0
  27. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/_downloads.py +0 -0
  28. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/artifacts.py +0 -0
  29. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/auth.py +0 -0
  30. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/brain.py +0 -0
  31. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/chat.py +0 -0
  32. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/mcp.py +0 -0
  33. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/sessions.py +0 -0
  34. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/skills.py +0 -0
  35. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/console.py +0 -0
  36. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/context.py +0 -0
  37. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/credentials.py +0 -0
  38. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/errors.py +0 -0
  39. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/main.py +0 -0
  40. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/oauth.py +0 -0
  41. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/errors.py +0 -0
  42. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/oauth.py +0 -0
  43. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/py.typed +0 -0
  44. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/__init__.py +0 -0
  45. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/artifacts.py +0 -0
  46. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/brain.py +0 -0
  47. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/chat.py +0 -0
  48. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/mcp.py +0 -0
  49. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/sessions.py +0 -0
  50. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/skills.py +0 -0
  51. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/teams.py +0 -0
  52. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/spec/__init__.py +0 -0
  53. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/spec/_compat.py +0 -0
  54. {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/spec/_extensions.py +0 -0
  55. {gumloop-0.3.3 → gumloop-0.3.4}/tests/__init__.py +0 -0
  56. {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/__init__.py +0 -0
  57. {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/conftest.py +0 -0
  58. {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_args_input.py +0 -0
  59. {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_artifacts.py +0 -0
  60. {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_brain.py +0 -0
  61. {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_chat.py +0 -0
  62. {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_context.py +0 -0
  63. {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_credentials.py +0 -0
  64. {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_login.py +0 -0
  65. {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_main.py +0 -0
  66. {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_mcp.py +0 -0
  67. {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_sessions.py +0 -0
  68. {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_skills.py +0 -0
  69. {gumloop-0.3.3 → gumloop-0.3.4}/tests/conftest.py +0 -0
  70. {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/__init__.py +0 -0
  71. {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/conftest.py +0 -0
  72. {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/helpers.py +0 -0
  73. {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_artifacts.py +0 -0
  74. {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_brain.py +0 -0
  75. {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_chat.py +0 -0
  76. {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_client.py +0 -0
  77. {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_mcp.py +0 -0
  78. {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_oauth.py +0 -0
  79. {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_sessions.py +0 -0
  80. {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_skills.py +0 -0
  81. {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_teams.py +0 -0
  82. {gumloop-0.3.3 → gumloop-0.3.4}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gumloop
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: Python client for the Gumloop API
5
5
  Project-URL: Homepage, https://github.com/gumloop/gumloop-py
6
6
  Project-URL: Bug Tracker, https://github.com/gumloop/gumloop-py/issues
@@ -12,6 +12,7 @@ Classifier: Programming Language :: Python :: 3.11
12
12
  Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Requires-Python: >=3.10
15
+ Requires-Dist: click>=8.0
15
16
  Requires-Dist: httpx-sse>=0.4.0
16
17
  Requires-Dist: httpx>=0.24.1
17
18
  Requires-Dist: keyring>=24
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gumloop"
7
- version = "0.3.3"
7
+ version = "0.3.4"
8
8
  description = "Python client for the Gumloop API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -28,6 +28,7 @@ dependencies = [
28
28
  "pydantic>=2.0.0",
29
29
  "questionary>=2.0",
30
30
  "rich",
31
+ "click>=8.0",
31
32
  "typer>=0.25.1",
32
33
  ]
33
34
 
@@ -110,6 +110,9 @@ class HttpClient:
110
110
  ) -> Any:
111
111
  return self._request("PATCH", path, json=json, data=_omit_none_params(data), files=files)
112
112
 
113
+ def put(self, path: str, *, json: Any = None) -> Any:
114
+ return self._request("PUT", path, json=json)
115
+
113
116
  def delete(self, path: str) -> Any:
114
117
  return self._request("DELETE", path)
115
118
 
@@ -251,6 +254,9 @@ class AsyncHttpClient:
251
254
  ) -> Any:
252
255
  return await self._request("PATCH", path, json=json, data=_omit_none_params(data), files=files)
253
256
 
257
+ async def put(self, path: str, *, json: Any = None) -> Any:
258
+ return await self._request("PUT", path, json=json)
259
+
254
260
  async def delete(self, path: str) -> Any:
255
261
  return await self._request("DELETE", path)
256
262
 
@@ -144,6 +144,10 @@ def create_agent(
144
144
  str | None,
145
145
  typer.Option("--tools-file", help="Path to a JSON file containing the tools array."),
146
146
  ] = None,
147
+ skill_ids: Annotated[
148
+ str | None,
149
+ typer.Option("--skill-ids", help="Comma-separated ids of existing skills to attach."),
150
+ ] = None,
147
151
  json_output: Annotated[
148
152
  bool,
149
153
  typer.Option("--json", help="Print the raw SDK response as JSON."),
@@ -186,6 +190,12 @@ def create_agent(
186
190
  raise GumloopError("Tools JSON must be an array at the top level.")
187
191
  tools = parsed
188
192
 
193
+ parsed_skill_ids = (
194
+ [skill_id.strip() for skill_id in skill_ids.split(",") if skill_id.strip()]
195
+ if skill_ids is not None
196
+ else None
197
+ )
198
+
189
199
  response = cli.call_with_refresh(
190
200
  lambda client: client.agents.create(
191
201
  name=name,
@@ -193,6 +203,7 @@ def create_agent(
193
203
  description=description,
194
204
  system_prompt=resolved_prompt,
195
205
  tools=tools,
206
+ skill_ids=parsed_skill_ids,
196
207
  team_id=cli.effective_team_id,
197
208
  )
198
209
  )
@@ -233,11 +244,23 @@ def update_agent(
233
244
  ] = None,
234
245
  tools_json: Annotated[
235
246
  str | None,
236
- typer.Option("--tools-json", help="Inline JSON array of tool config objects."),
247
+ typer.Option(
248
+ "--tools-json",
249
+ help=(
250
+ "Inline JSON array of tool config objects."
251
+ " (legacy: replaces the entire tools list; prefer attach-mcp-server/detach-mcp-server)"
252
+ ),
253
+ ),
237
254
  ] = None,
238
255
  tools_file: Annotated[
239
256
  str | None,
240
- typer.Option("--tools-file", help="Path to a JSON file containing the tools array."),
257
+ typer.Option(
258
+ "--tools-file",
259
+ help=(
260
+ "Path to a JSON file containing the tools array."
261
+ " (legacy: replaces the entire tools list; prefer attach-mcp-server/detach-mcp-server)"
262
+ ),
263
+ ),
241
264
  ] = None,
242
265
  is_active: Annotated[
243
266
  bool | None,
@@ -303,3 +326,125 @@ def update_agent(
303
326
  return
304
327
 
305
328
  console.print(f"[green]Updated agent[/green] {agent_id}")
329
+
330
+
331
+ @agents_app.command(
332
+ "attach-skills",
333
+ epilog="Example:\n gumloop agents attach-skills agent_abc skill_1 skill_2",
334
+ )
335
+ def attach_skills(
336
+ ctx: typer.Context,
337
+ agent_id: Annotated[str, typer.Argument(help="ID of the agent to attach skills to.")],
338
+ skill_ids: Annotated[list[str], typer.Argument(help="One or more skill ids to attach.")],
339
+ json_output: Annotated[
340
+ bool,
341
+ typer.Option("--json", help="Print the raw SDK response as JSON."),
342
+ ] = False,
343
+ ) -> None:
344
+ """Attach one or more skills to an agent (idempotent)."""
345
+ cli: CliContext = ctx.obj
346
+ try:
347
+ response = cli.call_with_refresh(lambda client: client.agents.attach_skills(agent_id, skill_ids))
348
+ except GumloopError as error:
349
+ exit_with_error(error, json_output=json_output)
350
+
351
+ if json_output:
352
+ print_json(response)
353
+ return
354
+
355
+ console.print(f"[green]Attached[/green] {len(response.attached)} skill(s) to {agent_id}")
356
+ if response.already_attached:
357
+ console.print(f" Already attached: {', '.join(response.already_attached)}", markup=False, highlight=False)
358
+
359
+
360
+ @agents_app.command(
361
+ "detach-skills",
362
+ epilog="Example:\n gumloop agents detach-skills agent_abc skill_1 skill_2",
363
+ )
364
+ def detach_skills(
365
+ ctx: typer.Context,
366
+ agent_id: Annotated[str, typer.Argument(help="ID of the agent to detach skills from.")],
367
+ skill_ids: Annotated[list[str], typer.Argument(help="One or more skill ids to detach.")],
368
+ json_output: Annotated[
369
+ bool,
370
+ typer.Option("--json", help="Print the raw SDK response as JSON."),
371
+ ] = False,
372
+ ) -> None:
373
+ """Detach one or more skills from an agent (idempotent)."""
374
+ cli: CliContext = ctx.obj
375
+ try:
376
+ response = cli.call_with_refresh(lambda client: client.agents.detach_skills(agent_id, skill_ids))
377
+ except GumloopError as error:
378
+ exit_with_error(error, json_output=json_output)
379
+
380
+ if json_output:
381
+ print_json(response)
382
+ return
383
+
384
+ console.print(f"[green]Detached[/green] {len(response.detached)} skill(s) from {agent_id}")
385
+ if response.already_detached:
386
+ console.print(f" Already detached: {', '.join(response.already_detached)}", markup=False, highlight=False)
387
+
388
+
389
+ @agents_app.command(
390
+ "attach-mcp-server",
391
+ epilog="Example:\n gumloop agents attach-mcp-server agent_abc gmail --approval-mode off",
392
+ )
393
+ def attach_mcp_server(
394
+ ctx: typer.Context,
395
+ agent_id: Annotated[str, typer.Argument(help="ID of the agent to attach the MCP server to.")],
396
+ server_id: Annotated[str, typer.Argument(help="ID of the MCP server to attach.")],
397
+ approval_mode: Annotated[
398
+ str | None,
399
+ typer.Option("--approval-mode", help="Tool-call approval mode for this server."),
400
+ ] = None,
401
+ json_output: Annotated[
402
+ bool,
403
+ typer.Option("--json", help="Print the raw SDK response as JSON."),
404
+ ] = False,
405
+ ) -> None:
406
+ """Attach an MCP server to an agent, or update its config (idempotent upsert)."""
407
+ cli: CliContext = ctx.obj
408
+ try:
409
+ response = cli.call_with_refresh(
410
+ lambda client: client.agents.attach_mcp_server(agent_id, server_id, approval_mode=approval_mode)
411
+ )
412
+ except GumloopError as error:
413
+ exit_with_error(error, json_output=json_output)
414
+
415
+ if json_output:
416
+ print_json(response)
417
+ return
418
+
419
+ verb = "Attached" if response.created else "Updated"
420
+ console.print(f"[green]{verb}[/green] MCP server {escape_markup(server_id)} on {agent_id}")
421
+ if response.auth_status:
422
+ console.print(f" Auth status: {response.auth_status}", markup=False, highlight=False)
423
+
424
+
425
+ @agents_app.command(
426
+ "detach-mcp-server",
427
+ epilog="Example:\n gumloop agents detach-mcp-server agent_abc gmail",
428
+ )
429
+ def detach_mcp_server(
430
+ ctx: typer.Context,
431
+ agent_id: Annotated[str, typer.Argument(help="ID of the agent to detach the MCP server from.")],
432
+ server_id: Annotated[str, typer.Argument(help="ID of the MCP server to detach.")],
433
+ json_output: Annotated[
434
+ bool,
435
+ typer.Option("--json", help="Print the raw SDK response as JSON."),
436
+ ] = False,
437
+ ) -> None:
438
+ """Detach an MCP server from an agent (idempotent)."""
439
+ cli: CliContext = ctx.obj
440
+ try:
441
+ response = cli.call_with_refresh(lambda client: client.agents.detach_mcp_server(agent_id, server_id))
442
+ except GumloopError as error:
443
+ exit_with_error(error, json_output=json_output)
444
+
445
+ if json_output:
446
+ print_json(response)
447
+ return
448
+
449
+ console.print(f"[green]Detached[/green] MCP server {escape_markup(server_id)} from {agent_id}")
450
+ console.print(f" detached: {'true' if response.detached else 'false'}", markup=False, highlight=False)
@@ -1,19 +1,29 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Mapping
4
+ from collections.abc import Sequence
4
5
  from typing import Any
5
6
 
6
7
  from gumloop._http import AsyncHttpClient
7
8
  from gumloop._http import HttpClient
8
9
  from gumloop.types import AgentCreateRequest
9
10
  from gumloop.types import AgentListResponse
11
+ from gumloop.types import AgentMcpServerDetachResponse
12
+ from gumloop.types import AgentMcpServerResponse
13
+ from gumloop.types import AgentMcpServersResponse
10
14
  from gumloop.types import AgentResponse
15
+ from gumloop.types import AgentSkillsResponse
11
16
  from gumloop.types import AgentUpdateRequest
12
17
  from gumloop.types import EvaluationConfigResponse
13
18
  from gumloop.types import EvaluationConfigUpdateRequest
14
19
  from gumloop.types import EvaluationResultListResponse
15
20
  from gumloop.types import EvaluationResultResponse
16
21
  from gumloop.types import ModelListResponse
22
+ from gumloop.types import SkillListResponse
23
+
24
+
25
+ def _skill_id_list(skill_ids: str | Sequence[str]) -> list[str]:
26
+ return [skill_ids] if isinstance(skill_ids, str) else list(skill_ids)
17
27
 
18
28
 
19
29
  class Agents:
@@ -49,10 +59,48 @@ class Agents:
49
59
  request: AgentUpdateRequest | Mapping[str, Any] | None = None,
50
60
  **kwargs: Any,
51
61
  ) -> AgentResponse:
62
+ """``tools`` replaces the entire list when provided (legacy bulk path); prefer
63
+ attach_mcp_server/detach_mcp_server for individual MCP servers and
64
+ attach_skills/detach_skills for skills."""
52
65
  return AgentResponse.model_validate(
53
66
  self._client.patch(f"agents/{agent_id}", json=AgentUpdateRequest.build(request, **kwargs))
54
67
  )
55
68
 
69
+ def attach_skills(self, agent_id: str, skill_ids: str | Sequence[str]) -> AgentSkillsResponse:
70
+ """Attach skills to an agent. Idempotent: safe to retry, already-attached ids are reported."""
71
+ return AgentSkillsResponse.model_validate(
72
+ self._client.patch(f"agents/{agent_id}/skills", json={"attach": _skill_id_list(skill_ids)})
73
+ )
74
+
75
+ def detach_skills(self, agent_id: str, skill_ids: str | Sequence[str]) -> AgentSkillsResponse:
76
+ """Detach skills from an agent. Idempotent: safe to retry, already-detached ids are reported."""
77
+ return AgentSkillsResponse.model_validate(
78
+ self._client.patch(f"agents/{agent_id}/skills", json={"detach": _skill_id_list(skill_ids)})
79
+ )
80
+
81
+ def list_skills(self, agent_id: str, **kwargs: Any) -> SkillListResponse:
82
+ return SkillListResponse.model_validate(
83
+ self._client.get("skills", params={"agent_id": agent_id, **kwargs})
84
+ )
85
+
86
+ def attach_mcp_server(self, agent_id: str, server_id: str, **config: Any) -> AgentMcpServerResponse:
87
+ """Attach an MCP server, or update its config if already attached (idempotent upsert)."""
88
+ return AgentMcpServerResponse.model_validate(
89
+ self._client.put(
90
+ f"agents/{agent_id}/mcp-servers/{server_id}",
91
+ json={k: v for k, v in config.items() if v is not None},
92
+ )
93
+ )
94
+
95
+ def detach_mcp_server(self, agent_id: str, server_id: str) -> AgentMcpServerDetachResponse:
96
+ """Detach an MCP server from an agent. Idempotent: safe to retry."""
97
+ return AgentMcpServerDetachResponse.model_validate(
98
+ self._client.delete(f"agents/{agent_id}/mcp-servers/{server_id}")
99
+ )
100
+
101
+ def list_mcp_servers(self, agent_id: str) -> AgentMcpServersResponse:
102
+ return AgentMcpServersResponse.model_validate(self._client.get(f"agents/{agent_id}/mcp-servers"))
103
+
56
104
  def get_evaluation_config(self, agent_id: str) -> EvaluationConfigResponse:
57
105
  return EvaluationConfigResponse.model_validate(self._client.get(f"agents/{agent_id}/evaluation-config"))
58
106
 
@@ -133,9 +181,43 @@ class AsyncAgents:
133
181
  request: AgentUpdateRequest | Mapping[str, Any] | None = None,
134
182
  **kwargs: Any,
135
183
  ) -> AgentResponse:
184
+ """``tools`` replaces the entire list when provided (legacy bulk path); prefer
185
+ attach_mcp_server/detach_mcp_server for individual MCP servers and
186
+ attach_skills/detach_skills for skills."""
136
187
  data = await self._client.patch(f"agents/{agent_id}", json=AgentUpdateRequest.build(request, **kwargs))
137
188
  return AgentResponse.model_validate(data)
138
189
 
190
+ async def attach_skills(self, agent_id: str, skill_ids: str | Sequence[str]) -> AgentSkillsResponse:
191
+ """Attach skills to an agent. Idempotent: safe to retry, already-attached ids are reported."""
192
+ data = await self._client.patch(f"agents/{agent_id}/skills", json={"attach": _skill_id_list(skill_ids)})
193
+ return AgentSkillsResponse.model_validate(data)
194
+
195
+ async def detach_skills(self, agent_id: str, skill_ids: str | Sequence[str]) -> AgentSkillsResponse:
196
+ """Detach skills from an agent. Idempotent: safe to retry, already-detached ids are reported."""
197
+ data = await self._client.patch(f"agents/{agent_id}/skills", json={"detach": _skill_id_list(skill_ids)})
198
+ return AgentSkillsResponse.model_validate(data)
199
+
200
+ async def list_skills(self, agent_id: str, **kwargs: Any) -> SkillListResponse:
201
+ data = await self._client.get("skills", params={"agent_id": agent_id, **kwargs})
202
+ return SkillListResponse.model_validate(data)
203
+
204
+ async def attach_mcp_server(self, agent_id: str, server_id: str, **config: Any) -> AgentMcpServerResponse:
205
+ """Attach an MCP server, or update its config if already attached (idempotent upsert)."""
206
+ data = await self._client.put(
207
+ f"agents/{agent_id}/mcp-servers/{server_id}",
208
+ json={k: v for k, v in config.items() if v is not None},
209
+ )
210
+ return AgentMcpServerResponse.model_validate(data)
211
+
212
+ async def detach_mcp_server(self, agent_id: str, server_id: str) -> AgentMcpServerDetachResponse:
213
+ """Detach an MCP server from an agent. Idempotent: safe to retry."""
214
+ data = await self._client.delete(f"agents/{agent_id}/mcp-servers/{server_id}")
215
+ return AgentMcpServerDetachResponse.model_validate(data)
216
+
217
+ async def list_mcp_servers(self, agent_id: str) -> AgentMcpServersResponse:
218
+ data = await self._client.get(f"agents/{agent_id}/mcp-servers")
219
+ return AgentMcpServersResponse.model_validate(data)
220
+
139
221
  async def get_evaluation_config(self, agent_id: str) -> EvaluationConfigResponse:
140
222
  data = await self._client.get(f"agents/{agent_id}/evaluation-config")
141
223
  return EvaluationConfigResponse.model_validate(data)
@@ -53,6 +53,7 @@ class AgentCreateRequest(_Model):
53
53
  system_prompt: str | None = None
54
54
  tools: list[dict[str, Any]] = Field(default_factory=list)
55
55
  resources: list[dict[str, Any]] = Field(default_factory=list)
56
+ skill_ids: list[str] | None = None
56
57
  metadata: dict[str, Any] | None = None
57
58
  folder_id: str | None = None
58
59
  is_active: bool = True
@@ -80,6 +81,7 @@ class Agent(_Model):
80
81
  is_active: bool = False
81
82
  tools: list[dict[str, Any]] = Field(default_factory=list)
82
83
  resources: list[dict[str, Any]] = Field(default_factory=list)
84
+ skill_ids: list[str] | None = None
83
85
  metadata: dict[str, Any] = Field(default_factory=dict)
84
86
  model_name: str | None = None
85
87
  system_prompt: str | None = None
@@ -90,6 +92,33 @@ class Agent(_Model):
90
92
  creator: CreatorPayload | None = None
91
93
 
92
94
 
95
+ class AgentSkillsResponse(_Model):
96
+ agent_id: str
97
+ skill_ids: list[str] = Field(default_factory=list)
98
+ attached: list[str] = Field(default_factory=list)
99
+ detached: list[str] = Field(default_factory=list)
100
+ already_attached: list[str] = Field(default_factory=list)
101
+ already_detached: list[str] = Field(default_factory=list)
102
+
103
+
104
+ class AgentMcpServerResponse(_Model):
105
+ agent_id: str
106
+ mcp_server: dict[str, Any] = Field(default_factory=dict)
107
+ created: bool = False
108
+ auth_status: str | None = None
109
+
110
+
111
+ class AgentMcpServerDetachResponse(_Model):
112
+ agent_id: str
113
+ server_id: str
114
+ detached: bool = False
115
+
116
+
117
+ class AgentMcpServersResponse(_Model):
118
+ agent_id: str
119
+ mcp_servers: list[dict[str, Any]] = Field(default_factory=list)
120
+
121
+
93
122
  class AgentResponse(_Model):
94
123
  agent: Agent
95
124
 
@@ -123,6 +123,73 @@ def test_agents_update_only_sends_specified_fields(cli_runner: CliRunner) -> Non
123
123
  assert sent == {"name": "Renamed", "is_active": False}
124
124
 
125
125
 
126
+ @respx.mock
127
+ def test_agents_attach_skills_command(cli_runner: CliRunner) -> None:
128
+ route = respx.patch(f"{API_BASE}/agents/agent_abc/skills").mock(
129
+ return_value=httpx.Response(200, json={"agent_id": "agent_abc", "attached": ["s1", "s2"]})
130
+ )
131
+ save_credentials(Credentials(api_key="key"))
132
+
133
+ result = cli_runner.invoke(app, ["agents", "attach-skills", "agent_abc", "s1", "s2"])
134
+
135
+ assert result.exit_code == 0, result.output
136
+ assert json.loads(route.calls[0].request.content) == {"attach": ["s1", "s2"]}
137
+
138
+
139
+ @respx.mock
140
+ def test_agents_detach_skills_command(cli_runner: CliRunner) -> None:
141
+ route = respx.patch(f"{API_BASE}/agents/agent_abc/skills").mock(
142
+ return_value=httpx.Response(200, json={"agent_id": "agent_abc", "detached": ["s1"]})
143
+ )
144
+ save_credentials(Credentials(api_key="key"))
145
+
146
+ result = cli_runner.invoke(app, ["agents", "detach-skills", "agent_abc", "s1"])
147
+
148
+ assert result.exit_code == 0, result.output
149
+ assert json.loads(route.calls[0].request.content) == {"detach": ["s1"]}
150
+
151
+
152
+ @respx.mock
153
+ def test_agents_attach_mcp_server_command(cli_runner: CliRunner) -> None:
154
+ route = respx.put(f"{API_BASE}/agents/agent_abc/mcp-servers/gmail").mock(
155
+ return_value=httpx.Response(
156
+ 200, json={"agent_id": "agent_abc", "created": True, "auth_status": "connected"}
157
+ )
158
+ )
159
+ save_credentials(Credentials(api_key="key"))
160
+
161
+ result = cli_runner.invoke(
162
+ app, ["agents", "attach-mcp-server", "agent_abc", "gmail", "--approval-mode", "off"]
163
+ )
164
+
165
+ assert result.exit_code == 0, result.output
166
+ assert json.loads(route.calls[0].request.content) == {"approval_mode": "off"}
167
+ assert "connected" in result.output
168
+
169
+
170
+ @respx.mock
171
+ def test_agents_detach_mcp_server_command(cli_runner: CliRunner) -> None:
172
+ route = respx.delete(f"{API_BASE}/agents/agent_abc/mcp-servers/gmail").mock(
173
+ return_value=httpx.Response(200, json={"agent_id": "agent_abc", "server_id": "gmail", "detached": True})
174
+ )
175
+ save_credentials(Credentials(api_key="key"))
176
+
177
+ result = cli_runner.invoke(app, ["agents", "detach-mcp-server", "agent_abc", "gmail"])
178
+
179
+ assert result.exit_code == 0, result.output
180
+ assert route.call_count == 1
181
+ assert "true" in result.output
182
+
183
+
184
+ @respx.mock
185
+ def test_agents_update_rejects_skill_ids_option(cli_runner: CliRunner) -> None:
186
+ save_credentials(Credentials(api_key="key"))
187
+
188
+ result = cli_runner.invoke(app, ["agents", "update", "agent_abc", "--skill-ids", "s1"])
189
+
190
+ assert result.exit_code != 0
191
+
192
+
126
193
  @respx.mock
127
194
  def test_agents_create_uses_effective_team_id(cli_runner: CliRunner, monkeypatch) -> None:
128
195
  route = respx.post(f"{API_BASE}/agents").mock(
@@ -52,3 +52,38 @@ def dev_client(api_key: str, user_id: str) -> Gumloop:
52
52
  # prod yet, so blindly hitting the default URL would be all 404s.
53
53
  base_url = _required("GUMLOOP_BASE_URL")
54
54
  return Gumloop(api_key=api_key, user_id=user_id, base_url=base_url)
55
+
56
+
57
+ _SKILL_MD = "---\nname: live-test-skill\ndescription: live test skill\n---\n\n# Live\nDo nothing."
58
+
59
+
60
+ @pytest.fixture
61
+ def make_skill(dev_client: Gumloop):
62
+ """Factory for live skills; deletes everything it created on teardown."""
63
+ created: list[str] = []
64
+
65
+ def _make(name: str = "live-skill"):
66
+ skill = dev_client.skills.create({"SKILL.md": _SKILL_MD}, name=name).skill
67
+ created.append(skill.id)
68
+ return skill
69
+
70
+ yield _make
71
+ for skill_id in created:
72
+ dev_client.skills.delete(skill_id)
73
+
74
+
75
+ @pytest.fixture
76
+ def make_agent(dev_client: Gumloop):
77
+ """Factory for live agents; deactivates everything it created on teardown."""
78
+ created: list[str] = []
79
+
80
+ def _make(**kwargs):
81
+ kwargs.setdefault("name", "Live Verb Agent")
82
+ kwargs.setdefault("model_name", "auto")
83
+ agent = dev_client.agents.create(**kwargs).agent
84
+ created.append(agent.id)
85
+ return agent
86
+
87
+ yield _make
88
+ for agent_id in created:
89
+ dev_client.agents.update(agent_id, is_active=False)
@@ -0,0 +1,170 @@
1
+ """Live integration tests against the real Gumloop API. Each test skips
2
+ when its required env var is missing."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import httpx
7
+ import pytest
8
+
9
+ from gumloop import APIStatusError
10
+ from gumloop import Gumloop
11
+ from gumloop import GumloopClient
12
+
13
+
14
+ def test_run_flow_completes_and_returns_outputs(live_client: GumloopClient, test_flow_id: str) -> None:
15
+ outputs = live_client.run_flow(test_flow_id, inputs={}, timeout=60.0)
16
+
17
+ assert isinstance(outputs, dict)
18
+
19
+
20
+ def test_invalid_run_id_surfaces_an_error(live_client: GumloopClient) -> None:
21
+ with pytest.raises(httpx.HTTPStatusError):
22
+ live_client.get_run_status("does-not-exist-" + "0" * 16)
23
+
24
+
25
+ def test_models_list_returns_model_groups_envelope(dev_client: Gumloop) -> None:
26
+ response = dev_client.models.list()
27
+
28
+ assert isinstance(response.model_groups, list)
29
+
30
+
31
+ def test_teams_list_returns_teams_with_id_and_name(dev_client: Gumloop) -> None:
32
+ response = dev_client.teams.list()
33
+
34
+ assert isinstance(response.teams, list)
35
+ for team in response.teams:
36
+ assert team.id
37
+ assert team.name
38
+
39
+
40
+ def test_agents_list_returns_paginated_envelope(dev_client: Gumloop) -> None:
41
+ response = dev_client.agents.list(page_size=5)
42
+
43
+ assert isinstance(response.agents, list)
44
+ # ``next_cursor`` can be None when there's a single page; just check it
45
+ # is the declared field rather than its truthiness.
46
+ assert hasattr(response, "next_cursor")
47
+
48
+
49
+ def test_mcp_servers_list_returns_servers_envelope(dev_client: Gumloop) -> None:
50
+ response = dev_client.mcp.list_servers()
51
+
52
+ assert isinstance(response.servers, list)
53
+
54
+
55
+ def test_skills_list_returns_paginated_envelope(dev_client: Gumloop) -> None:
56
+ response = dev_client.skills.list(page_size=5)
57
+
58
+ assert isinstance(response.skills, list)
59
+
60
+
61
+ def test_dev_api_invalid_agent_id_raises_api_status_error(dev_client: Gumloop) -> None:
62
+ with pytest.raises(APIStatusError) as exc:
63
+ dev_client.agents.retrieve("agent_does_not_exist_" + "0" * 16)
64
+ assert exc.value.status_code in (403, 404)
65
+
66
+
67
+ class TestAgentSkillAttachments:
68
+ def test_create_agent_attaches_initial_skills(self, dev_client: Gumloop, make_skill, make_agent) -> None:
69
+ skill = make_skill("live-skill-initial")
70
+
71
+ agent = make_agent(skill_ids=[skill.id])
72
+
73
+ assert agent.skill_ids == [skill.id]
74
+
75
+ def test_attach_skills_adds_without_touching_existing(self, dev_client: Gumloop, make_skill, make_agent) -> None:
76
+ existing, added = make_skill("live-skill-a"), make_skill("live-skill-b")
77
+ agent = make_agent(skill_ids=[existing.id])
78
+
79
+ response = dev_client.agents.attach_skills(agent.id, added.id)
80
+
81
+ assert response.attached == [added.id]
82
+ assert sorted(response.skill_ids) == sorted([existing.id, added.id])
83
+
84
+ def test_attach_replay_is_reported_not_errored(self, dev_client: Gumloop, make_skill, make_agent) -> None:
85
+ skill = make_skill()
86
+ agent = make_agent(skill_ids=[skill.id])
87
+
88
+ response = dev_client.agents.attach_skills(agent.id, skill.id)
89
+
90
+ assert response.attached == []
91
+ assert response.already_attached == [skill.id]
92
+
93
+ def test_unknown_skill_id_rejects_whole_batch(self, dev_client: Gumloop, make_skill, make_agent) -> None:
94
+ valid = make_skill()
95
+ agent = make_agent()
96
+
97
+ with pytest.raises(APIStatusError) as exc:
98
+ dev_client.agents.attach_skills(agent.id, [valid.id, "sk_does_not_exist"])
99
+
100
+ assert exc.value.code == "skill_not_found"
101
+ assert exc.value.details.get("skill_ids") == ["sk_does_not_exist"]
102
+ assert dev_client.agents.retrieve(agent.id).agent.skill_ids == []
103
+
104
+ def test_detach_skills_removes_and_replay_reports(self, dev_client: Gumloop, make_skill, make_agent) -> None:
105
+ keep, remove = make_skill("live-keep"), make_skill("live-remove")
106
+ agent = make_agent(skill_ids=[keep.id, remove.id])
107
+
108
+ response = dev_client.agents.detach_skills(agent.id, remove.id)
109
+
110
+ assert response.detached == [remove.id]
111
+ assert response.skill_ids == [keep.id]
112
+ assert dev_client.agents.detach_skills(agent.id, remove.id).already_detached == [remove.id]
113
+
114
+ def test_list_skills_returns_attached_set(self, dev_client: Gumloop, make_skill, make_agent) -> None:
115
+ skill = make_skill()
116
+ agent = make_agent(skill_ids=[skill.id])
117
+
118
+ listed = dev_client.agents.list_skills(agent.id)
119
+
120
+ assert [s.id for s in listed.skills] == [skill.id]
121
+
122
+
123
+ class TestAgentMcpServers:
124
+ def test_attach_mcp_server_creates_catalog_entry(self, dev_client: Gumloop, make_agent) -> None:
125
+ agent = make_agent()
126
+
127
+ response = dev_client.agents.attach_mcp_server(agent.id, "gmail", approval_mode="off")
128
+
129
+ assert response.created is True
130
+ assert response.mcp_server.get("server_id") == "gmail"
131
+ assert response.mcp_server.get("approval_mode") == "off"
132
+
133
+ def test_attach_again_updates_config_in_place(self, dev_client: Gumloop, make_agent) -> None:
134
+ agent = make_agent()
135
+ dev_client.agents.attach_mcp_server(agent.id, "gmail", approval_mode="off")
136
+
137
+ response = dev_client.agents.attach_mcp_server(agent.id, "gmail", approval_mode="always")
138
+
139
+ assert response.created is False
140
+ assert response.mcp_server.get("approval_mode") == "always"
141
+
142
+ def test_identity_keys_cannot_be_spoofed_and_secrets_never_round_trip(
143
+ self, dev_client: Gumloop, make_agent,
144
+ ) -> None:
145
+ agent = make_agent()
146
+
147
+ response = dev_client.agents.attach_mcp_server(
148
+ agent.id, "gmail",
149
+ mcp_server_url="https://evil.example.com", secret_id="spoofed",
150
+ )
151
+
152
+ assert response.mcp_server.get("server_id") == "gmail"
153
+ assert "secret_id" not in response.mcp_server
154
+ assert response.mcp_server.get("mcp_server_url") is None
155
+
156
+ def test_unknown_server_id_is_a_loud_404(self, dev_client: Gumloop, make_agent) -> None:
157
+ agent = make_agent()
158
+
159
+ with pytest.raises(APIStatusError) as exc:
160
+ dev_client.agents.attach_mcp_server(agent.id, "not_a_real_server")
161
+
162
+ assert exc.value.code == "mcp_server_not_found"
163
+ assert exc.value.status_code == 404
164
+
165
+ def test_detach_mcp_server_is_idempotent(self, dev_client: Gumloop, make_agent) -> None:
166
+ agent = make_agent()
167
+ dev_client.agents.attach_mcp_server(agent.id, "gmail")
168
+
169
+ assert dev_client.agents.detach_mcp_server(agent.id, "gmail").detached is True
170
+ assert dev_client.agents.detach_mcp_server(agent.id, "gmail").detached is False
@@ -93,6 +93,132 @@ def test_agents_retrieve_and_update_routes(client: Gumloop) -> None:
93
93
  }
94
94
 
95
95
 
96
+ @respx.mock
97
+ def test_agents_attach_skills_sends_only_attach_body(client: Gumloop) -> None:
98
+ route = respx.patch(f"{API_BASE}/agents/agent_123/skills").mock(
99
+ return_value=httpx.Response(
100
+ 200, json={"agent_id": "agent_123", "skill_ids": ["s1", "s2"], "attached": ["s2"]}
101
+ )
102
+ )
103
+
104
+ result = client.agents.attach_skills("agent_123", ["s1", "s2"])
105
+
106
+ assert result.agent_id == "agent_123"
107
+ assert result.skill_ids == ["s1", "s2"]
108
+ assert result.attached == ["s2"]
109
+ assert request_json(route.calls[0].request) == {"attach": ["s1", "s2"]}
110
+
111
+
112
+ @respx.mock
113
+ def test_agents_attach_skills_coerces_bare_string(client: Gumloop) -> None:
114
+ route = respx.patch(f"{API_BASE}/agents/agent_123/skills").mock(
115
+ return_value=httpx.Response(200, json={"agent_id": "agent_123", "attached": ["s1"]})
116
+ )
117
+
118
+ client.agents.attach_skills("agent_123", "s1")
119
+
120
+ assert request_json(route.calls[0].request) == {"attach": ["s1"]}
121
+
122
+
123
+ @respx.mock
124
+ def test_agents_detach_skills_sends_only_detach_body(client: Gumloop) -> None:
125
+ route = respx.patch(f"{API_BASE}/agents/agent_123/skills").mock(
126
+ return_value=httpx.Response(200, json={"agent_id": "agent_123", "detached": ["s1"]})
127
+ )
128
+
129
+ result = client.agents.detach_skills("agent_123", "s1")
130
+
131
+ assert result.detached == ["s1"]
132
+ assert request_json(route.calls[0].request) == {"detach": ["s1"]}
133
+
134
+
135
+ @respx.mock
136
+ def test_agents_list_skills_sends_agent_id_param(client: Gumloop) -> None:
137
+ route = respx.get(f"{API_BASE}/skills").mock(return_value=httpx.Response(200, json={"skills": []}))
138
+
139
+ result = client.agents.list_skills("agent_123")
140
+
141
+ assert result.skills == []
142
+ assert route.calls[0].request.url.params["agent_id"] == "agent_123"
143
+
144
+
145
+ @respx.mock
146
+ def test_agents_attach_mcp_server_puts_config(client: Gumloop) -> None:
147
+ route = respx.put(f"{API_BASE}/agents/agent_123/mcp-servers/gmail").mock(
148
+ return_value=httpx.Response(
149
+ 200,
150
+ json={
151
+ "agent_id": "agent_123",
152
+ "mcp_server": {"type": "gumcp_server", "server_id": "gmail", "approval_mode": "off"},
153
+ "created": True,
154
+ "auth_status": "connected",
155
+ },
156
+ )
157
+ )
158
+
159
+ result = client.agents.attach_mcp_server("agent_123", "gmail", approval_mode="off")
160
+
161
+ assert result.created is True
162
+ assert result.auth_status == "connected"
163
+ assert result.mcp_server["server_id"] == "gmail"
164
+ assert request_json(route.calls[0].request) == {"approval_mode": "off"}
165
+
166
+
167
+ @respx.mock
168
+ def test_agents_detach_mcp_server(client: Gumloop) -> None:
169
+ route = respx.delete(f"{API_BASE}/agents/agent_123/mcp-servers/gmail").mock(
170
+ return_value=httpx.Response(200, json={"agent_id": "agent_123", "server_id": "gmail", "detached": True})
171
+ )
172
+
173
+ result = client.agents.detach_mcp_server("agent_123", "gmail")
174
+
175
+ assert result.detached is True
176
+ assert result.server_id == "gmail"
177
+ assert route.call_count == 1
178
+
179
+
180
+ @respx.mock
181
+ def test_agents_list_mcp_servers(client: Gumloop) -> None:
182
+ respx.get(f"{API_BASE}/agents/agent_123/mcp-servers").mock(
183
+ return_value=httpx.Response(
184
+ 200, json={"agent_id": "agent_123", "mcp_servers": [{"server_id": "gmail"}]}
185
+ )
186
+ )
187
+
188
+ result = client.agents.list_mcp_servers("agent_123")
189
+
190
+ assert result.agent_id == "agent_123"
191
+ assert result.mcp_servers == [{"server_id": "gmail"}]
192
+
193
+
194
+ @respx.mock
195
+ def test_async_agents_skill_and_mcp_methods() -> None:
196
+ respx.patch(f"{API_BASE}/agents/agent_123/skills").mock(
197
+ return_value=httpx.Response(200, json={"agent_id": "agent_123", "attached": ["s1"]})
198
+ )
199
+ respx.get(f"{API_BASE}/skills").mock(return_value=httpx.Response(200, json={"skills": []}))
200
+ respx.put(f"{API_BASE}/agents/agent_123/mcp-servers/gmail").mock(
201
+ return_value=httpx.Response(200, json={"agent_id": "agent_123", "created": True})
202
+ )
203
+ respx.delete(f"{API_BASE}/agents/agent_123/mcp-servers/gmail").mock(
204
+ return_value=httpx.Response(200, json={"agent_id": "agent_123", "server_id": "gmail", "detached": True})
205
+ )
206
+ respx.get(f"{API_BASE}/agents/agent_123/mcp-servers").mock(
207
+ return_value=httpx.Response(200, json={"agent_id": "agent_123", "mcp_servers": []})
208
+ )
209
+
210
+ async def run() -> None:
211
+ async with AsyncGumloop(access_token="token") as client:
212
+ assert (await client.agents.attach_skills("agent_123", "s1")).attached == ["s1"]
213
+ assert (await client.agents.detach_skills("agent_123", ["s1"])).agent_id == "agent_123"
214
+ assert (await client.agents.list_skills("agent_123")).skills == []
215
+ assert (await client.agents.attach_mcp_server("agent_123", "gmail", approval_mode="off")).created is True
216
+ assert (await client.agents.detach_mcp_server("agent_123", "gmail")).detached is True
217
+ assert (await client.agents.list_mcp_servers("agent_123")).mcp_servers == []
218
+
219
+ asyncio.run(run())
220
+
221
+
96
222
  @respx.mock
97
223
  def test_models_list(client: Gumloop) -> None:
98
224
  respx.get(f"{API_BASE}/models").mock(return_value=httpx.Response(200, json={"model_groups": [{"id": "auto"}]}))
@@ -355,9 +355,10 @@ wheels = [
355
355
 
356
356
  [[package]]
357
357
  name = "gumloop"
358
- version = "0.3.3"
358
+ version = "0.3.4"
359
359
  source = { editable = "." }
360
360
  dependencies = [
361
+ { name = "click" },
361
362
  { name = "httpx" },
362
363
  { name = "httpx-sse" },
363
364
  { name = "keyring" },
@@ -387,6 +388,7 @@ dev = [
387
388
 
388
389
  [package.metadata]
389
390
  requires-dist = [
391
+ { name = "click", specifier = ">=8.0" },
390
392
  { name = "httpx", specifier = ">=0.24.1" },
391
393
  { name = "httpx-sse", specifier = ">=0.4.0" },
392
394
  { name = "keyring", specifier = ">=24" },
@@ -1,64 +0,0 @@
1
- """Live integration tests against the real Gumloop API. Each test skips
2
- when its required env var is missing."""
3
-
4
- from __future__ import annotations
5
-
6
- import httpx
7
- import pytest
8
-
9
- from gumloop import APIStatusError
10
- from gumloop import Gumloop
11
- from gumloop import GumloopClient
12
-
13
-
14
- def test_run_flow_completes_and_returns_outputs(live_client: GumloopClient, test_flow_id: str) -> None:
15
- outputs = live_client.run_flow(test_flow_id, inputs={}, timeout=60.0)
16
-
17
- assert isinstance(outputs, dict)
18
-
19
-
20
- def test_invalid_run_id_surfaces_an_error(live_client: GumloopClient) -> None:
21
- with pytest.raises(httpx.HTTPStatusError):
22
- live_client.get_run_status("does-not-exist-" + "0" * 16)
23
-
24
-
25
- def test_models_list_returns_model_groups_envelope(dev_client: Gumloop) -> None:
26
- response = dev_client.models.list()
27
-
28
- assert isinstance(response.model_groups, list)
29
-
30
-
31
- def test_teams_list_returns_teams_with_id_and_name(dev_client: Gumloop) -> None:
32
- response = dev_client.teams.list()
33
-
34
- assert isinstance(response.teams, list)
35
- for team in response.teams:
36
- assert team.id
37
- assert team.name
38
-
39
-
40
- def test_agents_list_returns_paginated_envelope(dev_client: Gumloop) -> None:
41
- response = dev_client.agents.list(page_size=5)
42
-
43
- assert isinstance(response.agents, list)
44
- # ``next_cursor`` can be None when there's a single page; just check it
45
- # is the declared field rather than its truthiness.
46
- assert hasattr(response, "next_cursor")
47
-
48
-
49
- def test_mcp_servers_list_returns_servers_envelope(dev_client: Gumloop) -> None:
50
- response = dev_client.mcp.list_servers()
51
-
52
- assert isinstance(response.servers, list)
53
-
54
-
55
- def test_skills_list_returns_paginated_envelope(dev_client: Gumloop) -> None:
56
- response = dev_client.skills.list(page_size=5)
57
-
58
- assert isinstance(response.skills, list)
59
-
60
-
61
- def test_dev_api_invalid_agent_id_raises_api_status_error(dev_client: Gumloop) -> None:
62
- with pytest.raises(APIStatusError) as exc:
63
- dev_client.agents.retrieve("agent_does_not_exist_" + "0" * 16)
64
- assert exc.value.status_code in (403, 404)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes