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" 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..74957c9 100644 --- a/src/gumloop/types.py +++ b/src/gumloop/types.py @@ -129,6 +129,103 @@ 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 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 + old_value: Any + 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 + + +class AgentVersionKnowledgeSourceChange(_Model): + connector_id: str + status: str + old_config: Any + new_config: Any + field_changes: list[AgentVersionFieldChange] = Field(default_factory=list) + + +class AgentVersionChanges(_Model): + 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) + + +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..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 @@ -93,6 +94,140 @@ 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": { + "base_version_id": "version_1", + "attachment_changes_complete": True, + "field_changes": [ + { + "field": "model_name", + "status": "changed", + "old_value": "gpt-4", + "new_value": "auto", + }, + { + "field": "system_prompt", + "status": "changed", + "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": [ + { + "connector_id": "connector_1", + "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"]}, + } + ], + } + ], + }, + }, + ) + ) + + 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.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" + + @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 +468,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 +511,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 == []