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
75 changes: 48 additions & 27 deletions api_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,17 @@
import urllib.error
import urllib.request
from pathlib import Path
from typing import Callable, NamedTuple
from typing import Any, Callable, NamedTuple, NoReturn
from urllib.parse import urlparse

import ollama
try:
import requests
REQUESTS_AVAILABLE = True
except ImportError:
requests = None
# The optional-dependency shape: the name is a module when the import
# works and None when it does not, which no annotation expresses.
requests = None # type: ignore[assignment]
REQUESTS_AVAILABLE = False

# Qt-removal plan R4.1: import the Qt-free split, not graphlink_config -
Expand Down Expand Up @@ -200,15 +202,18 @@ def _configure_ollama_client_timeout() -> None:
ANTHROPIC_REASONING_LEVEL = "off"
GEMINI_REASONING_LEVEL = "off"
OPENAI_REASONING_LEVEL = "off"
API_MODELS = {
# Per-task model id, or None for a task with nothing configured yet.
API_MODELS: dict[str, str | None] = {
config.TASK_TITLE: None,
config.TASK_CHAT: None,
config.TASK_CHART: None,
config.TASK_IMAGE_GEN: None,
config.TASK_WEB_VALIDATE: None,
config.TASK_WEB_SUMMARIZE: None,
}
LLAMA_CPP_SETTINGS = {
# Mixed value types (paths, ints, a chat format string), so an inferred
# dict[str, object] would make every .get() unusable at its use site.
LLAMA_CPP_SETTINGS: dict[str, Any] = {
"chat_model_path": "",
"title_model_path": "",
"reasoning_level": "high",
Expand All @@ -217,7 +222,10 @@ def _configure_ollama_client_timeout() -> None:
"n_gpu_layers": 0,
"n_threads": 0,
}
_LLAMA_CPP_CLIENT_CACHE = {}
# Keyed by llama_cpp_runtime's 5-tuple of (path, chat_format, n_ctx,
# n_gpu_layers, n_threads); the values are Llama handles, whose type is
# only importable when llama-cpp-python is installed.
_LLAMA_CPP_CLIENT_CACHE: dict[tuple[str, Any, int, int, int], Any] = {}
_LLAMA_CPP_CLIENT_LOCK = threading.RLock()

# _LLAMA_CPP_CLIENT_LOCK above guards only the CACHE (lookup/creation). It
Expand Down Expand Up @@ -403,7 +411,7 @@ def set_ollama_models(self, models: dict) -> None:
def snapshot(self) -> _ProviderSnapshot:
return _ProviderSnapshot(**self._read_all())

def initialize_api(self, provider: str, api_key: str, base_url: str = None):
def initialize_api(self, provider: str, api_key: str, base_url: str | None = None):
client, api_key, base_url = _build_api_client(provider, api_key, base_url)
self._write(
use_api_mode=True,
Expand Down Expand Up @@ -582,7 +590,7 @@ def _read_all(self) -> dict:
def _write(self, **updates) -> dict:
module_globals = globals()
with _PROVIDER_STATE_LOCK:
previous = {}
previous: dict[str, Any] = {}
for key, value in updates.items():
if key == "api_models":
previous[key] = dict(API_MODELS)
Expand Down Expand Up @@ -631,11 +639,14 @@ def set_task_model(self, task: str, api_model: str) -> None:
config.TASK_CHART: 4096,
config.TASK_WEB_SUMMARIZE: 4096,
}
_OLLAMA_CAPABILITY_CACHE = {}
# model name (lowercased) -> the capability set show() reported, or None
# for a model the daemon does not know. A failed PROBE is deliberately not
# cached - see _get_ollama_capabilities.
_OLLAMA_CAPABILITY_CACHE: dict[str, set[str] | None] = {}
# ADR-006 stage 6.6: context windows extracted from the same `ollama.show()`
# call the capability cache uses, cached under the same key discipline and
# invalidated by the same invalidate_ollama_capability_cache() entry point.
_OLLAMA_CONTEXT_WINDOW_CACHE = {}
_OLLAMA_CONTEXT_WINDOW_CACHE: dict[str, int | None] = {}

# ADR-006 stage 6.6: API-mode context windows, matched by model-id prefix.
# Same posture as anthropic_supports_reasoning below: a documented
Expand Down Expand Up @@ -847,8 +858,12 @@ def _extract_context_window_from_show(show_response) -> int | None:
key for key in model_info if str(key).endswith(".context_length")
)
for key in candidates:
raw_value = model_info.get(key)
if raw_value is None:
# Was reached by letting int(None) raise into the handler below.
continue
try:
value = int(model_info.get(key))
value = int(raw_value)
except (TypeError, ValueError):
continue
if value > 0:
Expand Down Expand Up @@ -938,14 +953,18 @@ def _prepare_ollama_messages(messages: list) -> list:
continue
tool_calls = msg.get("tool_calls")
if tool_calls:
processed_messages.append({
# Annotated because the value types differ: a checker reading the
# literal alone infers dict[str, str] from the first two keys and
# then rejects the list on the third.
assistant_turn: dict[str, Any] = {
"role": "assistant",
"content": str(msg.get("content") or ""),
"tool_calls": [
{"function": {"name": call["name"], "arguments": call["arguments"]}}
for call in tool_calls
],
})
}
processed_messages.append(assistant_turn)
continue

content = msg.get("content")
Expand Down Expand Up @@ -1257,13 +1276,13 @@ def _provider_for_model_ref(model_ref: ModelRef, state: "_ProviderSnapshot"):
from backend.providers.anthropic_provider import AnthropicProvider

return AnthropicProvider(
client=state.api_client, api_key=state.api_key, model=model_ref.model_id,
client=state.api_client, api_key=state.api_key or "", model=model_ref.model_id,
reasoning_level=state.anthropic_reasoning_level,
)
from backend.providers.gemini_provider import GeminiProvider

return GeminiProvider(
api_key=state.api_key, model=model_ref.model_id,
api_key=state.api_key or "", model=model_ref.model_id,
reasoning_level=state.gemini_reasoning_level,
)

Expand Down Expand Up @@ -1596,7 +1615,7 @@ def _chat_dispatch(task: str, messages: list, **kwargs) -> dict:
from backend.providers.anthropic_provider import AnthropicProvider

provider = AnthropicProvider(
client=state.api_client, api_key=state.api_key, model=api_model,
client=state.api_client, api_key=state.api_key or "", model=api_model,
reasoning_level=state.anthropic_reasoning_level,
)
# ADR-006 stage 6.8: transient-transport retry (429/5xx/
Expand All @@ -1608,7 +1627,7 @@ def _chat_dispatch(task: str, messages: list, **kwargs) -> dict:
from backend.providers.gemini_provider import GeminiProvider

provider = GeminiProvider(
api_key=state.api_key, model=api_model,
api_key=state.api_key or "", model=api_model,
reasoning_level=state.gemini_reasoning_level,
)
# ADR-006 stage 6.8: transient-transport retry (429/5xx/
Expand Down Expand Up @@ -1714,7 +1733,7 @@ def _complete_with_transport_retry(provider, chat_request, token, cancel_event):
attempt += 1


def _translate_chat_exception(exc: Exception, state, messages: list) -> None:
def _translate_chat_exception(exc: Exception, state, messages: list) -> NoReturn:
"""Shared exception-normalization for chat()/chat_stream(): translates raw
provider/network exceptions into actionable, user-facing messages. Always
raises - either a translated exception (chained `from exc`) or the
Expand Down Expand Up @@ -1974,14 +1993,14 @@ def _chat_stream_dispatch(task: str, messages: list, on_chunk: Callable[[str, bo
from backend.providers.anthropic_provider import AnthropicProvider

provider = AnthropicProvider(
client=state.api_client, api_key=state.api_key, model=api_model,
client=state.api_client, api_key=state.api_key or "", model=api_model,
reasoning_level=state.anthropic_reasoning_level,
)
elif state.api_provider_type == config.API_PROVIDER_GEMINI:
from backend.providers.gemini_provider import GeminiProvider

provider = GeminiProvider(
api_key=state.api_key, model=api_model,
api_key=state.api_key or "", model=api_model,
reasoning_level=state.gemini_reasoning_level,
)
else:
Expand Down Expand Up @@ -2162,14 +2181,14 @@ def chat_turn_with_tools(task: str, messages: list, tools: tuple = (), **kwargs)
from backend.providers.anthropic_provider import AnthropicProvider

provider = AnthropicProvider(
client=state.api_client, api_key=state.api_key, model=api_model,
client=state.api_client, api_key=state.api_key or "", model=api_model,
reasoning_level=state.anthropic_reasoning_level,
)
elif state.api_provider_type == config.API_PROVIDER_GEMINI:
from backend.providers.gemini_provider import GeminiProvider

provider = GeminiProvider(
api_key=state.api_key, model=api_model,
api_key=state.api_key or "", model=api_model,
reasoning_level=state.gemini_reasoning_level,
)
else:
Expand Down Expand Up @@ -2251,12 +2270,14 @@ def describe_active_model(task: str, runtime: "ProviderRuntime | None" = None) -
return ("ollama", state.ollama_models.get(task) or "")


def _build_api_client(provider: str, api_key: str, base_url: str = None):
def _build_api_client(provider: str, api_key: str, base_url: str | None = None):
"""The client-construction half of initialize_api, with NO state
mutation - ADR-006 stage 6.5 splits it out so ProviderRuntime instances
and the throwaway catalog listing (list_models_for_config) can build a
client without repointing anything. Returns (client, resolved_key,
resolved_base_url)."""
# OpenAI/Anthropic SDK client, or the plain dict the REST fallbacks use.
client: Any
if provider == config.API_PROVIDER_OPENAI:
try:
from openai import OpenAI
Expand All @@ -2268,7 +2289,7 @@ def _build_api_client(provider: str, api_key: str, base_url: str = None):
if not base_url:
base_url = "https://api.openai.com/v1"

api_key = api_key or _first_env_api_key(_OPENAI_API_KEY_ENV_VARS)
api_key = api_key or _first_env_api_key(_OPENAI_API_KEY_ENV_VARS) or ""
if not api_key:
if _is_local_base_url(base_url):
api_key = "dummy-key-for-local"
Expand All @@ -2287,7 +2308,7 @@ def _build_api_client(provider: str, api_key: str, base_url: str = None):
client = OpenAI(api_key=api_key, base_url=base_url)

elif provider == config.API_PROVIDER_ANTHROPIC:
api_key = api_key or _first_env_api_key(_ANTHROPIC_API_KEY_ENV_VARS)
api_key = api_key or _first_env_api_key(_ANTHROPIC_API_KEY_ENV_VARS) or ""
if not api_key:
raise RuntimeError("Anthropic API key not configured. Open Settings and save your Anthropic API key.")

Expand Down Expand Up @@ -2324,7 +2345,7 @@ def _build_api_client(provider: str, api_key: str, base_url: str = None):
# snapshot's api_key OR-branch never fired, silently falling
# through to a LIVE re-read of API_KEY and then os.environ at
# actual-call time instead of the value frozen at request entry.
api_key = api_key or _first_env_api_key(_GEMINI_API_KEY_ENV_VARS)
api_key = api_key or _first_env_api_key(_GEMINI_API_KEY_ENV_VARS) or ""
if not api_key:
raise RuntimeError("Gemini API key not configured. Open Settings and save your Gemini API key.")
client = {"provider": config.API_PROVIDER_GEMINI}
Expand All @@ -2334,7 +2355,7 @@ def _build_api_client(provider: str, api_key: str, base_url: str = None):
return client, api_key, base_url


def initialize_api(provider: str, api_key: str, base_url: str = None):
def initialize_api(provider: str, api_key: str, base_url: str | None = None):
"""Configure the DEFAULT session's runtime (the module globals) for an
API endpoint - ADR-006 stage 6.5: the logic lives on ProviderRuntime,
shared with per-session instances; this delegate keeps every existing
Expand Down Expand Up @@ -2383,7 +2404,7 @@ def get_available_models():
raise RuntimeError(f"Failed to fetch models from endpoint: {exc}") from exc


def list_models_for_config(provider: str, api_key: str, base_url: str = None):
def list_models_for_config(provider: str, api_key: str, base_url: str | None = None):
"""ADR-006 stage 6.5: catalog listing WITHOUT touching live provider
state. loadApiModels used to call initialize_api just to refresh a
Settings dropdown - a read-only catalog fetch silently repointed the
Expand Down
6 changes: 5 additions & 1 deletion backend/agent_dispatch/_composed.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
if TYPE_CHECKING:
from backend.events import SessionBus
from graphlink_settings_store import SettingsManager
from backend.tools import ToolRegistry


class DispatcherParts:
Expand All @@ -44,7 +45,10 @@ def _runtime_kwargs(self) -> dict: ...

def _cancel_with_pending_approval_denied(self, request_id: str, kind: str) -> bool: ...

def builder_tool_registry(self, document: Any) -> object: ...
# Narrowed from `object`: BuilderDispatchOps returns a real
# ToolRegistry, and declaring that here is what lets sibling
# mixins call it without restating the type at each site.
def builder_tool_registry(self, document: Any) -> "ToolRegistry": ...

# The run engine every start_* surface funnels through, and the
# plain-blocking-action skeleton the gitlink/code-review surfaces
Expand Down
18 changes: 15 additions & 3 deletions backend/agent_dispatch/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,32 @@

import asyncio
import threading
from typing import TYPE_CHECKING

from backend.agent_dispatch._composed import DispatcherParts

if TYPE_CHECKING:
from backend.tools import ToolRegistry


class BuilderDispatchOps(DispatcherParts):
"""The Builder agent loop and its tool-registry assembly (mixin - see module docstring)."""

def builder_tool_registry(self, document) -> "object":
# Declared, never assigned here: a bare annotation creates no class
# attribute, so builder_tool_registry()'s `getattr(self, ..., None)`
# lazy-build probe below behaves exactly as it always has. It exists so
# invalidate_builder_registry() can put None back without the checker
# inferring the attribute's type from the sole ToolRegistry assignment.
_builder_registry: "ToolRegistry | None"

def builder_tool_registry(self, document) -> "ToolRegistry":
"""The session's one ToolRegistry, built lazily on first builder
start (ADR-007 shipped the registry with zero production
constructors; the Builder is its designated first consumer).
Cached: tools bind the session's own SceneDocument/dispatcher, and
both live exactly as long as this dispatcher does."""
if getattr(self, "_builder_registry", None) is None:
registry = getattr(self, "_builder_registry", None)
if registry is None:
from backend.builder import register_builder_control_tools
from backend.tools import ToolRegistry
from backend.tools_graph import register_graph_tools, register_run_node_tool
Expand All @@ -55,7 +67,7 @@ def builder_tool_registry(self, document) -> "object":
self._register_configured_mcp_tools(registry)
self._register_plugin_tools(registry, document)
self._builder_registry = registry
return self._builder_registry
return registry

def invalidate_builder_registry(self) -> None:
"""SECURITY-FIX: builder_tool_registry() above builds the registry
Expand Down
6 changes: 5 additions & 1 deletion backend/agent_dispatch/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,13 @@

import asyncio
import threading
from typing import TYPE_CHECKING

from backend.agent_dispatch._composed import DispatcherParts

if TYPE_CHECKING:
from backend.tools import ToolRegistry


class HarnessDispatchOps(DispatcherParts):
"""The Harness agent: grants, process bookkeeping, and the Harness run (mixin - see module docstring)."""
Expand Down Expand Up @@ -124,7 +128,7 @@ def dispose_all_harness_processes(self) -> None:
if repls is not None:
repls.stop_all()

def harness_tool_registry(self, document) -> "object":
def harness_tool_registry(self, document) -> "ToolRegistry":
"""The harness rides the SAME per-session registry the Builder
built (tools.py: one registry per session, RunContext is what's
per-run) - fs tools are simply registered into it on first harness
Expand Down
14 changes: 13 additions & 1 deletion backend/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
import threading
import uuid # noqa: F401
from pathlib import Path
from typing import Protocol
from urllib.parse import quote

import api_provider
Expand Down Expand Up @@ -317,7 +318,18 @@ class AgentDispatcher(
# takes. Kept as data rather than an if/elif so adding a third note agent is
# one entry, not another branch in three places.
NOTE_AGENT_LABELS = {"takeaway": "Key takeaway", "explainer": "Explainer note"}
_NOTE_AGENTS = {"takeaway": KeyTakeawayAgent, "explainer": ExplainerAgent}


class _NoteAgent(Protocol):
"""What the table below actually requires of a note agent. The two
classes in it share no base class - they are independent agents in
graphlink_note_agent.py that happen to answer the same one call - so
the common type has to be structural rather than nominal."""

def get_response(self, text: str) -> str: ...


_NOTE_AGENTS: dict[str, type[_NoteAgent]] = {"takeaway": KeyTakeawayAgent, "explainer": ExplainerAgent}


def _call_note_agent(note_kind: str, source_text: str) -> str:
Expand Down
Loading
Loading