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 docs/architecture/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ A chat thread can be the agent's. opencode, bundled in the installer and started

## Which threads get it

A new thread is the agent's when the selected text model is in `TESTED_MODELS` or `SURFSENSE_LOCAL_AGENT_UNTESTED_MODELS=1` is set in the environment Electron starts the API from ([`engine_choice.py`](../../surfsense_local/backend/modules/agent/engine_choice.py)). `TESTED_MODELS` is empty: a model goes on it once it passes the agent test at a window of 32,768 tokens or more ([proposal](../proposals/agent/01-which-engine.md#what-tested-means)). When opencode is not staged, or does not become ready, the thread opens as a chat. A thread keeps its engine; its row stores the opencode session that holds its turns ([data model](data-model.md)).
A new thread is the agent's when the selected text model is in `TESTED_MODELS`, or `SURFSENSE_LOCAL_AGENT_UNTESTED_MODELS=1` is set in the environment Electron starts the API from, and the model is known to call tools: llama-server reports `supports_tool_calls` for a local model's template, or the remote catalog records `tool_call: true` for the model under its connection's catalog provider. A catalog that says nothing, or a runtime that cannot be read, counts as no ([`engine_choice.py`](../../surfsense_local/backend/modules/agent/engine_choice.py)). `TESTED_MODELS` is empty: a model goes on it once it passes the agent test at a window of 32,768 tokens or more ([proposal](../proposals/agent/01-which-engine.md#what-tested-means)). When opencode is not staged, or does not become ready, the thread opens as a chat. A thread keeps its engine; its row stores the opencode session that holds its turns ([data model](data-model.md)). Once the selected model changes to one that fails the check, a turn sent to the thread is refused with `409` before any prompt is sent: the user chooses another model or starts a new chat.

## From a thread to a running opencode

Expand Down Expand Up @@ -96,7 +96,7 @@ opencode needs no host beyond loopback: its model is the model endpoint, and its
## Known gaps

- No model is on the tested list, and no agent test exists to put one there; only the developer switch lets a model in.
- The engine choice does not check that the model can call tools. With the switch on, a model whose template has no tool support gets the agent, llama.cpp drops its tools without a warning, and the agent can take no step.
- A turn refused because the selected model cannot run the agent shows as an error with Retry; the composer does not offer a new thread, as the [proposal](../proposals/agent/01-which-engine.md#when-a-thread-gets-its-engine) has it.
- The model endpoint neither simplifies tool schemas to what llama.cpp's grammar takes nor turns a tool call a model writes as text into a real one, so small local models stall where a remote one would not.
- The agent has none of SurfSense's own tools: it cannot use hybrid search or start a Studio job, and a file it writes to `outputs/` does not become an artifact.
- A configuration rewrite, which a change of model or window causes, ends every running agent turn in every workspace.
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,11 @@ A turn can carry images, and a model that reads them receives them; every other

## Agent threads

How the agent runs is in [agent](agent.md). A thread is opened for the agent instead of the chat when the selected model may run it: a model on the tested list, which is empty, or any model while `SURFSENSE_LOCAL_AGENT_UNTESTED_MODELS=1` ([`modules/agent/engine_choice.py`](../../surfsense_local/backend/modules/agent/engine_choice.py)). Opening such a thread starts opencode if it is not running, waits until it serves SurfSense's configuration, and opens an opencode session under the thread's title; the session's id is stored on the thread, and `ThreadRead` reports `uses_agent`. When opencode is not part of the install or is not ready, the thread opens as a chat. A thread keeps the engine it was opened with ([agent proposal](../proposals/agent/01-which-engine.md)).
How the agent runs is in [agent](agent.md). A thread is opened for the agent instead of the chat when the selected model may run it: a model on the tested list, which is empty, or any model while `SURFSENSE_LOCAL_AGENT_UNTESTED_MODELS=1`, and only one known to call tools ([`modules/agent/engine_choice.py`](../../surfsense_local/backend/modules/agent/engine_choice.py), [agent](agent.md#which-threads-get-it)). Opening such a thread starts opencode if it is not running, waits until it serves SurfSense's configuration, and opens an opencode session under the thread's title; the session's id is stored on the thread, and `ThreadRead` reports `uses_agent`. When opencode is not part of the install or is not ready, the thread opens as a chat. A thread keeps the engine it was opened with ([agent proposal](../proposals/agent/01-which-engine.md)).

The same routes then reach the agent ([`modules/agent/agent_threads/`](../../surfsense_local/backend/modules/agent/agent_threads/)):

- **Sending** brings the workspace's sources folder in line, renames a thread still called "New chat" after its first message's first 60 characters, and streams the turn as opencode runs it. The frames are the chat's (`accepted`, `thread-title-update` after it, `delta`, `reasoning`, `reasoning-end`, `completed`, `error`, `[DONE]`) plus `agent-step` for each tool call as its status moves (`id`, `tool`, `status`, `title`, `input`, `output` up to 4,000 characters, `error`), `permission-request` (`id`, `permission`, `patterns`, `command`) and `permission-replied`. `completed` carries the reply as opencode stored it. Message ids are opencode's: a user turn's own, and `<user message id>:reply` for its reply. Closing the stream stops the turn. Images are refused with `409`.
- **Sending** brings the workspace's sources folder in line, renames a thread still called "New chat" after its first message's first 60 characters, and streams the turn as opencode runs it. The frames are the chat's (`accepted`, `thread-title-update` after it, `delta`, `reasoning`, `reasoning-end`, `completed`, `error`, `[DONE]`) plus `agent-step` for each tool call as its status moves (`id`, `tool`, `status`, `title`, `input`, `output` up to 4,000 characters, `error`), `permission-request` (`id`, `permission`, `patterns`, `command`) and `permission-replied`. `completed` carries the reply as opencode stored it. Message ids are opencode's: a user turn's own, and `<user message id>:reply` for its reply. Closing the stream stops the turn. Images are refused with `409`, and so is any turn once the selected model can no longer run the agent.
- **Listing** reads the session from opencode: each user turn, then one assistant reply joining the text and the steps of every assistant message opencode wrote for it, with `content.steps` beside `content.text`.
- **Deleting** stops the session's turn and deletes the session first, and deleting a workspace does the same for each of its agent threads before the rows go.

Expand Down
2 changes: 1 addition & 1 deletion docs/proposals/agent/01-which-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
## Tool support on the local runtime

- llama.cpp's parser rewrite, [PR #18675](https://github.com/ggml-org/llama.cpp/pull/18675) (merged 6 Mar 2026), removed the fallback that let any model emit tool calls. Its description says "a functional template with tool calling is required if someone wants tool calling".
- At `b11050`, llama-server reports a template's capabilities in `GET /props` under `chat_template_caps`. The keys include `supports_tools` and `supports_tool_calls` ([`common/jinja/caps.cpp`](https://github.com/ggml-org/llama.cpp/blob/b11050/common/jinja/caps.cpp)). Tool calls are parsed only when `supports_tool_calls` is true ([`common/chat-auto-parser-generator.cpp`](https://github.com/ggml-org/llama.cpp/blob/b11050/common/chat-auto-parser-generator.cpp)). The app reads `supports_tools` ([`capabilities.py`](../../../surfsense_local/backend/modules/llm/providers/llamacpp/capabilities.py)); the engine check needs `supports_tool_calls`.
- At `b11050`, llama-server reports a template's capabilities in `GET /props` under `chat_template_caps`. The keys include `supports_tools` and `supports_tool_calls` ([`common/jinja/caps.cpp`](https://github.com/ggml-org/llama.cpp/blob/b11050/common/jinja/caps.cpp)). Tool calls are parsed only when `supports_tool_calls` is true ([`common/chat-auto-parser-generator.cpp`](https://github.com/ggml-org/llama.cpp/blob/b11050/common/chat-auto-parser-generator.cpp)). The app reads both ([`capabilities.py`](../../../surfsense_local/backend/modules/llm/providers/llamacpp/capabilities.py)), and the engine check uses `supports_tool_calls`.
- A request that carries `tools` to a model whose template has no tool support still succeeds, and the tools are dropped without a warning ([llama.cpp#27129](https://github.com/ggml-org/llama.cpp/issues/27129), open, reported against `b10423`).
- Among the curated manifest's seven chat models, `template.tools` is `true` for the six Qwen3 models and `false` for `gemma-3-4b`. Its three image models carry no `template` ([`models.json`](../../../surfsense_local/backend/modules/llm/catalog/local/manifest/models.json)).

Expand Down
2 changes: 1 addition & 1 deletion docs/proposals/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Every fact here links to where it was checked: this repo, opencode `v1.18.34` (c
| **Workflows** | [`04-workflows.md`](04-workflows.md) | Studio's structured output, the formatting pass, the text fallback | nothing; can start now |
| **Sources folder** | [`05-sources-folder.md`](05-sources-folder.md) | linking a folder on disk as the Sources root | nothing; independent |

opencode came first, on its own file tools over the sources folder: what runs is in [agent](../../architecture/agent.md). Next are SurfSense's tools ([`02-tools.md`](02-tools.md)) and the engine choice's tool-support check ([`01-which-engine.md`](01-which-engine.md)), then the workflows and the linked sources folder, in parallel.
opencode came first, on its own file tools over the sources folder, and only for a model known to call tools ([`01-which-engine.md`](01-which-engine.md)): what runs is in [agent](../../architecture/agent.md). Next are SurfSense's tools ([`02-tools.md`](02-tools.md)), then the workflows and the linked sources folder, in parallel.

## Locked decisions

Expand Down
9 changes: 9 additions & 0 deletions surfsense_local/backend/modules/agent/agent_threads/turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from api.dependencies import transact
from modules.agent.agent_threads.replies import turn_reply
from modules.agent.agent_threads.turn_frames import TurnFrames
from modules.agent.engine_choice import selected_model_can_run_agent
from modules.agent.opencode_client import OpencodeVersionError
from modules.agent.opencode_runtime import (
AgentUnavailableError,
Expand Down Expand Up @@ -60,6 +61,14 @@ async def agent_turn(
raise HTTPException(status.HTTP_409_CONFLICT, str(error)) from error
except (AgentUnavailableError, OpencodeVersionError) as error:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(error)) from error
# Checked once the model resolves, so a thread left with no model still asks for one.
if not await selected_model_can_run_agent(session):
await ready.client.close()
raise HTTPException(
status.HTTP_409_CONFLICT,
"The selected model cannot run the agent. Choose another model, "
"or start a new chat to use this one.",
)
folder = await transact(session, sync_sources_folder, thread.workspace_id)
title = _first_title(thread, payload.text)
if title is not None:
Expand Down
57 changes: 52 additions & 5 deletions surfsense_local/backend/modules/agent/engine_choice.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,68 @@
"""Which engine a new thread gets: the agent for a tested model, the chat for every other.
"""Which engine a new thread gets: the agent for a tested model that can call tools, the chat for every other.

docs/proposals/agent/01-which-engine.md. A thread keeps what it got.
"""

import logging

from sqlalchemy.orm import Session

from api.dependencies import transact
from modules.llm.catalog.remote.manifest.loader import remote_lookup
from modules.llm.catalog.remote.rows import CUSTOM
from modules.llm.model_type import ModelType
from modules.llm.models import SelectedModel
from modules.llm.models import ProviderConnection, SelectedModel
from modules.llm.providers import get_provider, llamacpp
from shared.config import get_agent_settings

logger = logging.getLogger(__name__)

# Models that passed the agent's test at a window of 32,768 tokens or more.
TESTED_MODELS: frozenset[str] = frozenset()


def agent_answers(session: Session) -> bool:
"""Whether a thread opened now, with the model selected now, should be the agent's."""
async def selected_model_can_run_agent(session: Session) -> bool:
"""Whether the selected text model may run the agent: tested, and known to call tools."""
found = await transact(session, _selected)
if found is None:
return False
selected, catalog_provider = found
if not (
selected.name in TESTED_MODELS or get_agent_settings().agent_untested_models
):
return False
if selected.provider == llamacpp.PROVIDER:
return await _local_calls_tools(selected.name)
return _catalog_calls_tools(selected.name, catalog_provider)


def _selected(session: Session) -> tuple[SelectedModel, str | None] | None:
"""The selected text model and the catalog provider its connection names, if any is selected."""
selected = session.get(SelectedModel, ModelType.TEXT_GEN)
if selected is None:
return None
connection = (
session.get(ProviderConnection, selected.connection_id)
if selected.connection_id
else None
)
return selected, connection.catalog_provider if connection else None


async def _local_calls_tools(name: str) -> bool:
"""Whether llama-server parses the model's tool calls, loading it if it is not resident."""
provider = get_provider(llamacpp.PROVIDER)
if not isinstance(provider, llamacpp.LlamaCppProvider):
return False # pragma: no cover - fixed registry invariant
try:
return (await provider.capabilities(name)).tool_calls
except Exception:
logger.warning("could not read whether %s calls tools", name, exc_info=True)
return False
return selected.name in TESTED_MODELS or get_agent_settings().agent_untested_models


def _catalog_calls_tools(name: str, catalog_provider: str | None) -> bool:
"""Whether the remote catalog says the model calls tools; one it says nothing of does not."""
provider = None if catalog_provider in (None, CUSTOM) else catalog_provider
found = remote_lookup().classify(name, provider=provider)
return found.supports is not None and found.supports.tool_call is True
4 changes: 2 additions & 2 deletions surfsense_local/backend/modules/chat/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from modules.agent.agent_threads.thread_messages import agent_thread_messages
from modules.agent.agent_threads.turn import agent_turn
from modules.agent.dependencies import LaunchKeyDep
from modules.agent.engine_choice import agent_answers
from modules.agent.engine_choice import selected_model_can_run_agent
from modules.chat.budget import IMAGE_TOKENS, answer_max_tokens, history_budget
from modules.chat.dependencies import ThreadDep
from modules.chat.errors import classify_chat_error, empty_reply_error
Expand Down Expand Up @@ -76,7 +76,7 @@ async def create_thread(
Chosen here and kept: the thread's turns live with whichever engine got it.
"""
thread = await transact(session, _new_thread, workspace.id, payload.title)
if await transact(session, agent_answers):
if await selected_model_can_run_agent(session):
session_id = await open_agent_session(session, thread, launch_key)
if session_id is not None:
await transact(session, _give_to_agent, thread, session_id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ class Capabilities:
system_role: bool
typed_content: bool
tools: bool
# Whether llama-server parses the model's tool calls out of its reply; a
# template can render tools and still have none parsed (common/chat-auto-parser-generator.cpp).
tool_calls: bool
context_tokens: int | None

@property
Expand Down Expand Up @@ -74,5 +77,6 @@ def read_capabilities(model_id: str, models: dict, props: dict) -> Capabilities:
system_role=bool(template.get("supports_system_role", True)),
typed_content=bool(template.get("supports_typed_content", False)),
tools=bool(template.get("supports_tools", False)),
tool_calls=bool(template.get("supports_tool_calls", False)),
context_tokens=settings.get("n_ctx"),
)
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
wait_until_healthy,
)

# The catalog records it as calling tools for the provider the connection names,
# so a thread opened with it is the agent's; the scripted model answers in its place.
AGENT_MODEL = "gpt-4o-mini"


@dataclass
class Received:
Expand Down Expand Up @@ -173,6 +177,7 @@ async def agent_api(
label="Remote",
provider="openai_compatible",
base_url=f"{scripted_model.url}/v1",
catalog_provider="openai",
)
connection.api_key = "remote-key"
session.add(connection)
Expand All @@ -182,7 +187,7 @@ async def agent_api(
model_type=ModelType.TEXT_GEN,
provider="openai_compatible",
connection_id=connection.id,
name=MODEL,
name=AGENT_MODEL,
)
)
session.commit()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,34 @@ async def test_a_message_streams_the_agents_reply(agent_api: AgentAPI) -> None:
assert frames[-1] == {"type": "done"}


async def test_a_thread_cannot_continue_with_a_model_that_cannot_call_tools(
agent_api: AgentAPI,
) -> None:
"""The thread stays the agent's, and opencode would take no step with that model."""
thread = await open_thread(agent_api)
current = (await agent_api.http.get("/llm/selection/text_gen")).json()
chosen = await agent_api.http.put(
"/llm/selection/text_gen",
json={
"provider": "openai_compatible",
"connection_id": current["connection_id"],
"name": "gpt-3.5-turbo",
"allow_unlisted": True,
},
)
chosen.raise_for_status()

reply = await agent_api.http.post(
f"/chat/threads/{thread['id']}/messages", json={"text": "What happened in Q3?"}
)

assert reply.status_code == 409
assert "new chat" in reply.json()["detail"]
assert agent_api.model.requests == []
turns = await agent_api.http.get(f"/chat/threads/{thread['id']}/messages")
assert turns.json() == []


async def test_the_sources_are_in_the_folder_before_the_turn(
agent_api: AgentAPI,
) -> None:
Expand Down
Loading
Loading