From c311a0e0b1a0245a54a02a3e7b5c30d6c13ba1e4 Mon Sep 17 00:00:00 2001 From: marcelo Date: Mon, 27 Jul 2026 22:00:06 +0000 Subject: [PATCH 1/4] [GMLP-9567] Export immutable agent versions via CLI Expose typed version reads in the SDK so users can list and export historical agent configurations from the command line. --- src/gumloop/cli/commands/agents.py | 102 ++++++++++++++++++++++++++ src/gumloop/resources/agents.py | 58 +++++++++++++++ src/gumloop/types.py | 77 ++++++++++++++++++++ tests/cli/test_agents.py | 67 +++++++++++++++++ tests/sdk/test_agents.py | 112 +++++++++++++++++++++++++++++ 5 files changed, 416 insertions(+) diff --git a/src/gumloop/cli/commands/agents.py b/src/gumloop/cli/commands/agents.py index a2ec214..dce14fb 100644 --- a/src/gumloop/cli/commands/agents.py +++ b/src/gumloop/cli/commands/agents.py @@ -111,6 +111,108 @@ def get_agent( console.print(f" {agent.system_prompt}", markup=False, highlight=False) +@agents_app.command( + "versions", + epilog="Examples:\n gumloop agents versions agent_abc\n gumloop agents versions agent_abc --limit 50 --json", +) +def list_agent_versions( + ctx: typer.Context, + agent_id: Annotated[str, typer.Argument(help="ID of the agent whose versions should be listed.")], + limit: Annotated[ + int | None, + typer.Option("--limit", help="Maximum number of versions to return."), + ] = None, + cursor: Annotated[ + str | None, + typer.Option("--cursor", help="Pagination cursor from a previous versions call."), + ] = None, + json_output: Annotated[ + bool, + typer.Option("--json", help="Print the raw SDK response as JSON."), + ] = False, +) -> None: + """List immutable versions of an agent.""" + cli: CliContext = ctx.obj + try: + response = cli.call_with_refresh( + lambda client: client.agents.list_versions( + agent_id, + page_size=limit, + cursor=cursor, + team_id=cli.effective_team_id, + ) + ) + except GumloopError as error: + exit_with_error(error, json_output=json_output) + + if json_output: + print_json(response) + return + + if not response.versions: + console.print("No agent versions found.") + else: + console.print("ID", "VERSION", "NAME", "DEPLOYED", "CREATED", sep="\t", soft_wrap=True) + for version in response.versions: + console.print( + version.id, + str(version.major_version), + version.name, + "yes" if version.is_deployed else "no", + version.created_at or "", + sep="\t", + soft_wrap=True, + ) + + if response.next_cursor: + console.print(f"\n[dim]Next cursor:[/dim] {escape_markup(response.next_cursor)}") + + +@agents_app.command( + "export", + epilog=( + "Examples:\n" + " gumloop agents export agent_abc version_123\n" + " gumloop agents export agent_abc version_123 --output agent-version.json" + ), +) +def export_agent_version( + ctx: typer.Context, + agent_id: Annotated[str, typer.Argument(help="ID of the agent to export.")], + version_id: Annotated[str, typer.Argument(help="ID of the immutable version to export.")], + output: Annotated[ + str | None, + typer.Option("-o", "--output", help="File to write. Omit or use '-' for stdout."), + ] = None, +) -> None: + """Export one immutable agent version as JSON.""" + cli: CliContext = ctx.obj + try: + response = cli.call_with_refresh( + lambda client: client.agents.get_version( + agent_id, + version_id, + team_id=cli.effective_team_id, + ) + ) + if output in (None, "-"): + print_json(response) + return + + destination = Path(output).expanduser() + payload = json.dumps(response.model_dump(mode="json"), sort_keys=True) + destination.write_text(f"{payload}\n", encoding="utf-8") + except GumloopError as error: + exit_with_error(error, json_output=True) + except OSError as error: + exit_with_error( + GumloopError(f"Could not write {output}: {error.strerror or error}"), + json_output=True, + ) + + console.print(f"[green]Saved[/green] {destination}") + + @agents_app.command( "create", epilog=( diff --git a/src/gumloop/resources/agents.py b/src/gumloop/resources/agents.py index 7f241e5..90b13b3 100644 --- a/src/gumloop/resources/agents.py +++ b/src/gumloop/resources/agents.py @@ -14,6 +14,8 @@ from gumloop.types import AgentResponse from gumloop.types import AgentSkillsResponse from gumloop.types import AgentUpdateRequest +from gumloop.types import AgentVersionResponse +from gumloop.types import AgentVersionsResponse from gumloop.types import EvaluationConfigResponse from gumloop.types import EvaluationConfigUpdateRequest from gumloop.types import EvaluationResultListResponse @@ -55,6 +57,35 @@ def create( def retrieve(self, agent_id: str) -> AgentResponse: return AgentResponse.model_validate(self._client.get(f"agents/{agent_id}")) + def list_versions( + self, + agent_id: str, + *, + page_size: int | None = None, + cursor: str | None = None, + team_id: str | None = None, + ) -> AgentVersionsResponse: + return AgentVersionsResponse.model_validate( + self._client.get( + f"agents/{agent_id}/versions", + params={"page_size": page_size, "cursor": cursor, "team_id": team_id}, + ) + ) + + def get_version( + self, + agent_id: str, + version_id: str, + *, + team_id: str | None = None, + ) -> AgentVersionResponse: + return AgentVersionResponse.model_validate( + self._client.get( + f"agents/{agent_id}/versions/{version_id}", + params={"team_id": team_id}, + ) + ) + def update( self, agent_id: str, @@ -179,6 +210,33 @@ async def create( async def retrieve(self, agent_id: str) -> AgentResponse: return AgentResponse.model_validate(await self._client.get(f"agents/{agent_id}")) + async def list_versions( + self, + agent_id: str, + *, + page_size: int | None = None, + cursor: str | None = None, + team_id: str | None = None, + ) -> AgentVersionsResponse: + data = await self._client.get( + f"agents/{agent_id}/versions", + params={"page_size": page_size, "cursor": cursor, "team_id": team_id}, + ) + return AgentVersionsResponse.model_validate(data) + + async def get_version( + self, + agent_id: str, + version_id: str, + *, + team_id: str | None = None, + ) -> AgentVersionResponse: + data = await self._client.get( + f"agents/{agent_id}/versions/{version_id}", + params={"team_id": team_id}, + ) + return AgentVersionResponse.model_validate(data) + async def update( self, agent_id: str, diff --git a/src/gumloop/types.py b/src/gumloop/types.py index 5529db0..97319db 100644 --- a/src/gumloop/types.py +++ b/src/gumloop/types.py @@ -129,6 +129,83 @@ class AgentListResponse(_Model): next_cursor: str | None = None +class AgentVersionKnowledgeSource(_Model): + connector_id: str + config: dict[str, Any] | None = None + + +class AgentVersionComposition(_Model): + complete: bool + schema_version: int | None = None + name: str + description: str | None = None + model_name: str + system_prompt: str | None = None + tools: list[dict[str, Any]] = Field(default_factory=list) + resources: list[dict[str, Any]] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + skill_ids: list[str] | None = None + knowledge_sources: list[AgentVersionKnowledgeSource] | None = None + + +class AgentVersionSummary(_Model): + id: str + agent_id: str + major_version: int + name: str + is_deployed: bool | None = None + created_at: str | None = None + creator: CreatorPayload | None = None + + +class AgentVersion(AgentVersionSummary): + composition: AgentVersionComposition + + +class AgentVersionFieldChange(_Model): + field: str + status: str + old_value: Any + new_value: Any + + +class AgentVersionArrayChange(_Model): + field: str + old_value: list[Any] + new_value: list[Any] + + +class AgentVersionSkillChange(_Model): + skill_id: str + status: str + + +class AgentVersionKnowledgeSourceChange(_Model): + connector_id: str + status: str + old_config: Any + new_config: Any + + +class AgentVersionChanges(_Model): + relationships_unknown: bool + field_changes: list[AgentVersionFieldChange] = Field(default_factory=list) + tools_change: AgentVersionArrayChange | None = None + resources_change: AgentVersionArrayChange | None = None + skill_changes: list[AgentVersionSkillChange] = Field(default_factory=list) + knowledge_source_changes: list[AgentVersionKnowledgeSourceChange] = Field(default_factory=list) + + +class AgentVersionResponse(_Model): + version: AgentVersion + changes: AgentVersionChanges | None = None + + +class AgentVersionsResponse(_Model): + versions: list[AgentVersionSummary] = Field(default_factory=list) + next_cursor: str | None = None + + # --------------------------------------------------------------------------- # Model types # --------------------------------------------------------------------------- diff --git a/tests/cli/test_agents.py b/tests/cli/test_agents.py index ba77488..13a518b 100644 --- a/tests/cli/test_agents.py +++ b/tests/cli/test_agents.py @@ -40,6 +40,73 @@ def test_agents_get_calls_per_agent_endpoint(cli_runner: CliRunner) -> None: assert '"id": "agent_abc"' in result.output +@respx.mock +def test_agents_versions_lists_versions_with_pagination(cli_runner: CliRunner) -> None: + route = respx.get(f"{API_BASE}/agents/agent_abc/versions").mock( + return_value=httpx.Response( + 200, + json={ + "versions": [ + { + "id": "version_2", + "agent_id": "agent_abc", + "major_version": 2, + "name": "Support Bot", + "is_deployed": True, + } + ], + "next_cursor": "cursor_2", + }, + ) + ) + save_credentials(Credentials(api_key="key")) + + result = cli_runner.invoke(app, ["agents", "versions", "agent_abc", "--limit", "25", "--cursor", "cursor_1"]) + + assert result.exit_code == 0, result.output + assert "version_2" in result.output + assert "Support Bot" in result.output + assert "cursor_2" in result.output + assert route.calls[0].request.url.params["page_size"] == "25" + assert route.calls[0].request.url.params["cursor"] == "cursor_1" + + +@respx.mock +def test_agents_export_writes_version_response_as_json(cli_runner: CliRunner, tmp_path: Path) -> None: + respx.get(f"{API_BASE}/agents/agent_abc/versions/version_2").mock( + return_value=httpx.Response( + 200, + json={ + "version": { + "id": "version_2", + "agent_id": "agent_abc", + "major_version": 2, + "name": "Support Bot", + "composition": { + "complete": True, + "name": "Support Bot", + "model_name": "auto", + "system_prompt": "Be helpful.", + }, + }, + "changes": None, + }, + ) + ) + save_credentials(Credentials(api_key="key")) + output_path = tmp_path / "agent-version.json" + + result = cli_runner.invoke( + app, + ["agents", "export", "agent_abc", "version_2", "--output", str(output_path)], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert payload["version"]["id"] == "version_2" + assert payload["version"]["composition"]["system_prompt"] == "Be helpful." + + @respx.mock def test_agents_create_posts_required_fields(cli_runner: CliRunner) -> None: route = respx.post(f"{API_BASE}/agents").mock( diff --git a/tests/sdk/test_agents.py b/tests/sdk/test_agents.py index 71d7233..d2613f5 100644 --- a/tests/sdk/test_agents.py +++ b/tests/sdk/test_agents.py @@ -93,6 +93,83 @@ def test_agents_retrieve_and_update_routes(client: Gumloop) -> None: } +@respx.mock +def test_agents_list_versions_sends_pagination(client: Gumloop) -> None: + route = respx.get(f"{API_BASE}/agents/agent_123/versions").mock( + return_value=httpx.Response( + 200, + json={ + "versions": [ + { + "id": "version_2", + "agent_id": "agent_123", + "major_version": 2, + "name": "Support Agent", + } + ], + "next_cursor": "cursor_2", + }, + ) + ) + + result = client.agents.list_versions( + "agent_123", + page_size=10, + cursor="cursor_1", + team_id="team_123", + ) + + assert result.versions[0].id == "version_2" + assert result.next_cursor == "cursor_2" + assert route.calls[0].request.url.params["page_size"] == "10" + assert route.calls[0].request.url.params["cursor"] == "cursor_1" + assert route.calls[0].request.url.params["team_id"] == "team_123" + + +@respx.mock +def test_agents_get_version_returns_exportable_composition(client: Gumloop) -> None: + route = respx.get(f"{API_BASE}/agents/agent_123/versions/version_2").mock( + return_value=httpx.Response( + 200, + json={ + "version": { + "id": "version_2", + "agent_id": "agent_123", + "major_version": 2, + "name": "Support Agent", + "composition": { + "complete": True, + "schema_version": 1, + "name": "Support Agent", + "model_name": "auto", + "system_prompt": "Be helpful.", + "skill_ids": ["skill_1"], + }, + }, + "changes": { + "relationships_unknown": False, + "field_changes": [ + { + "field": "system_prompt", + "status": "modified", + "old_value": "Be concise.", + "new_value": "Be helpful.", + } + ], + }, + }, + ) + ) + + result = client.agents.get_version("agent_123", "version_2", team_id="team_123") + + assert result.version.composition.system_prompt == "Be helpful." + assert result.version.composition.skill_ids == ["skill_1"] + assert result.changes is not None + assert result.changes.field_changes[0].field == "system_prompt" + assert route.calls[0].request.url.params["team_id"] == "team_123" + + @respx.mock def test_agents_attach_skills_sends_only_attach_body(client: Gumloop) -> None: route = respx.patch(f"{API_BASE}/agents/agent_123/skills").mock( @@ -333,6 +410,39 @@ def test_async_agents_models_and_user_methods() -> None: respx.get(f"{API_BASE}/agents/agent_123").mock( return_value=httpx.Response(200, json={"agent": {"id": "agent_123", "name": "Support Agent"}}) ) + respx.get(f"{API_BASE}/agents/agent_123/versions").mock( + return_value=httpx.Response( + 200, + json={ + "versions": [ + { + "id": "version_1", + "agent_id": "agent_123", + "major_version": 1, + "name": "Support Agent", + } + ] + }, + ) + ) + respx.get(f"{API_BASE}/agents/agent_123/versions/version_1").mock( + return_value=httpx.Response( + 200, + json={ + "version": { + "id": "version_1", + "agent_id": "agent_123", + "major_version": 1, + "name": "Support Agent", + "composition": { + "complete": True, + "name": "Support Agent", + "model_name": "auto", + }, + } + }, + ) + ) respx.patch(f"{API_BASE}/agents/agent_123").mock( return_value=httpx.Response(200, json={"agent": {"id": "agent_123", "name": "Support Agent"}}) ) @@ -343,6 +453,8 @@ async def run() -> None: assert (await client.agents.list()).agents == [] assert (await client.agents.create(name="Support Agent", model_name="auto")).agent.id == "agent_123" assert (await client.agents.retrieve("agent_123")).agent.id == "agent_123" + assert (await client.agents.list_versions("agent_123")).versions[0].id == "version_1" + assert (await client.agents.get_version("agent_123", "version_1")).version.major_version == 1 assert (await client.agents.update("agent_123", model_name="auto")).agent.id == "agent_123" assert (await client.models.list()).model_groups == [] From 60d0348dd37519d4c0c5695504699bb10ade6b5b Mon Sep 17 00:00:00 2001 From: marcelo Date: Mon, 27 Jul 2026 22:11:03 +0000 Subject: [PATCH 2/4] [GMLP-9567] Align agent version change responses Model tool and resource updates through the unified field changes returned by the public API. --- src/gumloop/types.py | 8 -------- tests/sdk/test_agents.py | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/gumloop/types.py b/src/gumloop/types.py index 97319db..d386953 100644 --- a/src/gumloop/types.py +++ b/src/gumloop/types.py @@ -169,12 +169,6 @@ class AgentVersionFieldChange(_Model): new_value: Any -class AgentVersionArrayChange(_Model): - field: str - old_value: list[Any] - new_value: list[Any] - - class AgentVersionSkillChange(_Model): skill_id: str status: str @@ -190,8 +184,6 @@ class AgentVersionKnowledgeSourceChange(_Model): class AgentVersionChanges(_Model): relationships_unknown: bool field_changes: list[AgentVersionFieldChange] = Field(default_factory=list) - tools_change: AgentVersionArrayChange | None = None - resources_change: AgentVersionArrayChange | None = None skill_changes: list[AgentVersionSkillChange] = Field(default_factory=list) knowledge_source_changes: list[AgentVersionKnowledgeSourceChange] = Field(default_factory=list) diff --git a/tests/sdk/test_agents.py b/tests/sdk/test_agents.py index d2613f5..519bed3 100644 --- a/tests/sdk/test_agents.py +++ b/tests/sdk/test_agents.py @@ -154,6 +154,21 @@ def test_agents_get_version_returns_exportable_composition(client: Gumloop) -> N "status": "modified", "old_value": "Be concise.", "new_value": "Be helpful.", + }, + { + "field": "tools", + "status": "changed", + "old_value": [{"type": "mcp_server", "name": "Old MCP"}], + "new_value": [{"type": "mcp_server", "name": "New MCP"}], + }, + ], + "skill_changes": [{"skill_id": "skill_1", "status": "added"}], + "knowledge_source_changes": [ + { + "connector_id": "connector_1", + "status": "changed", + "old_config": None, + "new_config": {"folder_ids": ["folder_1"]}, } ], }, @@ -167,6 +182,10 @@ def test_agents_get_version_returns_exportable_composition(client: Gumloop) -> N assert result.version.composition.skill_ids == ["skill_1"] assert result.changes is not None assert result.changes.field_changes[0].field == "system_prompt" + assert result.changes.field_changes[1].field == "tools" + assert result.changes.field_changes[1].new_value == [{"type": "mcp_server", "name": "New MCP"}] + assert result.changes.skill_changes[0].status == "added" + assert result.changes.knowledge_source_changes[0].status == "changed" assert route.calls[0].request.url.params["team_id"] == "team_123" From c32b2798b2070159533555ea1e38f92b9280d0ee Mon Sep 17 00:00:00 2001 From: marcelo Date: Tue, 28 Jul 2026 00:08:11 +0000 Subject: [PATCH 3/4] [GMLP-9677] Align SDK version diff models Represent granular agent version changes in the Python SDK so CLI and API consumers receive the updated contract. --- src/gumloop/types.py | 32 +++++++++++++++++++-- tests/sdk/test_agents.py | 61 ++++++++++++++++++++++++++++++++-------- 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/src/gumloop/types.py b/src/gumloop/types.py index d386953..74957c9 100644 --- a/src/gumloop/types.py +++ b/src/gumloop/types.py @@ -162,6 +162,15 @@ class AgentVersion(AgentVersionSummary): composition: AgentVersionComposition +class AgentVersionTextHunk(_Model): + old_start: int + old_end: int + new_start: int + new_end: int + old_text: str + new_text: str + + class AgentVersionFieldChange(_Model): field: str status: str @@ -169,6 +178,22 @@ class AgentVersionFieldChange(_Model): new_value: Any +class AgentVersionTextFieldChange(_Model): + field: str + status: str + text_hunks: list[AgentVersionTextHunk] + + +class AgentVersionCollectionItemChange(_Model): + identity: dict[str, Any] + status: str + old_position: int | None = None + new_position: int | None = None + old_value: Any = None + new_value: Any = None + field_changes: list[AgentVersionFieldChange] = Field(default_factory=list) + + class AgentVersionSkillChange(_Model): skill_id: str status: str @@ -179,11 +204,14 @@ class AgentVersionKnowledgeSourceChange(_Model): status: str old_config: Any new_config: Any + field_changes: list[AgentVersionFieldChange] = Field(default_factory=list) class AgentVersionChanges(_Model): - relationships_unknown: bool - field_changes: list[AgentVersionFieldChange] = Field(default_factory=list) + base_version_id: str + attachment_changes_complete: bool + field_changes: list[AgentVersionTextFieldChange | AgentVersionFieldChange] = Field(default_factory=list) + tool_changes: list[AgentVersionCollectionItemChange] = Field(default_factory=list) skill_changes: list[AgentVersionSkillChange] = Field(default_factory=list) knowledge_source_changes: list[AgentVersionKnowledgeSourceChange] = Field(default_factory=list) diff --git a/tests/sdk/test_agents.py b/tests/sdk/test_agents.py index 519bed3..726c5d8 100644 --- a/tests/sdk/test_agents.py +++ b/tests/sdk/test_agents.py @@ -7,6 +7,7 @@ from gumloop import AsyncGumloop from gumloop import Gumloop +from gumloop.types import AgentVersionTextFieldChange from tests.sdk.helpers import API_BASE from tests.sdk.helpers import request_json @@ -147,21 +148,46 @@ def test_agents_get_version_returns_exportable_composition(client: Gumloop) -> N }, }, "changes": { - "relationships_unknown": False, + "base_version_id": "version_1", + "attachment_changes_complete": True, "field_changes": [ { - "field": "system_prompt", - "status": "modified", - "old_value": "Be concise.", - "new_value": "Be helpful.", + "field": "model_name", + "status": "changed", + "old_value": "gpt-4", + "new_value": "auto", }, { - "field": "tools", + "field": "system_prompt", "status": "changed", - "old_value": [{"type": "mcp_server", "name": "Old MCP"}], - "new_value": [{"type": "mcp_server", "name": "New MCP"}], + "text_hunks": [ + { + "old_start": 3, + "old_end": 10, + "new_start": 3, + "new_end": 10, + "old_text": "concise", + "new_text": "helpful", + } + ], }, ], + "tool_changes": [ + { + "identity": {"type": "mcp_server", "server_id": "server_1"}, + "status": "changed", + "old_position": 0, + "new_position": 0, + "field_changes": [ + { + "field": "approval_mode", + "status": "changed", + "old_value": "all", + "new_value": "off", + } + ], + } + ], "skill_changes": [{"skill_id": "skill_1", "status": "added"}], "knowledge_source_changes": [ { @@ -169,6 +195,14 @@ def test_agents_get_version_returns_exportable_composition(client: Gumloop) -> N "status": "changed", "old_config": None, "new_config": {"folder_ids": ["folder_1"]}, + "field_changes": [ + { + "field": "config", + "status": "changed", + "old_value": None, + "new_value": {"folder_ids": ["folder_1"]}, + } + ], } ], }, @@ -181,11 +215,16 @@ def test_agents_get_version_returns_exportable_composition(client: Gumloop) -> N assert result.version.composition.system_prompt == "Be helpful." assert result.version.composition.skill_ids == ["skill_1"] assert result.changes is not None - assert result.changes.field_changes[0].field == "system_prompt" - assert result.changes.field_changes[1].field == "tools" - assert result.changes.field_changes[1].new_value == [{"type": "mcp_server", "name": "New MCP"}] + assert result.changes.base_version_id == "version_1" + assert result.changes.field_changes[0].field == "model_name" + prompt_change = result.changes.field_changes[1] + assert isinstance(prompt_change, AgentVersionTextFieldChange) + assert prompt_change.text_hunks[0].new_text == "helpful" + assert result.changes.tool_changes[0].identity["server_id"] == "server_1" + assert result.changes.tool_changes[0].field_changes[0].field == "approval_mode" assert result.changes.skill_changes[0].status == "added" assert result.changes.knowledge_source_changes[0].status == "changed" + assert result.changes.knowledge_source_changes[0].field_changes[0].field == "config" assert route.calls[0].request.url.params["team_id"] == "team_123" From 7b5d4e31841f2e09485aeea05ae50b9d0937199b Mon Sep 17 00:00:00 2001 From: marcelo Date: Tue, 28 Jul 2026 19:16:43 +0000 Subject: [PATCH 4/4] [GMLP-9677] Bump package version to 0.4.7 --- src/gumloop/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gumloop/_version.py b/src/gumloop/_version.py index a1246f1..398a7a5 100644 --- a/src/gumloop/_version.py +++ b/src/gumloop/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "0.4.6" +__version__ = "0.4.7"