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. +

+