From 609adea006a6080e7279b1c57b1abee072844a1e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 22 Jul 2026 19:34:56 +0200 Subject: [PATCH 01/15] feat: add RUN citation source type --- .../app/agents/chat/multi_agent_chat/shared/citations/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/citations/models.py b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/citations/models.py index 1273271af..31a0e3372 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/citations/models.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/citations/models.py @@ -17,6 +17,7 @@ class CitationSourceType(StrEnum): WEB_RESULT = "web_result" CHAT_TURN = "chat_turn" ANON_CHUNK = "anon_chunk" + RUN = "run" class CitationEntry(BaseModel): From 81f2d694bd6e2ec37f46197dbbf2ac69ccd24379 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 22 Jul 2026 19:34:56 +0200 Subject: [PATCH 02/15] feat: map run citation to run_ payload --- .../multi_agent_chat/shared/citations/markers.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/citations/markers.py b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/citations/markers.py index 025d364f6..61d1cbd8b 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/citations/markers.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/citations/markers.py @@ -1,10 +1,11 @@ """Map a registered citation to the frontend ``[citation:]`` payload. The citation renderer understands a chunk id (``42``), a negative chunk id for -anonymous uploads (``-3``), and a URL. This is the seam that turns a server-side -source into one the renderer can resolve; it grows as more source kinds become -renderable. Kinds with no renderable form yet return ``None`` so the marker is -dropped rather than emitted broken. +anonymous uploads (``-3``), a URL, and a scraper-run handle (``run_``). +This is the seam that turns a server-side source into one the renderer can +resolve; it grows as more source kinds become renderable. Kinds with no +renderable form yet return ``None`` so the marker is dropped rather than +emitted broken. """ from __future__ import annotations @@ -22,6 +23,9 @@ def to_frontend_payload(entry: CitationEntry) -> str | None: case CitationSourceType.WEB_RESULT: url = locator.get("url") return url or None + case CitationSourceType.RUN: + run_id = locator.get("run_id") + return str(run_id) if run_id else None case _: # Connector items and chat turns have no client-side renderer yet # (the frontend resolves only chunk ids and URLs), so they stay From 957e57bb82b8c71e627640a6fe32912a0532c761 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 22 Jul 2026 19:34:56 +0200 Subject: [PATCH 03/15] feat: register a scraper run as a citation --- .../capabilities/core/access/run_citation.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 surfsense_backend/app/capabilities/core/access/run_citation.py diff --git a/surfsense_backend/app/capabilities/core/access/run_citation.py b/surfsense_backend/app/capabilities/core/access/run_citation.py new file mode 100644 index 000000000..9b7c3f3db --- /dev/null +++ b/surfsense_backend/app/capabilities/core/access/run_citation.py @@ -0,0 +1,23 @@ +"""Register a recorded scraper run as a citable ``[n]``.""" + +from __future__ import annotations + +from app.agents.chat.multi_agent_chat.shared.citations import ( + CitationRegistry, + CitationSourceType, +) + + +def attach_run_citation( + registry: CitationRegistry, + *, + run_external_id: str, + capability: str, +) -> tuple[int, str]: + """Register the ``run_`` handle; return its ``[n]`` and the label line.""" + n = registry.register( + CitationSourceType.RUN, + {"run_id": run_external_id}, + {"capability": capability}, + ) + return n, f"\n\nCite this scraper run as [{n}] after any claim drawn from its data." From c1ba4215fd3af4a102fd8efb19acd6d369dc859a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 22 Jul 2026 19:34:56 +0200 Subject: [PATCH 04/15] feat: mint run citation from capability tool --- .../app/capabilities/core/access/agent.py | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/surfsense_backend/app/capabilities/core/access/agent.py b/surfsense_backend/app/capabilities/core/access/agent.py index 4aa569e24..35c35774f 100644 --- a/surfsense_backend/app/capabilities/core/access/agent.py +++ b/surfsense_backend/app/capabilities/core/access/agent.py @@ -11,9 +11,13 @@ subagent can follow a truncation reference without extra wiring. from __future__ import annotations +import json import time +from langchain.tools import ToolRuntime +from langchain_core.messages import ToolMessage from langchain_core.tools import BaseTool, StructuredTool +from langgraph.types import Command from app.capabilities.core.billing import charge_capability, gate_capability from app.capabilities.core.progress import progress_scope @@ -64,7 +68,7 @@ def _capability_tool(capability: Capability, workspace_id: int) -> BaseTool: executor = capability.executor name = capability.name - async def _run(**kwargs: object) -> dict | str: + async def _run(runtime: ToolRuntime, **kwargs: object) -> dict | str | Command: payload = input_model(**kwargs) input_dump = payload.model_dump(exclude_none=True) thread_id = _current_thread_id() @@ -119,13 +123,39 @@ def _capability_tool(capability: Capability, workspace_id: int) -> BaseTool: progress=reporter.coarse, ) + # No stored run to cite: keep the legacy return shape, no citation. + if run_id is None: + if serialized.char_count <= RUN_OUTPUT_CHAR_CAP: + return output.model_dump(exclude_none=True) + return _build_preview(serialized, run_id) + + run_external_id = f"run_{run_id}" if serialized.char_count <= RUN_OUTPUT_CHAR_CAP: dump = output.model_dump(exclude_none=True) - if run_id is not None: - dump["run_id"] = f"run_{run_id}" - return dump + dump["run_id"] = run_external_id + content = json.dumps(dump, ensure_ascii=False, default=str) + else: + content = _build_preview(serialized, run_id) - return _build_preview(serialized, run_id) + # Deferred import: the citation spine imports from here; lazy avoids a cycle. + from app.agents.chat.multi_agent_chat.shared.citations import load_registry + from app.capabilities.core.access.run_citation import attach_run_citation + + registry = load_registry(getattr(runtime, "state", None)) + _, label = attach_run_citation( + registry, run_external_id=run_external_id, capability=name + ) + return Command( + update={ + "messages": [ + ToolMessage( + content=content + label, + tool_call_id=runtime.tool_call_id, + ) + ], + "citation_registry": registry, + } + ) return StructuredTool.from_function( coroutine=_run, From 95b4afddebcb72d6bdfd80212171c70504496032 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 22 Jul 2026 19:34:56 +0200 Subject: [PATCH 05/15] feat: declare citation channel for subagents --- .../chat/multi_agent_chat/subagents/middleware_stack.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/middleware_stack.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/middleware_stack.py index 124ccf704..4181180d1 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/middleware_stack.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/middleware_stack.py @@ -15,6 +15,9 @@ from __future__ import annotations from typing import Any from app.agents.chat.multi_agent_chat.shared.feature_flags import AgentFeatureFlags +from app.agents.chat.multi_agent_chat.shared.middleware.citation_state import ( + build_citation_state_mw, +) from app.agents.chat.multi_agent_chat.shared.middleware.resilience import ( ResilienceMiddlewares, ) @@ -45,6 +48,8 @@ def build_subagent_middleware_stack( return { "todos": build_todos_mw(), "permission": permission, + # Declares the citation_registry channel so run [n]s merge up from tools. + "citation": build_citation_state_mw(), "retry": resilience.retry, "fallback": resilience.fallback, "model_call_limit": resilience.model_call_limit, From dd5d267056ef623a2a78381e4c7edc49e3a30735 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 22 Jul 2026 19:34:56 +0200 Subject: [PATCH 06/15] docs: instruct agents to cite scraper runs --- .../main_agent/system_prompt/prompts/citations/on.md | 5 +++-- .../subagents/shared/snippets/output_contract_base.md | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/citations/on.md b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/citations/on.md index 371cd1e57..effe5078c 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/citations/on.md +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/citations/on.md @@ -1,8 +1,9 @@ Cite with one token: the bracket label `[n]`. Every citable result — prose from a `task` knowledge_base/research specialist (including the -knowledge_base specialist's `[n]`-labelled workspace findings) — already -carries `[n]` labels on a single shared count. +knowledge_base specialist's `[n]`-labelled workspace findings) and +scraper specialists' run-backed findings — already carries `[n]` labels +on a single shared count. Those labels are the only citation you write; the server resolves each one back to its source after the turn. diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/shared/snippets/output_contract_base.md b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/shared/snippets/output_contract_base.md index 35fd48814..cd3b94400 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/shared/snippets/output_contract_base.md +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/shared/snippets/output_contract_base.md @@ -5,3 +5,4 @@ Rules (universal): - `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. - `assumptions`: any inferences you made about the user's intent; `null` when no inferences were needed. - The `evidence` object's fields are documented in your route-specific `` above; never invent fields the tool did not return. +- When a finding is drawn from a scraper run, append that run's `[n]` (the tool result states `Cite this scraper run as [n]`) to the finding text so the citation survives into the final answer. Copy the label exactly; never invent one. From 57d6182ca3f20be86d1c62d092e03abfdd82e44f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 22 Jul 2026 19:35:04 +0200 Subject: [PATCH 07/15] feat: parse run citation tokens --- surfsense_web/lib/citations/citation-parser.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/surfsense_web/lib/citations/citation-parser.ts b/surfsense_web/lib/citations/citation-parser.ts index 533c644c2..3c2f0196c 100644 --- a/surfsense_web/lib/citations/citation-parser.ts +++ b/surfsense_web/lib/citations/citation-parser.ts @@ -11,18 +11,20 @@ import { FENCED_OR_INLINE_CODE } from "@/lib/markdown/code-regions"; /** * Matches `[citation:...]` with numeric IDs (incl. negative, doc- prefix, - * comma-separated), URL-based IDs from live web search, or `urlciteN` - * placeholders produced by `preprocessCitationMarkdown`. + * comma-separated), URL-based IDs from live web search, `urlciteN` + * placeholders produced by `preprocessCitationMarkdown`, or a scraper-run + * handle (`run_`). * * Also matches Chinese brackets 【】 and zero-width spaces that LLMs * sometimes emit. */ export const CITATION_REGEX = - /[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+|urlcite\d+|(?:doc-)?-?\d+(?:\s*,\s*(?:doc-)?-?\d+)*)\s*\u200B?[\]】]/g; + /[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+|urlcite\d+|run_[0-9a-fA-F-]+|(?:doc-)?-?\d+(?:\s*,\s*(?:doc-)?-?\d+)*)\s*\u200B?[\]】]/g; /** A single parsed citation reference. */ export type CitationToken = | { kind: "url"; url: string } + | { kind: "run"; runId: string } | { kind: "chunk"; chunkId: number; isDocsChunk: boolean }; /** Output of `parseTextWithCitations` — interleaved text + citation tokens. */ @@ -102,6 +104,8 @@ export function parseTextWithCitations(text: string, urlMap: CitationUrlMap): Pa if (url) { segments.push({ kind: "url", url }); } + } else if (captured.startsWith("run_")) { + segments.push({ kind: "run", runId: captured.trim() }); } else { const rawIds = captured.split(",").map((s) => s.trim()); for (const rawId of rawIds) { From 979ad02ef3006791b41230516f6ae71fb7091e76 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 22 Jul 2026 19:35:04 +0200 Subject: [PATCH 08/15] feat: render run citation chip --- .../citations/citation-renderer.tsx | 4 +++ .../components/citations/run-citation.tsx | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 surfsense_web/components/citations/run-citation.tsx diff --git a/surfsense_web/components/citations/citation-renderer.tsx b/surfsense_web/components/citations/citation-renderer.tsx index f2de4b27d..d3d78aa39 100644 --- a/surfsense_web/components/citations/citation-renderer.tsx +++ b/surfsense_web/components/citations/citation-renderer.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation"; +import { RunCitation } from "@/components/citations/run-citation"; import { type CitationToken, type CitationUrlMap, @@ -21,6 +22,9 @@ export function renderCitationToken(token: CitationToken, ordinalKey: number): R if (token.kind === "url") { return ; } + if (token.kind === "run") { + return ; + } return ( = ({ runId }) => { + const openRunPanel = useSetAtom(openRunCitationPanelAtom); + + return ( + + + + + View scraper run + + ); +}; From b86eadb8474069d7756ea383e738a1e592c7313f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 22 Jul 2026 19:35:04 +0200 Subject: [PATCH 09/15] feat: open runs in the citation panel --- .../atoms/citation/citation-panel.atom.ts | 26 ++++++---- .../citations/run-citation-panel.tsx | 47 +++++++++++++++++++ .../layout/ui/right-panel/RightPanel.tsx | 28 +++++++++-- 3 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 surfsense_web/components/citations/run-citation-panel.tsx diff --git a/surfsense_web/atoms/citation/citation-panel.atom.ts b/surfsense_web/atoms/citation/citation-panel.atom.ts index ca7312857..f92fd6f90 100644 --- a/surfsense_web/atoms/citation/citation-panel.atom.ts +++ b/surfsense_web/atoms/citation/citation-panel.atom.ts @@ -1,14 +1,19 @@ -import { atom } from "jotai"; +import { atom, type Getter, type Setter } from "jotai"; import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom"; +/** The source the citation panel is showing: a KB chunk or a scraper run. */ +export type CitationTarget = + | { kind: "chunk"; chunkId: number } + | { kind: "run"; runId: string }; + interface CitationPanelState { isOpen: boolean; - chunkId: number | null; + target: CitationTarget | null; } const initialState: CitationPanelState = { isOpen: false, - chunkId: null, + target: null, }; export const citationPanelAtom = atom(initialState); @@ -17,16 +22,21 @@ export const citationPanelOpenAtom = atom((get) => get(citationPanelAtom).isOpen const preCitationCollapsedAtom = atom(null); -export const openCitationPanelAtom = atom(null, (get, set, payload: { chunkId: number }) => { +function openWithTarget(get: Getter, set: Setter, target: CitationTarget) { if (!get(citationPanelAtom).isOpen) { set(preCitationCollapsedAtom, get(rightPanelCollapsedAtom)); } - set(citationPanelAtom, { - isOpen: true, - chunkId: payload.chunkId, - }); + set(citationPanelAtom, { isOpen: true, target }); set(rightPanelTabAtom, "citation"); set(rightPanelCollapsedAtom, false); +} + +export const openCitationPanelAtom = atom(null, (get, set, payload: { chunkId: number }) => { + openWithTarget(get, set, { kind: "chunk", chunkId: payload.chunkId }); +}); + +export const openRunCitationPanelAtom = atom(null, (get, set, payload: { runId: string }) => { + openWithTarget(get, set, { kind: "run", runId: payload.runId }); }); export const closeCitationPanelAtom = atom(null, (get, set) => { diff --git a/surfsense_web/components/citations/run-citation-panel.tsx b/surfsense_web/components/citations/run-citation-panel.tsx new file mode 100644 index 000000000..3b7af7ca1 --- /dev/null +++ b/surfsense_web/components/citations/run-citation-panel.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { XIcon } from "lucide-react"; +import { useParams } from "next/navigation"; +import type { FC } from "react"; +import { RunDetail } from "@/app/dashboard/[workspace_id]/playground/components/run-detail"; +import { Button } from "@/components/ui/button"; + +/** Right-panel viewer for a cited scraper run. `runId` is the `run_` handle. */ +export const RunCitationPanelContent: FC<{ runId: string; onClose?: () => void }> = ({ + runId, + onClose, +}) => { + const params = useParams(); + const rawWorkspaceId = Array.isArray(params?.workspace_id) + ? params.workspace_id[0] + : params?.workspace_id; + const workspaceId = Number(rawWorkspaceId); + const scraperRunId = runId.replace(/^run_/, ""); + + return ( + <> +
+

Scraper run

+ {onClose && ( + + )} +
+ +
+ {Number.isFinite(workspaceId) ? ( + + ) : ( +

Open a workspace to view this run.

+ )} +
+ + ); +}; diff --git a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx index 15025f4eb..362e676d8 100644 --- a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx +++ b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx @@ -35,6 +35,14 @@ const CitationPanelContent = dynamic( { ssr: false, loading: () => null } ); +const RunCitationPanelContent = dynamic( + () => + import("@/components/citations/run-citation-panel").then((m) => ({ + default: m.RunCitationPanelContent, + })), + { ssr: false, loading: () => null } +); + const HitlEditPanelContent = dynamic( () => import("@/features/chat-messages/hitl").then((m) => ({ @@ -89,7 +97,7 @@ export function RightPanelToggleButton({ ? !!editorState.memoryScope : !!editorState.localFilePath); const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; - const citationOpen = citationState.isOpen && citationState.chunkId != null; + const citationOpen = citationState.isOpen && citationState.target != null; const hasContent = reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen; const label = collapsed ? "Expand panel" : "Collapse panel"; @@ -141,7 +149,7 @@ export function RightPanelExpandButton() { ? !!editorState.memoryScope : !!editorState.localFilePath); const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; - const citationOpen = citationState.isOpen && citationState.chunkId != null; + const citationOpen = citationState.isOpen && citationState.target != null; const hasContent = reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen; if (!collapsed || !hasContent) return null; @@ -212,7 +220,7 @@ export function RightPanel({ showTopBorder = false }: RightPanelProps) { ? !!editorState.memoryScope : !!editorState.localFilePath); const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; - const citationOpen = citationState.isOpen && citationState.chunkId != null; + const citationOpen = citationState.isOpen && citationState.target != null; useEffect(() => { if (!reportOpen && !editorOpen && !hitlEditOpen && !citationOpen && !artifactsOpen) return; @@ -306,9 +314,19 @@ export function RightPanel({ showTopBorder = false }: RightPanelProps) { /> )} - {effectiveTab === "citation" && citationOpen && citationState.chunkId != null && ( + {effectiveTab === "citation" && citationOpen && citationState.target && (
- + {citationState.target.kind === "run" ? ( + + ) : ( + + )}
)} {effectiveTab === "artifacts" && artifactsOpen && ( From 120eb1f31b155e9cd2d27bec71b503ab8c8644a6 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 22 Jul 2026 19:35:04 +0200 Subject: [PATCH 10/15] test: cover run citation registration --- .../shared/citations/test_markers.py | 12 ++++++ .../capabilities/access/test_run_citation.py | 43 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 surfsense_backend/tests/unit/capabilities/access/test_run_citation.py diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/shared/citations/test_markers.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/shared/citations/test_markers.py index 53cf058a8..5710bc7a6 100644 --- a/surfsense_backend/tests/unit/agents/multi_agent_chat/shared/citations/test_markers.py +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/shared/citations/test_markers.py @@ -37,6 +37,18 @@ def test_web_result_maps_to_url() -> None: assert to_frontend_payload(entry) == "https://example.com/a" +def test_run_maps_to_run_handle() -> None: + entry = _entry(CitationSourceType.RUN, {"run_id": "run_abc-123"}) + + assert to_frontend_payload(entry) == "run_abc-123" + + +def test_run_without_handle_is_dropped() -> None: + entry = _entry(CitationSourceType.RUN, {}) + + assert to_frontend_payload(entry) is None + + def test_not_yet_renderable_kind_is_dropped() -> None: entry = _entry(CitationSourceType.CHAT_TURN, {"thread_id": 1, "turn": 2}) diff --git a/surfsense_backend/tests/unit/capabilities/access/test_run_citation.py b/surfsense_backend/tests/unit/capabilities/access/test_run_citation.py new file mode 100644 index 000000000..6da9e5f29 --- /dev/null +++ b/surfsense_backend/tests/unit/capabilities/access/test_run_citation.py @@ -0,0 +1,43 @@ +"""Unit tests for registering a scraper run as a citation.""" + +from __future__ import annotations + +import pytest + +from app.agents.chat.multi_agent_chat.shared.citations import ( + CitationRegistry, + CitationSourceType, + to_frontend_payload, +) +from app.capabilities.core.access.run_citation import attach_run_citation + +pytestmark = pytest.mark.unit + + +def test_attaches_run_and_returns_label_with_ordinal() -> None: + registry = CitationRegistry() + + n, label = attach_run_citation( + registry, run_external_id="run_abc-123", capability="walmart.scrape" + ) + + assert n == 1 + assert f"[{n}]" in label + entry = registry.resolve(n) + assert entry is not None + assert entry.source_type is CitationSourceType.RUN + assert to_frontend_payload(entry) == "run_abc-123" + + +def test_same_run_dedups_to_one_label() -> None: + registry = CitationRegistry() + + first, _ = attach_run_citation( + registry, run_external_id="run_x", capability="walmart.scrape" + ) + again, _ = attach_run_citation( + registry, run_external_id="run_x", capability="walmart.reviews" + ) + + assert first == again + assert len(registry.by_n) == 1 From d356605a0dccc96dca1ca84f55248e6f58a39b63 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 22 Jul 2026 19:35:04 +0200 Subject: [PATCH 11/15] test: capability tool mints run citation --- .../capabilities/access/test_agent_tools.py | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py b/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py index cdab68bc0..fb8f0c540 100644 --- a/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py +++ b/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py @@ -69,6 +69,12 @@ def _verb_tool(tools, name: str): return next(t for t in tools if t.name == name) +def _invoke(tool, text: str, *, state=None): + """Call the coroutine with a stand-in runtime (ToolNode injects it in prod).""" + runtime = SimpleNamespace(state=state or {}, tool_call_id="tc_1", context=None) + return tool.coroutine(runtime, text=text) + + async def test_registry_becomes_one_tool_per_verb_plus_readers(isolate): caps = [ _capability(name="web.scrape", output=_EchoOutput(echoed="a")), @@ -104,20 +110,40 @@ async def test_tool_runs_executor_and_returns_serialized_output(isolate): tools = isolate.module.build_capability_tools(workspace_id=7, capabilities=[cap]) tool = _verb_tool(tools, "web_scrape") - result = await tool.ainvoke({"text": "ping"}) + result = await _invoke(tool, "ping") # Fake session makes record_run fail -> no run_id key, plain serialized output. assert result == {"echoed": "hi there"} assert cap.executor.seen.text == "ping" +async def test_tool_registers_run_citation_when_stored(isolate, monkeypatch): + from langgraph.types import Command + + cap = _capability(name="web.scrape", output=_EchoOutput(echoed="hi")) + monkeypatch.setattr(isolate.module, "record_run", AsyncMock(return_value="abc-123")) + tools = isolate.module.build_capability_tools(workspace_id=7, capabilities=[cap]) + tool = _verb_tool(tools, "web_scrape") + + result = await _invoke(tool, "ping") + + assert isinstance(result, Command) + registry = result.update["citation_registry"] + entry = registry.resolve(1) + assert entry is not None + assert entry.locator["run_id"] == "run_abc-123" + message = result.update["messages"][0] + assert "[1]" in message.content + assert "run_abc-123" in message.content + + async def test_tool_charges_owner(isolate): output = _EchoOutput(echoed="hi") cap = _capability(name="web.scrape", output=output) tools = isolate.module.build_capability_tools(workspace_id=7, capabilities=[cap]) tool = _verb_tool(tools, "web_scrape") - await tool.ainvoke({"text": "ping"}) + await _invoke(tool, "ping") isolate.charge.assert_awaited_once() (charged_output, unit, ctx), _ = isolate.charge.call_args @@ -136,7 +162,7 @@ async def test_over_budget_returns_friendly_message(isolate): tools = isolate.module.build_capability_tools(workspace_id=7, capabilities=[cap]) tool = _verb_tool(tools, "web_scrape") - result = await tool.ainvoke({"text": "ping"}) + result = await _invoke(tool, "ping") assert isinstance(result, str) assert "credit" in result.lower() From 6c8d109d761efe91728bc58ade166bc0ef84423d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 22 Jul 2026 19:35:04 +0200 Subject: [PATCH 12/15] test: parse run citation tokens --- .../lib/citations/citation-parser.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 surfsense_web/lib/citations/citation-parser.test.ts diff --git a/surfsense_web/lib/citations/citation-parser.test.ts b/surfsense_web/lib/citations/citation-parser.test.ts new file mode 100644 index 000000000..1f5749f2f --- /dev/null +++ b/surfsense_web/lib/citations/citation-parser.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { parseTextWithCitations } from "./citation-parser"; + +// Run with: pnpm exec tsx --test lib/citations/citation-parser.test.ts + +const NO_URLS = new Map(); + +test("parses a scraper-run handle into a run token", () => { + const segments = parseTextWithCitations( + "Price is $19 [citation:run_1b2c3d4e-5f60-7081-9abc-def012345678].", + NO_URLS + ); + + assert.deepEqual(segments, [ + "Price is $19 ", + { kind: "run", runId: "run_1b2c3d4e-5f60-7081-9abc-def012345678" }, + ".", + ]); +}); + +test("run handles do not collide with numeric chunk citations", () => { + const segments = parseTextWithCitations( + "chunk [citation:42] vs run [citation:run_ab-12].", + NO_URLS + ); + + assert.deepEqual(segments, [ + "chunk ", + { kind: "chunk", chunkId: 42, isDocsChunk: false }, + " vs run ", + { kind: "run", runId: "run_ab-12" }, + ".", + ]); +}); From 950de5e670d919b7ee7076821de09772b7d7fb11 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 22 Jul 2026 20:06:18 +0200 Subject: [PATCH 13/15] fix: forward ToolRuntime to capability tools --- surfsense_backend/app/capabilities/core/access/agent.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/surfsense_backend/app/capabilities/core/access/agent.py b/surfsense_backend/app/capabilities/core/access/agent.py index 35c35774f..3d3864a53 100644 --- a/surfsense_backend/app/capabilities/core/access/agent.py +++ b/surfsense_backend/app/capabilities/core/access/agent.py @@ -157,6 +157,9 @@ def _capability_tool(capability: Capability, workspace_id: int) -> BaseTool: } ) + # Un-stringify for StructuredTool's signature-based runtime injection. + _run.__annotations__["runtime"] = ToolRuntime + return StructuredTool.from_function( coroutine=_run, name=name.replace(".", "_"), From 47d35faeedf91dca7dbb5416f1af6ea7f8873548 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 22 Jul 2026 20:06:18 +0200 Subject: [PATCH 14/15] test: guard capability tool runtime injection --- .../unit/capabilities/access/test_agent_tools.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py b/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py index fb8f0c540..3d4bed199 100644 --- a/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py +++ b/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py @@ -137,6 +137,17 @@ async def test_tool_registers_run_citation_when_stored(isolate, monkeypatch): assert "run_abc-123" in message.content +async def test_runtime_survives_langchain_arg_parsing(isolate): + """runtime must survive langchain arg parsing (else ToolNode drops it).""" + cap = _capability(name="web.scrape", output=_EchoOutput(echoed="hi")) + tools = isolate.module.build_capability_tools(workspace_id=7, capabilities=[cap]) + tool = _verb_tool(tools, "web_scrape") + + parsed = tool._parse_input({"text": "x", "runtime": "RT"}, "tc_1") + + assert parsed["runtime"] == "RT" + + async def test_tool_charges_owner(isolate): output = _EchoOutput(echoed="hi") cap = _capability(name="web.scrape", output=output) From 3f23f436fb57180a05b1d93876f37790649dc853 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 22 Jul 2026 21:01:05 +0200 Subject: [PATCH 15/15] refactor(citations): rename run chip to Source for non-technical users --- surfsense_web/components/citations/run-citation.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/surfsense_web/components/citations/run-citation.tsx b/surfsense_web/components/citations/run-citation.tsx index ef388373b..d8799a24c 100644 --- a/surfsense_web/components/citations/run-citation.tsx +++ b/surfsense_web/components/citations/run-citation.tsx @@ -19,13 +19,13 @@ export const RunCitation: FC<{ runId: string }> = ({ runId }) => { variant="ghost" onClick={() => openRunPanel({ runId })} className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline" - aria-label="View scraper run" + aria-label="See where this came from" > - run + Source - View scraper run + See where this came from ); };