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
104 changes: 104 additions & 0 deletions backend/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
from graphlink_artifact_agent import ArtifactAgent
from graphlink_chart_agent import ChartDataAgent
from graphlink_chat_agent import ChatAgent
from graphlink_note_agent import ExplainerAgent, KeyTakeawayAgent
from graphlink_licensing import SettingsManager # type hint only
from graphlink_plugins.common.github_client import GitHubRestClient
from graphlink_plugins.gitlink.agent import GitlinkAgent, _fingerprint_changes, _is_repo_text_path
Expand Down Expand Up @@ -343,6 +344,19 @@ def __init__(self, settings_manager: SettingsManager):
# no checkpoint to insert one at, and its own legacy caller,
# ChartWorkerThread, has no stop() method either).
self._chart_requests: dict[str, bool] = {}
# R8a: a TENTH independent in-flight-request GUARD, same directly-
# awaited shape (and therefore same dict-of-sentinels rather than
# dict-of-tasks) as self._chart_requests above, for the same reason:
# Key Takeaway / Explainer Note each create a brand new note node, so
# the caller needs the result back in the same round trip and there is
# no pre-existing node to hang a spinner on. ONE guard covers both
# agents deliberately - they are the same user-facing gesture
# ("summarise this node into a note") differing only in prompt, and
# letting a takeaway and an explainer run concurrently would race two
# notes onto overlapping canvas positions for no benefit.
# request_id -> True; no task/cancel_event to store, since the legacy
# workers these replace had no stop() either.
self._note_requests: dict[str, bool] = {}
# R5.4: Py-Coder's REPL subprocess outlives any single run (state
# persists between calls, same as legacy's own PyCoderReplManager -
# see that class's own docstring in graphlink_plugins/pycoder/domain.py
Expand Down Expand Up @@ -2204,6 +2218,96 @@ async def _invoke(fn, *a):
finally:
self._chart_requests.pop(request_id, None)

async def start_note_generation(
self,
*,
bus: SessionBus,
notifications_state,
node_id: str,
note_kind: str,
source_text: str,
on_success,
on_failure,
) -> None:
"""R8a: Key Takeaway / Explainer Note generation.

DIRECTLY AWAITED by its caller rather than scheduled via
asyncio.create_task, the same shape as start_chart_generation above
and for the same reason: the result is a brand new note node, so the
caller needs it back in the same round trip and there is no
pre-existing node to attach a spinner to.

`note_kind` selects the agent ("takeaway" | "explainer"). One method
rather than two near-identical ones because the two differ ONLY in
which agent class runs and how failures are worded - the guard,
timeout, callback and cleanup logic are identical, and duplicating
them would be two places to fix every future bug in.
"""
label = NOTE_AGENT_LABELS.get(note_kind, "Note")

if self._note_requests:
notifications_state.show(f"A {label.lower()} is already being generated.", "info")
await bus.publish("notification")
return

request_id = uuid.uuid4().hex
self._note_requests[request_id] = True

async def _invoke(fn, *a):
if inspect.iscoroutinefunction(fn):
await fn(*a)
else:
fn(*a)

try:
text = await asyncio.wait_for(
asyncio.to_thread(_call_note_agent, note_kind, source_text),
timeout=WATCHDOG_TIMEOUT_SECONDS,
)
if not str(text or "").strip():
# An agent that returns nothing usable must not silently
# create an empty note - that reads as a broken feature.
message = f"{label} generation returned an empty response. Please try again."
await _invoke(on_failure, message)
notifications_state.show(message, "error")
await bus.publish("notification")
else:
await _invoke(on_success, text)
except asyncio.TimeoutError:
message = (
f"{label} generation stopped responding before the request completed. "
"Please try again."
)
await _invoke(on_failure, message)
notifications_state.show(message, "error")
await bus.publish("notification")
except Exception as exc:
logger.exception("%s generation dispatch failed (source node %s)", label, node_id)
message = f"{label} generation failed: {exc}"
await _invoke(on_failure, message)
notifications_state.show(message, "error")
await bus.publish("notification")
finally:
self._note_requests.pop(request_id, None)


# R8a: the two note agents, keyed by the `note_kind` start_note_generation
# 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}


def _call_note_agent(note_kind: str, source_text: str) -> str:
"""Runs inside asyncio.to_thread - the blocking driver for
start_note_generation above, mirroring _call_chart_agent's own shape
(fresh agent instance per call). Returns the agent's already-cleaned
text; unlike the chart agent there is no JSON to parse."""
agent_cls = _NOTE_AGENTS.get(note_kind)
if agent_cls is None:
raise ValueError(f"unknown note kind: {note_kind}")
return agent_cls().get_response(source_text)


def _call_pycoder_execution_agent(conversation_history, user_prompt) -> str:
"""Runs inside asyncio.to_thread. Reuses PyCoderExecutionAgent.get_response
Expand Down
87 changes: 87 additions & 0 deletions backend/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,17 @@ def _decode_image_bytes(data):
# find_branch_position packing algorithm (a later refinement).
MESSAGE_VERTICAL_SPACING = 160

# R8a: where a generated Key Takeaway / Explainer Note lands relative to its
# source chat node, and how it is tinted. 400px clears a chat node's own
# width (~292px) with room to spare, matching the legacy offset. The colours
# are hex because the backend never resolves colour NAMES (see SceneNode's
# own comment) - these two are the frontend palette's "Mid Gray" body and
# "Blue" header, the closest surviving equivalents to legacy's Mid Gray +
# status_info pairing (there is no status_info token in the new stack).
NOTE_AGENT_X_OFFSET = 400
NOTE_AGENT_BODY_COLOR = "#7a7a7a"
NOTE_AGENT_HEADER_COLOR = "#3f7dc9"

# R6.1: Notes/Frames/Containers - legacy canvas decorations, ported for the
# first time. _recompute_group_bounds (below, on SceneDocument) is plain
# server-side math, NOT a React Flow extent/parentId feature - it computes a
Expand Down Expand Up @@ -3636,6 +3647,78 @@ def _on_failure(message):
)
return result_holder.get("node_id")

async def _generate_note_from_node(source_node_id, note_kind, x_offset, y_offset):
"""R8a: shared path for generateKeyTakeaway and generateExplainerNote.

Both take one chat node, run its text through an agent, and drop the
result into a new note beside it - identical except for the agent and
the note's offset, so they share one implementation rather than two
that can drift.

Source text is the node's OWN content, not the branch history that
generate_chart uses: legacy's takeaway/explainer passed a single
node's text, and widening that to the whole branch would change what
the feature summarises.
"""
if not source_node_id or source_node_id not in document.nodes:
notifications.show("Please select a valid node first.", "warning")
await bus.publish("notification")
return None

source = document.nodes[source_node_id]
if source.kind != "chat":
notifications.show("This node can't be summarised into a note.", "warning")
await bus.publish("notification")
return None
if not source.content or not source.content.strip():
notifications.show("The selected node has no text to summarise.", "warning")
await bus.publish("notification")
return None

result_holder: dict[str, str] = {}

async def _on_success(text):
if source_node_id not in document.nodes:
# Deleted mid-flight - silent no-op, same posture as
# _dispatch_image's own liveness check.
return
note = document.add_note(source.x + x_offset, source.y + y_offset)
document.set_note_content(note.id, text)
# Legacy tinted these notes "Mid Gray" with an info-coloured
# header. Both values come from the frontend's own palette
# (GroupColorPicker's GROUP_MONO_COLORS/GROUP_NAMED_COLORS) since
# the backend stores hex and never resolves a colour name. The
# legacy note width of 400 is NOT ported: note width is not a
# modeled field here (it is CSS-driven), so there is nothing to
# set it on.
document.set_group_color(note.id, NOTE_AGENT_BODY_COLOR, NOTE_AGENT_HEADER_COLOR)
result_holder["node_id"] = note.id
await bus.publish("scene")

def _on_failure(message):
# start_note_generation already surfaced the notification.
pass

await agent_dispatcher.start_note_generation(
bus=bus,
notifications_state=notifications,
node_id=source_node_id,
note_kind=note_kind,
source_text=source.content,
on_success=_on_success,
on_failure=_on_failure,
)
return result_holder.get("node_id")

async def generate_key_takeaway(source_node_id):
return await _generate_note_from_node(source_node_id, "takeaway", NOTE_AGENT_X_OFFSET, 0)

async def generate_explainer_note(source_node_id):
# Offset vertically as well as horizontally so a takeaway and an
# explainer generated from the same node don't land on top of each
# other - the same 100px stagger legacy used.
return await _generate_note_from_node(source_node_id, "explainer", NOTE_AGENT_X_OFFSET, 100)

async def resize_chart(node_id, width, height):
document.resize_chart(node_id, width, height)
await publish_scene()
Expand Down Expand Up @@ -4138,6 +4221,10 @@ async def set_view_state(zoom_factor, scroll_x, scroll_y):
# R6.2: Chart - a single combined create+generate action, unlike every
# node-creation flow above - see generate_chart's own docstring.
bus.register_intent("scene", "generateChart", generate_chart)
# R8a: the two note agents restored from the deleted Qt app - see
# graphlink_note_agent.py's own docstring for why they were dead stubs.
bus.register_intent("scene", "generateKeyTakeaway", generate_key_takeaway)
bus.register_intent("scene", "generateExplainerNote", generate_explainer_note)
bus.register_intent("scene", "resizeChart", resize_chart)
bus.register_intent("scene", "toggleChartAspectLock", toggle_chart_aspect_lock)
bus.register_intent("scene", "moveNode", move_node)
Expand Down
10 changes: 10 additions & 0 deletions backend/tests/test_agent_layer_qt_free.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,16 @@ def test_chart_agent_imports_qt_free():
_assert_import_is_qt_free("graphlink_chart_agent")


def test_note_agent_imports_qt_free():
# R8a: graphlink_note_agent.py restores KeyTakeawayAgent/ExplainerAgent,
# which were lost when R7.6b deleted graphlink_app/ (they lived in
# graphlink_agents_core.py alongside QThread workers). Their menu items
# had been disabled stubs ever since. backend/agents.py imports this
# module at module scope, so it must stay Qt-free or it re-taints the
# entire backend import graph.
_assert_import_is_qt_free("graphlink_note_agent")


def test_chart_rendering_imports_qt_free():
# R6.2: graphlink_chart_rendering.py ports the legacy ChartItem's
# Matplotlib rendering (already Qt-free upstream: matplotlib.use("Agg")
Expand Down
121 changes: 121 additions & 0 deletions backend/tests/test_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -4466,3 +4466,124 @@ async def run():
assert notifications.msg_type == "error"

asyncio.run(run())


# -- R8a: note agents (Key Takeaway / Explainer Note) -------------------------
#
# These two were implemented in the deleted Qt app and never ported, leaving
# their menu items as disabled stubs. Mocking follows the chart/artifact seam
# (patch the agent CLASS's get_response), not the api_provider.chat seam,
# because agents.py constructs a fresh agent instance per call.


def _make_note_env():
bus = SessionBus("agents-note-test")
notifications = NotificationState()
bus.register_topic("notification", notifications.payload)
bus.register_topic("scene", lambda: {})
dispatcher = AgentDispatcher(_FakeSettingsManager())
return bus, notifications, dispatcher


def test_start_note_generation_takeaway_calls_on_success_then_clears_the_slot(monkeypatch):
monkeypatch.setattr(
agents_module.KeyTakeawayAgent, "get_response",
lambda self, text: f"Key Takeaway\n\nMain Points:\n• from {text}",
)

async def run():
bus, notifications, dispatcher = _make_note_env()
successes, failures = [], []
await dispatcher.start_note_generation(
bus=bus, notifications_state=notifications, node_id="n1",
note_kind="takeaway", source_text="the source node's text",
on_success=successes.append, on_failure=failures.append,
)
assert successes == ["Key Takeaway\n\nMain Points:\n• from the source node's text"]
assert failures == []
assert dispatcher._note_requests == {}
assert notifications.visible is False

asyncio.run(run())


def test_start_note_generation_explainer_uses_the_explainer_agent(monkeypatch):
# note_kind must actually select the agent - a regression here would
# silently produce takeaways for both menu items.
monkeypatch.setattr(agents_module.KeyTakeawayAgent, "get_response", lambda self, text: "TAKEAWAY")
monkeypatch.setattr(agents_module.ExplainerAgent, "get_response", lambda self, text: "EXPLAINER")

async def run():
bus, notifications, dispatcher = _make_note_env()
got = []
await dispatcher.start_note_generation(
bus=bus, notifications_state=notifications, node_id="n1",
note_kind="explainer", source_text="x",
on_success=got.append, on_failure=lambda m: None,
)
assert got == ["EXPLAINER"]

asyncio.run(run())


def test_start_note_generation_rejects_a_second_concurrent_run(monkeypatch):
monkeypatch.setattr(agents_module.KeyTakeawayAgent, "get_response", lambda self, text: "ok")

async def run():
bus, notifications, dispatcher = _make_note_env()
dispatcher._note_requests["already-running"] = True
successes = []
await dispatcher.start_note_generation(
bus=bus, notifications_state=notifications, node_id="n1",
note_kind="takeaway", source_text="x",
on_success=successes.append, on_failure=lambda m: None,
)
assert successes == [], "the busy guard must not run a second agent"
assert notifications.visible is True
assert notifications.msg_type == "info"
# The pre-existing sentinel must survive - the guard rejects, it
# must never clear someone else's in-flight slot.
assert dispatcher._note_requests == {"already-running": True}

asyncio.run(run())


def test_start_note_generation_empty_response_fails_instead_of_creating_a_blank_note(monkeypatch):
monkeypatch.setattr(agents_module.KeyTakeawayAgent, "get_response", lambda self, text: " ")

async def run():
bus, notifications, dispatcher = _make_note_env()
successes, failures = [], []
await dispatcher.start_note_generation(
bus=bus, notifications_state=notifications, node_id="n1",
note_kind="takeaway", source_text="x",
on_success=successes.append, on_failure=failures.append,
)
assert successes == [], "an empty agent response must not become a note"
assert len(failures) == 1
assert notifications.msg_type == "error"
assert dispatcher._note_requests == {}

asyncio.run(run())


def test_start_note_generation_agent_exception_surfaces_and_clears_the_slot(monkeypatch):
def _boom(self, text):
raise RuntimeError("model exploded")

monkeypatch.setattr(agents_module.KeyTakeawayAgent, "get_response", _boom)

async def run():
bus, notifications, dispatcher = _make_note_env()
successes, failures = [], []
await dispatcher.start_note_generation(
bus=bus, notifications_state=notifications, node_id="n1",
note_kind="takeaway", source_text="x",
on_success=successes.append, on_failure=failures.append,
)
assert successes == []
assert "model exploded" in failures[0]
assert notifications.msg_type == "error"
assert dispatcher._note_requests == {}, "the slot must not leak after a failure"

asyncio.run(run())
Loading
Loading