gumloop 0.3.3__tar.gz → 0.3.5__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.5}/PKG-INFO +2 -1
- {gumloop-0.3.3 → gumloop-0.3.5}/pyproject.toml +2 -1
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/__init__.py +1 -1
- gumloop-0.3.5/src/gumloop/_auth.py +35 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/_client.py +3 -2
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/_http.py +15 -18
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/agents.py +147 -2
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/agents.py +82 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/types.py +29 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_agents.py +67 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/integration/conftest.py +35 -0
- gumloop-0.3.5/tests/integration/test_live.py +170 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_agents.py +126 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_client.py +69 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/uv.lock +3 -1
- gumloop-0.3.3/tests/integration/test_live.py +0 -64
- {gumloop-0.3.3 → gumloop-0.3.5}/.env.example +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/.gitignore +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/.pre-commit-config.yaml +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/.python-version +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/LICENSE +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/README.md +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/scripts/check +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/scripts/fmt +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/scripts/sdk_examples.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/__init__.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/__init__.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/_args_input.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/_downloads.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/artifacts.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/auth.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/brain.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/chat.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/mcp.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/sessions.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/skills.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/console.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/context.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/credentials.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/errors.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/main.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/oauth.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/errors.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/oauth.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/py.typed +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/__init__.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/artifacts.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/brain.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/chat.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/mcp.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/sessions.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/skills.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/teams.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/spec/__init__.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/spec/_compat.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/spec/_extensions.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/__init__.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/__init__.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/conftest.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_args_input.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_artifacts.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_brain.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_chat.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_context.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_credentials.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_login.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_main.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_mcp.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_sessions.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_skills.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/conftest.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/__init__.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/conftest.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/helpers.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_artifacts.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_brain.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_chat.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_mcp.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_oauth.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_sessions.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_skills.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_teams.py +0 -0
- {gumloop-0.3.3 → gumloop-0.3.5}/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.5
|
|
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.5"
|
|
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
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Credential resolution shared by the sync and async transports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from gumloop.errors import AuthenticationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EnvToken(str):
|
|
11
|
+
"""Access token sourced from ``GUMLOOP_ACCESS_TOKEN``.
|
|
12
|
+
|
|
13
|
+
Stays live: ``current()`` returns the env var's present value, so
|
|
14
|
+
platform-rotated tokens (e.g. per-call sandbox credentials) are picked up
|
|
15
|
+
on every request. Explicit tokens are plain strings and never change.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def from_env(cls) -> EnvToken | None:
|
|
20
|
+
token = os.environ.get("GUMLOOP_ACCESS_TOKEN")
|
|
21
|
+
return cls(token) if token else None
|
|
22
|
+
|
|
23
|
+
def current(self) -> str:
|
|
24
|
+
return os.environ.get("GUMLOOP_ACCESS_TOKEN") or str(self)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def auth_headers(access_token: str | None, user_id: str | None) -> dict[str, str]:
|
|
28
|
+
if isinstance(access_token, EnvToken):
|
|
29
|
+
access_token = access_token.current()
|
|
30
|
+
if not access_token:
|
|
31
|
+
raise AuthenticationError("access_token, api_key, or GUMLOOP_ACCESS_TOKEN is required")
|
|
32
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
33
|
+
if user_id:
|
|
34
|
+
headers["x-auth-key"] = user_id
|
|
35
|
+
return headers
|
|
@@ -7,6 +7,7 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
import httpx
|
|
9
9
|
|
|
10
|
+
from gumloop._auth import EnvToken
|
|
10
11
|
from gumloop._http import AsyncHttpClient
|
|
11
12
|
from gumloop._http import HttpClient
|
|
12
13
|
from gumloop.oauth import OAuth
|
|
@@ -59,7 +60,7 @@ class Gumloop:
|
|
|
59
60
|
stream_timeout: float | None = DEFAULT_STREAM_TIMEOUT,
|
|
60
61
|
) -> None:
|
|
61
62
|
self.api_key = api_key
|
|
62
|
-
self.access_token = access_token or api_key or
|
|
63
|
+
self.access_token = access_token or api_key or EnvToken.from_env()
|
|
63
64
|
# Personal Gumloop API keys are user-scoped; the backend uses the
|
|
64
65
|
# x-auth-key header to look up the owner's stored secret. OAuth
|
|
65
66
|
# callers omit it because the bearer token already identifies them.
|
|
@@ -115,7 +116,7 @@ class AsyncGumloop:
|
|
|
115
116
|
stream_timeout: float | None = DEFAULT_STREAM_TIMEOUT,
|
|
116
117
|
) -> None:
|
|
117
118
|
self.api_key = api_key
|
|
118
|
-
self.access_token = access_token or api_key or
|
|
119
|
+
self.access_token = access_token or api_key or EnvToken.from_env()
|
|
119
120
|
self.user_id = user_id or os.environ.get("GUMLOOP_USER_ID")
|
|
120
121
|
# Defaults to production; GUMLOOP_BASE_URL is an optional override.
|
|
121
122
|
self.base_url = (base_url or os.environ.get("GUMLOOP_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
@@ -13,7 +13,7 @@ from httpx_sse import ServerSentEvent
|
|
|
13
13
|
from pydantic import BaseModel
|
|
14
14
|
from pydantic import ValidationError
|
|
15
15
|
|
|
16
|
-
from gumloop.
|
|
16
|
+
from gumloop._auth import auth_headers
|
|
17
17
|
from gumloop.errors import to_api_error
|
|
18
18
|
from gumloop.types import StreamEvent
|
|
19
19
|
|
|
@@ -23,15 +23,6 @@ _DONE_SENTINEL = "[DONE]"
|
|
|
23
23
|
_T = TypeVar("_T", bound=BaseModel)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def _auth_headers(access_token: str | None, user_id: str | None) -> dict[str, str]:
|
|
27
|
-
if not access_token:
|
|
28
|
-
raise AuthenticationError("access_token, api_key, or GUMLOOP_ACCESS_TOKEN is required")
|
|
29
|
-
headers = {"Authorization": f"Bearer {access_token}"}
|
|
30
|
-
if user_id:
|
|
31
|
-
headers["x-auth-key"] = user_id
|
|
32
|
-
return headers
|
|
33
|
-
|
|
34
|
-
|
|
35
26
|
def _omit_none_params(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
36
27
|
# Backend treats absent ``?foo`` and ``?foo=`` as different signals
|
|
37
28
|
# ("not provided" vs "empty string"); drop None values so the wire URL
|
|
@@ -110,6 +101,9 @@ class HttpClient:
|
|
|
110
101
|
) -> Any:
|
|
111
102
|
return self._request("PATCH", path, json=json, data=_omit_none_params(data), files=files)
|
|
112
103
|
|
|
104
|
+
def put(self, path: str, *, json: Any = None) -> Any:
|
|
105
|
+
return self._request("PUT", path, json=json)
|
|
106
|
+
|
|
113
107
|
def delete(self, path: str) -> Any:
|
|
114
108
|
return self._request("DELETE", path)
|
|
115
109
|
|
|
@@ -117,7 +111,7 @@ class HttpClient:
|
|
|
117
111
|
# Endpoints whose streaming variant lives on the stream host (e.g. chat
|
|
118
112
|
# completions) must accept their unary counterparts at the same host —
|
|
119
113
|
# the api host has no handler for them.
|
|
120
|
-
headers =
|
|
114
|
+
headers = auth_headers(self.access_token, self.user_id)
|
|
121
115
|
headers["Content-Type"] = "application/json"
|
|
122
116
|
response = self._client.post(
|
|
123
117
|
f"{self._stream_base_url}/{path.lstrip('/')}",
|
|
@@ -137,7 +131,7 @@ class HttpClient:
|
|
|
137
131
|
json: Any = None,
|
|
138
132
|
params: Mapping[str, Any] | None = None,
|
|
139
133
|
) -> Iterator[StreamEvent]:
|
|
140
|
-
headers = {**
|
|
134
|
+
headers = {**auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
|
|
141
135
|
with self._client.stream(
|
|
142
136
|
method,
|
|
143
137
|
f"{self._stream_base_url}/{path.lstrip('/')}",
|
|
@@ -163,7 +157,7 @@ class HttpClient:
|
|
|
163
157
|
) -> Iterator[_T]:
|
|
164
158
|
# Skips the StreamEvent envelope and honors OpenRouter's `data: [DONE]`
|
|
165
159
|
# terminator. Unparseable events (keep-alives, comments) are skipped.
|
|
166
|
-
headers = {**
|
|
160
|
+
headers = {**auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
|
|
167
161
|
with self._client.stream(
|
|
168
162
|
method,
|
|
169
163
|
f"{self._stream_base_url}/{path.lstrip('/')}",
|
|
@@ -191,7 +185,7 @@ class HttpClient:
|
|
|
191
185
|
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
192
186
|
# Headers are rebuilt per request so ``access_token`` / ``user_id``
|
|
193
187
|
# can be rotated on a live client without reconstructing it.
|
|
194
|
-
headers =
|
|
188
|
+
headers = auth_headers(self.access_token, self.user_id)
|
|
195
189
|
if not kwargs.get("files"):
|
|
196
190
|
headers["Content-Type"] = "application/json"
|
|
197
191
|
response = self._client.request(method, path, headers=headers, **kwargs)
|
|
@@ -251,11 +245,14 @@ class AsyncHttpClient:
|
|
|
251
245
|
) -> Any:
|
|
252
246
|
return await self._request("PATCH", path, json=json, data=_omit_none_params(data), files=files)
|
|
253
247
|
|
|
248
|
+
async def put(self, path: str, *, json: Any = None) -> Any:
|
|
249
|
+
return await self._request("PUT", path, json=json)
|
|
250
|
+
|
|
254
251
|
async def delete(self, path: str) -> Any:
|
|
255
252
|
return await self._request("DELETE", path)
|
|
256
253
|
|
|
257
254
|
async def post_to_stream_host(self, path: str, *, json: Any = None) -> Any:
|
|
258
|
-
headers =
|
|
255
|
+
headers = auth_headers(self.access_token, self.user_id)
|
|
259
256
|
headers["Content-Type"] = "application/json"
|
|
260
257
|
response = await self._client.post(
|
|
261
258
|
f"{self._stream_base_url}/{path.lstrip('/')}",
|
|
@@ -275,7 +272,7 @@ class AsyncHttpClient:
|
|
|
275
272
|
json: Any = None,
|
|
276
273
|
params: Mapping[str, Any] | None = None,
|
|
277
274
|
) -> AsyncIterator[StreamEvent]:
|
|
278
|
-
headers = {**
|
|
275
|
+
headers = {**auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
|
|
279
276
|
async with self._client.stream(
|
|
280
277
|
method,
|
|
281
278
|
f"{self._stream_base_url}/{path.lstrip('/')}",
|
|
@@ -299,7 +296,7 @@ class AsyncHttpClient:
|
|
|
299
296
|
json: Any = None,
|
|
300
297
|
params: Mapping[str, Any] | None = None,
|
|
301
298
|
) -> AsyncIterator[_T]:
|
|
302
|
-
headers = {**
|
|
299
|
+
headers = {**auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
|
|
303
300
|
async with self._client.stream(
|
|
304
301
|
method,
|
|
305
302
|
f"{self._stream_base_url}/{path.lstrip('/')}",
|
|
@@ -325,7 +322,7 @@ class AsyncHttpClient:
|
|
|
325
322
|
continue
|
|
326
323
|
|
|
327
324
|
async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
328
|
-
headers =
|
|
325
|
+
headers = auth_headers(self.access_token, self.user_id)
|
|
329
326
|
if not kwargs.get("files"):
|
|
330
327
|
headers["Content-Type"] = "application/json"
|
|
331
328
|
response = await self._client.request(method, path, headers=headers, **kwargs)
|
|
@@ -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(
|