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
4 changes: 2 additions & 2 deletions GRAPHLINK_REPO_NAVIGATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Last refreshed: 2026-07-27 (post R7.6b Qt-removal cutover)
- 18 loose top-level `.py` modules at the repo root (see list below) - unchanged content, just living at the repo root now instead of inside `graphlink_app/`.
- `tests/` - not a package (no `__init__.py`). Currently one file, `test_no_qt_anywhere.py`, the permanent Qt-removal gate.
- `doc/` - **gitignored** (`.gitignore` has `/doc/`), local-only planning scratch. It is never pushed to the remote and is not part of what a clone or contributor sees. Treat it as a historical record of past planning, not shipped documentation - do not "fix" its content as if it were user-facing.
- 18 loose top-level `.py` modules (unchanged content, relocated over R7.2 and earlier increments): `api_provider.py`, `graphlink_artifact_agent.py`, `graphlink_audio.py`, `graphlink_chart_agent.py`, `graphlink_chart_data.py`, `graphlink_chart_rendering.py`, `graphlink_chat_agent.py`, `graphlink_desktop.py`, `graphlink_grid_view_settings.py`, `graphlink_memory.py`, `graphlink_model_catalog.py`, `graphlink_navigation_pins.py`, `graphlink_prompts.py`, `graphlink_secrets.py`, `graphlink_settings_store.py`, `graphlink_task_config.py`, `graphlink_token_estimator.py`, `graphlink_version.py`.
- 18 loose top-level `.py` modules (unchanged content, relocated over R7.2 and earlier increments): `api_provider.py`, `graphlink_artifact_agent.py`, `graphlink_audio.py`, `graphlink_chart_data.py`, `graphlink_chart_rendering.py`, `graphlink_chat_agent.py`, `graphlink_desktop.py`, `graphlink_grid_view_settings.py`, `graphlink_memory.py`, `graphlink_model_catalog.py`, `graphlink_navigation_pins.py`, `graphlink_prompts.py`, `graphlink_secrets.py`, `graphlink_settings_store.py`, `graphlink_task_config.py`, `graphlink_token_estimator.py`, `graphlink_version.py`.
- Real entry point: `graphlink_desktop.py` (repo root). `pyproject.toml`'s `[project.gui-scripts]` reads `graphlink = "graphlink_desktop:main"`.
- Runtime modes exposed in Settings: `Ollama (Local)`, `Llama.cpp (Local)`, `API Endpoint` (OpenAI-Compatible / Anthropic Claude / Google Gemini). The AppBar's own provider-mode `<select>` is still hardcoded-disabled to one option (`Ollama (Local)`) with `title="Switching provider modes isn't available yet"` - see the Architecture Truths section.
- Runtime persistence outside the repo:
Expand Down Expand Up @@ -321,7 +321,7 @@ This is the practical lookup map for where code actually lives today.
- `graphlink_task_config.py` - task keys, mode labels, `API_PROVIDER_*` constants.
- `graphlink_desktop.py` - the real entry point (see Runtime Ownership Map).
- `graphlink_memory.py` - branch/history helpers used by `backend/canvas.py::send_message`.
- `graphlink_chart_agent.py`, `graphlink_chart_data.py`, `graphlink_chart_rendering.py` - the chart-node pipeline (spec extraction/repair, rendering to PNG).
- `graphlink_chart_data.py` - canonical chart-data validation/schemas + the shared respond_json prompt (ADR-013 stage 13.3); `graphlink_chart_rendering.py` - chart rendering to PNG.
- `graphlink_artifact_agent.py`, `graphlink_chat_agent.py` - LLM-facing agent logic `backend/agents.py`/`backend/canvas.py` call into.
- `graphlink_wire_schema.py` - dataclass -> JSON Schema generation + payload validation (ADR-003 stage 3.2). Shared by `contracts/codegen.py` (dev-time TS generation) and `backend/events.py` (runtime intent-arg validation) - lives at the repo root, not inside `contracts/`, specifically so the runtime backend can import it (`contracts/` is excluded from the shipped wheel).

Expand Down
22 changes: 18 additions & 4 deletions api_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -3027,6 +3027,13 @@ def _chat_dispatch(task: str, messages: list, **kwargs) -> dict:
# ADR-018 stage 18.4: popped the same way - only used by the auto-
# fallback rung below, never forwarded to a provider call.
settings_manager = kwargs.pop("settings_manager", None)
# ADR-013 stage 13.3: popped the same way and threaded into ChatRequest
# explicitly below (not left in extra_kwargs) - only backend/
# structured_output.py's Anthropic native path sets these today (a
# single forced tool whose input_schema is the caller's JSON schema),
# but the shape is provider-agnostic should a future caller need it.
tools = kwargs.pop("tools", ())
tool_choice = kwargs.pop("tool_choice", None)

# One consistent view of the provider state for the whole request (#9). Worker
# threads call chat() while the UI thread can re-run initialize_* at any time;
Expand All @@ -3048,7 +3055,10 @@ def _chat_dispatch(task: str, messages: list, **kwargs) -> dict:
from backend.providers.base import CancelToken, ChatRequest

provider = _provider_for_model_ref(model_ref, state)
chat_request = ChatRequest(task=task, messages=messages, extra_kwargs=kwargs, model_ref=model_ref)
chat_request = ChatRequest(
task=task, messages=messages, extra_kwargs=kwargs,
tools=tools, tool_choice=tool_choice, model_ref=model_ref,
)
token = CancelToken(cancel_event)
if model_ref.provider == config.LOCAL_PROVIDER_LLAMACPP:
# llama.cpp is excluded from transport retry - in-process
Expand Down Expand Up @@ -3091,7 +3101,8 @@ def _chat_dispatch(task: str, messages: list, **kwargs) -> dict:
# still fires, just without a notification.
return chat(
task, messages, model_ref=auto_ref, settings_manager=settings_manager,
cancellation_event=cancel_event, runtime=runtime, **kwargs,
cancellation_event=cancel_event, runtime=runtime,
tools=tools, tool_choice=tool_choice, **kwargs,
)
raise ValueError(f"No Ollama model configured for task: {task}")

Expand Down Expand Up @@ -3172,7 +3183,8 @@ def _chat_dispatch(task: str, messages: list, **kwargs) -> dict:
# fallback attempt.
return chat(
task, messages, model_ref=auto_ref, settings_manager=settings_manager,
cancellation_event=cancel_event, runtime=runtime, **kwargs,
cancellation_event=cancel_event, runtime=runtime,
tools=tools, tool_choice=tool_choice, **kwargs,
)
raise RuntimeError(
f"No API model configured for task '{task}'.\n"
Expand All @@ -3187,7 +3199,9 @@ def _chat_dispatch(task: str, messages: list, **kwargs) -> dict:
# the OpenAI content-part format instead of being passed through raw.
from backend.providers.base import CancelToken, ChatRequest

chat_request = ChatRequest(task=task, messages=messages, extra_kwargs=kwargs)
chat_request = ChatRequest(
task=task, messages=messages, extra_kwargs=kwargs, tools=tools, tool_choice=tool_choice,
)
token = CancelToken(cancel_event)

if state.api_provider_type == config.API_PROVIDER_OPENAI:
Expand Down
106 changes: 73 additions & 33 deletions backend/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
import api_provider
import graphlink_task_config as config
from graphlink_artifact_agent import ArtifactAgent
from graphlink_chart_agent import ChartDataAgent
from graphlink_chart_data import CHART_JSON_SCHEMAS, chart_generation_messages
from graphlink_chat_agent import ChatAgent
from graphlink_note_agent import BranchComparisonAgent, BranchSynthesisAgent, ExplainerAgent, KeyTakeawayAgent
from graphlink_settings_store import SettingsManager # type hint only
Expand Down Expand Up @@ -112,6 +112,7 @@

from backend.events import SessionBus # type hint only
from backend.run_lifecycle import RunRegistry, run_single_shot
from backend.structured_output import StructuredOutputError, respond_json

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -3111,36 +3112,38 @@ async def start_chart_generation(
marker, so two overlapping generateChart calls (e.g. from two tabs
open on the same session) cannot race each other.

No cancel_event: ChartDataAgent has no cancellation checkpoint of
its own, and its own legacy caller (ChartWorkerThread) has no
stop() method either - same honestly-documented limitation as every
other dispatch surface.
ADR-013 stage 13.3: `_call_chart_agent` now receives run_single_shot's
own cancel_event and forwards it to respond_json as a real
cancellation_event - a genuine interruption (api_provider.
RequestCancelledError), not the "no cancellation checkpoint of its
own" gap this docstring used to document for ChartDataAgent
(retired this same stage). run_single_shot's own exception handling
already treats a caught exception while cancel_event.is_set() as a
silent cancel rather than a failure, so a cancelled generation
neither calls on_failure nor shows a notification.

Two distinct failure shapes, both routed through on_failure plus a
notification, NEITHER of which creates a node (node creation only
ever happens in on_success):
1. `_call_chart_agent` returns a dict carrying a top-level "error"
key - ChartDataAgent.get_response's own fully-degraded case
(even its heuristic_chart_data fallback found nothing usable).
Mirrors ChartWorkerThread.run()'s identical `if 'error' in
parsed: raise ValueError(...)` check at the one legacy call
site.
key - respond_json's StructuredOutputError case (the model
could not produce schema-conforming JSON even after its own
one repair attempt).
2. A timeout or any other exception raised getting there.
A dict with NO "error" key is still not guaranteed to be canonical -
on_success (backend/canvas.py's own closure) is responsible for its
own defensive canonicalize_chart_data/ChartDataError handling before
calling document.add_chart_node, exactly as this feature's own
contract requires; this method's job ends at handing back whatever
ChartDataAgent produced.
_call_chart_agent produced.

Reuses WATCHDOG_TIMEOUT_SECONDS (420s), not a new constant:
ChartDataAgent.get_response makes at most TWO sequential blocking
api_provider.chat() calls (the initial extraction call, plus one
repair_chart_data round trip on a non-canonical first attempt) -
double Artifact's own single-call shape, but nowhere near Web
Research's ~10-call chain that justified ITS own 900s bump, and 420s
already carries ample headroom for two calls at any realistic
per-call latency.
respond_json makes at most TWO sequential blocking api_provider.
chat() calls (the initial extraction call, plus one repair round
trip on a non-canonical first attempt) - double Artifact's own
single-call shape, but nowhere near Web Research's ~10-call chain
that justified ITS own 900s bump, and 420s already carries ample
headroom for two calls at any realistic per-call latency.

ADR-002 stage 2.3: the guard/timeout/exception/notify skeleton
below now lives once, shared with start_note_generation, in
Expand All @@ -3157,7 +3160,7 @@ async def start_chart_generation(
notifications_state=notifications_state,
node_id=node_id,
timeout=WATCHDOG_TIMEOUT_SECONDS,
call=lambda: _call_chart_agent(source_text, chart_type),
call=lambda cancel_event: _call_chart_agent(source_text, chart_type, cancel_event),
validate=lambda result: (
str(result["error"]) if isinstance(result, dict) and "error" in result else None
),
Expand Down Expand Up @@ -3215,7 +3218,7 @@ async def start_note_generation(
notifications_state=notifications_state,
node_id=node_id,
timeout=WATCHDOG_TIMEOUT_SECONDS,
call=lambda: _call_note_agent(note_kind, source_text),
call=lambda _cancel_event: _call_note_agent(note_kind, source_text),
validate=lambda text: (
# An agent that returns nothing usable must not silently
# create an empty note - that reads as a broken feature.
Expand Down Expand Up @@ -3268,7 +3271,7 @@ async def start_branch_comparison(
notifications_state=notifications_state,
node_id=None,
timeout=WATCHDOG_TIMEOUT_SECONDS,
call=lambda: _call_branch_comparison_agent(source_text),
call=lambda _cancel_event: _call_branch_comparison_agent(source_text),
validate=lambda text: (
"Branch comparison returned an empty response. Please try again."
if not str(text or "").strip() else None
Expand Down Expand Up @@ -3316,7 +3319,7 @@ async def start_branch_synthesis(
notifications_state=notifications_state,
node_id=None,
timeout=WATCHDOG_TIMEOUT_SECONDS,
call=lambda: _call_branch_synthesis_agent(source_text, instructions),
call=lambda _cancel_event: _call_branch_synthesis_agent(source_text, instructions),
validate=lambda text: (
"Branch synthesis returned an empty response. Please try again."
if not str(text or "").strip() else None
Expand Down Expand Up @@ -3539,19 +3542,56 @@ def _call_artifact_agent(current_artifact, history):
return ArtifactAgent().get_response(current_artifact, history)


def _call_chart_agent(source_text: str, chart_type: str) -> dict:
def _call_chart_agent(source_text: str, chart_type: str, cancel_event: threading.Event | None = None) -> dict:
"""Runs inside asyncio.to_thread - the blocking driver for
start_chart_generation above, mirroring _call_artifact_agent's own
shape. ChartDataAgent.get_response returns a JSON STRING (its own
unchanged public contract, preserved byte-for-byte by the R6.2
extraction into graphlink_chart_agent.py - see that module's own
docstring), so this parses it back into a dict the same way
ChartWorkerThread.run() already does at the one legacy call site
(`parsed = json.loads(data)`) before start_chart_generation inspects it
for a top-level "error" key."""
agent = ChartDataAgent()
raw = agent.get_response(source_text, chart_type)
return json.loads(raw)
shape. ADR-013 stage 13.3: retired graphlink_chart_agent.py's whole
ChartDataAgent pipeline (five hand-maintained per-type prompts, a
bespoke clean_response/repair_chart_data pair, a manual per-provider
JSON-mode if/elif, and a heuristic-regex fallback of last resort) in
favor of ONE respond_json call against a real JSON Schema - the module
ChartDataAgent existed specifically to replace (see structured_output.py's
own docstring). graphlink_chart_data.CHART_JSON_SCHEMAS/
chart_generation_messages are shared with backend/evals/runner.py's own
chart eval fixture, so the eval drives the identical shape this
actually ships.

`cancel_event`, when set, is respond_json's own cancellation_event -
forwarded all the way to api_provider.chat(), a REAL interruption
(api_provider.RequestCancelledError, checked before the request is sent
and between any transport retries) rather than the pre-13.3 gap this
surface's own start_chart_generation docstring used to document ("no
cancellation checkpoint of its own"). A cancellation raises straight
through - run_single_shot's own exception handling already treats a
caught exception while cancel_event.is_set() as a silent cancel, not a
failure, so this function does not catch it itself.

Returns a dict either way: the canonical shape on success, or
{"error": <message>} on respond_json's own StructuredOutputError (the
model could not produce schema-conforming JSON even after one repair
attempt) - the exact contract start_chart_generation/_run_chart already
expect (top-level "error" key => surfaced as a failure, never reaches
on_success/add_chart_node)."""
try:
schema = CHART_JSON_SCHEMAS[chart_type]
except KeyError:
# Defensive only - both real callers (intents_chart.py's
# generate_chart, tools_graph.py's _run_chart) already validate
# chart_type against SUPPORTED_CHART_TYPES before ever reaching
# here; this guards against a raw KeyError if a future caller
# regresses that, matching the retired ChartDataAgent's own
# equivalent internal guard.
return {"error": f"Unsupported chart type: {chart_type!r}"}
try:
return respond_json(
config.TASK_CHART,
chart_generation_messages(source_text, chart_type),
schema,
schema_name=f"{chart_type}_chart",
cancellation_event=cancel_event,
)
except StructuredOutputError as exc:
return {"error": str(exc)}


# -- R5.3: Gitlink - blocking helpers, each runs inside asyncio.to_thread ----
Expand Down
17 changes: 9 additions & 8 deletions backend/api/intents_chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,12 @@ async def _on_success(result):
chart_data = canonicalize_chart_data(result, normalized_chart_type)
chart_error = ""
except ChartDataError as exc:
# R6.2 contract: ChartDataAgent's own validate_chart_data
# pipeline (repair round trip, then heuristic fallback)
# already tries hard to guarantee canonical output before
# ever returning successfully - this is the rare defensive
# case where it still somehow didn't. Never a silent no-op:
# R6.2 contract, ADR-013 stage 13.3: _call_chart_agent's own
# respond_json call (one repair round trip against the real
# JSON Schema, then StructuredOutputError) already tries
# hard to guarantee schema-conforming output before ever
# returning successfully - this is the rare defensive case
# where it still somehow didn't. Never a silent no-op:
# still create a real chart node with a minimal placeholder
# shape and chart_error set, same "degrade gracefully, never
# drop the request" contract as the agent's own internal
Expand All @@ -95,9 +96,9 @@ async def _on_success(result):
# ADR-010 stage 10.1: agent provenance - this node is produced by
# a model generation, not a direct user action (stage 10.5's
# "undo this build" is what will consume that distinction).
# add_chart_node also mints chart asset bytes into image_assets;
# record_command captures those alongside the node, so undoing a
# generated chart does not strand its PNG.
# ADR-013 stage 13.4 retired add_chart_node's own PNG render (see
# ChartState's own docstring) - there is no longer an asset for
# record_command to snapshot alongside the node.
node, _command = document.record_command(
"generateChart", "agent",
lambda: document.add_chart_node(
Expand Down
Loading
Loading