# Abstra AI Agents Guide

`run_agent` runs an autonomous LLM loop inside a regular Python stage: the agent reads a prompt, decides which tools to call, executes them, observes results, and continues until it produces a final answer or hits `max_steps`. Agents are not a stage type — they are a function call you make from inside Tasklets, Jobs, Hooks, or Form logic.

## When to use `run_agent` vs `prompt` vs plain Python

| Use case | Reach for |
|---|---|
| Single-shot extraction, classification, translation, Q&A, formatting | `prompt()` |
| Fixed deterministic pipeline (read CSV → transform → insert) | plain Python (no LLM) |
| Multi-step task where the next action depends on intermediate results | `run_agent()` |
| Unstructured content where the right action is not knowable in advance (emails, screenshots, mixed-format documents) | `run_agent()` |
| Operations that need conditional branching or recovery from tool errors | `run_agent()` |

If the task is *"do X, then Y, then Z"* with no decisions in between, an agent is the wrong tool — it costs LLM calls and introduces non-determinism. Use plain Python.

## Signature

```python
from abstra.ai import run_agent

result = run_agent(
    prompt,          # str | Path | list mixing str/Path/images
    tools=None,      # list of callables and/or AgentTools instances
    max_steps=30,    # hard cap on reasoning iterations
)
```

`prompt` can be:
- a string instruction
- a `Path` to a file (PDF/image — sent as multimodal input)
- a list mixing strings, files, and images

`tools` accepts a flat list mixing:
- plain Python callables (name, type hints, and docstring become the tool spec — both are mandatory for the agent to call the function correctly)
- `AgentTools` instances (`TablesTools`, `FilesTools`, `ConnectorsTools`, `BrowserTools`) — each instance expands into multiple agent-visible tools

Duplicate tool names across the list raise `ValueError` before the agent starts.

`max_steps` defaults to 30; lower it (10–20) for focused tasks. Higher caps mean longer-running, more expensive runs.

## Custom Python functions as tools

Any function can become a tool. The agent uses three things to decide when and how to call it:

1. The function `__name__`
2. The docstring (becomes the tool description)
3. The type hints (become the JSON schema)

```python
def lookup_order(order_id: str) -> str:
    """Look up the current status of an order by its ID."""
    ...

run_agent(prompt="Check order 123.", tools=[lookup_order])
```

**Rules:**
- Every parameter must have a type hint. Untyped params produce a degraded schema and the agent will misuse them.
- The docstring must explain *when* to use the function, not just what it does. The agent picks tools by description.
- Return a JSON-serializable value (str, int, dict, list, etc.). If you return an object, expose `.dict()` / `.model_dump()` or stringify it yourself.
- Raise specific exceptions on failure — the message goes back to the agent and it can recover. Generic `Exception` is fine but a clear message helps.

## Built-in toolkits

All toolkits inherit from `AgentTools`. Each instance exposes one or more methods to the agent (declared via `__tools__()`). Construct one per concern and pass them in the `tools=` list.

### TablesTools — Abstra Tables CRUD

```python
TablesTools(
    method="all",      # "all" | "select" | "insert" | "update" | "delete" | list of these
    table=None,        # None | "all" | "users" | ["users", "orders"]
    where=None,        # default WHERE merged into every select/update/delete
    allow_sql=False,   # adds run_sql tool if True (raw SQL — use with caution)
)
```

The agent gets one tool per allowed method: `select(table, where)`, `insert(table, data)`, `update(table, set, where)`, `delete(table, where)`.

**The `where` argument is a scoping invariant.** It is merged into every `select`/`update`/`delete` AFTER the agent's call-site `where`, so toolkit keys ALWAYS WIN. On `insert`, the same keys are added as data tags so that rows the agent creates are visible to subsequent `select`s under the same scope. This is the safe default for multi-tenancy:

```python
TablesTools(table="orders", where={"tenant_id": current_tenant_id})
```

The agent literally cannot leak across tenants — it can't override or drop `tenant_id`.

**`table` restrictions** are name-based; pass `None` or `"all"` to allow every table. Passing a list (or single string) restricts.

**`allow_sql=True`** exposes a `run_sql(query)` tool that bypasses the table/where scoping. Use only when you actually need analytics joins; otherwise the scoped CRUD tools are safer.

### FilesTools — filesystem access

```python
FilesTools(
    actions=("read_text", "write_text", "list", ...),  # default: all 10 actions
    globs="*",                                          # path patterns the agent can touch
)
```

Tools exposed: `read_text`, `read_bytes`, `write_text`, `write_bytes`, `delete`, `move`, `list`, `append`.

`"read"` is shorthand for both `read_text` + `read_bytes`. Same for `"write"`. Pass either the shorthand or the specific action.

**Globs are NOT recursive by default.** `globs="*"` matches files in the working directory only. For nested paths use `"**"` or e.g. `"data/**/*.csv"`. Forgetting this is the #1 cause of "agent can't read the file" errors when paths look like `data/2024/orders.csv`.

`globs` accepts a string, a `PathLike`, or any iterable of them. Pass a list to allow multiple patterns:

```python
FilesTools(actions=["read_text"], globs=["data/**/*.csv", "config/*.yaml"])
```

### ConnectorsTools — call your project's connectors

```python
ConnectorsTools(
    connection=None,   # None | "slack" | ["slack", "stripe"]
    action=None,       # None | "send_message" | ["send_message", "list_users"]
    params=None,       # default params, filled in only when the agent omits the key
)
```

Tools exposed: a single `call(connection, action, params)`.

**The `params` default is for defaulting, not scoping.** The merge is `{**self.params, **call_site_params}` — the agent's call-site values WIN. Useful for "fill in this channel ID unless the agent specifies one", not for locking values.

If you need to LOCK a parameter so the agent cannot override it, either don't expose `params` and pre-fill via a custom function:

```python
def send_to_oncall(text: str) -> dict:
    """Post a message to the on-call Slack channel."""
    return run_connection_action("slack", "send_message", {"channel": "C123", "text": text})
```

…or restrict `action=` to a specific action and assume the agent won't fight you.

### BrowserTools — headless Chromium via Playwright

```python
BrowserTools(
    url=None,                # None | "https://..." | list of allowed URLs
    listen_network=False,    # capture HTTP requests (adds get_network_requests tool)
    listen_console=False,    # capture console messages (adds get_console_logs)
    listen_websocket=False,  # capture WS frames (adds get_websocket_frames)
    headless=True,           # set False locally to watch the browser
    allow_close_page=True,
    debug_mode=False,
    client_certificate=None, # optional mTLS: {"origin", "pfx_base64", "passphrase"}
)
```

The agent gets ~20 tools. The important ones:

- **`navigate_to_url(url, page_id=None)`** — creates a new page or reuses one. Returns `{tab_id, url, title}`. `tab_id` is the `page_id` to pass to every other tool.
- **`get_page_summary(page_id, max_elements=50)`** — extracts visible interactive elements (links, buttons, inputs, dropdowns) with stable `index` values. Call this BEFORE any click/fill.
- **`click_element(page_id, index)` / `fill_element(page_id, index, value)`** — preferred over selector-based variants. The agent uses indices from the last `get_page_summary`.
- **`click(selector)` / `fill(selector, value)`** — fallback when you know a stable selector. Errors include a hint to use index-based variants.
- **`get_text(page_id, selector)`, `get_html(page_id)`, `get_element_html(page_id, selector)`, `get_attribute`, `get_attributes`, `get_all_links`** — extraction.
- **`execute_javascript(page_id, script)`** — runs `(async () => { script })()`. To return a value, use `return X;`. Without `return`, the result is `undefined`.
- **`wait(page_id, milliseconds)`** — sleep up to 30s. Use this instead of `setTimeout` in JS — see cache invariant below.
- **`screenshot(page_id, show_markers=False, highlight_element=None)`** — returns a JPEG `Path`. `show_markers=True` overlays numbered labels matching `get_page_summary` indices.
- **`download_file(page_id, selector|index)` vs `download_url(page_id, url)`** — see below.
- **`upload_file(page_id, file_path, selector|index)`** — `file_path` must be inside the project's persistent folder.

**The page-summary cache invariant.** `get_page_summary` indices are stable until the DOM changes. Any of these invalidate the cache:
- `navigate_to_url`
- `click` / `click_element` (likely re-renders)
- `fill` / `fill_element`
- `execute_javascript`
- `download_file` (triggers a click)

These do NOT invalidate the cache:
- `wait`, `screenshot`, `get_text`, `get_html`, `get_attribute`, `get_all_links`, `list_pages`, `get_network_requests`, `get_console_logs`, `get_websocket_frames`

Rule of thumb: after any action that changes the page, call `get_page_summary` again before using an index.

**`download_file` vs `download_url`.** They look similar; pick correctly or the agent loops.
- Use **`download_url(page_id, url)`** when you already know the URL of the file. It uses the browser's authenticated request state (cookies/session) and is more robust than driving a click. Works for any URL that returns binary content with `Content-Disposition: attachment` OR returns content the browser would otherwise preview inline.
- Use **`download_file(page_id, selector|index)`** ONLY when the URL is not knowable ahead of time — e.g., clicking a button that creates a Blob URL in-page, or a POST-triggered download. It requires a real browser download event, so it does NOT work for links to inline-previewed content (SVG, HTML, plain text, images displayed in-page).

Both save to `persistent_dir/browser_tools/downloads/<execution_id>/<filename>` if `output_path` is omitted, so files survive across tool calls and can be fed into `upload_file` directly via the returned `path`.

**`upload_file` requires** the file to live inside the persistent dir. The natural pattern is `download_url → upload_file` using the returned path.

**Same-origin rule for `download_url`.** If you constructed `BrowserTools(url=...)` with a restricted list, `download_url` will refuse cross-origin URLs unless they are explicitly in the list. Relative URLs are resolved against the current page URL.

## Multimodal input (PDFs, images)

Pass files inline in the `prompt` list:

```python
from pathlib import Path

run_agent(
    prompt=[
        "Extract store name, date, total, and itemized lines from this receipt.",
        Path("receipts/2024-01-15.jpg"),
    ],
    tools=[TablesTools(method=["insert"], table="receipts")],
)
```

Strings interleave with files in the order you provide. Inline files work best for images and visual documents (receipts, screenshots, scanned layouts). For text-heavy documents (contracts, reports, official letters), extract the text first with `extract_text(path)` and pass the resulting string instead — models process complex documents more reliably as text than as inline files.

For Brazilian financial documents, prefer the specialized parsers (`parse_boleto`, `parse_nfse`, `parse_nfe`, `parse_bank_statement`) over passing them to an agent — they return structured Pydantic responses without the LLM-roundtrip cost.

## `max_steps` tuning

| Task shape | Suggested cap |
|---|---|
| Single tool call + summarize | 5 |
| Look-up-then-act (2–3 tools) | 10 |
| Multi-step extraction + write | 15–20 |
| Open-ended exploration (default) | 30 |
| Heavy browser automation | 50+ |

Hitting `max_steps` is failure, not a soft limit — the agent stops mid-task. If you frequently hit the cap, the task probably needs to be split or the tools narrowed.

## Common footguns

- **Untyped or undocumented custom tools.** The agent guesses parameters from name alone — usually wrong. Always type and docstring.
- **Glob without recursion.** `globs="*"` blocks nested files. Use `"**"` for arbitrary depth.
- **Stale page-summary indices.** Re-call `get_page_summary` after any DOM-changing action.
- **Choosing `download_file` for a known URL.** It requires a click that fires a download event; fails on inline-rendered content. Use `download_url` whenever the URL is known.
- **Trusting `params=` to lock connector args.** It only defaults — the agent can override. Lock via a custom function instead.
- **Returning non-serializable objects from custom tools.** Stringify or `.model_dump()`.
- **Spawning an agent for a deterministic pipeline.** If the steps don't depend on intermediate results, write plain Python instead.
- **One giant toolkit for every task.** Narrow the scope — `table=["orders"]`, `globs=["data/*.csv"]`, `connection="slack"`. The agent makes fewer wrong-tool errors when its options are tight.

## Where agents run

`run_agent` is a function call, not a stage type. You invoke it from inside any normal Python stage:

- **Tasklets** — most common. Process a task with an agent in the background.
- **Jobs** — scheduled agent runs (daily reconciliation, weekly reports).
- **Hooks** — agent-driven webhook handlers.
- **Form logic** — after `run()` collects user input, hand off to an agent for processing.

Agents in Forms should run in computation steps (`def fn(state):` with no return), never in reactive pages (which re-execute on every keystroke).
