Skip to content

Built-in tools

Built-in tools must be imported and attached explicitly. An Agent's construction site therefore shows every external capability it can use.

from lovia import Agent
from lovia.tools import duckduckgo_search, now, web_fetch

agent = Agent(
    name="researcher",
    model="<model>",
    tools=[web_fetch(), duckduckgo_search(), now],
)

File and Shell Tools come from Workspace. The operator-input Tool is covered in Ask a human below.

Need Tool
Read a URL: a web page as Markdown, JSON, text web_fetch()
Search the public web duckduckgo_search() / tavily_search()
Read the current time now
Pause and ask the user ask_human

Fetching URLs

lovia.tools.web_fetch() builds a tool that reads one URL with a GET. HTML becomes Markdown that keeps its structure and links; JSON comes back compacted, text as-is, binary as metadata only.

The model sees three arguments:

Argument Default Notes
url required absolute http:// / https:// only
images False also return every image the page references
offset 0 resume reading a long page at this character offset

Everything else is an operator decision, so it lives on the web_fetch() factory rather than costing tokens in the schema on every call:

from lovia.tools import HttpReader, web_fetch

tool = web_fetch(                       # or just web_fetch() for the defaults
    HttpReader(timeout=15.0, max_bytes=2_000_000, cache_ttl=60),
    max_chars=40_000,
)

There are no method, headers or body arguments. To let the model call a specific API, wrap that API in a @tool — get_order(order_id) rather than a free-form HTTP client the model fills in.

What the model gets back

URL: https://example.com/guide
HTTP 200 · text/html

--- BEGIN EXTERNAL CONTENT (reference material) ---
Title: Example Domain

# Example Domain

Images (1):
1. https://example.com/img/hero.png — Hero banner
--- END EXTERNAL CONTENT ---

[... truncated. Continue with offset=20000.]

The page's own words sit inside the fence (Fenced output); the URL, status, and truncation notice are lovia's. The truncation notice carries the offset to continue from, so a long page is never a dead end. The returned Page contains the final URL, status, title, Markdown text, and images; the block above is the default rendering.

Images

Inline Markdown already carries every <img src>. images=True adds a deduplicated, absolute-URL list that also covers the sources Markdown cannot express: the largest srcset candidate, <picture><source>, and og:image. Relative URLs resolve against <base href> and the post-redirect URL; data:, javascript: and fragment targets are dropped — a single inline base64 image can be 100 KB inside one attribute, and the model can do nothing with it anyway.

No JavaScript — swap the backend

HttpReader makes one plain HTTP request and parses with the standard library. Nothing renders client-side, so a single-page app may come back nearly empty. PageReader is the extension point (the same shape as the WebSearch protocol):

class PageReader(Protocol):
    async def read(self, url: str, *, images: bool = False) -> Page: ...

A hosted extraction service is a short class — here Jina Reader, which returns Markdown directly:

import httpx
from lovia.tools import Page, web_fetch

class JinaReader:
    async def read(self, url: str, *, images: bool = False) -> Page:
        headers = {"X-Return-Format": "markdown"}
        if images:
            headers["X-With-Images-Summary"] = "true"
        async with httpx.AsyncClient(timeout=30) as client:
            resp = await client.get(f"https://r.jina.ai/{url}", headers=headers)
        return Page(
            url=url,
            status_code=resp.status_code,
            media_type="text/markdown",
            text=resp.text,
        )

agent = Agent(name="x", model="<model>", tools=[web_fetch(JinaReader())])

Clipping to a character budget stays the tool's job, so a backend should return the whole body.

Caching and limits

Responses are cached by URL for cache_ttl seconds (default 300, bounded to cache_size entries). That is mostly correctness: continuing with offset would otherwise re-download the page and could splice two different versions together. Downloads stop at a hard 1 MB cap — when that happens size_capped is set and the notice says so, because no offset will ever reach the missing tail. 4xx/5xx bodies are capped at 500 characters; an error template is not worth a full budget. Each web_fetch() call builds its own reader, so its cache is never shared with another tool. TLS honors the LOVIA_HTTP_* settings.

No SSRF filtering. web_fetch fetches whatever the host can reach — private and internal addresses included, and redirects may lead there. When the model is exposed to untrusted input, gate the tool or isolate the network.

web_search — a pluggable search tool. Two backends are bundled: DuckDuckGo (keyless, behind the ddg extra) and Tavily (no extra install — set TAVILY_API_KEY or pass api_key=):

pip install "lovia[ddg]"   # only needed for the DuckDuckGo backend
from lovia.tools import duckduckgo_search, tavily_search, web_search

tools = [duckduckgo_search()]            # keyless, requires lovia[ddg]
tools = [tavily_search()]                # Tavily API, reads TAVILY_API_KEY
tools = [web_search(MySearchBackend())]  # or your own

The tool (named web_search by default; override with name=) takes query, max_results (1–20, default 5), and an optional time_range recency filter ("d" / "w" / "m" / "y"). Results render as readable title/URL/snippet blocks rather than raw JSON.

A custom backend implements one method — the WebSearch protocol:

class WebSearch(Protocol):
    async def search(
        self, query: str, *, max_results: int = 5, time_range: str | None = None
    ) -> list[SearchResult]: ...

Backends must be safe for concurrent calls. Passing the backend explicitly (web_search(impl)) means a missing optional dependency fails at construction time, not mid-run.

Time

Two small utilities:

  • now (tool) — current wall-clock time as ISO-8601; optional tz takes an IANA name ("Asia/Shanghai"). Defaults to the server's local zone. (On Windows, IANA names need pip install tzdata.)
  • current_date(tz=None) — not a tool: a factory returning an instruction fragment that states today's date in the system prompt:
agent = Agent(name="researcher", model="<model>", tools=[duckduckgo_search()])
agent.instruction(current_date())

With the date in the prompt, the model writes the current year into searches instead of wasting a turn calling now first. It is date-only by design: a date is constant within any prompt-cache window, so it never meaningfully busts the provider cache — precise time, when needed, is now's job.

Ask a human

ask_human(channel) — lets the model request operator input mid-run (the inverse of approval, where the runner asks):

from lovia import Agent
from lovia.tools import HumanChannel, ask_human

channel = HumanChannel()
agent = Agent(name="assistant", model="<model>", tools=[ask_human(channel)])

# elsewhere, the operator side:
async for q in channel.questions():   # ends when channel.close() is called
    channel.answer(q.id, "Use option A.")

The Tool call blocks until an answer arrives, the question is cancelled, or the channel closes.

When the question is a pick-one (or, with multi_select, pick-many), the model attaches 2–4 options — each a QuestionOption with a label and an optional one-line description. These options are suggestions; any string remains a valid answer. Join multi-select labels with newlines. Labels are schema-enforced to be single-line and unique, so the result is unambiguous:

async for q in channel.questions():
    if q.options:
        print(q.question, [o.label for o in q.options], q.multi_select)
    channel.answer(q.id, "Kyoto\nOsaka")

Each question also carries the asking run's session_id / run_id (when set), so a consumer serving several sessions can route it to the right surface. ask_human is an execution barrier (parallel=False): a run has at most one pending question at a time.

API Effect
questions() Async-iterate queued questions; one consumer
pending Snapshot of unanswered questions for polling
answer(id, text) Resolve the question; the Tool returns text
cancel(id, reason=...) Fail one question with a ToolError the model can see
close(reason=...) Cancel outstanding questions, end iteration, and reject future asks

Cancellation and closure become Tool-error results, so the model can continue without the answer. Add a per-tool timeout when operators may be unavailable. Calls from another thread must hop to the event-loop thread first, for example with loop.call_soon_threadsafe(channel.answer, qid, text).

Use approval when the question is “may I do this?” and expects yes/no. Use ask_human when the model needs information only a person has and expects free text.

Fenced output

web_fetch and the web_search tools return what they fetched between fixed markers — --- BEGIN EXTERNAL CONTENT (reference material) --- / --- END EXTERNAL CONTENT --- — and each tool's description tells the model that fenced text is material to report on, never instructions to follow. Inside the fence, whatever could pass for the agent's own framing is disarmed: copies of the markers, <system-reminder> tags, chat-template tokens, and invisible control, bidirectional, and Unicode-tag characters. lovia's own lines (status, URL, truncation notice) stay outside.

Your own tools that return third-party text can reuse the same function:

from lovia.tools import FENCE_NOTE, fence, tool

@tool(description=f"Fetch a ticket's latest customer reply.\n{FENCE_NOTE}")
async def latest_reply(ticket_id: str) -> str:
    return fence(await helpdesk.latest_reply(ticket_id))

Fencing is a mitigation, not a boundary — a model can still be talked into things; approvals and a sandboxed network are what actually hold. It also rewrites text, so don't fence content the model must reproduce byte for byte (files it will edit, scripts it will save).

Sharp edges

  • web_fetch is the sharpest built-in. Combined with untrusted input it is an SSRF primitive — gate it or sandbox the network before exposing it in anything public-facing.
  • web_fetch caches responses for 5 minutes by default. A page that changes faster than that needs web_fetch(HttpReader(cache_ttl=0)).
  • duckduckgo_search() / tavily_search() construct eagerly. They raise UserError at build time — missing ddgs package, missing TAVILY_API_KEY — a fail-fast you want at startup, not one to catch and ignore.
  • Search result quality is the backend's. The DDG backend is keyless and rate-limited in practice; production apps usually use a keyed backend (tavily_search()) or their own WebSearch.

See also