Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
enable-cache: true
- run: uv sync --frozen
- run: uv run --frozen ruff format --check .
- run: uv run --frozen ruff check .
- run: uv lock --check
- name: Check install.sh syntax
run: |
sh -n scripts/install.sh
bash -n scripts/install.sh

typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
enable-cache: true
- run: uv sync --frozen
- run: uv run --frozen pyright

test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
- run: uv sync --frozen
- run: uv run --frozen pytest -m "not live" -n auto
19 changes: 5 additions & 14 deletions src/gumloop/_gumcp_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def gumcp_env_ready() -> bool:

def _import_async_client() -> Any:
try:
from gumcp_client import AsyncClient
from gumcp_client import AsyncClient # type: ignore[import-not-found]
except ImportError as exc:
raise GumloopError(
"GUMCP_ACCESS_TOKEN is set but gumcp_client is not installed. "
Expand Down Expand Up @@ -164,9 +164,7 @@ def _map_exception(exc: BaseException, *, ref: str, server_id: str, tool_name: s
tool_name=tool_name,
status="error",
code="mcp_server_connection_failed",
message=(
"MCP server connection failed. Check the MCP server URL, authentication, and credentials."
),
message=("MCP server connection failed. Check the MCP server URL, authentication, and credentials."),
error_type="api_error",
details={"server_id": server_id},
)
Expand All @@ -181,8 +179,7 @@ def _map_exception(exc: BaseException, *, ref: str, server_id: str, tool_name: s
status="error",
code="mcp_server_http_error",
message=(
f"MCP server returned HTTP {http_status}. "
"Check the MCP server URL, authentication, and credentials."
f"MCP server returned HTTP {http_status}. Check the MCP server URL, authentication, and credentials."
),
error_type=_api_error_type(http_status),
details={"server_id": server_id, "status_code": http_status},
Expand All @@ -197,9 +194,7 @@ def _map_exception(exc: BaseException, *, ref: str, server_id: str, tool_name: s
tool_name=tool_name,
status="error",
code="mcp_server_connection_failed",
message=(
"MCP server connection failed. Check the MCP server URL, authentication, and credentials."
),
message=("MCP server connection failed. Check the MCP server URL, authentication, and credentials."),
error_type="api_error",
details={"server_id": server_id},
)
Expand Down Expand Up @@ -309,11 +304,7 @@ async def call_one(
return _map_exception(exc, ref=result_ref, server_id=server_id, tool_name=tool_name)
except Exception as exc:
result = _map_exception(exc, ref=result_ref, server_id=server_id, tool_name=tool_name)
if (
not _retried
and result.status == "unauthenticated"
and self._current_fingerprint() != self._fingerprint
):
if not _retried and result.status == "unauthenticated" and self._current_fingerprint() != self._fingerprint:
# Env token rotated mid-flight: rebuild once and retry.
await self._close_client()
return await self.call_one(
Expand Down
7 changes: 2 additions & 5 deletions src/gumloop/cli/commands/_sync_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,7 @@ def print_enrollment(
table.add_column("Coding agents")

console.print()
console.print(
f"Enrolled in [cyan]{escape_markup(plan.organization.organization_name)}[/cyan]"
)
console.print(f"Enrolled in [cyan]{escape_markup(plan.organization.organization_name)}[/cyan]")
if not targets:
table.add_row(
Text("—"),
Expand Down Expand Up @@ -278,8 +276,7 @@ def _print_changes(self, execution: SyncExecution, *, verbose: bool) -> None:
visible = [
change
for change in changes
if isinstance(change, dict)
and self._change_is_visible(change, verbose=verbose)
if isinstance(change, dict) and self._change_is_visible(change, verbose=verbose)
]
if not visible:
return
Expand Down
14 changes: 6 additions & 8 deletions src/gumloop/cli/commands/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

import json
import sys
from collections.abc import Iterator
from pathlib import Path
from typing import Annotated
from typing import Any
from typing import Iterator

import typer

Expand Down Expand Up @@ -112,7 +112,9 @@ def create_completion(
] = False,
json_output: Annotated[
bool,
typer.Option("--json", help="Print the response as JSON. Streaming + --json emits ndjson (one chunk per line)."),
typer.Option(
"--json", help="Print the response as JSON. Streaming + --json emits ndjson (one chunk per line)."
),
] = False,
) -> None:
"""Create a chat completion."""
Expand All @@ -134,9 +136,7 @@ def create_completion(
if not user_message:
raise GumloopError("Pass a PROMPT or --message-stdin - with text to send.")

messages: list[dict[str, Any]] = [
{"role": "system", "content": s} for s in (system or [])
]
messages: list[dict[str, Any]] = [{"role": "system", "content": s} for s in (system or [])]
messages.append({"role": "user", "content": user_message})

# Stream resolution: explicit flags always win; --json without --stream
Expand Down Expand Up @@ -174,9 +174,7 @@ def create_completion(
console.print() # trailing newline to separate the prompt that follows
return

result = cli.call_with_refresh(
lambda client: client.chat.completions.create(**kwargs)
)
result = cli.call_with_refresh(lambda client: client.chat.completions.create(**kwargs))
except GumloopError as error:
exit_with_error(error, json_output=json_output)

Expand Down
13 changes: 3 additions & 10 deletions src/gumloop/cli/commands/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,7 @@ def _prepare_persistent_sync(
scheduler = scheduler_for_current_platform(home=home)
if non_interactive:
background: dict[str, object] = {
"enabled": scheduler.is_current(
Path(config.scheduler_gumloop_path)
),
"enabled": scheduler.is_current(Path(config.scheduler_gumloop_path)),
"interval_seconds": SYNC_INTERVAL_SECONDS,
"scheduler": "launch_agent",
}
Expand All @@ -172,20 +170,15 @@ def _prepare_persistent_sync(

executable_path = resolve_gumloop_executable()
scheduler.validate(executable_path)
was_current = (
config.scheduler_gumloop_path == str(executable_path)
and scheduler.is_current(executable_path)
)
was_current = config.scheduler_gumloop_path == str(executable_path) and scheduler.is_current(executable_path)
scheduler.install(executable_path)
background = {
"enabled": True,
"interval_seconds": SYNC_INTERVAL_SECONDS,
"scheduler": "launch_agent",
}
if config.scheduler_gumloop_path != str(executable_path):
config = config.model_copy(
update={"scheduler_gumloop_path": str(executable_path)}
)
config = config.model_copy(update={"scheduler_gumloop_path": str(executable_path)})
write_config(config, home)
if not json_output and not was_current:
output.print_background_status("repaired")
Expand Down
4 changes: 1 addition & 3 deletions src/gumloop/resources/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,7 @@ def detach_skills(self, agent_id: str, skill_ids: str | Sequence[str]) -> AgentS
)

def list_skills(self, agent_id: str, **kwargs: Any) -> SkillListResponse:
return SkillListResponse.model_validate(
self._client.get("skills", params={"agent_id": agent_id, **kwargs})
)
return SkillListResponse.model_validate(self._client.get("skills", params={"agent_id": agent_id, **kwargs}))

def attach_mcp_server(self, agent_id: str, server_id: str, **config: Any) -> AgentMcpServerResponse:
"""Attach an MCP server, or update its config if already attached (idempotent upsert)."""
Expand Down
6 changes: 3 additions & 3 deletions src/gumloop/spec/_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@
from __future__ import annotations

from typing import Any
from typing import Optional

from openrouter.components import ChatAssistantImages
from openrouter.components import ChatStreamChoice as _ChatStreamChoice
from openrouter.components import ChatStreamChunk as _ChatStreamChunk
from openrouter.components import ChatStreamDelta as _ChatStreamDelta
from openrouter.components import ChatUsage as _ChatUsage
from openrouter.types import UNSET, OptionalNullable
from openrouter.types import UNSET
from openrouter.types import OptionalNullable


class ChatUsage(_ChatUsage):
Expand Down Expand Up @@ -53,5 +53,5 @@ class ChatStreamChunk(_ChatStreamChunk):
# Re-annotate so validation constructs our extended ChatUsage and
# ChatStreamChoice. Pydantic resolves by annotation, not isinstance —
# without this, parent annotations would drop the added fields.
usage: Optional[ChatUsage] = None # type: ignore[assignment]
usage: ChatUsage | None = None # type: ignore[assignment]
choices: list[ChatStreamChoice] # type: ignore[assignment]
3 changes: 1 addition & 2 deletions src/gumloop/sync/installed.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,7 @@ def scan_target(
marker_read=marker_read,
content_hash=(
directory_content_hash(resolved)
if resolved.is_dir()
and (marker_read.status == "valid" or entry.name in hash_unmanaged_names)
if resolved.is_dir() and (marker_read.status == "valid" or entry.name in hash_unmanaged_names)
else None
),
shared_symlink=True,
Expand Down
5 changes: 2 additions & 3 deletions src/gumloop/sync/target_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,9 +284,8 @@ def _operation_entries(
) -> tuple[Path, ...]:
entries = tuple(sorted(root.iterdir(), key=lambda path: path.name))
for entry in entries:
valid_name = (
(install_names and is_safe_install_name(entry.name))
or (prepared_names and entry.name.startswith("skill-"))
valid_name = (install_names and is_safe_install_name(entry.name)) or (
prepared_names and entry.name.startswith("skill-")
)
if not valid_name or entry.is_symlink() or not entry.is_dir():
raise SyncError("target_failed", f"The Gumloop workspace contains an invalid operation entry: {entry}")
Expand Down
8 changes: 2 additions & 6 deletions tests/cli/test_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,15 +152,11 @@ def test_agents_detach_skills_command(cli_runner: CliRunner) -> None:
@respx.mock
def test_agents_attach_mcp_server_command(cli_runner: CliRunner) -> None:
route = respx.put(f"{API_BASE}/agents/agent_abc/mcp-servers/gmail").mock(
return_value=httpx.Response(
200, json={"agent_id": "agent_abc", "created": True, "auth_status": "connected"}
)
return_value=httpx.Response(200, json={"agent_id": "agent_abc", "created": True, "auth_status": "connected"})
)
save_credentials(Credentials(api_key="key"))

result = cli_runner.invoke(
app, ["agents", "attach-mcp-server", "agent_abc", "gmail", "--approval-mode", "off"]
)
result = cli_runner.invoke(app, ["agents", "attach-mcp-server", "agent_abc", "gmail", "--approval-mode", "off"])

assert result.exit_code == 0, result.output
assert json.loads(route.calls[0].request.content) == {"approval_mode": "off"}
Expand Down
Loading