From fcfa1dd71e59ecaddf1c40bbb63c7c96c91c6cac Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Thu, 12 Mar 2026 14:59:19 +0000 Subject: [PATCH 1/4] fix(backend,runner): implement GitHub token expiry tracking and refresh GitHub App installation tokens expire after ~1 hour but the runner had no expiry tracking. The backend now returns expiresAt in the credential response, and the runner proactively refreshes tokens before they expire (5 min buffer). Backend changes: - GetGitHubToken returns (token, expiresAt, error) instead of (token, error) - runtime_credentials.go includes expiresAt in GitHub credential response - WrapGitHubTokenForRepo updated for new signature Runner changes: - auth.py tracks GitHub token expiry from backend response - bridge.py checks token expiry in _refresh_credentials_if_stale() Fixes: RHOAIENG-52858 Co-Authored-By: Claude Opus 4.6 --- components/backend/git/operations.go | 27 +++++++++------- components/backend/handlers/github_auth.go | 8 +++-- .../backend/handlers/operations_test.go | 2 +- .../backend/handlers/runtime_credentials.go | 10 ++++-- .../ambient-runner/ambient_runner/bridge.py | 24 +++++++++----- .../ambient_runner/platform/auth.py | 31 +++++++++++++++++-- 6 files changed, 73 insertions(+), 29 deletions(-) mode change 100644 => 100755 components/backend/git/operations.go mode change 100644 => 100755 components/backend/handlers/github_auth.go mode change 100644 => 100755 components/backend/handlers/operations_test.go mode change 100644 => 100755 components/backend/handlers/runtime_credentials.go mode change 100644 => 100755 components/runners/ambient-runner/ambient_runner/bridge.py mode change 100644 => 100755 components/runners/ambient-runner/ambient_runner/platform/auth.py diff --git a/components/backend/git/operations.go b/components/backend/git/operations.go old mode 100644 new mode 100755 index 85a08649d..956da3f5d --- a/components/backend/git/operations.go +++ b/components/backend/git/operations.go @@ -54,7 +54,7 @@ type DiffSummary struct { // 1. User's Personal Access Token (cluster-level, highest priority) // 2. GitHub App installation token (cluster-level) // 3. Project-level GITHUB_TOKEN (legacy fallback) -func GetGitHubToken(ctx context.Context, k8sClient *kubernetes.Clientset, dynClient dynamic.Interface, project, userID string) (string, error) { +func GetGitHubToken(ctx context.Context, k8sClient *kubernetes.Clientset, dynClient dynamic.Interface, project, userID string) (string, time.Time, error) { // Priority 1: Check for user's GitHub PAT (cluster-level) if GetGitHubPATCredentials != nil { patCreds, err := GetGitHubPATCredentials(ctx, userID) @@ -66,7 +66,8 @@ func GetGitHubToken(ctx context.Context, k8sClient *kubernetes.Clientset, dynCli token := pat.GetToken() if token != "" { log.Printf("Using GitHub PAT for user %s (overrides GitHub App)", userID) - return token, nil + // PATs don't expire on a short schedule; return zero time + return token, time.Time{}, nil } } } @@ -88,10 +89,10 @@ func GetGitHubToken(ctx context.Context, k8sClient *kubernetes.Clientset, dynCli if inst, ok := installation.(githubInstallation); ok { if mgr, ok := GitHubTokenManager.(tokenManager); ok { - token, _, err := mgr.MintInstallationTokenForHost(ctx, inst.GetInstallationID(), inst.GetHost()) + token, expiresAt, err := mgr.MintInstallationTokenForHost(ctx, inst.GetInstallationID(), inst.GetHost()) if err == nil && token != "" { - log.Printf("Using GitHub App token for user %s", userID) - return token, nil + log.Printf("Using GitHub App token for user %s (expires %s)", userID, expiresAt.Format(time.RFC3339)) + return token, expiresAt, nil } log.Printf("Failed to mint GitHub App token for user %s: %v", userID, err) } @@ -102,7 +103,7 @@ func GetGitHubToken(ctx context.Context, k8sClient *kubernetes.Clientset, dynCli // Priority 3: Fall back to project integration secret GITHUB_TOKEN (legacy, deprecated) if k8sClient == nil { log.Printf("Cannot read integration secret: k8s client is nil") - return "", fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page") + return "", time.Time{}, fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page") } const secretName = "ambient-non-vertex-integrations" @@ -112,29 +113,30 @@ func GetGitHubToken(ctx context.Context, k8sClient *kubernetes.Clientset, dynCli secret, err := k8sClient.CoreV1().Secrets(project).Get(ctx, secretName, v1.GetOptions{}) if err != nil { log.Printf("Failed to get integration secret %s/%s: %v", project, secretName, err) - return "", fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page") + return "", time.Time{}, fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page") } if secret.Data == nil { log.Printf("Secret %s/%s exists but Data is nil", project, secretName) - return "", fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page") + return "", time.Time{}, fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page") } token, ok := secret.Data["GITHUB_TOKEN"] if !ok { log.Printf("Secret %s/%s exists but has no GITHUB_TOKEN key (available keys: %v)", project, secretName, getSecretKeys(secret.Data)) - return "", fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page") + return "", time.Time{}, fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page") } if len(token) == 0 { log.Printf("Secret %s/%s has GITHUB_TOKEN key but value is empty", project, secretName) - return "", fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page") + return "", time.Time{}, fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page") } // Trim whitespace and newlines from token (common issue when copying from web UI) cleanToken := strings.TrimSpace(string(token)) log.Printf("Using GITHUB_TOKEN from integration secret %s/%s (length=%d, legacy fallback)", project, secretName, len(cleanToken)) - return cleanToken, nil + // Legacy PATs don't have known expiry; return zero time + return cleanToken, time.Time{}, nil } // GetGitLabToken retrieves a GitLab Personal Access Token for a user @@ -197,7 +199,8 @@ func GetGitToken(ctx context.Context, k8sClient *kubernetes.Clientset, dynClient switch provider { case types.ProviderGitHub: - return GetGitHubToken(ctx, k8sClient, dynClient, project, userID) + token, _, err := GetGitHubToken(ctx, k8sClient, dynClient, project, userID) + return token, err case types.ProviderGitLab: return GetGitLabToken(ctx, k8sClient, project, userID) default: diff --git a/components/backend/handlers/github_auth.go b/components/backend/handlers/github_auth.go old mode 100644 new mode 100755 index 7a72c3e88..0eab59362 --- a/components/backend/handlers/github_auth.go +++ b/components/backend/handlers/github_auth.go @@ -43,8 +43,9 @@ var ( ) // WrapGitHubTokenForRepo wraps git.GetGitHubToken to accept kubernetes.Interface instead of *kubernetes.Clientset -// This allows dependency injection while maintaining compatibility with git.GetGitHubToken -func WrapGitHubTokenForRepo(originalFunc func(context.Context, *kubernetes.Clientset, dynamic.Interface, string, string) (string, error)) func(context.Context, kubernetes.Interface, dynamic.Interface, string, string) (string, error) { +// This allows dependency injection while maintaining compatibility with git.GetGitHubToken. +// The expiresAt return value is discarded since callers only need the token string. +func WrapGitHubTokenForRepo(originalFunc func(context.Context, *kubernetes.Clientset, dynamic.Interface, string, string) (string, time.Time, error)) func(context.Context, kubernetes.Interface, dynamic.Interface, string, string) (string, error) { return func(ctx context.Context, k8s kubernetes.Interface, dyn dynamic.Interface, project, userID string) (string, error) { // Type assert to *kubernetes.Clientset for git.GetGitHubToken var k8sClient *kubernetes.Clientset @@ -55,7 +56,8 @@ func WrapGitHubTokenForRepo(originalFunc func(context.Context, *kubernetes.Clien return "", fmt.Errorf("kubernetes client is not a *Clientset (got %T)", k8s) } } - return originalFunc(ctx, k8sClient, dyn, project, userID) + token, _, err := originalFunc(ctx, k8sClient, dyn, project, userID) + return token, err } } diff --git a/components/backend/handlers/operations_test.go b/components/backend/handlers/operations_test.go old mode 100644 new mode 100755 index 20167864a..1e87974fd --- a/components/backend/handlers/operations_test.go +++ b/components/backend/handlers/operations_test.go @@ -40,7 +40,7 @@ var _ = Describe("Git Operations", Label(test_constants.LabelUnit, test_constant // Act k8sClient := k8sUtils.K8sClient clientset, _ := k8sClient.(*kubernetes.Clientset) - token, err := git.GetGitHubToken(ctx, clientset, k8sUtils.DynamicClient, projectName, userID) + token, _, err := git.GetGitHubToken(ctx, clientset, k8sUtils.DynamicClient, projectName, userID) // Assert - function should return error for missing/invalid setup Expect(err).To(HaveOccurred(), "Should return error for missing token/secret") diff --git a/components/backend/handlers/runtime_credentials.go b/components/backend/handlers/runtime_credentials.go old mode 100644 new mode 100755 index 8f4db2a33..12768b853 --- a/components/backend/handlers/runtime_credentials.go +++ b/components/backend/handlers/runtime_credentials.go @@ -78,7 +78,7 @@ func GetGitHubTokenForSession(c *gin.Context) { return } - token, err := git.GetGitHubToken(c.Request.Context(), k8sClientset, DynamicClient, project, userID) + token, expiresAt, err := git.GetGitHubToken(c.Request.Context(), k8sClientset, DynamicClient, project, userID) if err != nil { log.Printf("Failed to get GitHub token for user %s: %v", userID, err) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) @@ -92,12 +92,16 @@ func GetGitHubTokenForSession(c *gin.Context) { log.Printf("Returning GitHub credentials with identity for session %s/%s", project, session) } - c.JSON(http.StatusOK, gin.H{ + resp := gin.H{ "token": token, "userName": userName, "email": userEmail, "provider": "github", - }) + } + if !expiresAt.IsZero() { + resp["expiresAt"] = expiresAt.Format(time.RFC3339) + } + c.JSON(http.StatusOK, resp) } // GetGoogleCredentialsForSession handles GET /api/projects/:project/agentic-sessions/:session/credentials/google diff --git a/components/runners/ambient-runner/ambient_runner/bridge.py b/components/runners/ambient-runner/ambient_runner/bridge.py old mode 100644 new mode 100755 index 4a5762473..28855bf40 --- a/components/runners/ambient-runner/ambient_runner/bridge.py +++ b/components/runners/ambient-runner/ambient_runner/bridge.py @@ -56,17 +56,21 @@ def _async_safe_manager_shutdown(manager: Any) -> None: loop = asyncio.get_running_loop() task = loop.create_task(manager.shutdown()) task.add_done_callback( - lambda f: _bridge_logger.warning( - "mark_dirty: session_manager shutdown error: %s", f.exception() + lambda f: ( + _bridge_logger.warning( + "mark_dirty: session_manager shutdown error: %s", f.exception() + ) + if f.exception() + else None ) - if f.exception() - else None ) except RuntimeError: try: asyncio.run(manager.shutdown()) except Exception as exc: - _bridge_logger.warning("mark_dirty: session_manager shutdown error: %s", exc) + _bridge_logger.warning( + "mark_dirty: session_manager shutdown error: %s", exc + ) @dataclass @@ -151,12 +155,18 @@ def set_context(self, context: RunnerContext) -> None: self._context = context async def _refresh_credentials_if_stale(self) -> None: - """Refresh platform credentials if the refresh interval has elapsed. + """Refresh platform credentials if the refresh interval has elapsed + or if the GitHub token is expiring soon. Call this at the start of each ``run()`` to keep tokens fresh. """ now = time.monotonic() - if now - self._last_creds_refresh > CREDS_REFRESH_INTERVAL_SEC: + needs_refresh = now - self._last_creds_refresh > CREDS_REFRESH_INTERVAL_SEC + if not needs_refresh: + from ambient_runner.platform.auth import github_token_expiring_soon + + needs_refresh = github_token_expiring_soon() + if needs_refresh: from ambient_runner.platform.auth import populate_runtime_credentials await populate_runtime_credentials(self._context) diff --git a/components/runners/ambient-runner/ambient_runner/platform/auth.py b/components/runners/ambient-runner/ambient_runner/platform/auth.py old mode 100644 new mode 100755 index 005989caf..709fd58bf --- a/components/runners/ambient-runner/ambient_runner/platform/auth.py +++ b/components/runners/ambient-runner/ambient_runner/platform/auth.py @@ -10,6 +10,8 @@ import logging import os import re +import time +from datetime import datetime from pathlib import Path from urllib import request as _urllib_request from urllib.parse import urlparse @@ -21,6 +23,12 @@ # Placeholder email used by the platform when no real email is available. _PLACEHOLDER_EMAIL = "user@example.com" +# Tracks credential expiry timestamps (epoch seconds) by provider name. +_credential_expiry: dict[str, float] = {} + +# How many seconds before expiry to trigger a proactive refresh. +_EXPIRY_BUFFER_SEC = 5 * 60 + # --------------------------------------------------------------------------- # Vertex AI credential validation (shared across all bridges) @@ -127,7 +135,7 @@ def _do_req(): async def fetch_github_credentials(context: RunnerContext) -> dict: """Fetch GitHub credentials from backend API (always fresh — PAT or minted App token). - Returns dict with: token, userName, email, provider + Returns dict with: token, userName, email, provider, and optionally expiresAt """ data = await _fetch_credential(context, "github") if data.get("token"): @@ -135,6 +143,16 @@ async def fetch_github_credentials(context: RunnerContext) -> dict: f"Using fresh GitHub credentials from backend " f"(user: {data.get('userName', 'unknown')}, hasEmail: {bool(data.get('email'))})" ) + if data.get("expiresAt"): + try: + exp_dt = datetime.fromisoformat(data["expiresAt"]) + _credential_expiry["github"] = exp_dt.timestamp() + logger.info(f"GitHub token expires at {data['expiresAt']}") + except (ValueError, TypeError) as e: + logger.warning(f"Failed to parse GitHub expiresAt: {e}") + else: + # PAT or legacy token without expiry — clear any stale tracking + _credential_expiry.pop("github", None) return data @@ -144,6 +162,14 @@ async def fetch_github_token(context: RunnerContext) -> str: return data.get("token", "") +def github_token_expiring_soon() -> bool: + """Return True if the cached GitHub token will expire within the buffer window.""" + expiry = _credential_expiry.get("github") + if not expiry: + return False + return time.time() > expiry - _EXPIRY_BUFFER_SEC + + async def fetch_google_credentials(context: RunnerContext) -> dict: """Fetch Google OAuth credentials from backend API.""" data = await _fetch_credential(context, "google") @@ -329,8 +355,7 @@ async def populate_mcp_server_credentials(context: RunnerContext) -> None: # Check if any env value references ${MCP_*} pattern needs_creds = any( - isinstance(v, str) and mcp_env_pattern.search(v) - for v in env_block.values() + isinstance(v, str) and mcp_env_pattern.search(v) for v in env_block.values() ) if not needs_creds: continue From a1c86524936692f28bead48bf6a577e294720080 Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Thu, 12 Mar 2026 19:12:28 +0000 Subject: [PATCH 2/4] fix(backend,runner): remove token cache and fix restart_session NoneType Remove the 3-minute GitHub App token cache so refresh_credentials always mints fresh tokens. Fix restart_session tool crash by passing bridge instance instead of None for deferred adapter access. Fixes: RHOAIENG-52858 Co-Authored-By: Claude Opus 4.6 --- components/backend/github/token.go | 25 ------------------- .../ambient_runner/bridges/claude/bridge.py | 4 ++- .../ambient_runner/bridges/claude/mcp.py | 10 ++++++-- .../ambient_runner/bridges/claude/tools.py | 21 +++++++++++++--- 4 files changed, 28 insertions(+), 32 deletions(-) diff --git a/components/backend/github/token.go b/components/backend/github/token.go index e31b37584..28ac35e58 100644 --- a/components/backend/github/token.go +++ b/components/backend/github/token.go @@ -13,7 +13,6 @@ import ( "net/http" "os" "strings" - "sync" "time" "github.com/golang-jwt/jwt/v5" @@ -23,13 +22,6 @@ import ( type TokenManager struct { AppID string PrivateKey *rsa.PrivateKey - cacheMu *sync.Mutex - cache map[int64]cachedInstallationToken -} - -type cachedInstallationToken struct { - token string - expiresAt time.Time } // NewTokenManager creates a new token manager @@ -62,8 +54,6 @@ func NewTokenManager() (*TokenManager, error) { return &TokenManager{ AppID: appID, PrivateKey: privateKey, - cacheMu: &sync.Mutex{}, - cache: map[int64]cachedInstallationToken{}, }, nil } @@ -117,18 +107,6 @@ func (m *TokenManager) MintInstallationTokenForHost(ctx context.Context, install if m == nil { return "", time.Time{}, fmt.Errorf("GitHub App not configured") } - // Serve from cache if still valid (>3 minutes left) - m.cacheMu.Lock() - if entry, ok := m.cache[installationID]; ok { - if time.Until(entry.expiresAt) > 3*time.Minute { - token := entry.token - exp := entry.expiresAt - m.cacheMu.Unlock() - return token, exp, nil - } - } - m.cacheMu.Unlock() - jwtToken, err := m.GenerateJWT() if err != nil { return "", time.Time{}, fmt.Errorf("failed to generate JWT: %w", err) @@ -163,9 +141,6 @@ func (m *TokenManager) MintInstallationTokenForHost(ctx context.Context, install if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { return "", time.Time{}, fmt.Errorf("failed to parse token response: %w", err) } - m.cacheMu.Lock() - m.cache[installationID] = cachedInstallationToken{token: parsed.Token, expiresAt: parsed.ExpiresAt} - m.cacheMu.Unlock() return parsed.Token, parsed.ExpiresAt, nil } diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py index c1702cc6f..3e4da78b7 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py @@ -336,7 +336,9 @@ async def _setup_platform(self) -> None: log_auth_status, ) - mcp_servers = build_mcp_servers(self._context, cwd_path, self._obs) + mcp_servers = build_mcp_servers( + self._context, cwd_path, self._obs, bridge_ref=self + ) log_auth_status(mcp_servers) allowed_tools = build_allowed_tools(mcp_servers) diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py index 431645cde..d3927be1b 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py @@ -30,13 +30,19 @@ ] -def build_mcp_servers(context: RunnerContext, cwd_path: str, obs: Any = None) -> dict: +def build_mcp_servers( + context: RunnerContext, + cwd_path: str, + obs: Any = None, + bridge_ref: Any = None, +) -> dict: """Build the full MCP server config dict including platform tools. Args: context: Runner context. cwd_path: Working directory (used to find rubric files). obs: Optional ObservabilityManager (passed to rubric tool). + bridge_ref: Reference to the bridge instance (deferred adapter access). Returns: Dict of MCP server name -> server config. @@ -56,7 +62,7 @@ def build_mcp_servers(context: RunnerContext, cwd_path: str, obs: Any = None) -> mcp_servers = load_mcp_config(context, cwd_path) or {} # Session control tools - restart_tool = create_restart_session_tool(None, sdk_tool) + restart_tool = create_restart_session_tool(bridge_ref, sdk_tool) refresh_creds_tool = create_refresh_credentials_tool(context, sdk_tool) session_server = create_sdk_mcp_server( name="session", version="1.0.0", tools=[restart_tool, refresh_creds_tool] diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/tools.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/tools.py index d41561696..1bbf6b644 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/tools.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/tools.py @@ -29,12 +29,13 @@ # ------------------------------------------------------------------ -def create_restart_session_tool(adapter_ref, sdk_tool_decorator): +def create_restart_session_tool(bridge_ref, sdk_tool_decorator): """Create the restart_session MCP tool. Args: - adapter_ref: Reference to the ClaudeCodeAdapter instance - (used to set _restart_requested flag). + bridge_ref: Reference to the bridge instance whose ``_adapter`` + attribute is resolved at call time (may be ``None`` during + MCP setup). sdk_tool_decorator: The ``tool`` decorator from ``claude_agent_sdk``. Returns: @@ -48,7 +49,19 @@ def create_restart_session_tool(adapter_ref, sdk_tool_decorator): ) async def restart_session_tool(args: dict) -> dict: """Tool that allows Claude to request a session restart.""" - adapter_ref._restart_requested = True + adapter = getattr(bridge_ref, "_adapter", None) if bridge_ref else None + if adapter is None: + logger.warning("restart_session called but adapter not ready") + return { + "content": [ + { + "type": "text", + "text": "Session not ready — cannot restart yet. Try again shortly.", + } + ], + "isError": True, + } + adapter._restart_requested = True logger.info("Session restart requested by Claude via MCP tool") return { "content": [ From 5584cff9dcee1640f5be2bb82fb6dd92cc78446a Mon Sep 17 00:00:00 2001 From: "ambient-code[bot]" Date: Fri, 13 Mar 2026 00:19:08 +0000 Subject: [PATCH 3/4] fix: address CodeRabbit review feedback - Fix CancelledError when calling exception() on cancelled futures in shutdown callback - Fix Python 3.10 RFC3339 parsing by replacing Z with +00:00 timezone offset - Clear stale GitHub token expiry on parse failures - Auto-format code per pre-commit hooks Co-Authored-By: Claude --- .github/workflows/e2e.yml | 2 +- components/ambient-api-server/secrets/db.port | 2 +- .../ambient-cli/cmd/acpctl/project/cmd.go | 10 +- .../ambient-runner/ambient_runner/bridge.py | 16 +-- .../bridges/claude/mock_client.py | 4 +- .../ambient_runner/bridges/claude/session.py | 6 +- .../bridges/gemini_cli/bridge.py | 4 +- .../bridges/gemini_cli/feedback_server.py | 9 +- .../bridges/gemini_cli/system_prompt.py | 4 +- .../ambient_runner/platform/auth.py | 5 +- .../scripts/capture-fixtures.py | 14 ++- .../tests/test_bridge_claude.py | 2 + .../ambient-runner/tests/test_context.py | 1 - .../ambient-runner/tests/test_git_identity.py | 98 ++++++++++++++----- .../developer/local-development/openshift.md | 8 +- 15 files changed, 128 insertions(+), 57 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 35485d866..29b1df9fe 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -49,7 +49,7 @@ jobs: runs-on: ubuntu-latest needs: [detect-changes] if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' - + timeout-minutes: 25 steps: diff --git a/components/ambient-api-server/secrets/db.port b/components/ambient-api-server/secrets/db.port index fbdcedfb7..38627a6f0 100644 --- a/components/ambient-api-server/secrets/db.port +++ b/components/ambient-api-server/secrets/db.port @@ -1 +1 @@ -5432 \ No newline at end of file +5432 diff --git a/components/ambient-cli/cmd/acpctl/project/cmd.go b/components/ambient-cli/cmd/acpctl/project/cmd.go index c8388a4df..910c4c267 100644 --- a/components/ambient-cli/cmd/acpctl/project/cmd.go +++ b/components/ambient-cli/cmd/acpctl/project/cmd.go @@ -24,13 +24,13 @@ var Cmd = &cobra.Command{ Long: `Manage projects in the Ambient Code Platform.`, Example: ` # Set current project context (shorthand) acpctl project my-project - - # Set current project context (explicit) + + # Set current project context (explicit) acpctl project set my-project - - # Get current project context + + # Get current project context acpctl project current - + # List all projects acpctl project list`, Args: cobra.MaximumNArgs(1), diff --git a/components/runners/ambient-runner/ambient_runner/bridge.py b/components/runners/ambient-runner/ambient_runner/bridge.py index 28855bf40..6dc90f609 100755 --- a/components/runners/ambient-runner/ambient_runner/bridge.py +++ b/components/runners/ambient-runner/ambient_runner/bridge.py @@ -55,15 +55,17 @@ def _async_safe_manager_shutdown(manager: Any) -> None: try: loop = asyncio.get_running_loop() task = loop.create_task(manager.shutdown()) - task.add_done_callback( - lambda f: ( + + def _log_shutdown_error(f: asyncio.Future) -> None: + if f.cancelled(): + return + exc = f.exception() + if exc is not None: _bridge_logger.warning( - "mark_dirty: session_manager shutdown error: %s", f.exception() + "mark_dirty: session_manager shutdown error: %s", exc ) - if f.exception() - else None - ) - ) + + task.add_done_callback(_log_shutdown_error) except RuntimeError: try: asyncio.run(manager.shutdown()) diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/mock_client.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/mock_client.py index 692b70d3d..23fac4a98 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/mock_client.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/mock_client.py @@ -137,9 +137,7 @@ def _deserialize(data: dict) -> Optional[Any]: if cls in (AssistantMessage, UserMessage) and "content" in data: content = data["content"] if isinstance(content, list): - data["content"] = [ - b for b in (_deserialize_block(b) for b in content) if b - ] + data["content"] = [b for b in (_deserialize_block(b) for b in content) if b] try: return cls(**data) diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/session.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/session.py index 1e581f191..b43674d35 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/session.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/session.py @@ -100,7 +100,11 @@ async def _run(self) -> None: os.environ["ANTHROPIC_API_KEY"] = self._api_key - from ambient_runner.bridges.claude.mock_client import MOCK_API_KEY, MockClaudeSDKClient + from ambient_runner.bridges.claude.mock_client import ( + MOCK_API_KEY, + MockClaudeSDKClient, + ) + if self._api_key == MOCK_API_KEY: logger.info("[SessionWorker] Using MockClaudeSDKClient (replay mode)") client: Any = MockClaudeSDKClient(options=self._options) diff --git a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/bridge.py b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/bridge.py index 77915f922..1f5e027d5 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/bridge.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/bridge.py @@ -287,7 +287,9 @@ async def _setup_platform(self) -> None: # MCP servers — write .gemini/settings.json so the CLI discovers them from ambient_runner.bridges.gemini_cli.mcp import setup_gemini_mcp - from ambient_runner.bridges.gemini_cli.system_prompt import write_gemini_system_prompt + from ambient_runner.bridges.gemini_cli.system_prompt import ( + write_gemini_system_prompt, + ) mcp_settings_path = setup_gemini_mcp(self._context, cwd_path) diff --git a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/feedback_server.py b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/feedback_server.py index 9ae9d2d18..2406685cc 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/feedback_server.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/feedback_server.py @@ -107,6 +107,7 @@ def _error(msg_id, code: int, message: str) -> None: # Tool handlers # --------------------------------------------------------------------------- + def _handle_evaluate_rubric(args: dict) -> dict: from ambient_runner.platform.feedback import log_rubric_score @@ -116,7 +117,9 @@ def _handle_evaluate_rubric(args: dict) -> dict: success, err = log_rubric_score(score=score, comment=comment, session_id=session_id) if success: - return {"content": [{"type": "text", "text": f"Score {score} logged to Langfuse."}]} + return { + "content": [{"type": "text", "text": f"Score {score} logged to Langfuse."}] + } return { "content": [{"type": "text", "text": f"Failed to log score: {err}"}], "isError": True, @@ -137,7 +140,9 @@ def _handle_log_correction(args: dict) -> dict: source=args.get("source", "human"), ) if success: - return {"content": [{"type": "text", "text": "Correction logged successfully."}]} + return { + "content": [{"type": "text", "text": "Correction logged successfully."}] + } return { "content": [{"type": "text", "text": f"Failed to log correction: {err}"}], "isError": True, diff --git a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py index 23c0406a9..e696f1a7f 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py @@ -214,7 +214,9 @@ def _build_system_prompt(cwd_path: str) -> str: if active_workflow_url: ambient_config = load_ambient_config(cwd_path) or {} if ambient_config.get("systemPrompt"): - sections.append(f"## Workflow Instructions\n\n{ambient_config['systemPrompt']}\n") + sections.append( + f"## Workflow Instructions\n\n{ambient_config['systemPrompt']}\n" + ) # ---- Rubric instructions (when rubric config exists) ---- rubric_config = ambient_config.get("rubric", {}) diff --git a/components/runners/ambient-runner/ambient_runner/platform/auth.py b/components/runners/ambient-runner/ambient_runner/platform/auth.py index 709fd58bf..ff4153d59 100755 --- a/components/runners/ambient-runner/ambient_runner/platform/auth.py +++ b/components/runners/ambient-runner/ambient_runner/platform/auth.py @@ -145,10 +145,13 @@ async def fetch_github_credentials(context: RunnerContext) -> dict: ) if data.get("expiresAt"): try: - exp_dt = datetime.fromisoformat(data["expiresAt"]) + exp_dt = datetime.fromisoformat( + data["expiresAt"].replace("Z", "+00:00") + ) _credential_expiry["github"] = exp_dt.timestamp() logger.info(f"GitHub token expires at {data['expiresAt']}") except (ValueError, TypeError) as e: + _credential_expiry.pop("github", None) logger.warning(f"Failed to parse GitHub expiresAt: {e}") else: # PAT or legacy token without expiry — clear any stale tracking diff --git a/components/runners/ambient-runner/scripts/capture-fixtures.py b/components/runners/ambient-runner/scripts/capture-fixtures.py index 310603313..d4e4d415e 100755 --- a/components/runners/ambient-runner/scripts/capture-fixtures.py +++ b/components/runners/ambient-runner/scripts/capture-fixtures.py @@ -62,7 +62,11 @@ async def capture(prompt: str, output_path: Path) -> None: # Tag content blocks with their type so they can be deserialized if type_name in ("AssistantMessage", "UserMessage") and "content" in data: content = data["content"] - if isinstance(content, list) and hasattr(msg, "content") and isinstance(msg.content, list): + if ( + isinstance(content, list) + and hasattr(msg, "content") + and isinstance(msg.content, list) + ): for block, orig in zip(content, msg.content): if isinstance(block, dict): block["_type"] = type(orig).__name__ @@ -81,13 +85,17 @@ def main() -> None: formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("prompt", help="Prompt to send to Claude (use single quotes)") - parser.add_argument("--out", help="Output JSONL path (default: fixtures/.jsonl)") + parser.add_argument( + "--out", help="Output JSONL path (default: fixtures/.jsonl)" + ) args = parser.parse_args() if args.out: output_path = Path(args.out) else: - slug = "".join(c if c.isalnum() else "-" for c in args.prompt.lower()[:30]).strip("-") + slug = "".join( + c if c.isalnum() else "-" for c in args.prompt.lower()[:30] + ).strip("-") output_path = ( Path(__file__).parent.parent / "ambient_runner/bridges/claude/fixtures" diff --git a/components/runners/ambient-runner/tests/test_bridge_claude.py b/components/runners/ambient-runner/tests/test_bridge_claude.py index 07368f721..b21eba510 100644 --- a/components/runners/ambient-runner/tests/test_bridge_claude.py +++ b/components/runners/ambient-runner/tests/test_bridge_claude.py @@ -242,6 +242,7 @@ async def test_forwards_workflow_env_vars_to_initialize(self): return_value=mock_obs_instance, ) as mock_obs_cls: from ambient_runner.bridge import setup_bridge_observability + await setup_bridge_observability(ctx, "claude-sonnet-4-5") mock_obs_cls.assert_called_once() @@ -276,6 +277,7 @@ async def test_forwards_empty_defaults_when_workflow_vars_unset(self): return_value=mock_obs_instance, ): from ambient_runner.bridge import setup_bridge_observability + await setup_bridge_observability(ctx, "claude-sonnet-4-5") call_kwargs = mock_obs_instance.initialize.call_args[1] diff --git a/components/runners/ambient-runner/tests/test_context.py b/components/runners/ambient-runner/tests/test_context.py index ad196a997..8b5e3bda1 100644 --- a/components/runners/ambient-runner/tests/test_context.py +++ b/components/runners/ambient-runner/tests/test_context.py @@ -2,7 +2,6 @@ import os -import pytest from ambient_runner.platform.context import RunnerContext diff --git a/components/runners/ambient-runner/tests/test_git_identity.py b/components/runners/ambient-runner/tests/test_git_identity.py index 6df747e56..d3101a9ea 100644 --- a/components/runners/ambient-runner/tests/test_git_identity.py +++ b/components/runners/ambient-runner/tests/test_git_identity.py @@ -129,7 +129,9 @@ async def test_fetch_github_credentials_returns_identity(self): "provider": "github", } - with patch("ambient_runner.platform.auth._fetch_credential", new_callable=AsyncMock) as mock_fetch: + with patch( + "ambient_runner.platform.auth._fetch_credential", new_callable=AsyncMock + ) as mock_fetch: mock_fetch.return_value = mock_response result = await fetch_github_credentials(mock_context) @@ -149,7 +151,8 @@ async def test_fetch_github_token_delegates_to_fetch_github_credentials(self): mock_context.session_id = "test-session" with patch( - "ambient_runner.platform.auth.fetch_github_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_github_credentials", + new_callable=AsyncMock, ) as mock_fetch: mock_fetch.return_value = {"token": "ghp_test_token", "userName": "Test"} @@ -189,7 +192,9 @@ async def test_fetch_gitlab_credentials_returns_identity(self): "provider": "gitlab", } - with patch("ambient_runner.platform.auth._fetch_credential", new_callable=AsyncMock) as mock_fetch: + with patch( + "ambient_runner.platform.auth._fetch_credential", new_callable=AsyncMock + ) as mock_fetch: mock_fetch.return_value = mock_response result = await fetch_gitlab_credentials(mock_context) @@ -210,7 +215,8 @@ async def test_fetch_gitlab_token_delegates_to_fetch_gitlab_credentials(self): mock_context.session_id = "test-session" with patch( - "ambient_runner.platform.auth.fetch_gitlab_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_gitlab_credentials", + new_callable=AsyncMock, ) as mock_fetch: mock_fetch.return_value = {"token": "glpat-test_token"} @@ -251,16 +257,25 @@ async def test_git_identity_from_github(self): with ( patch( - "ambient_runner.platform.auth.fetch_google_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_google_credentials", + new_callable=AsyncMock, ) as mock_google, - patch("ambient_runner.platform.auth.fetch_jira_credentials", new_callable=AsyncMock) as mock_jira, patch( - "ambient_runner.platform.auth.fetch_gitlab_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_jira_credentials", + new_callable=AsyncMock, + ) as mock_jira, + patch( + "ambient_runner.platform.auth.fetch_gitlab_credentials", + new_callable=AsyncMock, ) as mock_gitlab, patch( - "ambient_runner.platform.auth.fetch_github_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_github_credentials", + new_callable=AsyncMock, ) as mock_github, - patch("ambient_runner.platform.auth.configure_git_identity", new_callable=AsyncMock) as mock_config, + patch( + "ambient_runner.platform.auth.configure_git_identity", + new_callable=AsyncMock, + ) as mock_config, ): mock_google.return_value = {} mock_jira.return_value = {} @@ -290,16 +305,25 @@ async def test_git_identity_from_gitlab_when_no_github(self): with ( patch( - "ambient_runner.platform.auth.fetch_google_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_google_credentials", + new_callable=AsyncMock, ) as mock_google, - patch("ambient_runner.platform.auth.fetch_jira_credentials", new_callable=AsyncMock) as mock_jira, patch( - "ambient_runner.platform.auth.fetch_gitlab_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_jira_credentials", + new_callable=AsyncMock, + ) as mock_jira, + patch( + "ambient_runner.platform.auth.fetch_gitlab_credentials", + new_callable=AsyncMock, ) as mock_gitlab, patch( - "ambient_runner.platform.auth.fetch_github_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_github_credentials", + new_callable=AsyncMock, ) as mock_github, - patch("ambient_runner.platform.auth.configure_git_identity", new_callable=AsyncMock) as mock_config, + patch( + "ambient_runner.platform.auth.configure_git_identity", + new_callable=AsyncMock, + ) as mock_config, ): mock_google.return_value = {} mock_jira.return_value = {} @@ -335,16 +359,25 @@ async def test_github_takes_precedence_over_gitlab(self): with ( patch( - "ambient_runner.platform.auth.fetch_google_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_google_credentials", + new_callable=AsyncMock, ) as mock_google, - patch("ambient_runner.platform.auth.fetch_jira_credentials", new_callable=AsyncMock) as mock_jira, patch( - "ambient_runner.platform.auth.fetch_gitlab_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_jira_credentials", + new_callable=AsyncMock, + ) as mock_jira, + patch( + "ambient_runner.platform.auth.fetch_gitlab_credentials", + new_callable=AsyncMock, ) as mock_gitlab, patch( - "ambient_runner.platform.auth.fetch_github_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_github_credentials", + new_callable=AsyncMock, ) as mock_github, - patch("ambient_runner.platform.auth.configure_git_identity", new_callable=AsyncMock) as mock_config, + patch( + "ambient_runner.platform.auth.configure_git_identity", + new_callable=AsyncMock, + ) as mock_config, ): mock_google.return_value = {} mock_jira.return_value = {} @@ -367,16 +400,25 @@ async def test_defaults_when_no_credentials(self): with ( patch( - "ambient_runner.platform.auth.fetch_google_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_google_credentials", + new_callable=AsyncMock, ) as mock_google, - patch("ambient_runner.platform.auth.fetch_jira_credentials", new_callable=AsyncMock) as mock_jira, patch( - "ambient_runner.platform.auth.fetch_gitlab_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_jira_credentials", + new_callable=AsyncMock, + ) as mock_jira, + patch( + "ambient_runner.platform.auth.fetch_gitlab_credentials", + new_callable=AsyncMock, ) as mock_gitlab, patch( - "ambient_runner.platform.auth.fetch_github_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_github_credentials", + new_callable=AsyncMock, ) as mock_github, - patch("ambient_runner.platform.auth.configure_git_identity", new_callable=AsyncMock) as mock_config, + patch( + "ambient_runner.platform.auth.configure_git_identity", + new_callable=AsyncMock, + ) as mock_config, ): mock_google.return_value = {} mock_jira.return_value = {} @@ -401,7 +443,9 @@ async def test_github_provider_field(self): mock_context = MagicMock(spec=RunnerContext) mock_context.session_id = "test-session" - with patch("ambient_runner.platform.auth._fetch_credential", new_callable=AsyncMock) as mock_fetch: + with patch( + "ambient_runner.platform.auth._fetch_credential", new_callable=AsyncMock + ) as mock_fetch: mock_fetch.return_value = { "token": "ghp_test", "provider": "github", @@ -419,7 +463,9 @@ async def test_gitlab_provider_field(self): mock_context = MagicMock(spec=RunnerContext) mock_context.session_id = "test-session" - with patch("ambient_runner.platform.auth._fetch_credential", new_callable=AsyncMock) as mock_fetch: + with patch( + "ambient_runner.platform.auth._fetch_credential", new_callable=AsyncMock + ) as mock_fetch: mock_fetch.return_value = { "token": "glpat-test", "provider": "gitlab", diff --git a/docs/internal/developer/local-development/openshift.md b/docs/internal/developer/local-development/openshift.md index 8099ad8ee..e57b806e0 100644 --- a/docs/internal/developer/local-development/openshift.md +++ b/docs/internal/developer/local-development/openshift.md @@ -209,7 +209,7 @@ oc exec deployment/ambient-api-server-db -n ambient-code -- psql -U ambient -d a # Check control plane is connecting via TLS gRPC oc logs deployment/ambient-control-plane -n ambient-code --tail=10 | grep -i grpc -# Verify API server gRPC streams are active +# Verify API server gRPC streams are active oc logs deployment/ambient-api-server -n ambient-code --tail=20 | grep "gRPC stream started" ``` @@ -229,7 +229,7 @@ oc get route -n ambient-code **Main routes:** - **Frontend**: https://ambient-code.apps./ -- **Backend API**: https://backend-route-ambient-code.apps./ +- **Backend API**: https://backend-route-ambient-code.apps./ - **Public API**: https://public-api-route-ambient-code.apps./ - **Ambient API Server**: https://ambient-api-server-ambient-code.apps./ @@ -335,7 +335,7 @@ OAuth configuration requires cluster-admin permissions for creating the OAuthCli ## What the Deployment Provides - ✅ **Applies all CRDs** (Custom Resource Definitions) -- ✅ **Creates RBAC** roles and service accounts +- ✅ **Creates RBAC** roles and service accounts - ✅ **Deploys all components** with correct OpenShift-compatible security contexts - ✅ **Configures OAuth** integration automatically (with cluster-admin) - ✅ **Creates all routes** for external access @@ -379,4 +379,4 @@ curl -H "Authorization: Bearer $(oc whoami -t)" \ 2. Configure ANTHROPIC_API_KEY in project settings 3. Test SDKs using the commands above 4. Create your first AgenticSession via UI or SDK -5. Monitor with: `oc get pods -n ambient-code -w` \ No newline at end of file +5. Monitor with: `oc get pods -n ambient-code -w` From f421fc490ad51cad9ccbfb2602d70850d26b5f82 Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Thu, 12 Mar 2026 22:44:01 -0500 Subject: [PATCH 4/4] fix(runner): remove broken restart_session MCP tool The restart_session tool crashed the CLI with exit code 1 because setting _restart_requested on the adapter mid-stream caused an unrecoverable error. Remove the tool entirely until a proper deferred restart mechanism is implemented. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ambient_runner/bridges/claude/bridge.py | 4 +- .../ambient_runner/bridges/claude/mcp.py | 10 +--- .../ambient_runner/bridges/claude/tools.py | 55 ------------------- 3 files changed, 4 insertions(+), 65 deletions(-) diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py index 3e4da78b7..c1702cc6f 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py @@ -336,9 +336,7 @@ async def _setup_platform(self) -> None: log_auth_status, ) - mcp_servers = build_mcp_servers( - self._context, cwd_path, self._obs, bridge_ref=self - ) + mcp_servers = build_mcp_servers(self._context, cwd_path, self._obs) log_auth_status(mcp_servers) allowed_tools = build_allowed_tools(mcp_servers) diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py index d3927be1b..0a236acdb 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py @@ -2,7 +2,7 @@ Claude-specific MCP server building and authentication checks. Assembles the full MCP server dict (external servers from .mcp.json + -platform tools like restart_session and rubric evaluation) and provides +platform tools like refresh_credentials and rubric evaluation) and provides a pre-flight auth check that logs status without emitting events. """ @@ -34,7 +34,6 @@ def build_mcp_servers( context: RunnerContext, cwd_path: str, obs: Any = None, - bridge_ref: Any = None, ) -> dict: """Build the full MCP server config dict including platform tools. @@ -42,7 +41,6 @@ def build_mcp_servers( context: Runner context. cwd_path: Working directory (used to find rubric files). obs: Optional ObservabilityManager (passed to rubric tool). - bridge_ref: Reference to the bridge instance (deferred adapter access). Returns: Dict of MCP server name -> server config. @@ -53,7 +51,6 @@ def build_mcp_servers( from ambient_runner.platform.config import load_mcp_config from ambient_runner.bridges.claude.tools import ( create_refresh_credentials_tool, - create_restart_session_tool, create_rubric_mcp_tool, load_rubric_content, ) @@ -62,14 +59,13 @@ def build_mcp_servers( mcp_servers = load_mcp_config(context, cwd_path) or {} # Session control tools - restart_tool = create_restart_session_tool(bridge_ref, sdk_tool) refresh_creds_tool = create_refresh_credentials_tool(context, sdk_tool) session_server = create_sdk_mcp_server( - name="session", version="1.0.0", tools=[restart_tool, refresh_creds_tool] + name="session", version="1.0.0", tools=[refresh_creds_tool] ) mcp_servers["session"] = session_server logger.info( - "Added session control MCP tools (restart_session, refresh_credentials)" + "Added session control MCP tools (refresh_credentials)" ) # Rubric evaluation tool diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/tools.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/tools.py index 1bbf6b644..824069be8 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/tools.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/tools.py @@ -4,7 +4,6 @@ Tools are created dynamically per-run and registered as in-process MCP servers alongside the Claude Agent SDK. -- ``restart_session`` — allows Claude to request a session restart - ``refresh_credentials`` — allows Claude to refresh auth tokens mid-run - ``evaluate_rubric`` — logs a rubric evaluation score to Langfuse """ @@ -18,65 +17,11 @@ from ambient_runner.bridge import TOOL_REFRESH_MIN_INTERVAL_SEC from ambient_runner.platform.prompts import ( REFRESH_CREDENTIALS_TOOL_DESCRIPTION, - RESTART_TOOL_DESCRIPTION, ) logger = logging.getLogger(__name__) -# ------------------------------------------------------------------ -# Session restart tool -# ------------------------------------------------------------------ - - -def create_restart_session_tool(bridge_ref, sdk_tool_decorator): - """Create the restart_session MCP tool. - - Args: - bridge_ref: Reference to the bridge instance whose ``_adapter`` - attribute is resolved at call time (may be ``None`` during - MCP setup). - sdk_tool_decorator: The ``tool`` decorator from ``claude_agent_sdk``. - - Returns: - Decorated async tool function. - """ - - @sdk_tool_decorator( - "restart_session", - RESTART_TOOL_DESCRIPTION, - {}, - ) - async def restart_session_tool(args: dict) -> dict: - """Tool that allows Claude to request a session restart.""" - adapter = getattr(bridge_ref, "_adapter", None) if bridge_ref else None - if adapter is None: - logger.warning("restart_session called but adapter not ready") - return { - "content": [ - { - "type": "text", - "text": "Session not ready — cannot restart yet. Try again shortly.", - } - ], - "isError": True, - } - adapter._restart_requested = True - logger.info("Session restart requested by Claude via MCP tool") - return { - "content": [ - { - "type": "text", - "text": ( - "Session restart has been requested. The current run " - "will complete and a fresh session will be established. " - "Your conversation context will be preserved on disk." - ), - } - ] - } - - return restart_session_tool # ------------------------------------------------------------------