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
354 changes: 347 additions & 7 deletions api_provider.py

Large diffs are not rendered by default.

89 changes: 87 additions & 2 deletions backend/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,29 @@ def persona(self) -> str:
return ""
return BASE_SYSTEM_PROMPT

def _resolve_model_ref_for_dispatch(self, canvas_document, node_id: str | None):
"""ADR-018 stage 18.2: the node/branch-override rungs of
graphlink_model_catalog.resolve_model_ref, computed here (mirroring
_resolve_branch_system_prompt's own "only when canvas_document/
node_id are supplied" restriction) and returned already resolved.
auto/workspace-default (which need the unified catalog and the
session's task-keyed default) stay out of scope here - when neither
rung fires, this returns None and the caller passes no model_ref at
all, falling through to api_provider's existing task-keyed lookup
UNCHANGED (see _provider_for_model_ref's own docstring in
api_provider.py). Diagnostics-worthy (the "why this model" rung
name) is thrown away at this layer on purpose - stage 18.3's
explain-resolution intent recomputes it fresh from the SAME
SceneDocument state the UI can already read, rather than this
already-in-flight dispatch trying to smuggle it back out."""
if canvas_document is None or node_id is None:
return None
resolve_model_for_node = getattr(canvas_document, "resolve_model_for_node", None)
if resolve_model_for_node is None:
return None
node_ref, branch_ref = resolve_model_for_node(node_id)
return node_ref or branch_ref

def _resolve_branch_system_prompt(self, canvas_document, node_id: str | None) -> str | None:
"""R6.1 port of legacy graphlink_chat_agent.py's
resolve_branch_system_prompt: given the id of a chat node about to be
Expand Down Expand Up @@ -937,6 +960,19 @@ async def _commit_partial() -> None:
# now both read this single resolved value instead).
override = self._resolve_branch_system_prompt(canvas_document, node_id)
persona_text = override if override is not None else self.persona()
# ADR-018 stage 18.2: computed once, shared by both branches
# below, exactly like persona_text/override_kwargs above.
model_ref = self._resolve_model_ref_for_dispatch(canvas_document, node_id)
model_ref_kwargs = {"model_ref": model_ref} if model_ref is not None else {}
# ADR-018 stage 18.4: the auto-policy rung's own catalog/
# settings access lives in api_provider, not here (mirrors
# model_ref's own node/branch-only scope at this layer) -
# this only threads the SettingsManager reference down,
# same omit-when-None posture as every other additive kwarg
# in this dispatch.
settings_manager_kwargs = (
{"settings_manager": self._settings_manager} if self._settings_manager is not None else {}
)
# ADR-006 stage 6.7: a note override reaches the wire RAW
# (never wrapped in "You are Graphlink Assistant. ...") -
# flagged to _call_chat_agent(_stream) only when an override
Expand Down Expand Up @@ -968,6 +1004,27 @@ async def _notify() -> None:

asyncio.run_coroutine_threadsafe(_notify(), dispatch_loop)

# ADR-018 stage 18.5: fallback-substitution notification.
# api_provider's chat()/chat_stream() outer wrapper invokes
# this on the WORKER thread the instant it decides to
# dispatch a SECOND time against a different provider -
# "never a silent swap" per the ADR's own decision #4. Same
# marshal-to-loop pattern as _thread_on_context_trimmed
# above; always supplied (unconditionally, matching
# on_context_trimmed's own posture), since notifications_state/
# bus/dispatch_loop are always available in this scope.
def _thread_on_fallback(failed_provider: str, fallback_ref, exc: Exception) -> None:
message = (
f"{failed_provider} is unavailable right now - this reply used "
f"{fallback_ref.provider} ({fallback_ref.model_id}) instead."
)

async def _notify() -> None:
notifications_state.show(message, "warning")
await bus.publish("notification")

asyncio.run_coroutine_threadsafe(_notify(), dispatch_loop)

# ADR-006 stage 6.8: real-usage capture. The worker writes
# the provider's normalized usage dict into this holder
# BEFORE its to_thread future resolves (ChatWorker.run calls
Expand Down Expand Up @@ -1098,7 +1155,10 @@ async def _emit(text: str, *, done: bool = False, reset: bool = False) -> None:
**self._runtime_kwargs(),
**override_kwargs,
**usage_kwargs,
**model_ref_kwargs,
**settings_manager_kwargs,
on_context_trimmed=_thread_on_context_trimmed,
on_fallback=_thread_on_fallback,
),
timeout=WATCHDOG_TIMEOUT_SECONDS,
)
Expand All @@ -1119,7 +1179,10 @@ async def _emit(text: str, *, done: bool = False, reset: bool = False) -> None:
**self._runtime_kwargs(),
**override_kwargs,
**usage_kwargs,
**model_ref_kwargs,
**settings_manager_kwargs,
on_context_trimmed=_thread_on_context_trimmed,
on_fallback=_thread_on_fallback,
),
timeout=WATCHDOG_TIMEOUT_SECONDS,
)
Expand Down Expand Up @@ -3169,7 +3232,8 @@ def _is_sandbox_error_output(output_text, return_code) -> bool:


def _call_chat_agent(conversation_history, persona_text, cancel_event, *, runtime=None,
persona_is_override=False, on_context_trimmed=None, on_usage=None) -> str:
persona_is_override=False, on_context_trimmed=None, on_usage=None,
model_ref=None, settings_manager=None, on_fallback=None) -> str:
"""Runs inside asyncio.to_thread - a real OS thread, not the event loop.

ADR-006 stage 6.5: `runtime` is an additive keyword-only kwarg, forwarded
Expand Down Expand Up @@ -3205,11 +3269,23 @@ def _call_chat_agent(conversation_history, persona_text, cancel_event, *, runtim
**({"on_context_trimmed": on_context_trimmed} if on_context_trimmed is not None else {}),
# ADR-006 stage 6.8: real-usage signal - forwarded omit-when-None.
**({"on_usage": on_usage} if on_usage is not None else {}),
# ADR-018 stage 18.2: resolved node/branch model pin - forwarded
# omit-when-None, same posture as every other additive kwarg here.
**({"model_ref": model_ref} if model_ref is not None else {}),
# ADR-018 stage 18.4: the session's SettingsManager, forwarded
# omit-when-None - only ever consumed by api_provider's auto-policy
# fallback (see its own docstring), never by anything in this
# module or ChatAgent/ChatWorker themselves.
**({"settings_manager": settings_manager} if settings_manager is not None else {}),
# ADR-018 stage 18.5: fallback-substitution notification - forwarded
# omit-when-None, same posture as every other additive kwarg here.
**({"on_fallback": on_fallback} if on_fallback is not None else {}),
)


def _call_chat_agent_stream(conversation_history, persona_text, cancel_event, on_chunk, *, runtime=None,
persona_is_override=False, on_context_trimmed=None, on_usage=None) -> str:
persona_is_override=False, on_context_trimmed=None, on_usage=None,
model_ref=None, settings_manager=None, on_fallback=None) -> str:
"""Runs inside asyncio.to_thread - a real OS thread, not the event loop.
Streaming counterpart to _call_chat_agent (R4.4) - same persona/
current_node/resolved_system_prompt guarantees as that function (see its
Expand Down Expand Up @@ -3245,6 +3321,15 @@ def _call_chat_agent_stream(conversation_history, persona_text, cancel_event, on
**({"on_context_trimmed": on_context_trimmed} if on_context_trimmed is not None else {}),
# ADR-006 stage 6.8: real-usage signal - forwarded omit-when-None.
**({"on_usage": on_usage} if on_usage is not None else {}),
# ADR-018 stage 18.2: resolved node/branch model pin - forwarded
# omit-when-None, same posture as every other additive kwarg here.
**({"model_ref": model_ref} if model_ref is not None else {}),
# ADR-018 stage 18.4: forwarded omit-when-None, same posture as
# _call_chat_agent's own settings_manager kwarg.
**({"settings_manager": settings_manager} if settings_manager is not None else {}),
# ADR-018 stage 18.5: forwarded omit-when-None, same posture as
# _call_chat_agent's own on_fallback kwarg.
**({"on_fallback": on_fallback} if on_fallback is not None else {}),
)


Expand Down
38 changes: 38 additions & 0 deletions backend/api/intents_model_routing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""ADR-018 stage 18.3: the node/branch model-override intents.

Two plain setters (setModelOverride/clearModelOverride), same shape as
backend/api/intents_groups.py's setGroupColor - a record_command-wrapped
scalar mutation, no agent dispatch involved. Kept in their own module
rather than folded into intents_groups.py/intents_chat.py: this is a new,
independent concern (ADR-018), not an extension of either of those ADRs'
own scope.
"""

from __future__ import annotations

from backend.api._shared import make_publish_scene
from backend.domain.graph import SceneDocument
from backend.events import SessionBus


def register_model_routing_intents(bus: SessionBus, document: SceneDocument) -> None:
publish_scene = make_publish_scene(bus)

async def set_model_override(node_id, provider, model_id):
document.record_command(
"setModelOverride", "user",
lambda: document.set_model_override(node_id, provider, model_id),
node_ids=[node_id],
)
await publish_scene()

async def clear_model_override(node_id):
document.record_command(
"clearModelOverride", "user",
lambda: document.clear_model_override(node_id),
node_ids=[node_id],
)
await publish_scene()

bus.register_intent("scene", "setModelOverride", set_model_override)
bus.register_intent("scene", "clearModelOverride", clear_model_override)
9 changes: 9 additions & 0 deletions backend/api/intents_settings_general.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ async def set_log_level(level: str):
apply_log_level(level)
await bus.publish("app-settings")

async def set_auto_model_policy(policy: str):
# ADR-018 stage 18.4: closed-vocabulary persist, same posture as
# set_log_level - manager.set_auto_model_policy itself already
# silently ignores an unrecognized string (see its own docstring),
# so a malformed intent arg just leaves the setting unchanged.
await asyncio.to_thread(run_locked, manager.set_auto_model_policy, str(policy))
await bus.publish("app-settings")

async def set_notification_preference(notification_type: str, enabled: bool):
await asyncio.to_thread(
run_locked, manager.set_notification_preferences, {str(notification_type): bool(enabled)}
Expand Down Expand Up @@ -131,6 +139,7 @@ async def set_provider_mode(mode: str):
bus.register_intent("app-settings", "setShowTokenCounter", set_show_token_counter)
bus.register_intent("app-settings", "setEnableSystemPrompt", set_enable_system_prompt)
bus.register_intent("app-settings", "setLogLevel", set_log_level)
bus.register_intent("app-settings", "setAutoModelPolicy", set_auto_model_policy)
bus.register_intent("app-settings", "setNotificationPreference", set_notification_preference)
bus.register_intent("app-settings", "setGithubToken", set_github_token)
bus.register_intent("app-settings", "clearGithubToken", clear_github_token)
Expand Down
2 changes: 2 additions & 0 deletions backend/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ def _placeholder_chart_data(chart_type: str) -> dict[str, Any]:
from backend.api.intents_gitlink import register_gitlink_intents # noqa: E402
from backend.api.intents_grid import register_grid_intents # noqa: E402
from backend.api.intents_groups import register_groups_intents # noqa: E402
from backend.api.intents_model_routing import register_model_routing_intents # noqa: E402
from backend.api.intents_nodes import register_node_intents # noqa: E402
from backend.api.intents_pins import register_pins_intents # noqa: E402
from backend.api.intents_pycoder import register_pycoder_intents # noqa: E402
Expand Down Expand Up @@ -360,6 +361,7 @@ def register_canvas(
register_code_sandbox_intents(bus, document, notifications, agent_dispatcher)

register_groups_intents(bus, document)
register_model_routing_intents(bus, document)
register_pins_intents(bus, document)
register_view_intents(bus, document)
# ADR-010 stage 10.2: undo/redo rides the scene topic (see that
Expand Down
56 changes: 56 additions & 0 deletions backend/domain/branches.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,62 @@ def set_final_deliverable(self, node_id: str, is_final: bool) -> None:
elif self.final_deliverable_node_id == node_id:
self.final_deliverable_node_id = None

def set_model_override(self, node_id: str, provider: str, model_id: str) -> None:
"""ADR-018 stage 18.2: pin the model this node (and, when it is a
branch root, every descendant that doesn't pin its own - see
resolve_model_for_node's own docstring) resolves to. Both fields
write together, mirroring set_group_color's own "no partial value"
posture - a pin is a real (provider, model_id) pair or nothing."""
node = self.nodes.get(node_id)
if node is None:
raise SceneError(f"unknown node: {node_id}")
if node.kind != "chat":
raise SceneError(f"node is not a chat node: {node_id}")
provider = str(provider or "").strip()
model_id = str(model_id or "").strip()
if not provider or not model_id:
raise SceneError("set_model_override requires both provider and model_id")
node.state.override_provider = provider
node.state.override_model_id = model_id

def clear_model_override(self, node_id: str) -> None:
node = self.nodes.get(node_id)
if node is None:
raise SceneError(f"unknown node: {node_id}")
if node.kind != "chat":
raise SceneError(f"node is not a chat node: {node_id}")
node.state.override_provider = ""
node.state.override_model_id = ""

def resolve_model_for_node(self, node_id: str | None):
"""ADR-018 stage 18.2: the node-override -> branch-override half of
graphlink_model_catalog.resolve_model_ref's chain - returns
(node_ref, branch_ref), both `ModelRef | None`, for the caller
(backend/agents.py's _dispatch) to pass straight through to that
function alongside the workspace-default/catalog/policy it already
owns. Mirrors _resolve_branch_system_prompt's own root-walk shape
exactly (get_branch_root, then read one field off the root) - the
two features share the same "root pin, inherited down the branch"
semantics, just against a different field."""
from graphlink_model_catalog import ModelRef

if node_id is None:
return None, None
node = self.nodes.get(node_id)
if node is None:
return None, None

def _ref_from(candidate) -> "ModelRef | None":
state = getattr(candidate, "state", None)
provider = getattr(state, "override_provider", "") if state is not None else ""
model_id = getattr(state, "override_model_id", "") if state is not None else ""
return ModelRef(provider, model_id) if provider and model_id else None

node_ref = _ref_from(node) if node.kind == "chat" else None
root = self.get_branch_root(node_id)
branch_ref = _ref_from(root) if root is not None and root.id != node_id and root.kind == "chat" else None
return node_ref, branch_ref

def _branch_parent_edge(self, node_id: str) -> SceneEdge | None:
"""R6.1: the shared 'find the edge whose target == node_id' lookup
chat_branch_history/get_branch_root/regenerate_response/
Expand Down
6 changes: 6 additions & 0 deletions backend/domain/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,12 @@ def _node_wire(self, n: SceneNode) -> dict[str, Any]:
# ChatState's own comment, backend/domain/node_states.py.
"provider": n.state.provider if isinstance(n.state, ChatState) else None,
"model": n.state.model if isinstance(n.state, ChatState) else None,
# ADR-018 stage 18.3: see ChatState's own comment on override_
# provider/override_model_id for why this is a distinct pair
# from provider/model directly above (output provenance vs.
# input routing pin).
"overrideProvider": n.state.override_provider if isinstance(n.state, ChatState) else "",
"overrideModelId": n.state.override_model_id if isinstance(n.state, ChatState) else "",
"isBranchSynthesis": n.state.is_branch_synthesis if isinstance(n.state, ChatState) else False,
"synthesisInstructions": (
n.state.synthesis_instructions if isinstance(n.state, ChatState) else ""
Expand Down
13 changes: 13 additions & 0 deletions backend/domain/node_states.py
Original file line number Diff line number Diff line change
Expand Up @@ -724,3 +724,16 @@ class ChatState(NodeState):
# "an assistant turn that calls tools renders the calls and their
# results (collapsible)").
tool_invocations: list[dict[str, Any]] = field(default_factory=list)
# ADR-018 stage 18.2: an explicit model PIN, the opposite direction from
# provider/model above - those record what a completed reply's content
# was actually generated by (output provenance); these two decide what
# the NEXT reply from this node (or, when this node is a branch root,
# every node in the branch that doesn't pin its own) will be generated
# by (input routing). Both empty means "no pin here" - resolution falls
# through to the branch root's own pin, then the workspace task
# default, then auto (see graphlink_model_catalog.resolve_model_ref).
# A pin is a real (provider, model_id) pair or nothing - there is no
# partial-pin state; set_model_override always writes both together and
# clear_model_override always clears both together.
override_provider: str = ""
override_model_id: str = ""
Loading
Loading