diff --git a/src/lembas/api/admin_agents.py b/src/lembas/api/admin_agents.py
index 257cf0b..a65e86a 100644
--- a/src/lembas/api/admin_agents.py
+++ b/src/lembas/api/admin_agents.py
@@ -108,6 +108,8 @@ async def save_agents(
# directory for the file picker but put none of it in the
# prompt", which nothing else can say.
"index_chars": min(max(index_chars, 0), 20_000),
+ "instructions_enabled": instructions_enabled,
+ "instructions_chars": min(max(instructions_chars, 0), 20_000),
},
key=settings_store.AGENTS,
)
diff --git a/src/lembas/api/admin_models.py b/src/lembas/api/admin_models.py
index 3ab158f..9604f3e 100644
--- a/src/lembas/api/admin_models.py
+++ b/src/lembas/api/admin_models.py
@@ -36,6 +36,7 @@ PROTOCOL_CAPABILITIES = ("reasoning", "vision", "tools")
# page nobody can read.
TOOL_CAPABILITIES = (
("tool_web_search", "Web search"),
+ ("tool_fetch", "Fetch a page"),
("tool_knowledge", "Knowledge"),
("tool_notes", "Notes"),
("tool_memory", "Memory"),
diff --git a/src/lembas/api/admin_search.py b/src/lembas/api/admin_search.py
index c6f6c44..f5deff7 100644
--- a/src/lembas/api/admin_search.py
+++ b/src/lembas/api/admin_search.py
@@ -57,6 +57,7 @@ async def save_search(
firecrawl_api_key: str = Form(""),
timeout: float = Form(20.0),
allow_private_fetch: bool = Form(False),
+ fetch_enabled: bool = Form(False),
) -> Response:
current = settings_store.search(db)
known = {p.key for p in search_service.PROVIDERS}
@@ -79,6 +80,7 @@ async def save_search(
),
"timeout": min(max(timeout, 5.0), 120.0),
"allow_private_fetch": allow_private_fetch,
+ "fetch_enabled": fetch_enabled,
},
key=settings_store.SEARCH,
)
diff --git a/src/lembas/security/permissions.py b/src/lembas/security/permissions.py
index b3e6e4b..90af794 100644
--- a/src/lembas/security/permissions.py
+++ b/src/lembas/security/permissions.py
@@ -86,6 +86,15 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
True,
"Chat",
),
+ PermissionDef(
+ "tools.fetch",
+ "Fetch a page",
+ "Let a model retrieve one web page and read it, given its address. "
+ "Addresses on this machine and this network are refused unless an "
+ "administrator has allowed them.",
+ True,
+ "Chat",
+ ),
PermissionDef(
"tools.custom",
"Use custom tools",
diff --git a/src/lembas/services/agent/instructions.py b/src/lembas/services/agent/instructions.py
new file mode 100644
index 0000000..f9315c8
--- /dev/null
+++ b/src/lembas/services/agent/instructions.py
@@ -0,0 +1,212 @@
+"""The project's own notes on how to work in it — AGENTS.md, CLAUDE.md.
+
+A file in the root of the project directory, read once per reply and put in the
+system message. Everything about the shape of this module is copied from
+`index.py`, and for the same three reasons:
+
+* **`cached()` never does work.** `harness.context_variables` is synchronous and
+ runs on the request path, so an SFTP round trip from there would hold a
+ request open while somebody's box thought about it. The build happens in
+ `generation._warm_project`, which is async and already doing network work.
+* **`ensure()` shares one build between concurrent callers**, via `_BUILDING`
+ and `asyncio.shield`.
+* **Each name catches its own `ExecError`.** This is the ladder lesson from
+ `index.py` arriving before the bug does: an `AGENTS.md` that cannot be read --
+ a permission, an SFTP-only account, a directory where a file was expected --
+ must not stop `CLAUDE.md` being tried.
+
+The contents are **untrusted**, and go into the *system* message of a chat that
+can run commands. Nothing here can fix that; what does is the wording of the
+`context.agent_instructions` fragment, which names where the file came from and
+bounds what it is allowed to do. Two things are done here: control characters
+are stripped, and backticks are neutralised so the file cannot close the fence
+it is put inside and start writing what looks like our own prose.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import posixpath
+import re
+import time
+from dataclasses import dataclass
+
+from lembas.services.agent.base import ExecError, Executor
+
+log = logging.getLogger(__name__)
+
+# In order. AGENTS.md first because it is the vendor-neutral convention a shared
+# repository is likeliest to carry; CLAUDE.md next because it is the one most
+# widely written in practice. Root only, no recursion: a per-directory
+# convention is a different feature with a different cost model.
+NAMES = ("AGENTS.md", "CLAUDE.md", "AGENT.md", ".agents.md")
+
+TTL = 300.0
+MAX_CACHED = 64
+
+# The default ceiling on what reaches the prompt. The admin setting wins.
+MAX_CHARS = 4000
+
+_CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
+
+
+@dataclass(frozen=True)
+class Instructions:
+ """What was found in the project root, and where."""
+
+ filename: str = ""
+ text: str = ""
+ built_at: float = 0.0
+
+ @property
+ def ok(self) -> bool:
+ return bool(self.filename and self.text.strip())
+
+
+def clean(raw: str) -> str:
+ """Made safe to put inside a fenced block in a system message."""
+ text = _CONTROL.sub("", raw).replace("\r\n", "\n").replace("\r", "\n")
+ # It must not be able to close our fence and carry on in what then reads as
+ # our own voice. Replaced rather than escaped: this is a display of somebody
+ # else's file, not a round trip.
+ return text.replace("```", "'''")
+
+
+async def build(executor: Executor, budget: int = MAX_CHARS) -> Instructions:
+ """Look for each name in turn, and stop at the first one that reads."""
+ for name in NAMES:
+ try:
+ # Four bytes a character is generous for UTF-8 prose and stops a
+ # two-megabyte file being pulled across to be thrown away.
+ raw = await executor.read_file(name, max_bytes=max(budget, 1) * 4)
+ except ExecError:
+ # Its own catch, per name. A rung that raises must not end the
+ # ladder -- that bug has already been paid for once in index.py.
+ continue
+ except Exception: # noqa: BLE001 - a warm-up must never kill a reply
+ log.debug("could not read %s", name, exc_info=True)
+ continue
+
+ text = clean(raw)
+ if text.strip():
+ return Instructions(filename=name, text=text, built_at=time.monotonic())
+
+ return Instructions(built_at=time.monotonic())
+
+
+# --- The cache ---------------------------------------------------------------
+# Keyed on the connection and the directory, exactly as the listing is: two
+# chats on one tree are looking at the same file.
+_CACHE: dict[tuple[str, str], Instructions] = {}
+_BUILDING: dict[tuple[str, str], asyncio.Task] = {}
+
+
+def cached(profile_id: str, project_dir: str) -> Instructions | None:
+ """What is already known, or None. Never does any work.
+
+ A miss is not "there is no file" -- it is "nobody has looked yet", and the
+ fragment's `requires` turns both into the same thing: no section at all.
+ """
+ found = _CACHE.get((profile_id, project_dir))
+ if found is None:
+ return None
+ if time.monotonic() - found.built_at > TTL:
+ _CACHE.pop((profile_id, project_dir), None)
+ return None
+ return found
+
+
+async def ensure(
+ executor: Executor,
+ profile_id: str,
+ project_dir: str,
+ *,
+ budget: int = MAX_CHARS,
+ refresh: bool = False,
+) -> Instructions:
+ key = (profile_id, project_dir)
+ if refresh:
+ _CACHE.pop(key, None)
+ elif (found := cached(profile_id, project_dir)) is not None:
+ return found
+
+ if (running := _BUILDING.get(key)) is not None:
+ return await asyncio.shield(running)
+
+ task = asyncio.create_task(build(executor, budget))
+ _BUILDING[key] = task
+ try:
+ found = await task
+ finally:
+ _BUILDING.pop(key, None)
+
+ _CACHE[key] = found
+ while len(_CACHE) > MAX_CACHED:
+ _CACHE.pop(next(iter(_CACHE)))
+ return found
+
+
+def is_instruction_file(path: str, project_dir: str) -> bool:
+ """Whether a written path is the file this module caches.
+
+ Resolved against the project directory rather than matched on the basename,
+ so `./AGENTS.md`, `AGENTS.md` and `/work/AGENTS.md` are all it and
+ `docs/AGENTS.md` is not -- root only, the same rule `build` follows. A
+ basename match would drop the cache every time any subdirectory's own
+ AGENTS.md was touched, which is a fetch nobody asked for.
+ """
+ wanted = path.strip()
+ if not wanted:
+ return False
+ if not posixpath.isabs(wanted) and project_dir:
+ wanted = posixpath.join(project_dir, wanted)
+ wanted = posixpath.normpath(wanted)
+ return any(
+ wanted == posixpath.normpath(posixpath.join(project_dir or "", name)) for name in NAMES
+ )
+
+
+def forget(profile_id: str, project_dir: str) -> None:
+ """Drop it, because something just rewrote it.
+
+ The one case the TTL cannot cover: this process changing the file it has
+ just quoted. Unlike the directory listing, an *edit* counts here as much as
+ a write -- the listing only cares that the file exists, this cares what is
+ in it.
+ """
+ _CACHE.pop((profile_id, project_dir), None)
+
+
+def clear() -> None:
+ _CACHE.clear()
+
+
+def render(found: Instructions | None, budget: int) -> str:
+ """The text, within the budget, cut at a line boundary."""
+ if found is None or not found.ok or budget <= 0:
+ return ""
+ text = found.text.strip()
+ if len(text) <= budget:
+ return text
+ cut = text[:budget]
+ at = cut.rfind("\n")
+ if at > budget // 2:
+ cut = cut[:at]
+ return f"{cut.rstrip()}\n… (truncated)"
+
+
+__all__ = [
+ "MAX_CHARS",
+ "NAMES",
+ "TTL",
+ "Instructions",
+ "build",
+ "cached",
+ "clean",
+ "clear",
+ "ensure",
+ "forget",
+ "is_instruction_file",
+ "render",
+]
diff --git a/src/lembas/services/agent/tools.py b/src/lembas/services/agent/tools.py
index 73e58a9..8ab0612 100644
--- a/src/lembas/services/agent/tools.py
+++ b/src/lembas/services/agent/tools.py
@@ -23,7 +23,7 @@ import posixpath
from typing import Any
from lembas.services import plans
-from lembas.services.agent import index, patch, policy
+from lembas.services.agent import index, instructions, patch, policy
from lembas.services.agent.base import ExecError, ExecRequest
from lembas.services.agent.session import AgentContext
from lembas.services.tools import (
@@ -184,6 +184,16 @@ def _path_key(agent: AgentContext, path: str) -> str:
return posixpath.normpath(path)
+def _forget_instructions(agent: AgentContext, path: str) -> None:
+ """Drop the cached AGENTS.md when the thing just written *is* it.
+
+ The one case its TTL cannot cover: this process changing the file it has
+ been quoting into every request for the last five minutes.
+ """
+ if agent.profile_id and instructions.is_instruction_file(path, agent.project_dir):
+ instructions.forget(agent.profile_id, agent.project_dir)
+
+
async def _current(agent: AgentContext, path: str) -> tuple[str, bool]:
"""What is in the file now, and whether it is safe to diff against.
@@ -265,6 +275,7 @@ async def _run_write(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
# written does not exist.
if agent.profile_id:
index.forget_dir(agent.profile_id, agent.project_dir)
+ _forget_instructions(agent, path)
event = _event("file_write", agent, path, status="ok", text=f"{written} bytes")
if diffable and before != content:
@@ -334,7 +345,10 @@ async def _run_edit(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
# Deliberately NOT index.forget_dir: an edit does not change the listing,
# because the file was already there. Forgetting it would cost the next
# reply either a wait on `INDEX_WAIT` or a turn with no listing at all, and
- # buy nothing.
+ # buy nothing. The instruction file is the opposite case -- the listing only
+ # cares that it exists, that cache is a copy of what is in it.
+ _forget_instructions(agent, path)
+
event = _event("file_edit", agent, path, status="ok", text=f"{written} bytes")
if diffable:
event["diff"] = patch.render(before, after, path, max_lines=MAX_DIFF_LINES)
diff --git a/src/lembas/services/fetch.py b/src/lembas/services/fetch.py
index 6fa064d..377a3fd 100644
--- a/src/lembas/services/fetch.py
+++ b/src/lembas/services/fetch.py
@@ -47,6 +47,27 @@ _DROPPED = re.compile(
re.IGNORECASE | re.DOTALL,
)
_TITLE = re.compile(r"
]*>(.*?)", re.IGNORECASE | re.DOTALL)
+
+# Content types that are text but are not spelled `text/*`. The sniff below was
+# written for "save this page into my library" and refused every one of them,
+# which meant every JSON API there is -- wrong for the link-attach path already,
+# and unusable once a model can ask for a URL itself. Widened by exactly this
+# list plus the `+json` / `+xml` suffixes, and no further: images, PDFs and
+# application/octet-stream still raise, because handing a model five megabytes
+# of binary is the thing the refusal was for.
+_TEXTUAL = frozenset(
+ {
+ "application/json",
+ "application/xml",
+ "application/xhtml+xml",
+ "application/javascript",
+ "application/x-ndjson",
+ "application/yaml",
+ "application/x-yaml",
+ "application/toml",
+ "application/sql",
+ }
+)
# Tags that end a line of prose. Turning them into newlines before the tags are
# stripped is the difference between readable text and one enormous paragraph.
_BREAKS = re.compile(
@@ -193,9 +214,15 @@ async def fetch(url: str, *, allow_private: bool = False) -> Fetched:
payload = response.content[:MAX_PAGE_BYTES]
content_type = response.headers.get("content-type", "")
+ bare = content_type.split(";")[0].strip().lower()
if "html" in content_type or payload[:512].lstrip()[:1] == b"<":
title, text = html_to_text(payload.decode(response.encoding or "utf-8", "replace"))
- elif content_type.startswith("text/") or not content_type:
+ elif (
+ content_type.startswith("text/")
+ or not content_type
+ or bare in _TEXTUAL
+ or bare.endswith(("+json", "+xml"))
+ ):
title, text = "", payload.decode(response.encoding or "utf-8", "replace")
else:
raise FetchError(
diff --git a/src/lembas/services/generation.py b/src/lembas/services/generation.py
index 7a2c20b..9a4b97d 100644
--- a/src/lembas/services/generation.py
+++ b/src/lembas/services/generation.py
@@ -327,7 +327,7 @@ async def _run(generation: Generation) -> None:
# listing is an SSH round trip, and holding a database session across
# one to save opening a second is the wrong trade. `build_request`
# below reads whatever this left in the cache and never fetches.
- await _warm_index(generation)
+ await _warm_project(generation)
with session_scope() as db:
chat = db.get(Chat, generation.chat_id)
@@ -611,27 +611,34 @@ async def _run(generation: Generation) -> None:
INDEX_WAIT = 6.0
-async def _warm_index(generation: Generation) -> None:
- """Build this chat's project listing, or leave whatever is cached.
+async def _warm_project(generation: Generation) -> None:
+ """Fill this chat's project caches: the directory listing, and AGENTS.md.
- Never raises and never blocks for long. `harness` reads the cache
+ Never raises and never blocks for long. `harness` reads both caches
synchronously while assembling the system message, so something has to fill
- it, and this is the one place in a reply's life that is both asynchronous
- and already doing network work.
+ them, and this is the one place in a reply's life that is both asynchronous
+ and already doing network work. One function for both because it already
+ resolves the chat, the owner and the context, and doing that twice would be
+ two sessions for nothing.
The first reply in a brand-new chat on a big tree may start before the walk
- finishes. That is deliberate: the fragment carrying the listing vanishes
- when it is empty rather than appearing as a heading with nothing under it,
- and by the following turn it is there.
+ finishes. That is deliberate: the fragments carrying them vanish when they
+ are empty rather than appearing as headings with nothing under them, and by
+ the following turn they are there.
+
+ **The skip is per cache.** It used to be one early return on the listing
+ being present, and bolting a second cache on behind that would have meant
+ the new one was silently never warmed on any chat that had a listing --
+ which is to say, on every chat after the first reply.
"""
from lembas.services import settings_store
from lembas.services.agent import index as index_service
+ from lembas.services.agent import instructions as instructions_service
from lembas.services.agent import session as agent_session
try:
with session_scope() as db:
- if not settings_store.agents(db).get("index_enabled"):
- return
+ values = settings_store.agents(db)
chat = db.get(Chat, generation.chat_id)
if chat is None or chat.kind != KIND_AGENT:
return
@@ -640,13 +647,26 @@ async def _warm_index(generation: Generation) -> None:
profile_id = chat.ssh_profile_id or ""
if context is None or not profile_id:
return
- if index_service.cached(profile_id, context.project_dir) is not None:
+
+ where = (profile_id, context.project_dir)
+ jobs = []
+ if values.get("index_enabled") and index_service.cached(*where) is None:
+ jobs.append(
+ index_service.ensure(context.executor(), profile_id, context.project_dir)
+ )
+ if values.get("instructions_enabled") and instructions_service.cached(*where) is None:
+ jobs.append(
+ instructions_service.ensure(
+ context.executor(),
+ profile_id,
+ context.project_dir,
+ budget=int(values.get("instructions_chars") or 0),
+ )
+ )
+ if not jobs:
return
- await asyncio.wait_for(
- index_service.ensure(context.executor(), profile_id, context.project_dir),
- timeout=INDEX_WAIT,
- )
+ await asyncio.wait_for(asyncio.gather(*jobs), timeout=INDEX_WAIT)
except TimeoutError:
log.debug("index for chat %s outran its wait; carrying on", generation.chat_id)
except Exception as exc: # noqa: BLE001 - a missing listing is not a failed reply
diff --git a/src/lembas/services/harness.py b/src/lembas/services/harness.py
index 4e23d03..5f86163 100644
--- a/src/lembas/services/harness.py
+++ b/src/lembas/services/harness.py
@@ -141,6 +141,9 @@ def context_variables(
"agent_mode": "",
"agent_rewound": "",
"project_files": "",
+ "agent_instructions": "",
+ "agent_instructions_file": "",
+ "plan": "",
}
if chat is not None:
@@ -196,9 +199,34 @@ def _agent_values(db: DBSession, chat, user) -> dict[str, str]:
# `agent_session.resolve`. A plan the model cannot see is a plan it
# cannot keep current, which is the whole of why this is here.
"plan": plans_service.render_block(context.plan),
+ **_project_instructions(db, chat, context, settings_store),
}
+def _project_instructions(db: DBSession, chat, context, settings_store) -> dict[str, str]:
+ """The project's own AGENTS.md, from cache and never fetched.
+
+ Written to mirror `_project_files` line for line, and under the same rule:
+ `cached()` only. `generation._warm_project` is what fills it.
+ """
+ from lembas.services.agent import instructions as instructions_service
+
+ agents = settings_store.agents(db)
+ blank = {"agent_instructions": "", "agent_instructions_file": ""}
+ if not agents.get("instructions_enabled"):
+ return blank
+ budget = int(agents.get("instructions_chars") or 0)
+ if budget <= 0:
+ return blank
+
+ profile_id = getattr(chat, "ssh_profile_id", "") or ""
+ found = instructions_service.cached(profile_id, context.project_dir)
+ text = instructions_service.render(found, budget)
+ if not text:
+ return blank
+ return {"agent_instructions": text, "agent_instructions_file": found.filename}
+
+
def _project_files(db: DBSession, chat, context, settings_store, index_service) -> str:
"""The directory listing, *read from cache and never fetched*.
diff --git a/src/lembas/services/prompts.py b/src/lembas/services/prompts.py
index 87670c5..3cedcdf 100644
--- a/src/lembas/services/prompts.py
+++ b/src/lembas/services/prompts.py
@@ -753,6 +753,25 @@ BUILTIN: tuple[Fragment, ...] = (
"result, with its URL."
),
),
+ Fragment(
+ key="tool.fetch",
+ label="Fetching a page",
+ group=GROUP_TOOLS,
+ order=205,
+ families=("fetch",),
+ hint="Appears when the fetch tool is offered. The sentence about "
+ "JavaScript is the one that earns its place: an empty page is the "
+ "commonest confusing result, and without it a model concludes the "
+ "page is gone rather than that it could not be read.",
+ default=(
+ "- You can read one web page at a time with fetch, given its address. Use "
+ "it after a search when the snippet is not enough, on a link somebody gave "
+ "you, or on a link inside a page you have just read. It returns the page's "
+ "text with the markup gone and cannot run JavaScript, so a page that comes "
+ "back empty is usually one that builds itself in the browser rather than "
+ "one that is missing. Quote the address of anything you take from it."
+ ),
+ ),
Fragment(
key="tool.knowledge",
label="Knowledge library",
diff --git a/src/lembas/services/settings_store.py b/src/lembas/services/settings_store.py
index 739e004..746a178 100644
--- a/src/lembas/services/settings_store.py
+++ b/src/lembas/services/settings_store.py
@@ -111,6 +111,11 @@ def _agents_defaults() -> dict[str, Any]:
# Characters. Clamped on read: a huge value here would quietly spend
# somebody's whole context window on filenames.
"index_chars": 2000,
+ # A file in the project root -- AGENTS.md, CLAUDE.md -- saying how to
+ # work in that project. Read off somebody else's disk, so it is
+ # untrusted, and the fragment carrying it is where that is dealt with.
+ "instructions_enabled": True,
+ "instructions_chars": 4000,
}
@@ -158,6 +163,11 @@ def _search_defaults() -> dict[str, Any]:
# be pointed at a router's admin page or at LLeMbas itself, and the URL
# can come from a model. See services/fetch.py.
"allow_private_fetch": False,
+ # Whether a *model* may ask for a page itself. Separate from the switch
+ # above, and separate from web search: attaching a link is a person's
+ # instruction, while this is a model choosing an address -- possibly one
+ # it read in a page it just fetched.
+ "fetch_enabled": True,
}
diff --git a/src/lembas/services/tools.py b/src/lembas/services/tools.py
index 08c7841..2d06bc4 100644
--- a/src/lembas/services/tools.py
+++ b/src/lembas/services/tools.py
@@ -96,6 +96,7 @@ FAMILY_AGENT = "agent"
# The built-in families, in the order they are offered.
FAMILIES = (
FAMILY_SEARCH,
+ FAMILY_FETCH,
FAMILY_KNOWLEDGE,
FAMILY_NOTES,
FAMILY_MEMORY,
@@ -125,6 +126,12 @@ RISK_ASK = "ask"
RISKS = (RISK_READ, RISK_WRITE, RISK_EXECUTE, RISK_ASK)
+# How much of a fetched page reaches the model. `fetch()` returns up to 120_000
+# characters, which is roughly thirty thousand tokens -- one call would fill an
+# ordinary window and, in an agent chat, spend the whole output budget on a
+# single page. Cut with the model told so, rather than refused.
+MAX_FETCH_CHARS = 20_000
+
@dataclass
class ToolContext:
@@ -270,6 +277,52 @@ async def _run_web_search(context: ToolContext, args: dict[str, Any]) -> ToolOut
return ToolOutcome("\n".join(lines), event)
+# --- Fetching one page ---------------------------------------------------------
+async def _run_fetch(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
+ """Retrieve one URL and hand back its text.
+
+ Straight through `services/fetch.py`, which owns the SSRF guard, the
+ hand-rolled redirect loop that re-checks every hop, and the content-type
+ sniff. Deliberately not a second HTTP client: CLAUDE.md already names three
+ places that follow redirects by hand as the ceiling, and a fourth is how one
+ of them loses its check.
+ """
+ from lembas.services import fetch as fetch_service
+
+ url = str(args.get("url") or "").strip()
+ if not url:
+ return ToolOutcome(
+ "No address was given.",
+ {"name": "fetch", "status": "error", "error": "No URL."},
+ )
+
+ try:
+ page = await fetch_service.fetch(
+ url, allow_private=bool(context.search_config.get("allow_private_fetch"))
+ )
+ except fetch_service.FetchError as exc:
+ # Its messages are already written to be shown to a person, which is
+ # close enough to being written for a model to act on.
+ return ToolOutcome(
+ f"That page could not be read: {exc.message}",
+ {"name": "fetch", "query": url, "status": "error", "error": exc.message},
+ )
+
+ text = page.text[:MAX_FETCH_CHARS]
+ cut = page.truncated or len(page.text) > MAX_FETCH_CHARS
+ event = {
+ "name": "fetch",
+ "kind": "fetch",
+ "query": page.title or url,
+ "detail": page.url,
+ "status": "ok",
+ "results": [],
+ "text": text[:2000],
+ }
+ note = "\n\n(The page was longer than this and has been cut off.)" if cut else ""
+ return ToolOutcome(f"{page.title}\n{page.url}\n\n{text}{note}", event)
+
+
# --- Knowledge ---------------------------------------------------------------
async def _run_knowledge_search(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
query = str(args.get("query") or "").strip()
@@ -626,6 +679,31 @@ REGISTRY: dict[str, ToolDef] = {
),
run=_run_web_search,
),
+ ToolDef(
+ name="fetch",
+ family=FAMILY_FETCH,
+ description=(
+ "Retrieve one web page and read it as text. Use it on an address "
+ "you already have — from a search result, from the person you are "
+ "talking to, or from a link in a page you have just read. "
+ "Redirects are followed and the markup is removed, so what comes "
+ "back is the prose rather than the HTML. It cannot run "
+ "JavaScript: a page that comes back empty is usually one that "
+ "builds itself in the browser rather than one that is missing. It "
+ "is not a general HTTP client — GET only, no headers, no body — "
+ "and a long page is cut off at the end."
+ ),
+ parameters=_object(
+ {
+ "url": {
+ **_STRING,
+ "description": "The http or https address of the page.",
+ }
+ },
+ ["url"],
+ ),
+ run=_run_fetch,
+ ),
ToolDef(
name="knowledge_search",
family=FAMILY_KNOWLEDGE,
@@ -850,6 +928,12 @@ def _family_allowed(
and config.get("enabled")
and not search_service.availability(str(config.get("provider") or "ddgs"))
)
+ if gate == FAMILY_FETCH:
+ # Its own instance switch, and no `library.use`. The switch is worth
+ # having on its own: it stops a *model* fetching while the `@`-link
+ # attach path keeps working, because that one is a person's instruction
+ # rather than a model's choice.
+ return bool(allowed.get("tools.fetch") and config.get("fetch_enabled"))
if gate in (FAMILY_CUSTOM, FAMILY_MCP, FAMILY_ASK, FAMILY_AGENT):
# Deliberately without `library.use`: an HTTP endpoint an administrator
# wrote has nothing to do with this person's own documents and notes,
diff --git a/src/lembas/web/templates/admin/agents.html b/src/lembas/web/templates/admin/agents.html
index 67348c0..585170d 100644
--- a/src/lembas/web/templates/admin/agents.html
+++ b/src/lembas/web/templates/admin/agents.html
@@ -291,6 +291,34 @@
of it in the prompt.
+
+
+
+
+ Looks for AGENTS.md or CLAUDE.md in the root
+ of the project directory and puts it in the prompt, so a model follows
+ the conventions of the project it is working in. The file is written by
+ whoever works on that project, so it is treated as untrusted: it can say
+ how to work, and cannot grant permission for anything. The exact wording
+ around it is the The project's own instructions fragment on
+ Prompts, and clearing that fragment removes
+ the only path by which the file reaches a model.
+
+
+
+
+
+
+
+ Cut at a line boundary past this. 0 is the same as
+ switching it off.
+
+
diff --git a/src/lembas/web/templates/admin/search.html b/src/lembas/web/templates/admin/search.html
index a1877a6..6c58976 100644
--- a/src/lembas/web/templates/admin/search.html
+++ b/src/lembas/web/templates/admin/search.html
@@ -114,6 +114,20 @@
Applies to the composer's Link option and to anything the
model fetches: LLeMbas retrieves the page and keeps its text.
+
+
+
+ Offers the fetch tool, so a model can read an address it
+ found in a search result or was given. Turning this off leaves the
+ composer's Link option working: that one is somebody's instruction,
+ while this is the model choosing an address — possibly one it read in a
+ page it had just fetched.
+