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.
- {gumloop-0.3.3 → gumloop-0.3.4}/PKG-INFO +2 -1
- {gumloop-0.3.3 → gumloop-0.3.4}/pyproject.toml +2 -1
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/_http.py +6 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/agents.py +147 -2
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/agents.py +82 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/types.py +29 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_agents.py +67 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/integration/conftest.py +35 -0
- gumloop-0.3.4/tests/integration/test_live.py +170 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_agents.py +126 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/uv.lock +3 -1
- gumloop-0.3.3/tests/integration/test_live.py +0 -64
- {gumloop-0.3.3 → gumloop-0.3.4}/.env.example +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/.gitignore +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/.pre-commit-config.yaml +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/.python-version +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/LICENSE +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/README.md +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/scripts/check +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/scripts/fmt +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/scripts/sdk_examples.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/__init__.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/_client.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/__init__.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/__init__.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/_args_input.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/_downloads.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/artifacts.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/auth.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/brain.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/chat.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/mcp.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/sessions.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/commands/skills.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/console.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/context.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/credentials.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/errors.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/main.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/cli/oauth.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/errors.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/oauth.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/py.typed +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/__init__.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/artifacts.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/brain.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/chat.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/mcp.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/sessions.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/skills.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/resources/teams.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/spec/__init__.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/spec/_compat.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/src/gumloop/spec/_extensions.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/__init__.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/__init__.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/conftest.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_args_input.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_artifacts.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_brain.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_chat.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_context.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_credentials.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_login.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_main.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_mcp.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_sessions.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/cli/test_skills.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/conftest.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/__init__.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/conftest.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/helpers.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_artifacts.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_brain.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_chat.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_client.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_mcp.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_oauth.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_sessions.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_skills.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.4}/tests/sdk/test_teams.py +0 -0
- {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
|
+
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.
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|