-
Notifications
You must be signed in to change notification settings - Fork 46
add managed CodingAgentConfig read client, normalize, and local persist #262
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
asujithan
wants to merge
4
commits into
main
Choose a base branch
from
asujithan/managed-config
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+626
−8
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
af6c51e
add managed CodingAgentConfig read client, normalize, and local persist
asujithan 3ad7bd8
Merge branch 'main' into asujithan/managed-config
asujithan 5acf081
fix failing e2e tests
asujithan 814ee9a
add explicit config not found error message
asujithan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,317 @@ | ||
| """Admin-authored managed coding-agent config: fetch, normalize, and local persistence. | ||
|
|
||
| An org admin authors a ``CodingAgentConfig`` through the Databricks AI Gateway; developers read it | ||
| (non-admin) and ``ucode`` applies it locally. This module owns the developer-read half: | ||
|
|
||
| - fetching the raw manifest (via :func:`ucode.databricks.fetch_managed_coding_agent_configs`), | ||
| - normalizing the proto-JSON into a stable internal dict keyed by ucode's own tool names, and | ||
| - persisting it to ``~/.ucode/managed-state.json`` (0600) so launches can reconcile against it. | ||
|
|
||
| Reconciliation against the local ``state.json`` and applying the manifest to agents live in later | ||
| changes; this module deliberately stops at "read + normalize + persist". | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| from pathlib import Path | ||
| from typing import cast | ||
|
|
||
| import ucode.config_io as config_io | ||
| from ucode.databricks import fetch_managed_coding_agent_configs | ||
|
|
||
| MANAGED_STATE_PATH = config_io.APP_DIR / "managed-state.json" | ||
|
|
||
| # Shown to a developer when their workspace has no admin-defined managed config yet — the normal | ||
| # case, not an error. Kept here so the CLI (which surfaces it) uses one consistent message. | ||
| NO_MANAGED_CONFIG_MESSAGE = "No coding-agent config has been set up by your workspace admin yet." | ||
|
|
||
| # CodingAgent proto enum -> ucode tool name. Anything unrecognized (e.g. a newer agent this ucode | ||
| # build doesn't know) is dropped during normalization rather than guessed at. | ||
| _AGENT_ENUM_TO_TOOL: dict[str, str] = { | ||
| "CODING_AGENT_CLAUDE_CODE": "claude", | ||
| "CODING_AGENT_CODEX": "codex", | ||
| "CODING_AGENT_GEMINI": "gemini", | ||
| "CODING_AGENT_COPILOT": "copilot", | ||
| "CODING_AGENT_PI": "pi", | ||
| "CODING_AGENT_OPENCODE": "opencode", | ||
| } | ||
|
|
||
| # McpServerType proto enum -> ucode's short type tag. Mirrors the selection prefixes in ``mcp.py``; | ||
| # the actual name->URL resolution happens there when the manifest is applied (a later change). | ||
| _MCP_TYPE_ENUM_TO_TAG: dict[str, str] = { | ||
| "MCP_SERVER_TYPE_UC_SERVICE": "mcp-service", | ||
| "MCP_SERVER_TYPE_EXTERNAL": "external", | ||
| "MCP_SERVER_TYPE_GENIE": "genie-space", | ||
| "MCP_SERVER_TYPE_VECTOR_SEARCH": "vector-search", | ||
| "MCP_SERVER_TYPE_UC_FUNCTIONS": "uc-functions", | ||
| "MCP_SERVER_TYPE_DATABRICKS_APP": "app", | ||
| "MCP_SERVER_TYPE_DATABRICKS_SQL": "sql", | ||
| } | ||
|
|
||
|
|
||
| def _as_dict(value: object) -> dict[str, object]: | ||
| """Return ``value`` as a ``dict[str, object]`` when it is a dict, else an empty dict. | ||
|
|
||
| Centralizes the isinstance-narrowing so downstream ``.get`` calls type-check (a bare | ||
| ``isinstance(x, dict)`` narrows to ``dict[Never, Never]``, which rejects string keys).""" | ||
| return cast("dict[str, object]", value) if isinstance(value, dict) else {} | ||
|
|
||
|
|
||
| def _str(value: object) -> str | None: | ||
| """Return a non-empty stripped string, or None.""" | ||
| if isinstance(value, str): | ||
| stripped = value.strip() | ||
| return stripped or None | ||
| return None | ||
|
|
||
|
|
||
| def _str_list(value: object) -> list[str]: | ||
| if not isinstance(value, list): | ||
| return [] | ||
| out: list[str] = [] | ||
| for item in value: | ||
| s = _str(item) | ||
| if s: | ||
| out.append(s) | ||
| return out | ||
|
|
||
|
|
||
| def _normalize_model_config(model_config: object) -> dict | None: | ||
| """Normalize an ``AgentModelConfig`` oneof into ``{model_provider_service?, default_model?, | ||
| models}``. | ||
|
|
||
| The proto is a oneof over per-agent variants (claude/codex/opencode/pi/gemini/copilot). We | ||
| don't care which variant tag it is here — the enclosing agent already tells us — so we read the | ||
| common fields. Claude's ``models`` is a dict of family slots; the rest are a flat list. Returns | ||
| None when there's no usable model config. | ||
| """ | ||
| mc = _as_dict(model_config) | ||
| if not mc: | ||
| return None | ||
| # Unwrap the oneof: take whichever single variant sub-dict is present. | ||
| variant = next((_as_dict(v) for v in mc.values() if isinstance(v, dict)), None) | ||
| if not variant: | ||
| return None | ||
| result: dict = {} | ||
| mps = _str(variant.get("model_provider_service")) | ||
| if mps: | ||
| result["model_provider_service"] = mps | ||
| default_model = _str(variant.get("default_model")) | ||
| if default_model: | ||
| result["default_model"] = default_model | ||
| models = variant.get("models") | ||
| if isinstance(models, dict): | ||
| # Claude family slots (default_opus_model, default_sonnet_model, ...). | ||
| slots = {k: _str(v) for k, v in _as_dict(models).items() if _str(v)} | ||
| if slots: | ||
| result["models"] = slots | ||
| else: | ||
| model_list = _str_list(models) | ||
| if model_list: | ||
| result["models"] = model_list | ||
| return result or None | ||
|
|
||
|
|
||
| def _normalize_enabled_agent(entry: object) -> tuple[str, dict] | None: | ||
| """Normalize one ``EnabledAgent`` into ``(tool, agent_config)``, or None if unusable. | ||
|
|
||
| Drops entries whose agent enum is unset/unknown to this ucode build. | ||
| """ | ||
| entry_dict = _as_dict(entry) | ||
| if not entry_dict: | ||
| return None | ||
| tool = _AGENT_ENUM_TO_TOOL.get(_str(entry_dict.get("agent")) or "") | ||
| if tool is None: | ||
| return None | ||
| config_in = _as_dict(entry_dict.get("config")) | ||
| agent_config: dict = {} | ||
| if isinstance(config_in.get("use_as_global_settings"), bool): | ||
| agent_config["use_as_global_settings"] = config_in["use_as_global_settings"] | ||
| headers = config_in.get("custom_headers") | ||
| if isinstance(headers, dict): | ||
| clean = { | ||
| k: v for k, v in _as_dict(headers).items() if isinstance(k, str) and isinstance(v, str) | ||
| } | ||
| if clean: | ||
| agent_config["custom_headers"] = clean | ||
| tracing_table = _tracing_table(config_in.get("tracing_config")) | ||
| if tracing_table: | ||
| agent_config["tracing_table"] = tracing_table | ||
| model_config = _normalize_model_config(config_in.get("model_config")) | ||
| if model_config is not None: | ||
| agent_config["model_config"] = model_config | ||
| return tool, agent_config | ||
|
|
||
|
|
||
| def _tracing_table(tracing: object) -> str | None: | ||
| """Extract ``TracingConfig.table`` (a UC table FQN), or None.""" | ||
| return _str(_as_dict(tracing).get("table")) | ||
|
|
||
|
|
||
| def _normalize_mcp_servers(value: object) -> list[dict]: | ||
| if not isinstance(value, list): | ||
| return [] | ||
| out: list[dict] = [] | ||
| for entry in value: | ||
| entry_dict = _as_dict(entry) | ||
| name = _str(entry_dict.get("name")) | ||
| tag = _MCP_TYPE_ENUM_TO_TAG.get(_str(entry_dict.get("type")) or "") | ||
| if name and tag: | ||
| out.append({"name": name, "type": tag}) | ||
| return out | ||
|
|
||
|
|
||
| def _normalize_budget_policy(value: object) -> dict | None: | ||
| bp = _as_dict(value) | ||
| if not bp: | ||
| return None | ||
| policy: dict = {} | ||
| display_name = _str(bp.get("display_name")) | ||
| if display_name: | ||
| policy["display_name"] = display_name | ||
| budget_id = _str(bp.get("budget_id")) | ||
| if budget_id: | ||
| policy["budget_id"] = budget_id | ||
| tiers: list[dict] = [] | ||
| raw_tiers = bp.get("tiers") | ||
| for tier in raw_tiers if isinstance(raw_tiers, list) else []: | ||
| tier_dict = _as_dict(tier) | ||
| pct = tier_dict.get("spending_percentage") | ||
| if not isinstance(pct, (int, float)) or isinstance(pct, bool): | ||
| continue | ||
| tier_out: dict = {"spending_percentage": float(pct)} | ||
| agent = _AGENT_ENUM_TO_TOOL.get(_str(tier_dict.get("default_agent")) or "") | ||
| if agent: | ||
| tier_out["default_agent"] = agent | ||
| model = _str(tier_dict.get("default_model")) | ||
| if model: | ||
| tier_out["default_model"] = model | ||
| tiers.append(tier_out) | ||
| if tiers: | ||
| policy["tiers"] = tiers | ||
| return policy or None | ||
|
|
||
|
|
||
| def normalize_managed_config(raw: dict) -> dict: | ||
| """Normalize a raw ``CodingAgentConfig`` proto-JSON dict into ucode's internal shape. | ||
|
|
||
| The internal shape uses ucode's own tool names and short MCP type tags so downstream reconcile | ||
| and apply code never touches proto enum spellings. Unknown agents / MCP types are dropped. | ||
| """ | ||
| raw = _as_dict(raw) | ||
| result: dict = {} | ||
| name = _str(raw.get("name")) | ||
| if name: | ||
| result["name"] = name | ||
| default_agent = _AGENT_ENUM_TO_TOOL.get(_str(raw.get("default_agent")) or "") | ||
| if default_agent: | ||
| result["default_agent"] = default_agent | ||
| enabled_agents: dict[str, dict] = {} | ||
| raw_agents = raw.get("enabled_agents") | ||
| for entry in raw_agents if isinstance(raw_agents, list) else []: | ||
| normalized = _normalize_enabled_agent(entry) | ||
| if normalized is not None: | ||
| tool, agent_config = normalized | ||
| enabled_agents[tool] = agent_config | ||
| if enabled_agents: | ||
| result["enabled_agents"] = enabled_agents | ||
| mcp_servers = _normalize_mcp_servers(raw.get("mcp_servers")) | ||
| if mcp_servers: | ||
| result["mcp_servers"] = mcp_servers | ||
| skill_names = _str_list(_as_dict(raw.get("skills")).get("names")) | ||
| if skill_names: | ||
| result["skills"] = {"names": skill_names} | ||
| tracing_table = _tracing_table(raw.get("tracing")) | ||
| if tracing_table: | ||
| result["tracing_table"] = tracing_table | ||
| budget_policy = _normalize_budget_policy(raw.get("budget_policy")) | ||
| if budget_policy is not None: | ||
| result["budget_policy"] = budget_policy | ||
| return result | ||
|
|
||
|
|
||
| def get_managed_config(workspace: str, token: str) -> tuple[dict | None, str | None]: | ||
| """Fetch and normalize the workspace's managed config. | ||
|
|
||
| Returns ``(config, reason)``: | ||
| - ``(config, None)`` — the normalized manifest for the workspace's single config; | ||
| - ``(None, None)`` — no managed config is defined for the workspace (not an error); | ||
| - ``(None, reason)`` — the read failed; ``reason`` says why. | ||
|
|
||
| "No config defined" arrives two ways depending on the backend: an empty listing (HTTP 200 | ||
| with no configs) or a NOT_FOUND (HTTP 404). Both are the normal, non-error case for a workspace | ||
| whose admin hasn't set one up, so both collapse to ``(None, None)``. | ||
|
|
||
| v0 stores at most one config per workspace, so the first entry is the workspace's config. | ||
| """ | ||
| configs, reason = fetch_managed_coding_agent_configs(workspace, token) | ||
| if reason is not None: | ||
| # A NOT_FOUND means the admin hasn't defined a config for this workspace — not a failure. | ||
| if _is_not_found(reason): | ||
| return None, None | ||
| return None, reason | ||
| if not configs: | ||
| return None, None | ||
| return normalize_managed_config(configs[0]), None | ||
|
|
||
|
|
||
| def _is_not_found(reason: str) -> bool: | ||
| """True when a read failure reason indicates the config simply doesn't exist yet. | ||
|
|
||
| ``_http_get_json`` formats failures as ``HTTP <code> <text>[: <body>]``; a NOT_FOUND surfaces | ||
| as an ``HTTP 404`` there (and the API's error body carries ``NOT_FOUND``).""" | ||
| lowered = reason.lower() | ||
| return "http 404" in lowered or "not_found" in lowered | ||
|
|
||
|
|
||
| def save_managed_state(workspace: str, config: dict) -> None: | ||
| """Persist the normalized managed config to ``~/.ucode/managed-state.json`` at mode 0600. | ||
|
|
||
| The file is org-authored, not developer-editable — 0600 keeps it readable/writable only by the | ||
| user (a light guard; hard enforcement / sudo ownership is a separate concern). No-op in dry-run. | ||
| """ | ||
| if config_io.is_dry_run(): | ||
| return | ||
| payload = {"workspace": workspace, "config": config} | ||
| config_io.ensure_parent_dir(MANAGED_STATE_PATH) | ||
| try: | ||
| MANAGED_STATE_PATH.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") | ||
| except OSError as exc: | ||
| raise RuntimeError(f"Failed to write managed state file: {MANAGED_STATE_PATH}") from exc | ||
| _restrict_permissions(MANAGED_STATE_PATH) | ||
|
|
||
|
|
||
| def _restrict_permissions(path: Path) -> None: | ||
| """Best-effort chmod 0600. No-op where unsupported (e.g. Windows), where the effective | ||
| read-only guarantee is left to a later change.""" | ||
| try: | ||
| os.chmod(path, 0o600) | ||
| except (OSError, NotImplementedError): | ||
| pass | ||
|
|
||
|
|
||
| def load_managed_state(workspace: str | None) -> dict | None: | ||
| """Load the persisted managed config for ``workspace``, or None if absent/mismatched. | ||
|
|
||
| Returns the normalized config dict (the ``config`` field), only when the stored file is for the | ||
| same workspace — so a stale file from another workspace is ignored rather than misapplied. | ||
| """ | ||
| if not workspace: | ||
| return None | ||
| data = config_io.read_json_safe(MANAGED_STATE_PATH) | ||
| if data.get("workspace") != workspace: | ||
| return None | ||
| config = data.get("config") | ||
| return config if isinstance(config, dict) else None | ||
|
|
||
|
|
||
| def delete_managed_state() -> None: | ||
| """Remove the managed-state file, if any. No-op in dry-run.""" | ||
| if config_io.is_dry_run(): | ||
| return | ||
| try: | ||
| MANAGED_STATE_PATH.unlink(missing_ok=True) | ||
| except OSError as exc: | ||
| raise RuntimeError(f"Failed to remove managed state file: {MANAGED_STATE_PATH}") from exc | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think if no config exists it will throw NOT_FOUND error? https://sourcegraph.prod.databricks-corp.com/databricks-eng/universe/-/blob/ai-gateway-api/dbv2/main/CodingAgentConfigEntityStore.scala?L118-131 - we should catch that