gumloop 0.3.2__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.2 → gumloop-0.3.4}/PKG-INFO +2 -1
  2. {gumloop-0.3.2 → gumloop-0.3.4}/pyproject.toml +2 -1
  3. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/__init__.py +1 -1
  4. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/_client.py +7 -3
  5. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/_http.py +6 -0
  6. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/cli/commands/agents.py +147 -2
  7. gumloop-0.3.4/src/gumloop/cli/commands/brain.py +66 -0
  8. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/cli/main.py +2 -0
  9. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/resources/__init__.py +4 -0
  10. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/resources/agents.py +82 -0
  11. gumloop-0.3.4/src/gumloop/resources/brain.py +47 -0
  12. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/types.py +58 -0
  13. {gumloop-0.3.2 → gumloop-0.3.4}/tests/cli/test_agents.py +67 -0
  14. gumloop-0.3.4/tests/cli/test_brain.py +81 -0
  15. {gumloop-0.3.2 → gumloop-0.3.4}/tests/integration/conftest.py +35 -0
  16. gumloop-0.3.4/tests/integration/test_live.py +170 -0
  17. {gumloop-0.3.2 → gumloop-0.3.4}/tests/sdk/test_agents.py +126 -0
  18. gumloop-0.3.4/tests/sdk/test_brain.py +75 -0
  19. {gumloop-0.3.2 → gumloop-0.3.4}/uv.lock +3 -1
  20. gumloop-0.3.2/tests/integration/test_live.py +0 -64
  21. {gumloop-0.3.2 → gumloop-0.3.4}/.env.example +0 -0
  22. {gumloop-0.3.2 → gumloop-0.3.4}/.gitignore +0 -0
  23. {gumloop-0.3.2 → gumloop-0.3.4}/.pre-commit-config.yaml +0 -0
  24. {gumloop-0.3.2 → gumloop-0.3.4}/.python-version +0 -0
  25. {gumloop-0.3.2 → gumloop-0.3.4}/LICENSE +0 -0
  26. {gumloop-0.3.2 → gumloop-0.3.4}/README.md +0 -0
  27. {gumloop-0.3.2 → gumloop-0.3.4}/scripts/check +0 -0
  28. {gumloop-0.3.2 → gumloop-0.3.4}/scripts/fmt +0 -0
  29. {gumloop-0.3.2 → gumloop-0.3.4}/scripts/sdk_examples.py +0 -0
  30. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/cli/__init__.py +0 -0
  31. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/cli/commands/__init__.py +0 -0
  32. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/cli/commands/_args_input.py +0 -0
  33. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/cli/commands/_downloads.py +0 -0
  34. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/cli/commands/artifacts.py +0 -0
  35. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/cli/commands/auth.py +0 -0
  36. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/cli/commands/chat.py +0 -0
  37. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/cli/commands/mcp.py +0 -0
  38. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/cli/commands/sessions.py +0 -0
  39. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/cli/commands/skills.py +0 -0
  40. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/cli/console.py +0 -0
  41. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/cli/context.py +0 -0
  42. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/cli/credentials.py +0 -0
  43. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/cli/errors.py +0 -0
  44. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/cli/oauth.py +0 -0
  45. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/errors.py +0 -0
  46. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/oauth.py +0 -0
  47. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/py.typed +0 -0
  48. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/resources/artifacts.py +0 -0
  49. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/resources/chat.py +0 -0
  50. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/resources/mcp.py +0 -0
  51. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/resources/sessions.py +0 -0
  52. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/resources/skills.py +0 -0
  53. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/resources/teams.py +0 -0
  54. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/spec/__init__.py +0 -0
  55. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/spec/_compat.py +0 -0
  56. {gumloop-0.3.2 → gumloop-0.3.4}/src/gumloop/spec/_extensions.py +0 -0
  57. {gumloop-0.3.2 → gumloop-0.3.4}/tests/__init__.py +0 -0
  58. {gumloop-0.3.2 → gumloop-0.3.4}/tests/cli/__init__.py +0 -0
  59. {gumloop-0.3.2 → gumloop-0.3.4}/tests/cli/conftest.py +0 -0
  60. {gumloop-0.3.2 → gumloop-0.3.4}/tests/cli/test_args_input.py +0 -0
  61. {gumloop-0.3.2 → gumloop-0.3.4}/tests/cli/test_artifacts.py +0 -0
  62. {gumloop-0.3.2 → gumloop-0.3.4}/tests/cli/test_chat.py +0 -0
  63. {gumloop-0.3.2 → gumloop-0.3.4}/tests/cli/test_context.py +0 -0
  64. {gumloop-0.3.2 → gumloop-0.3.4}/tests/cli/test_credentials.py +0 -0
  65. {gumloop-0.3.2 → gumloop-0.3.4}/tests/cli/test_login.py +0 -0
  66. {gumloop-0.3.2 → gumloop-0.3.4}/tests/cli/test_main.py +0 -0
  67. {gumloop-0.3.2 → gumloop-0.3.4}/tests/cli/test_mcp.py +0 -0
  68. {gumloop-0.3.2 → gumloop-0.3.4}/tests/cli/test_sessions.py +0 -0
  69. {gumloop-0.3.2 → gumloop-0.3.4}/tests/cli/test_skills.py +0 -0
  70. {gumloop-0.3.2 → gumloop-0.3.4}/tests/conftest.py +0 -0
  71. {gumloop-0.3.2 → gumloop-0.3.4}/tests/sdk/__init__.py +0 -0
  72. {gumloop-0.3.2 → gumloop-0.3.4}/tests/sdk/conftest.py +0 -0
  73. {gumloop-0.3.2 → gumloop-0.3.4}/tests/sdk/helpers.py +0 -0
  74. {gumloop-0.3.2 → gumloop-0.3.4}/tests/sdk/test_artifacts.py +0 -0
  75. {gumloop-0.3.2 → gumloop-0.3.4}/tests/sdk/test_chat.py +0 -0
  76. {gumloop-0.3.2 → gumloop-0.3.4}/tests/sdk/test_client.py +0 -0
  77. {gumloop-0.3.2 → gumloop-0.3.4}/tests/sdk/test_mcp.py +0 -0
  78. {gumloop-0.3.2 → gumloop-0.3.4}/tests/sdk/test_oauth.py +0 -0
  79. {gumloop-0.3.2 → gumloop-0.3.4}/tests/sdk/test_sessions.py +0 -0
  80. {gumloop-0.3.2 → gumloop-0.3.4}/tests/sdk/test_skills.py +0 -0
  81. {gumloop-0.3.2 → gumloop-0.3.4}/tests/sdk/test_teams.py +0 -0
  82. {gumloop-0.3.2 → 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.2
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.2"
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
 
@@ -6,7 +6,7 @@ from gumloop.errors import AuthenticationError
6
6
  from gumloop.errors import GumloopError
7
7
  from gumloop.oauth import OAuth
8
8
 
9
- __version__ = "0.3.2"
9
+ __version__ = "0.3.3"
10
10
  __all__ = [
11
11
  "APIStatusError",
12
12
  "AsyncGumloop",
@@ -15,12 +15,14 @@ from gumloop.resources import Agents
15
15
  from gumloop.resources import Artifacts
16
16
  from gumloop.resources import AsyncAgents
17
17
  from gumloop.resources import AsyncArtifacts
18
+ from gumloop.resources import AsyncBrain
18
19
  from gumloop.resources import AsyncChat
19
20
  from gumloop.resources import AsyncMCP
20
21
  from gumloop.resources import AsyncModels
21
22
  from gumloop.resources import AsyncSessions
22
23
  from gumloop.resources import AsyncSkills
23
24
  from gumloop.resources import AsyncTeams
25
+ from gumloop.resources import Brain
24
26
  from gumloop.resources import Chat
25
27
  from gumloop.resources import Models
26
28
  from gumloop.resources import Sessions
@@ -41,9 +43,9 @@ def _derive_stream_base_url(base_url: str) -> str:
41
43
  class Gumloop:
42
44
  """Sync Gumloop SDK client. Composes an :class:`HttpClient` with the
43
45
  resource classes (``agents``, ``sessions``, ``mcp``, ``teams``,
44
- ``skills``, ``artifacts``, ``models``). The :attr:`oauth` attribute
45
- holds OAuth helpers — kept off the transport because it bootstraps the
46
- bearer token rather than consuming one."""
46
+ ``skills``, ``artifacts``, ``brain``, ``models``). The :attr:`oauth`
47
+ attribute holds OAuth helpers — kept off the transport because it
48
+ bootstraps the bearer token rather than consuming one."""
47
49
 
48
50
  def __init__(
49
51
  self,
@@ -85,6 +87,7 @@ class Gumloop:
85
87
  self.teams = Teams(self._http)
86
88
  self.skills = Skills(self._http)
87
89
  self.artifacts = Artifacts(self._http)
90
+ self.brain = Brain(self._http)
88
91
  self.oauth = OAuth(base_url=self.base_url, timeout=self.timeout)
89
92
 
90
93
  def close(self) -> None:
@@ -137,6 +140,7 @@ class AsyncGumloop:
137
140
  self.teams = AsyncTeams(self._http)
138
141
  self.skills = AsyncSkills(self._http)
139
142
  self.artifacts = AsyncArtifacts(self._http)
143
+ self.brain = AsyncBrain(self._http)
140
144
  self.oauth = OAuth(base_url=self.base_url, timeout=self.timeout)
141
145
 
142
146
  async def __aenter__(self) -> AsyncGumloop:
@@ -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)
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from gumloop import GumloopError
8
+ from gumloop.cli.console import console
9
+ from gumloop.cli.console import print_json
10
+ from gumloop.cli.context import CliContext
11
+ from gumloop.cli.errors import exit_with_error
12
+
13
+ brain_app = typer.Typer(help="Search your Company Brain.", no_args_is_help=True, rich_markup_mode="rich")
14
+
15
+
16
+ @brain_app.command(
17
+ "search",
18
+ epilog=(
19
+ "Examples:\n"
20
+ ' gumloop brain search "onboarding process"\n'
21
+ ' gumloop brain search "pricing" --limit 5 --source notion --json'
22
+ ),
23
+ )
24
+ def search_brain(
25
+ ctx: typer.Context,
26
+ query: Annotated[str, typer.Argument(help="Search query.")],
27
+ limit: Annotated[
28
+ int | None,
29
+ typer.Option("--limit", help="Maximum number of results to return (1-50)."),
30
+ ] = None,
31
+ source: Annotated[
32
+ list[str] | None,
33
+ typer.Option("--source", help="Filter by source type (repeatable), e.g. notion, google_drive, slack."),
34
+ ] = None,
35
+ json_output: Annotated[
36
+ bool,
37
+ typer.Option("--json", help="Print the raw SDK response as JSON."),
38
+ ] = False,
39
+ ) -> None:
40
+ """Search indexed Company Brain sources."""
41
+ cli: CliContext = ctx.obj
42
+ try:
43
+ response = cli.call_with_refresh(
44
+ lambda client: client.brain.search(query, limit=limit, source_type=source or None)
45
+ )
46
+ except GumloopError as error:
47
+ exit_with_error(error, json_output=json_output)
48
+
49
+ if json_output:
50
+ print_json(response)
51
+ return
52
+
53
+ if not response.results:
54
+ console.print("No results found.")
55
+ return
56
+
57
+ console.print("SCORE", "SOURCE", "TITLE", "URL", sep="\t", soft_wrap=True)
58
+ for result in response.results:
59
+ console.print(
60
+ "" if result.score is None else f"{result.score:.3f}",
61
+ result.source or "",
62
+ result.title or "",
63
+ result.url or "",
64
+ sep="\t",
65
+ soft_wrap=True,
66
+ )
@@ -11,6 +11,7 @@ from gumloop.cli.commands.agents import agents_app
11
11
  from gumloop.cli.commands.artifacts import artifacts_app
12
12
  from gumloop.cli.commands.auth import login as login_command
13
13
  from gumloop.cli.commands.auth import logout as logout_command
14
+ from gumloop.cli.commands.brain import brain_app
14
15
  from gumloop.cli.commands.chat import chat_app
15
16
  from gumloop.cli.commands.mcp import mcp_app
16
17
  from gumloop.cli.commands.sessions import sessions_app
@@ -97,6 +98,7 @@ app.add_typer(sessions_app, name="sessions")
97
98
  app.add_typer(skills_app, name="skills")
98
99
  app.add_typer(artifacts_app, name="artifacts")
99
100
  app.add_typer(chat_app, name="chat")
101
+ app.add_typer(brain_app, name="brain")
100
102
 
101
103
 
102
104
  def _require_supported_platform() -> None:
@@ -6,6 +6,8 @@ from gumloop.resources.agents import AsyncModels
6
6
  from gumloop.resources.agents import Models
7
7
  from gumloop.resources.artifacts import Artifacts
8
8
  from gumloop.resources.artifacts import AsyncArtifacts
9
+ from gumloop.resources.brain import AsyncBrain
10
+ from gumloop.resources.brain import Brain
9
11
  from gumloop.resources.chat import AsyncChat
10
12
  from gumloop.resources.chat import Chat
11
13
  from gumloop.resources.mcp import MCP
@@ -23,12 +25,14 @@ __all__ = [
23
25
  "Artifacts",
24
26
  "AsyncAgents",
25
27
  "AsyncArtifacts",
28
+ "AsyncBrain",
26
29
  "AsyncChat",
27
30
  "AsyncMCP",
28
31
  "AsyncModels",
29
32
  "AsyncSessions",
30
33
  "AsyncSkills",
31
34
  "AsyncTeams",
35
+ "Brain",
32
36
  "Chat",
33
37
  "Models",
34
38
  "Sessions",
@@ -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)
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from gumloop._http import AsyncHttpClient
6
+ from gumloop._http import HttpClient
7
+ from gumloop.types import BrainSearchRequest
8
+ from gumloop.types import BrainSearchResponse
9
+
10
+
11
+ class Brain:
12
+ def __init__(self, client: HttpClient) -> None:
13
+ self._client = client
14
+
15
+ def search(
16
+ self,
17
+ query: str,
18
+ *,
19
+ limit: int | None = None,
20
+ source_type: list[str] | None = None,
21
+ **kwargs: Any,
22
+ ) -> BrainSearchResponse:
23
+ return BrainSearchResponse.model_validate(
24
+ self._client.post(
25
+ "brain/search",
26
+ json=BrainSearchRequest.build(query=query, limit=limit, source_type=source_type, **kwargs),
27
+ )
28
+ )
29
+
30
+
31
+ class AsyncBrain:
32
+ def __init__(self, client: AsyncHttpClient) -> None:
33
+ self._client = client
34
+
35
+ async def search(
36
+ self,
37
+ query: str,
38
+ *,
39
+ limit: int | None = None,
40
+ source_type: list[str] | None = None,
41
+ **kwargs: Any,
42
+ ) -> BrainSearchResponse:
43
+ data = await self._client.post(
44
+ "brain/search",
45
+ json=BrainSearchRequest.build(query=query, limit=limit, source_type=source_type, **kwargs),
46
+ )
47
+ return BrainSearchResponse.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
 
@@ -236,6 +265,35 @@ class SkillDownloadResponse(_Model):
236
265
  major_version: int | None = None
237
266
 
238
267
 
268
+ # ---------------------------------------------------------------------------
269
+ # Brain types
270
+ # ---------------------------------------------------------------------------
271
+
272
+
273
+ class BrainSearchRequest(_Model):
274
+ query: str
275
+ limit: int | None = None
276
+ source_type: list[str] | None = Field(default=None, min_length=1)
277
+
278
+
279
+ class BrainSearchResult(_Model):
280
+ document_id: str | None = None
281
+ source: str | None = None
282
+ title: str | None = None
283
+ content: str | None = None
284
+ url: str | None = None
285
+ score: float | None = None
286
+ updated_at: str | None = None
287
+ owner_name: str | None = None
288
+ owner_email: str | None = None
289
+ parent_title: str | None = None
290
+ metadata: dict[str, Any] = Field(default_factory=dict)
291
+
292
+
293
+ class BrainSearchResponse(_Model):
294
+ results: list[BrainSearchResult] = Field(default_factory=list)
295
+
296
+
239
297
  # ---------------------------------------------------------------------------
240
298
  # Artifact types
241
299
  # ---------------------------------------------------------------------------