From 3b07879a0d9d6263062128e378c11a1202a742ab Mon Sep 17 00:00:00 2001 From: Siddharth-DWT Date: Wed, 7 Oct 2026 16:44:01 +0530 Subject: [PATCH 1/3] Add web search: web_search, get_search, searches and the web_search MCP tool - Client: web_search(query, ...) runs POST /search and waits; get_search(id); searches(q=None, limit=25) pages the history. - MCP: web_search, a sixteenth tool with an output schema, within the 60,000-character budget; hosted hands back a job. get_job takes kind search. - MCP errors carry the API's code and request_id; the read-only answer names 'Also allow changes'. - The settings guide gives json_schema, llm_extract and formats in shapes the API accepts. --- CHANGELOG.md | 15 +++ README.md | 48 ++++++-- mesharc/__init__.py | 81 +++++++++++++ mesharc/mcp.py | 226 +++++++++++++++++++++++++++++++++---- tests/test_behaviour.py | 82 +++++++++++++- tests/test_client.py | 13 +++ tests/test_definitions.py | 16 ++- tests/test_folded_tools.py | 2 +- tests/test_mcp_http.py | 22 +++- tests/test_results.py | 142 ++++++++++++++++++++++- 10 files changed, 602 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb0675c..3bd88cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ All notable changes to this package are recorded here. The format follows ## Unreleased +### Added + +- **The client searches the web, for pages whose URLs you do not know.** `web_search(query, limit=, country=, lang=, freshness=, include_domains=, exclude_domains=, scrape=, ...)` returns the whole envelope, the hits under `data`; `scrape=True` also fetches each hit as markdown. `get_search(id)` reopens a search started earlier, and `searches(q=None, limit=25)` walks the workspace's searches, following `next`. A search is `queued`, `running`, `done`, `blocked` or `error`: `blocked` (every engine refused) is returned, `error` raises `MeshArcError`. It needs a key that can write, and spends credits: a results page every engine refused is free, an equal search (same query, `country`, `lang`, `freshness` and domains) within an hour of a finished one comes from the cache with no results-page charge, and scraped pages are always charged. `search()` is unchanged and is still the project search. **This needs an API that has the `/search` route**, which is live on mesharc.dev. +- **`web_search`, a sixteenth MCP tool, so an assistant can find pages before it reads them.** It needs write access; a read-only connection is answered `{"code": "read_only"}`. Its answer keeps within 60,000 characters, a scraped result's page as an excerpt, with `scrape_urls(full=true)` to read one in full, and an output schema describes it. Hosted, a search still going when `MESHARC_MCP_WAIT` runs out comes back as a job, and a scraping search whose pages are still landing returns its results with a job for the pages. It sends no idempotency key, by design: the API's one-hour cache already answers an equal search. +- `get_job` takes `kind="search"`, for a search `web_search` handed back. `cancel_job` does not: the API has no way to stop a search. + +### Changed + +- **MCP error answers carry the API's `code` and `request_id`**, alongside `error` and `status`, so an assistant can tell one refusal from another and a support conversation can start from the request id. Both were dropped before; each is included when the API gave it. +- The read-only answer names the box to tick on reconnecting, 'Also allow changes', and says "search inside a project" where it lists what a read-only connection can still do -- searching the web is not among them. + +### Fixed + +- **The settings guide (`get_project` with no `project_id`) described two settings in shapes the API refuses**, so an assistant following it wrote configs that failed. `json_schema` is a list of `{name, type, required}` fields, not a JSON schema. `llm_extract` is an object with a `connection_id` (or `null` for off); `true` was refused by the API. `formats` now lists `raw`. + ## 0.4.0 - 2026-10-07 The MCP server goes from nineteen tools to fifteen. **This breaks anything that calls the five removed tool names**; each one's call is still there, as an argument of the sibling it overlapped. The Python client's API is unchanged. diff --git a/README.md b/README.md index 2be1bfa..ea3f9b0 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ The Python client for the [MeshArc](https://mesharc.dev) API: a URL in, clean co - **Scrape** one page or a batch — markdown, text, HTML, links, structured fields, a screenshot. - **Crawl** a whole site with no project to set up first, and keep it as one if it turns out to be worth watching. - **Map** what a site declares in its sitemaps before fetching any of it. +- **Search** the web for pages whose URLs you do not know, and read each result's page in the same call. - **Watch** a site over time: projects, scheduled runs, and a change record — pages added, removed, modified, field by field. Python 3.10 or newer. One dependency (`httpx`). Fully typed. @@ -135,6 +136,37 @@ print(details["totals"], details["creditsUsed"]) # {'files': 29, 'urls': 50843 A map costs one credit per sitemap file read — most sites are one file. +## Searching the web + +```python +out = arc.web_search( + "python packaging guide", + limit=5, # 1 to 10 results + freshness="month", # hour | day | week | month | year + include_domains=["python.org"], # exclude_domains= too; an exclude wins + scrape=True, # each result's page as markdown as well +) + +if out["status"] == "blocked": # every engine refused; nothing was charged + print(out["attempts"]) +else: + for hit in out["data"]: + print(hit["position"], hit["title"], hit["url"], hit["snippet"]) + if hit.get("page"): + print(hit["page"].get("markdown", "")[:200]) +print(out["engine"], out["cached"], out["creditsUsed"]) + +again = arc.get_search(out["id"]) # a search started earlier, by id +for s in arc.searches(q="python"): # the workspace's searches, newest first + print(s["id"], s["query"], s["status"], s["resultCount"], s["creditsUsed"]) +``` + +`web_search` waits for the results and returns the whole envelope, the hits under `data`. A search is `queued`, `running`, `done`, `blocked` or `error`: `blocked` — every engine refused — is returned, not raised; `error` raises `MeshArcError`. A search that scrapes stays `running` until its pages land. `scrape=` also takes a dict, sent as given (`{"formats": ["markdown", "links"], "maxCredits": 20}`); `country=` and `lang=` say where and in what language to search; `wait=False` returns at once, and `timeout_s` is how long the API holds the request (60 s by default, 120 at most) before the client polls. + +A search needs a key that can write, and it spends credits. The results page costs the engine that read it; a results page every engine refused is free. An equal search — same query, `country`, `lang`, `freshness` and domains — within an hour of a finished one comes from the cache (`cached` is true) with no charge for the results page. Scraped pages are charged, cached or not. + +`arc.search(project_id, q)` is something else: it searches the pages a project has stored, not the web (see below). + ## Watching a site: projects and runs ```python @@ -207,22 +239,22 @@ except MeshArcError as exc: `request_id` is the id the API put on the response and in its own logs, so a support conversation starts from one string. -Two more cases: a network failure or a request that hits `timeout` raises `MeshArcError` with `status == 0` and `code` `network` or `timeout`; a job the client stopped waiting for raises `MeshArcTimeoutError` — both a `MeshArcError` and a `TimeoutError` — which carries `job_id` so you can poll it later (`arc.get_crawl(id)`, `arc.batch(id)`). +Two more cases: a network failure or a request that hits `timeout` raises `MeshArcError` with `status == 0` and `code` `network` or `timeout`; a job the client stopped waiting for raises `MeshArcTimeoutError` — both a `MeshArcError` and a `TimeoutError` — which carries `job_id` so you can poll it later (`arc.get_crawl(id)`, `arc.batch(id)`, `arc.get_search(id)`). ## Idempotency and timeouts -- `scrape`, `scrape_one` and `crawl` take `idempotency_key=`: send the same key again within 24 hours and you get the first answer back rather than a second job. +- `scrape`, `scrape_one`, `crawl` and `web_search` take `idempotency_key=`: send the same key again within 24 hours and you get the first answer back rather than a second job. The MCP server's `web_search` tool sends none, on purpose: an equal search within the hour of a finished one comes from the cache anyway. - Waiting calls take `wait=`, `poll=` (seconds between polls) and `timeout=` (seconds before `TimeoutError`). `wait=False` returns the envelope at once; the default polls every 3 s for up to an hour. - `timeout_s` on a single scrape is how long the API itself holds the request open (60 s by default, 120 at most); a slower page comes back as an id and is polled. - `MeshArc(..., timeout=150.0)` is the HTTP timeout per request. A request is retried on 429, 502, 503, 504 and network failures when it is safe to repeat — a GET, a DELETE, or a POST with an idempotency key — up to `max_retries` times (2), honouring `Retry-After`. ## Credits -Every response says what it cost: `credits` on a page, `creditsUsed` on a job envelope, `X-MeshArc-Credits` on the HTTP response. A page costs the engine that read it — a plain fetch 1, a render 4 — and a refused page or a 404 costs nothing. The schedule and the plans are at [mesharc.dev/docs/billing](https://mesharc.dev/docs/billing). +Every response says what it cost: `credits` on a page, `creditsUsed` on a job envelope, `X-MeshArc-Credits` on the HTTP response. A page costs the engine that read it — a plain fetch 1, a render 4 — and a refused page or a 404 costs nothing. A web search's results page costs the engine that read it, and nothing when every engine refused; an equal search (same query, country, lang, freshness and domains) within an hour of a finished one comes from the cache with no charge for the results page, while the pages it scrapes are charged either way. The schedule and the plans are at [mesharc.dev/docs/billing](https://mesharc.dev/docs/billing). ## The MCP server -The package also ships MeshArc as an MCP server, so Claude Desktop, Claude Code, Cursor and any MCP client can scrape, crawl, map and read change records as tools. Python 3.10+. +The package also ships MeshArc as an MCP server, so Claude Desktop, Claude Code, Cursor and any MCP client can scrape, crawl, map, search the web and read change records as tools. Python 3.10+. There is a hosted one, so most people need install nothing: @@ -243,13 +275,13 @@ MESHARC_API_KEY=mesharc_... mesharc-mcp # serves over stdio claude mcp add mesharc -e MESHARC_API_KEY=mesharc_... -- mesharc-mcp ``` -Tools: `scrape_urls`, `map_site`, `crawl_site`, `list_projects`, `get_project`, `create_project`, `update_project`, `delete_project`, `start_run`, `list_runs`, `list_pages`, `get_page`, `get_changes`, `get_job`, `cancel_job`. Every tool is a call through this client, and each one's answer is described by an output schema. +Tools: `scrape_urls`, `map_site`, `crawl_site`, `web_search`, `list_projects`, `get_project`, `create_project`, `update_project`, `delete_project`, `start_run`, `list_runs`, `list_pages`, `get_page`, `get_changes`, `get_job`, `cancel_job`. Every tool is a call through this client, and each one's answer is described by an output schema. A tool that fails answers `{error, status}`, with the API's `code` and `request_id` as well when it gave them. -A result with many pages in it is a map, not the territory: a crawl answers with an index of the pages read (up to 500, fewer if the budget needs the room), an excerpt of as many as a 60,000-character budget pays for (fifty at most), and a count of each page's links rather than the links. Ask for the one page you want on its own with `get_job(kind="crawl", id=…, url=…)`, or carry on through the crawl with `cursor=`. A multi-URL scrape past its budget counts the rest by status and names any that did not come back ok. One page asked for on its own -- `get_page`, or `scrape_urls` with a single url (with `full=true` for every format) -- comes back whole, capped at 12,000 characters. +A result with many pages in it is a map, not the territory: a crawl answers with an index of the pages read (up to 500, fewer if the budget needs the room), an excerpt of as many as a 60,000-character budget pays for (fifty at most), and a count of each page's links rather than the links. Ask for the one page you want on its own with `get_job(kind="crawl", id=…, url=…)`, or carry on through the crawl with `cursor=`. A multi-URL scrape past its budget counts the rest by status and names any that did not come back ok. One page asked for on its own -- `get_page`, or `scrape_urls` with a single url (with `full=true` for every format) -- comes back whole, capped at 12,000 characters. A `web_search` answer keeps within the same 60,000 characters, a scraped result's page as an excerpt; `scrape_urls` with `full=true` reads one in full. -Hosted, a connection can be approved read-only, and read-only cuts in a place worth knowing: it reads the whole workspace, but it cannot reach the site. Anything that fetches is a write, because it leaves the workspace and usually spends its credits -- `map_site` reads only a site's sitemaps, at a credit per sitemap file, and it is gated with the rest. `list_projects`, `get_project`, `list_runs`, `list_pages`, `get_page`, `get_changes` and `get_job` work; the eight that fetch or write -- `scrape_urls`, `map_site`, `crawl_site`, `create_project`, `update_project`, `delete_project`, `start_run`, `cancel_job` -- answer `{"code": "read_only"}` with what to do about it, until the app is reconnected with write access. `delete_project` needs more than write: an admin API key, which a connected app never holds, so it only works run locally with such a key. +Hosted, a connection can be approved read-only, and read-only cuts in a place worth knowing: it reads the whole workspace, but it cannot reach the site. Anything that fetches is a write, because it leaves the workspace and usually spends its credits -- `map_site` reads only a site's sitemaps, at a credit per sitemap file, and it is gated with the rest. `list_projects`, `get_project`, `list_runs`, `list_pages`, `get_page`, `get_changes` and `get_job` work; the nine that fetch or write -- `scrape_urls`, `map_site`, `crawl_site`, `web_search`, `create_project`, `update_project`, `delete_project`, `start_run`, `cancel_job` -- answer `{"code": "read_only"}` with what to do about it, until the app is reconnected with 'Also allow changes' ticked (write access). `delete_project` needs more than write: an admin API key, which a connected app never holds, so it only works run locally with such a key. -Hosted, a tool that would hold a connection open for minutes — a crawl, a run you asked to wait for, a multi-URL scrape — hands back a job after `MESHARC_MCP_WAIT` seconds (25 by default, because many MCP hosts time a tool call out sooner). The work carries on server-side; `get_job` picks it up and `cancel_job` stops it. Run locally, those tools block as they always have. +Hosted, a tool that would hold a connection open for minutes — a crawl, a run you asked to wait for, a multi-URL scrape, a web search — hands back a job after `MESHARC_MCP_WAIT` seconds (25 by default, because many MCP hosts time a tool call out sooner). The work carries on server-side; `get_job` picks it up and `cancel_job` stops a crawl, run or batch. A search that scrapes, with its results in and its pages still landing, answers with the results and a job for the pages. Run locally, those tools block as they always have. ### Hosting it yourself diff --git a/mesharc/__init__.py b/mesharc/__init__.py index 6944e5d..1743808 100644 --- a/mesharc/__init__.py +++ b/mesharc/__init__.py @@ -487,6 +487,87 @@ def map_details(self, url: str, search: Optional[str] = None, limit: Optional[in raise MeshArcError(502, out.get("error") or "no sitemap could be read", "job_failed") return out + def web_search(self, query: str, limit: Optional[int] = None, country: Optional[str] = None, + lang: Optional[str] = None, freshness: Optional[str] = None, + include_domains: Optional[Iterable[str]] = None, exclude_domains: Optional[Iterable[str]] = None, + scrape: Union[bool, Json, None] = None, destination: Optional[str] = None, wait: bool = True, + timeout_s: Optional[float] = None, poll: float = 2.0, timeout: float = 600, + idempotency_key: Optional[str] = None) -> Json: + """Search the web. Returns the whole envelope, the hits under ``data``. + + Needs an API key that can write, and spends credits: a results page + every engine refused is free, an equal search (same query, + ``country``, ``lang``, ``freshness`` and domains) within the hour of + a finished one comes from the cache with no results-page charge, and + scraped pages are charged even then. ``freshness`` is ``hour``, ``day``, + ``week``, ``month`` or ``year``. ``scrape=True`` also fetches each + hit as markdown; a dict such as ``{"formats": ["markdown", "links"], + "maxCredits": 20}`` is sent as given. The formats are ``markdown``, + ``text``, ``rawHtml``, ``cleanHtml``, ``links``, ``raw``, + ``screenshot`` and ``json``. A search that scrapes stays ``running`` + until its pages land, with the hits already in ``data``. + + A ``blocked`` search (every engine refused) is returned, not + raised; ``error`` raises ``MeshArcError``. ``timeout_s`` is how + long the API holds the request open (60 by default when waiting, + 120 at most); a slower search is then polled every ``poll`` seconds + for up to ``timeout`` seconds. ``wait=False`` returns at once, + queued or running, unless ``timeout_s`` is given. + """ + body: Json = {"query": query} + for key, value in (("limit", limit), ("country", country), ("lang", lang), ("freshness", freshness), + ("destination", destination)): + if value is not None: + body[key] = value + if include_domains is not None: + body["includeDomains"] = list(include_domains) + if exclude_domains is not None: + body["excludeDomains"] = list(exclude_domains) + if scrape is True: + body["scrape"] = {"formats": ["markdown"]} + elif isinstance(scrape, dict): + body["scrape"] = scrape + body["timeout"] = timeout_s if timeout_s is not None else (60 if wait else 0) + out = self._h("POST", "/search", json=body, idempotency_key=idempotency_key) + if not wait: + return out + deadline = time.time() + timeout + while True: + if out.get("status") in ("done", "blocked"): + return out + if not _running(out.get("status")): + raise MeshArcError(502, out.get("error") or f"search {out.get('status')}", "job_failed") + if time.time() >= deadline: + raise MeshArcTimeoutError(f"search {out['id']} is still {out['status']} after {timeout}s", out["id"]) + time.sleep(poll) + self._h.pace() + out = self._h("GET", f"/search/{out['id']}") + + def get_search(self, search_id: str) -> Json: + """A web search started earlier, as ``web_search`` returns it.""" + return self._h("GET", f"/search/{search_id}") + + def searches(self, q: Optional[str] = None, limit: int = 25) -> Iterator[Json]: + """Every web search of the workspace, as summaries. + + ``q`` filters the list by query; ``limit`` is the page size, sent + again with ``q`` on every page. Follows the ``next`` link until the + list ends. + """ + cursor = "" + while True: + params: Dict[str, Any] = {"limit": limit} + if q: + params["q"] = q + if cursor: + params["cursor"] = cursor + page = self._h("GET", "/search", params=params) + for row in page.get("data") or []: + yield row + cursor = _cursor_of(page["next"]) if page.get("next") else "" + if not cursor: + return + def pages(self, project_id: str, run_id: Optional[str] = None) -> Json: """The pages of a run (the latest finished run by default).""" return self._h("GET", f"/projects/{project_id}/pages", params={"run_id": run_id} if run_id else None) diff --git a/mesharc/mcp.py b/mesharc/mcp.py index 230f406..403a7e2 100644 --- a/mesharc/mcp.py +++ b/mesharc/mcp.py @@ -84,7 +84,9 @@ "crawl_site to crawl a whole site once without setting a project up first " "(create_project with its crawl_id turns it into a watched project afterwards). " "The project tools are for a site watched over time: its runs, its pages, search inside a run, " - "and its change record. A long tool may hand back a job: get_job follows it and cancel_job stops it. " + "and its change record. web_search searches the web for pages whose URLs you do not know; " + "list_pages with q searches only inside a project's run. " + "A long tool may hand back a job: get_job follows it and cancel_job stops it. " "Blocked pages are reported as blocked, never as missing." ) @@ -94,7 +96,7 @@ # on a second server later, built with auth in its constructor. The registry is # what carries the descriptions across: a description is the only thing an # assistant has to pick a tool by, and re-registering without them would leave -# fifteen nameless verbs. +# sixteen nameless verbs. _TOOLS: "list[tuple]" = [] # What each tool does to the world, in the hints MCP clients read. A tool that @@ -360,7 +362,7 @@ class ChangesOut(TypedDict, total=False): @with_config(ConfigDict(extra="allow")) class JobOut(TypedDict, total=False): """A job's state, or its result once finished: a crawl gives crawl_site's answer, a batch scrape_urls', - a run the run record, and `url` one page in full.""" + a search web_search's, a run the run record, and `url` one page in full.""" status: Annotated[Any, Field(description=( "'running' while it goes on; otherwise the finished job's status. On an error, the HTTP status code."))] job: Annotated[Any, Field(description="While running: {kind, id, project_id} to call again with.")] @@ -370,7 +372,9 @@ class JobOut(TypedDict, total=False): index: Annotated[Any, Field(description="A finished crawl or batch: every page as {url, title, words, status}.")] cursor: Annotated[Any, Field(description="A crawl with more pages: pass it back for the next window.")] markdown: Annotated[Any, Field(description="With url: that page's markdown, capped at 12,000 characters.")] - id: Annotated[Any, Field(description="A run: its id.")] + id: Annotated[Any, Field(description="A run or a search: its id.")] + results: Annotated[Any, Field(description=( + "A search: the ranked results, as web_search returns them, with page excerpts when it scraped."))] note: Annotated[Any, Field(description="What to do next.")] error: Annotated[Any, Field(description=_ERR)] code: Annotated[Any, Field(description=_CODE)] @@ -393,6 +397,32 @@ class CancelOut(TypedDict, total=False): detail: Annotated[Any, Field(description=_DETAIL)] +@with_config(ConfigDict(extra="allow")) +class WebSearchOut(TypedDict, total=False): + """A web search: the ranked results, with an excerpt of each page when scraped, or a job when it is still + going.""" + id: Annotated[Any, Field(description="The search's id: for get_job with kind 'search'.")] + status: Annotated[Any, Field(description=( + "'done'; 'blocked' when every engine refused the results page (nothing charged); 'running' with a `job` " + "while it goes on. On an error, the HTTP status code."))] + query: Annotated[Any, Field(description="The query as searched.")] + engine: Annotated[Any, Field(description="The engine whose results page answered, e.g. 'google' or 'bing'.")] + cached: Annotated[Any, Field(description=( + "true when an equal search within the hour answered it, with no charge for the results page."))] + creditsUsed: Annotated[Any, Field(description="Credits this search spent, scraped pages included.")] + results: Annotated[Any, Field(description=( + "Up to 10, as ranked, each {position, title, url, snippet, source, engine}; when scraped also `page`, " + "{url, title, words, status, links (a count), markdown excerpt}, the excerpts sharing 60,000 characters."))] + attempts: Annotated[Any, Field(description="Blocked only: each engine tried, and why it was refused.")] + job: Annotated[Any, Field(description=( + "While it is still going: {kind: 'search', id} for get_job. A scraping search can carry its results and " + "a job for the pages still landing."))] + note: Annotated[Any, Field(description="The cache rule, and what to do next when it is still going.")] + error: Annotated[Any, Field(description=_ERR)] + code: Annotated[Any, Field(description=_CODE)] + detail: Annotated[Any, Field(description=_DETAIL)] + + def tool(**how): """Declare a tool, and remember how it was declared.""" def register(fn): @@ -804,7 +834,7 @@ def _scopes(): return list(getattr(at, "scopes", None) or []) if at else [] -# Eight of the fifteen tools need write, including every one that fetches a +# Nine of the sixteen tools need write, including every one that fetches a # page: fetching spends the workspace's credits, so it is not a read however # it reads to an assistant asking for one URL. The API answers "this needs the # member role", which is true and tells an assistant nothing it can act on -- @@ -812,10 +842,10 @@ def _scopes(): # approved the connection chose it. READ_ONLY = ( "this connection was approved read-only. It can read what the workspace has " - "already stored -- projects, pages, change records, search, get_job -- but it " - "cannot fetch a new page or change anything: those reach the site or alter the " - "workspace, and most of them spend credits. " - "Ask the person to reconnect the app and tick write access." + "already stored -- projects, pages, change records, search inside a project, " + "get_job -- but it cannot fetch a new page or change anything: those reach the " + "site or alter the workspace, and most of them spend credits. " + "Ask the person to reconnect the app and tick 'Also allow changes' (write access)." ) @@ -842,6 +872,10 @@ def _safe(fn): return _no_secret(fn()) except MeshArcError as exc: out = {"error": exc.detail, "status": exc.status} + if exc.code: + out["code"] = exc.code + if exc.request_id: + out["request_id"] = exc.request_id # A 403 carrying the status's own default code is the role check. The # named ones -- suspended, email_unverified, mfa_required -- are other # refusals and have to keep saying what they say. @@ -855,6 +889,8 @@ def _safe(fn): if (exc.status == 403 and (exc.code or "") in ("", "forbidden") and scopes is not None and "write" not in scopes): out = {"error": READ_ONLY, "status": 403, "code": "read_only", "detail": exc.detail} + if exc.request_id: + out["request_id"] = exc.request_id return out except Exception as exc: # noqa: BLE001 return {"error": f"{type(exc).__name__}: {exc}"} @@ -1008,6 +1044,131 @@ def go(): return _safe(go) +SEARCH_NOTE = ("an equal search (same query, country, lang, freshness and domains) within an hour of a " + "finished one is answered from cache with no charge for the results page; scraped pages are " + "charged. scrape_urls with full=true reads one result in full") + + +def _search_result(out, running=False): + """A web search, shaped once for an assistant. + + The hits as the API ranked them, and a scraped page as an excerpt rather + than the page: ten results at a full page each is the answer a crawl used + to give, and the cap is chosen the same way -- what the rest of the answer + leaves, measured, shared between the pages. Built, weighed, and rebuilt + with the overshoot reserved, as a batch is. + """ + data = out.get("data") + hits = [h for h in data if isinstance(h, dict)] if isinstance(data, list) else [] + reserve, shaped = 0, None + for _attempt in range(4): + shaped = _shape_search(out, hits, running, reserve) + over = _cost(shaped) - RESULT_BUDGET + if over <= 0: + break + reserve += over + 64 + return shaped + + +def _shape_search(out, hits, running, reserve): + """One pass at a search's answer, at the budget `reserve` leaves.""" + how = "call scrape_urls with that url" + results = [{k: h.get(k) for k in ("position", "title", "url", "snippet", "source", "engine")} + for h in hits] + paged = [i for i, h in enumerate(hits) if isinstance(h.get("page"), dict)] + shaped = {"id": out.get("id", ""), "status": out.get("status"), "query": out.get("query", ""), + "engine": out.get("engine", ""), "cached": bool(out.get("cached")), + "creditsUsed": out.get("creditsUsed"), "results": results} + if out.get("error"): + shaped["error"] = out["error"] + if out.get("status") == "blocked": + shaped["attempts"] = out.get("attempts") or [] + notes = [] + if running: + shaped["job"] = {"kind": "search", "id": out.get("id", "")} + notes.append("the results are in and their pages are still being fetched: call get_job with " + "this job for the pages") + notes.append(SEARCH_NOTE) + shaped["note"] = ". ".join(notes) + if paged: + # Every page at a cap of zero is the frame; what is left is the excerpts'. + for i in paged: + results[i]["page"] = _summary_page(hits[i]["page"], 0, how) + left = RESULT_BUDGET - reserve - _cost(shaped) + cap = max(EXCERPT_FLOOR, min(MARKDOWN_CAP, left // len(paged))) + for i in paged: + results[i]["page"] = _summary_page(hits[i]["page"], cap, how) + return shaped + + +@tool(title="Search the web", + annotations=_acts(), + description="Search the web for a query and return the ranked results, each with its title, url and " + "snippet. The results are read from Google's results page, or Bing's when Google refuses.\n" + "Use it to find pages whose URLs you do not know. It searches the web, unlike list_pages with q, " + "which searches inside a project's stored run. For URLs you already have use scrape_urls; " + "scrape_urls with full=true reads one result in full.\n" + "Inputs: query as typed into a search engine, e.g. 'python http client'. scrape=true also fetches " + "each result's page as markdown in the same call. include_domains=['python.org'] keeps the search " + "to those sites; exclude_domains drops sites, and an exclude wins.\n" + "Cost: the results page is charged as a scrape is, 1 credit for a plain fetch and 4 with a " + "browser; a results page the engines refused is free. An equal search (same query, country, lang, freshness and domains) within an " + "hour of a finished one comes from cache with no charge for the results page. Scraped pages are " + "charged, cached or not.\n" + "Timing: it waits for the results. If a search outlasts the connection's time limit, a job comes " + "back for get_job; a scraping search whose pages are still landing returns its results with a " + "job for the pages.\n" + "Needs write access; a read-only connection is refused with code 'read_only'.") +def web_search(query: Annotated[str, Field( + description="What to search the web for, 1 to 400 characters, as it would be typed into a " + "search engine.")], + limit: Annotated[int, Field( + description="How many results to return, 1 to 10 (default 10).")] = 10, + country: Annotated[str | None, Field( + description="A 2-letter country code to search from, e.g. 'us' or 'de'; omit for the " + "default.")] = None, + lang: Annotated[str | None, Field( + description="A language code for the results, e.g. 'en'; omit for the default.")] = None, + freshness: Annotated[Literal["hour", "day", "week", "month", "year"] | None, Field( + description="Only results from the last hour, day, week, month or year; omit for any " + "time.")] = None, + include_domains: Annotated[list[str] | None, Field( + description="Up to 20 sites to search within, e.g. ['python.org']; omit for the whole " + "web.")] = None, + exclude_domains: Annotated[list[str] | None, Field( + description="Up to 20 sites to leave out of the results, e.g. ['pinterest.com']; an " + "exclude wins over an include.")] = None, + scrape: Annotated[bool, Field( + description="true also fetches each result's page as markdown; each page costs credits. " + "false (default) returns the results only.")] = False) -> WebSearchOut: + def go(): + opts = {"limit": max(1, min(limit, 10)), "country": country, "lang": lang, "freshness": freshness, + "include_domains": include_domains or None, "exclude_domains": exclude_domains or None, + "scrape": scrape or None} + # No idempotency key: an equal search within the hour is served from + # the API's cache anyway, and a repeat is a new search by design. + with _client() as s: + budget = _budget() + if budget is None: + try: + return _search_result(s.web_search(query, **opts)) + except MeshArcTimeoutError as exc: + # Past the client's own wait: still going server-side, and + # get_job picks it up, as crawl_site's long crawls are. + return _still_running("search", getattr(exc, "job_id", "") or "") + # Hosted: the API holds the request for most of the budget and + # answers with whatever it has, never a poll loop down this socket. + out = s.web_search(query, wait=False, timeout_s=max(1.0, min(120.0, budget - 5)), **opts) + if out.get("status") == "error": + raise MeshArcError(502, out.get("error") or "the search failed", "job_failed") + if _running(out.get("status")): + if not out.get("data"): + return _still_running("search", out.get("id", "")) + return _search_result(out, running=True) + return _search_result(out) + return _safe(go) + + @tool(title="List all projects", annotations=READS_STORED, description="List every project in the workspace: the sites it watches over time, each with its id, name, " @@ -1043,7 +1204,8 @@ def list_projects() -> ProjectsOut: "render_js": "'auto' (a browser only when the page needs one; default), 'always', 'never'", "max_tier": "the highest rung allowed: 'auto' (as high as the plan allows), 'http', 'browser', 'stealth'", "max_credits_per_page": "cap on what one page may cost; 0 = no cap", - "formats": "list of bodies to keep: 'markdown', 'text', 'cleanHtml', 'rawHtml', 'links', 'screenshot', 'json'", + "formats": "list of bodies to keep: 'markdown', 'text', 'cleanHtml', 'rawHtml', 'raw', 'links', 'screenshot', " + "'json'; markdown is always kept (default ['markdown', 'links'])", "only_main_content": "true drops navigation, headers, footers and sidebars from the markdown", "include_tags": "CSS selectors to keep, e.g. ['article', '.post']", "exclude_tags": "CSS selectors to drop, e.g. ['.comments', '#newsletter']", @@ -1053,8 +1215,18 @@ def list_projects() -> ProjectsOut: "respect_robots": "obey robots.txt (default true)", "allow_subdomains": "follow links to subdomains of the seed's domain", "use_proxy": "route through the residential exits (costs more; for walled sites)", - "json_schema": "a JSON schema of fields to extract from every page", - "llm_extract": "true lets a model fill fields the markup could not", + "json_schema": "a LIST of fields to fill on every page, each {name, type, required}: type one of 'string' " + "(default), 'number', 'date', 'boolean', 'url', 'array'; required defaults to false; a name " + "starts with a letter, then letters, digits, _ . - (up to 64, no repeats); at most 40 fields " + "(more are dropped). Filled from the page's markup, e.g. " + "[{\"name\": \"price\", \"type\": \"number\", \"required\": true}]", + "llm_extract": "{connection_id, instructions?, only_missing?, max_pages?, max_chars?, max_output_tokens?} or " + "null = off (true is refused): a model (an llm connection the workspace added under " + "Connectors) fills the json_schema fields the markup could not; does nothing unless json_schema " + "has fields. instructions cut at 2000 chars; only_missing default true (false asks for every " + "field); max_pages 1-2000, default 500; max_chars 0 = as much as the model holds (default) " + "else 2000-400000; " + "max_output_tokens 0 = sized to the schema (default) else 200-16000", "webhook_url": "where to POST run and change events", "webhook_events": "which events to send, e.g. ['run.finished', 'page.changed']", "notify_min_words": "a change smaller than this many words is not notified", @@ -1369,18 +1541,22 @@ def go(): @tool(title="Follow a long-running job", annotations=READS_STORED, - description="Check on a crawl, run or batch a tool handed back as a job, and return its result once finished.\n" + description="Check on a crawl, run, batch or web search a tool handed back as a job, and return its result " + "once finished.\n" "Call it when a tool answered with status 'running' and a job; repeating that tool is not needed. " - "To stop a job use cancel_job. For a project's stored pages use get_page.\n" + "To stop a crawl, run or batch use cancel_job. For a project's stored pages use get_page.\n" "Inputs: kind, id and project_id come from the 'job' object; a crawl_site crawl_id is a crawl's " "id. For a crawl, url reads one page in full, even mid-crawl, and cursor from the last answer " "reads the next window.\n" "Behaviour: it answers at once and never waits, so call it again every 10 to 30 seconds while the " "status is 'running'. A finished crawl answers as crawl_site does (up to 50 pages excerpted per " - "window), a batch as scrape_urls, a run with its record. A crawl expires a day after it started " - "unless kept, then answers 404.\n" + "window), a batch as scrape_urls, a search as web_search, a run with its record. A scraping " + "search whose pages are still landing answers with its results and the job. A crawl expires a day " + "after it started unless kept, then answers 404.\n" "Read-only and free: it reads stored data and never fetches.") -def get_job(kind: JobKind, +def get_job(kind: Annotated[Literal["crawl", "run", "batch", "search"], Field( + description="'crawl' (crawl_site), 'run' (start_run), 'batch' (scrape_urls with several URLs) or " + "'search' (web_search).")], id: JobId, project_id: JobProject = None, url: Annotated[str | None, Field( @@ -1428,7 +1604,17 @@ def go(): return run if kind == "batch": return _batch_result(s.batch(id)) - return {"error": f"kind must be crawl, run or batch, not {kind!r}", "code": "validation"} + if kind == "search": + out = s.get_search(id) + status = out.get("status") + if status == "error": + raise MeshArcError(502, out.get("error") or "the search failed", "job_failed") + if _running(status): + # A scraping search has its hits before its pages land: those + # go back now, with the job, rather than a bare "running". + return _search_result(out, running=True) if out.get("data") else _still_running("search", id) + return _search_result(out) + return {"error": f"kind must be crawl, run, batch or search, not {kind!r}", "code": "validation"} return _safe(go) @@ -1492,7 +1678,7 @@ def go(): return _safe(go) -# Stdio's server, built now that the fifteen are declared. Hosted mode +# Stdio's server, built now that the sixteen are declared. Hosted mode # builds its own in `authorize`, because auth is set in the constructor. server = _build() @@ -1619,7 +1805,7 @@ def _transport_security(public_url): def authorize(issuer, public, secret): - """The hosted server: the same fifteen tools, with OAuth attached. + """The hosted server: the same sixteen tools, with OAuth attached. A server of its own rather than the stdio one with auth bolted on after the fact. `token_verifier` and `auth` are constructor arguments, and diff --git a/tests/test_behaviour.py b/tests/test_behaviour.py index 65a51a5..d340435 100644 --- a/tests/test_behaviour.py +++ b/tests/test_behaviour.py @@ -1,9 +1,10 @@ import pathlib import httpx +import pytest import mesharc -from mesharc import Crawl, MeshArc, MeshArcError +from mesharc import Crawl, MeshArc, MeshArcError, MeshArcTimeoutError class FakeHttp: @@ -92,6 +93,81 @@ def test_the_page_walk_can_start_from_a_cursor(): assert http.calls[-1][2]["cursor"] == "c7" +def test_a_search_answered_at_once_returns_the_whole_envelope_and_sends_camelcase(): + done = {"id": "s1", "status": "done", "data": [{"url": "https://x.test/", "title": "X"}]} + arc, http = client([done]) + assert arc.web_search("mesh arc", limit=5, freshness="week", include_domains=["x.test"], + exclude_domains=("y.test",), scrape=True, idempotency_key="k-s") == done + assert http.calls == [("POST", "/search", {}, { + "query": "mesh arc", "limit": 5, "freshness": "week", "includeDomains": ["x.test"], + "excludeDomains": ["y.test"], "scrape": {"formats": ["markdown"]}, "timeout": 60}, "k-s")] + + +def test_a_queued_search_is_polled_until_done(): + arc, http = client([{"id": "s2", "status": "queued"}, {"id": "s2", "status": "running"}, + {"id": "s2", "status": "done", "data": [{"url": "a"}]}]) + assert arc.web_search("q", poll=0)["data"] == [{"url": "a"}] + assert [c[:3] for c in http.calls[1:]] == [("GET", "/search/s2", {}), ("GET", "/search/s2", {})] + + +def test_a_blocked_search_is_returned_not_raised(): + arc, http = client([{"id": "s3", "status": "running"}, {"id": "s3", "status": "blocked", "data": []}]) + assert arc.web_search("q", poll=0)["status"] == "blocked" + assert len(http.calls) == 2 + + +def test_a_search_that_ends_in_error_raises_with_the_apis_text(): + arc, _ = client([{"id": "s4", "status": "error", "error": "every engine failed"}]) + with pytest.raises(MeshArcError) as exc: + arc.web_search("q", poll=0) + assert (exc.value.status, exc.value.code, exc.value.detail) == (502, "job_failed", "every engine failed") + + +def test_a_search_still_running_at_the_deadline_raises_a_timeout_naming_it(): + arc, _ = client([{"id": "s5", "status": "running"}]) + with pytest.raises(MeshArcTimeoutError) as exc: + arc.web_search("q", poll=0, timeout=0) + assert exc.value.job_id == "s5" + + +def test_a_search_without_waiting_asks_once_with_no_hold(): + arc, http = client([{"id": "s6", "status": "queued"}]) + assert arc.web_search("q", wait=False) == {"id": "s6", "status": "queued"} + assert len(http.calls) == 1 + assert http.calls[0][3] == {"query": "q", "timeout": 0} + + +def test_a_search_sends_an_explicit_hold_and_a_scrape_dict_as_given(): + scrape = {"formats": ["markdown", "links"], "maxCredits": 20} + arc, http = client([{"id": "s7", "status": "queued"}, {"id": "s8", "status": "done"}]) + arc.web_search("q", wait=False, timeout_s=30, scrape=scrape) + assert http.calls[0][3] == {"query": "q", "scrape": scrape, "timeout": 30} + arc.web_search("q", scrape=False) + assert "scrape" not in http.calls[1][3] + assert http.calls[1][3]["timeout"] == 60 + + +def test_a_search_started_earlier_is_asked_for_by_id(): + arc, http = client([{"id": "s9", "status": "done"}]) + assert arc.get_search("s9") == {"id": "s9", "status": "done"} + assert http.calls == [("GET", "/search/s9", {}, {}, None)] + + +def test_the_search_list_follows_next_and_sends_q_and_limit_on_every_page(): + arc, http = client([{"data": [{"id": "a"}, {"id": "b"}], "next": "/api/v1/search?limit=2&q=mesh&cursor=c2"}, + {"data": [{"id": "c"}], "next": None}]) + assert [row["id"] for row in arc.searches(q="mesh", limit=2)] == ["a", "b", "c"] + assert [c[:3] for c in http.calls] == [("GET", "/search", {"limit": 2, "q": "mesh"}), + ("GET", "/search", {"limit": 2, "q": "mesh", "cursor": "c2"})] + + +def test_the_project_search_still_asks_the_projects_pages(): + arc, http = client([{"data": []}]) + arc.search("p1", "pricing") + assert http.calls[0][:2] == ("POST", "/projects/p1/pages/search") + assert http.calls[0][3] == {"mode": "content", "q": "pricing", "run_id": None} + + def test_an_error_carries_its_code_and_request_id(): err = MeshArcError(429, "too many", "rate_limited", "req_abc") assert (err.status, err.code, err.request_id) == (429, "rate_limited", "req_abc") @@ -127,7 +203,7 @@ def test_pace_waits_out_the_window_only_when_nearly_out(monkeypatch): def test_the_mcp_server_offers_the_verbs_and_the_config_tools(): source = (pathlib.Path(mesharc.__file__).parent / "mcp.py").read_text(encoding="utf-8") - for name in ("scrape_urls", "map_site", "crawl_site", "list_projects", "get_changes", "get_job", - "cancel_job", "list_runs", "list_pages", "get_page", "start_run", "get_project", + for name in ("scrape_urls", "map_site", "crawl_site", "web_search", "list_projects", "get_changes", + "get_job", "cancel_job", "list_runs", "list_pages", "get_page", "start_run", "get_project", "create_project", "update_project", "delete_project"): assert f"def {name}(" in source, name diff --git a/tests/test_client.py b/tests/test_client.py index 9c2c1b8..42c0804 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -110,6 +110,19 @@ def test_a_crawl_handle_pages_through_its_rows_and_follows_the_cursor(): assert "cursor=abc" in str(arc.calls[2].url) +def test_a_web_search_posts_its_body_with_the_key_and_polls_by_id(): + arc = scripted({"id": "s1", "status": "running", "data": []}, + {"id": "s1", "status": "done", "data": [{"url": "https://a.test/"}]}) + out = arc.web_search("mesh arc", limit=3, include_domains=["a.test"], scrape=True, poll=0, idempotency_key="srch-1") + assert out["data"] == [{"url": "https://a.test/"}] + post, poll = arc.calls + assert (post.method, post.url.path) == ("POST", "/api/v1/search") + assert post.headers["Idempotency-Key"] == "srch-1" + assert json.loads(post.content) == {"query": "mesh arc", "limit": 3, "includeDomains": ["a.test"], + "scrape": {"formats": ["markdown"]}, "timeout": 60} + assert (poll.method, str(poll.url)) == ("GET", "https://api.mesharc.dev/api/v1/search/s1") + + def test_export_streams_to_a_file(tmp_path): arc = scripted({"rows": 1}) out = arc.export("p1", str(tmp_path / "pages.jsonl"), dataset="pages") diff --git a/tests/test_definitions.py b/tests/test_definitions.py index b2c82dc..10a79ca 100644 --- a/tests/test_definitions.py +++ b/tests/test_definitions.py @@ -12,6 +12,12 @@ mcp_mod = pytest.importorskip("mesharc.mcp", reason="needs the mcp extra") +# Every tool the server offers. Adding one is a line here, not a count to +# find and bump. +EXPECTED = {"scrape_urls", "map_site", "crawl_site", "web_search", "list_projects", "get_project", + "create_project", "update_project", "delete_project", "list_runs", "list_pages", "get_page", + "get_changes", "start_run", "get_job", "cancel_job"} + # The tools that only read what the workspace has stored. Everything else # reaches a site or changes the workspace, and a read-only connection is # refused it -- the same seven the read_only answer is about. @@ -22,10 +28,11 @@ def _tools(): return {t.name: t for t in asyncio.run(mcp_mod.server.list_tools())} -def test_all_fifteen_are_listed(): +def test_every_tool_is_listed(): # 0.4.0 folded five tools into the siblings they overlapped and added - # delete_project: nineteen became fifteen. - assert len(_tools()) == 15 + # delete_project, nineteen becoming fifteen; web_search makes sixteen. + assert set(_tools()) == EXPECTED + assert READ_ONLY < EXPECTED def test_every_parameter_is_described(): @@ -65,7 +72,8 @@ def enum(tool, param): options = [schema] + list(schema.get("anyOf") or []) return next(o["enum"] for o in options if "enum" in o) - assert enum("get_job", "kind") == ["crawl", "run", "batch"] + assert enum("get_job", "kind") == ["crawl", "run", "batch", "search"] + # A web search cannot be stopped: the API has no cancel for one. assert enum("cancel_job", "kind") == ["crawl", "run", "batch"] assert enum("list_pages", "mode") == ["content", "selector"] for tool in ("create_project", "update_project"): diff --git a/tests/test_folded_tools.py b/tests/test_folded_tools.py index 71e0fdf..be6b33d 100644 --- a/tests/test_folded_tools.py +++ b/tests/test_folded_tools.py @@ -257,6 +257,6 @@ def pages(self, project_id, run_id=None): c = _Failing() monkeypatch.setattr(mcp_mod, "_client", lambda: c) bad = asyncio.run(mcp_mod.server.call_tool("list_pages", {"project_id": "p1", "q": "x"})) - assert bad.structured_content == {"error": "no such project", "status": 404} + assert bad.structured_content == {"error": "no such project", "status": 404, "code": "not_found"} good = asyncio.run(mcp_mod.server.call_tool("list_pages", {"project_id": "p1"})) assert good.structured_content["aFieldAddedLater"] == {"n": 1} diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index 214cbb5..f58ab3e 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -20,6 +20,14 @@ RESOURCE = "http://mcp.example.test/mcp" +# Every tool the server offers, by name. Adding one is a line here, not a +# count to find and bump. +EXPECTED = frozenset({ + "scrape_urls", "map_site", "crawl_site", "web_search", "list_projects", "get_project", + "create_project", "update_project", "delete_project", "list_runs", "list_pages", "get_page", + "get_changes", "start_run", "get_job", "cancel_job", +}) + class _User(AuthenticatedUser): def __init__(self, at): @@ -237,14 +245,15 @@ def boom(*a, **k): def test_get_job_is_offered_alongside_the_rest(): - """Fifteen declared, and fifteen on the server that was built from + """Every tool declared, and every one on the server that was built from them. Counting `@server.tool` in the source said nothing about what the server ended up carrying.""" import anyio - assert len(mcp_mod._TOOLS) == 15, "thirteen verbs plus get_job and cancel_job" + declared = [fn.__name__ for fn, _how in mcp_mod._TOOLS] + assert len(declared) == len(EXPECTED) and set(declared) == EXPECTED, "the verbs plus get_job and cancel_job" served = [t.name for t in anyio.run(mcp_mod.server.list_tools)] - assert len(served) == 15 + assert len(served) == len(EXPECTED) and set(served) == EXPECTED assert "get_job" in served assert callable(mcp_mod.get_job) @@ -426,7 +435,7 @@ def test_the_server_answers_a_real_call_as_the_caller_who_sent_it(served): r, listed = _rpc(served, "tools/list", session=sid) assert r.status_code == 200, r.text names = [t["name"] for t in listed["result"]["tools"]] - assert len(names) == 15, names + assert len(names) == len(EXPECTED) and set(names) == EXPECTED, names assert "get_job" in names r, out = _rpc(served, "tools/call", {"name": "list_projects", "arguments": {}}, session=sid) @@ -450,8 +459,9 @@ def test_auth_is_attached_through_the_constructor(served): assert str(mcp_mod.server.settings.auth.resource_server_url).rstrip("/") == RESOURCE.rstrip("/") assert mcp_mod.server.settings.auth.validate_token_resource is True - # Both servers carry the same fifteen tools, declared once. - assert len(mcp_mod._TOOLS) == 15 + # Both servers carry the same tools, declared once. + assert len(mcp_mod._TOOLS) == len(EXPECTED) names = {fn.__name__ for fn, _how in mcp_mod._TOOLS} + assert names == EXPECTED assert "get_job" in names assert all(how.get("description") for _fn, how in mcp_mod._TOOLS), "a tool with no description is a verb an assistant cannot choose" diff --git a/tests/test_results.py b/tests/test_results.py index 402c97c..84df891 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -267,13 +267,14 @@ def test_a_read_only_connection_is_told_why_rather_than_about_roles(monkeypatch) assert out["code"] == "read_only" assert "approved read-only" in out["error"] assert "write access" in out["error"], "it has to say what would fix it" + assert "Also allow changes" in out["error"], "name the box on the consent screen" assert out["detail"] == "this needs the member role", "the API's own words are kept" def test_a_connection_with_write_hears_the_api(monkeypatch): monkeypatch.setattr(mcp_mod, "_scopes", lambda: ["read", "write"]) out = mcp_mod._safe(_refuse(403, "this needs the owner role", code="forbidden")) - assert out == {"error": "this needs the owner role", "status": 403} + assert out == {"error": "this needs the owner role", "status": 403, "code": "forbidden"} def test_the_other_refusals_keep_saying_what_they_say(monkeypatch): @@ -284,7 +285,7 @@ def test_the_other_refusals_keep_saying_what_they_say(monkeypatch): ("email_unverified", "verify your email address first"), ("mfa_required", "this workspace requires two-factor authentication")): out = mcp_mod._safe(_refuse(403, detail, code=code)) - assert out == {"error": detail, "status": 403}, code + assert out == {"error": detail, "status": 403, "code": code}, code def test_locally_the_server_does_not_guess_at_the_key(monkeypatch): @@ -293,7 +294,19 @@ def test_locally_the_server_does_not_guess_at_the_key(monkeypatch): monkeypatch.setattr(mcp_mod, "_HTTP", False) assert mcp_mod._scopes() is None out = mcp_mod._safe(_refuse(403, "this needs the member role", code="forbidden")) - assert out == {"error": "this needs the member role", "status": 403} + assert out == {"error": "this needs the member role", "status": 403, "code": "forbidden"} + + +def test_an_error_carries_its_code_and_request_id(): + """The code says why a call was refused and the request id is what + support looks it up by; dropping either leaves nobody able to act.""" + from mesharc import MeshArcError + + def boom(): + raise MeshArcError(429, "slow down", code="rate_limited", request_id="req_1") + out = mcp_mod._safe(boom) + assert out == {"error": "slow down", "status": 429, "code": "rate_limited", + "request_id": "req_1"} def test_a_webhook_signing_secret_never_reaches_an_assistant(): @@ -455,3 +468,126 @@ def page(i): job = _Job([page(i) for i in range(500)]) out = mcp_mod._crawl_result(job, job.pages()) assert len(out["pages"]) >= mcp_mod.MIN_EXCERPTS and len(json.dumps(out)) <= mcp_mod.RESULT_BUDGET + + +# --- web_search, waited for down a pipe and bounded hosted ---------------- + +class _Searches: + """Enough of a client for web_search and get_job's search branch: each + call takes the next answer, or raises what it was given.""" + + def __init__(self, *answers, raises=None): + self.answers, self.raises, self.calls = list(answers), raises, [] + + def web_search(self, query, **kw): + self.calls.append((query, kw)) + if self.raises is not None: + raise self.raises + return self.answers.pop(0) + + def get_search(self, id): + self.calls.append((id, {})) + return self.answers.pop(0) + + def __enter__(self): + return self + + def __exit__(self, *_exc): + return None + + +def _hit(i, page=None): + h = {"position": i + 1, "title": f"Result {i}", "url": f"https://x.test/{i}", + "snippet": "s" * 160, "source": "organic", "engine": "google"} + if page is not None: + h["page"] = page + return h + + +def _search(status="done", hits=(), **extra): + return {"id": "s1", "status": status, "query": "q", "engine": "google", "cached": False, + "creditsUsed": 3, "data": list(hits), **extra} + + +def test_ten_scraped_results_stay_inside_the_budget(monkeypatch): + """Ten hits at a full page each is what a crawl used to answer with: the + pages come back as excerpts with a link count, inside the same promise.""" + arc = _Searches(_search(hits=[_hit(i, _page(i)) for i in range(10)])) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + monkeypatch.setattr(mcp_mod, "_budget", lambda: None) + out = mcp_mod.web_search("q", scrape=True) + size = len(json.dumps(out)) + assert size <= mcp_mod.RESULT_BUDGET, f"{size:,} characters against a promise of {mcp_mod.RESULT_BUDGET:,}" + assert len(out["results"]) == 10 + assert all(isinstance(r["page"]["links"], int) for r in out["results"]) + assert "href" not in json.dumps(out), "not one link list survived" + assert all("call scrape_urls with that url" in r["page"]["markdown"] for r in out["results"]) + (query, kw), = arc.calls + assert query == "q" and kw["scrape"] is True + assert "idempotency_key" not in kw, "a repeat search is a new search by design" + + +def test_a_blocked_search_says_how_it_was_refused(monkeypatch): + blocked = _search(status="blocked", error="every engine refused the results page", + attempts=[{"engine": "google", "status": 429}, {"engine": "bing", "status": 403}]) + monkeypatch.setattr(mcp_mod, "_client", lambda: _Searches(blocked)) + monkeypatch.setattr(mcp_mod, "_budget", lambda: None) + out = mcp_mod.web_search("q") + assert out["status"] == "blocked" and out["results"] == [] + assert out["error"] == "every engine refused the results page" + assert out["attempts"] == blocked["attempts"] + + +def test_a_local_search_that_outlasts_the_clients_wait_hands_back_its_job(monkeypatch): + arc = _Searches(raises=mcp_mod.MeshArcTimeoutError("still running", "s9")) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + monkeypatch.setattr(mcp_mod, "_budget", lambda: None) + out = mcp_mod.web_search("q") + assert out["status"] == "running" and out["job"] == {"kind": "search", "id": "s9"} + + +def test_hosted_web_search_asks_once_inside_its_budget(monkeypatch): + """No poll loop down the socket: one call that the API holds for the + budget less five seconds, and whatever it has comes back.""" + monkeypatch.setattr(mcp_mod, "_budget", lambda: 25.0) + monkeypatch.setattr(mcp_mod, "_scopes", lambda: ["read", "write"]) + + arc = _Searches(_search(status="queued")) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + out = mcp_mod.web_search("q") + (_query, kw), = arc.calls + assert kw["wait"] is False and kw["timeout_s"] == 20.0 + assert out["status"] == "running" and out["job"] == {"kind": "search", "id": "s1"} + assert "results" not in out + + arc = _Searches(_search(status="running", hits=[_hit(i) for i in range(3)])) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + out = mcp_mod.web_search("q", scrape=True) + assert [r["url"] for r in out["results"]] == [f"https://x.test/{i}" for i in range(3)] + assert out["job"] == {"kind": "search", "id": "s1"} + assert "get_job" in out["note"] + + arc = _Searches(_search(status="error", error="every engine failed")) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + out = mcp_mod.web_search("q") + assert out == {"error": "every engine failed", "status": 502, "code": "job_failed"} + + +def test_get_job_follows_a_search(monkeypatch): + monkeypatch.setattr(mcp_mod, "_budget", lambda: None) + monkeypatch.setattr(mcp_mod, "_client", lambda: _Searches(_search(status="queued"))) + out = mcp_mod.get_job("search", "s1") + assert out["status"] == "running" and out["job"] == {"kind": "search", "id": "s1"} + + done = _search(hits=[_hit(i, _page(i)) for i in range(4)]) + monkeypatch.setattr(mcp_mod, "_client", lambda: _Searches(done)) + polled = mcp_mod.get_job("search", "s1") + monkeypatch.setattr(mcp_mod, "_client", lambda: _Searches(done)) + waited = mcp_mod.web_search("q", scrape=True) + assert polled == waited, "what an assistant gets by polling is what it would have got by waiting" + assert "job" not in polled + + monkeypatch.setattr(mcp_mod, "_client", + lambda: _Searches(_search(status="error", error="every engine failed"))) + out = mcp_mod.get_job("search", "s1") + assert out == {"error": "every engine failed", "status": 502, "code": "job_failed"} From 0ffa7682de22bc75be3bff78d8a3bc6fc233450b Mon Sep 17 00:00:00 2001 From: Siddharth-DWT Date: Fri, 9 Oct 2026 11:41:09 +0530 Subject: [PATCH 2/3] Add the agent and monitors: agent(), AgentRun with continue_, arc.monitors, run_agent and continue_agent Client: - agent(prompt, urls=, schema=, max_credits=, max_steps=, allowed_domains=, webhook=, connection_id=, timeout_s=, idempotency_key=) returns an AgentRun: wait(), refresh(), cancel(), trace(), data, sources, field_sources, webhook_secret (kept across refresh and continue_), and continue_() for a run stopped at credit_limit. get_agent(id), agent_runs(status=, model=, since=, until=). - web_search(news=True, page=) for news results and later pages. - arc.monitors: create, list, get, update, pause, resume, run, runs, delete -- a search or agent request kept on a schedule. MCP (17 tools on 0.6.0's names): - run_agent and continue_agent, both needing write access, answering at once with a job; get_job and cancel_job take kind="agent". A finished answer keeps within 60,000 characters (fieldSources dropped first). Their own idempotency keys; a 409 in_flight is answered as running. --- CHANGELOG.md | 24 ++ README.md | 154 +++++++++++- mesharc/__init__.py | 396 ++++++++++++++++++++++++++++++- mesharc/mcp.py | 375 ++++++++++++++++++++++++++--- tests/test_behaviour.py | 401 ++++++++++++++++++++++++++++++- tests/test_client.py | 70 +++++- tests/test_definitions.py | 7 +- tests/test_mcp_http.py | 2 +- tests/test_results.py | 484 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 1853 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9978ec..19d915d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ All notable changes to this package are recorded here. The format follows ## Unreleased +### Added + +- **The client hands a question to the agent, which searches, reads pages and answers on its own.** `agent(prompt, urls=, schema=, max_credits=, max_steps=, allowed_domains=, timeout_s=0, idempotency_key=)` returns an `AgentRun` at once; `timeout_s` asks the API to hold the request for the answer, 120 s at most. `AgentRun.wait(poll=2.0, timeout=3600)` returns the envelope once the run is `done`, `cancelled` or `credit_limit` (what it had found under `data.partial`) and raises `MeshArcError` for `error`; `refresh()` reads it as it stands, `cancel()` stops it (`cancelled` when queued, `cancelling` when running, which stops before its next step), and `trace(after=0, follow=True)` yields what the agent does, step by step. `data` is JSON matching the schema, or `{"text": ...}` without one, and `sources` the pages it rests on. `get_agent(id)` reopens a run, and raises `MeshArcError` 410 with code `expired` once it is past its 7 days; `agent_runs(status=None, limit=25)` walks the workspace's runs, following `next`. It needs a key that can write, and spends credits: pages as they are read (a refused page is free) and the model's tokens at the model provider's price plus 20%, within `max_credits` (2,000 by default). **This needs an API that has the `/agent` route**, which is not live on mesharc.dev yet. +- **An agent run can report to a webhook and run on the workspace's own model.** `agent()` takes `webhook=` (a URL, or `{url, events, metadata}`, told about `agent.started`, `agent.action`, `agent.completed`, `agent.failed` and `agent.cancelled`) and `connection_id=` (one of the workspace's LLM connections, whose tokens then cost 1 credit per 1,000). `AgentRun.webhook_secret` is the secret the messages are signed with, returned once on the POST and kept by the handle past `refresh()`, and by the handle `continue_()` returns; `""` without a webhook. **These need an API with the agent's webhook and connection features**, which are on the API's feature branch, not yet on mesharc.dev. +- **`AgentRun.field_sources` says where each value of the answer came from**: its path (`"plans[0].price"`, `"[2].name"` for a list answer) to `{url, pageId}`, a page the run read; `{}` when the API said nothing. **This needs an API with field sources**, on the API's feature branch, not yet on mesharc.dev. +- **`AgentRun.continue_(max_credits=None, max_steps=None, timeout_s=0, idempotency_key=None)` carries on a run that stopped at `credit_limit`**, with a new budget. It returns an `AgentRun` on the new run, which resumes from where the stopped one was, keeps the pages it read (not paid for again) and its webhook, and answers in full. The API carries the webhook's secret over without repeating it, so the new handle's `webhook_secret` is the stopped handle's. `continues_run_id` and `continued_by` link the two runs. A run that did not stop at its limit, was already continued or has no saved progress raises `MeshArcError` 409 `conflict`; one past its keep date, 410 `expired`. **This needs an API that has the `/agent/{id}/continue` route**, on the API's feature branch, not yet on mesharc.dev. +- `agent_runs()` also filters by `model=` and by `since=` / `until=` (an ISO 8601 string, or a `date` / `datetime`, sent as ISO 8601), sent on every page like `status`; an empty string sends no filter. A date means its midnight UTC; a date-time without a zone is read as UTC, so a naive `datetime.now()` is local time read as UTC. +- **`web_search(news=True)` searches the engines' news results**, each hit with `publisher` and `age`; `page=` (1 to 10) reads results further in, page 2 being results 11-20. Both come last in the signature, after `idempotency_key`, so no existing call changes meaning. An equal news search comes from the cache for ten minutes, not an hour. **These need an API with news and results-page search**, on the API's feature branch, not yet on mesharc.dev. +- **Monitors: a search or an agent request, run again on a schedule and compared with the last run that answered**, under `arc.monitors` as projects are under `arc.projects`: + + | Method | What it does | + |---|---| + | `monitors.create(kind, request, schedule, name=None, webhook=None, baseline_id=None, idempotency_key=None)` | Keep a search or agent request on a schedule | + | `monitors.list(kind=None)` · `monitors.get(id)` | The workspace's monitors, each with its `lastRun`; one monitor | + | `monitors.update(id, name=None, schedule=None, status=None, webhook=None)` | Sends only what is not None; `webhook=""` removes the webhook | + | `monitors.pause(id)` · `monitors.resume(id)` | Stop and restart the schedule | + | `monitors.run(id, idempotency_key=None)` | Run it now; 409 `conflict` while a run is under way | + | `monitors.runs(id, limit=25)` | Its runs, with what changed, following `next` | + | `monitors.delete(id)` | The monitor and its runs | + + Each returns the API's JSON. `request` is a POST /search or POST /agent body in the API's own (camelCase) names, so a news monitor says `"sources": ["news"]` (`web_search(news=True)` maps to that; `monitors.create` does not); a monitored search reads its results page only, so `scrape` is refused; a search baseline's `limit` must be at least the monitor's. The webhook hears `search.changed` / `agent.changed`, and its secret comes back once as `webhookSecret`. Creating and running a monitor spend credits, and every call but the three reads needs a key that can write. `arc.monitor()`, the workspace's job queue, is unchanged. **This needs an API that has the `/monitors` route**, which is on the API's feature branch and not yet on mesharc.dev. +- **`run_agent`, a sixteenth MCP tool, so an assistant can hand a research question to the agent.** It needs write access; a read-only connection is answered `{"code": "read_only"}`. It returns at once with a job, hosted or local, and a retry that lands on a finished run gets its result; `get_job` and `cancel_job` take `kind="agent"` to follow and stop it, and a run past its 7 days answers `status: "expired"`. A finished answer carries `fieldSources`, where each value of `data` came from, and keeps within 60,000 characters: `fieldSources` is left out first, then its sources are cut to the first 50, then `data` to its JSON text, with a note saying where the full answer is. The model's tokens are charged at the model provider's price plus 20%. It sends its own idempotency key, made from its inputs and the ten-minute window, so a retry in the same ten minutes lands on the same run; a 409 saying that key is still being accepted (code `in_flight`, or an older API's message naming the Idempotency-Key) is answered as running, and any other 409 as an error. An output schema describes its answer. **This needs an API that has the `/agent` route**, which is not live on mesharc.dev yet. +- **`continue_agent`, a seventeenth MCP tool, carries on an agent run that stopped at its credit limit.** `continue_agent(id, max_credits=None, max_steps=None)` starts a new run on the same thread, which resumes where the stopped one was and keeps the pages it read (not paid for again); left out, the budget and steps are the stopped run's. It needs write access; a read-only connection is answered `{"code": "read_only"}`. It returns at once with the new run's job, as `run_agent` does, for `get_job` and `cancel_job` with `kind="agent"`, and sends its own idempotency key, made from its inputs and the ten-minute window. A run already carried on (a retry past that window included) is answered with the run that carried it on, and nothing new is started. A run that did not stop at its limit is answered with code `conflict`, an unknown one `not_found`, one past its 7 days `expired`. A `credit_limit` answer's note now points at `continue_agent` with the run's id, or at the run that already carried it on. **This needs an API that has the `/agent/{id}/continue` route**, which is not live on mesharc.dev yet. + ## 0.6.0 - 2026-10-07 Two MCP tool names change, so **this breaks anything that calls `web_search` or `get_page` by name**; both still do what they did, under the new names below. The Python client's API is unchanged apart from two additions: `web_search()` keeps its name there. diff --git a/README.md b/README.md index 7e14b65..9c89ec9 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ The Python client for the [MeshArc](https://mesharc.dev) API: a URL in, clean co - **Crawl** a whole site with no project to set up first, and keep it as one if it turns out to be worth watching. - **Map** what a site declares in its sitemaps before fetching any of it. - **Search** the web for pages whose URLs you do not know, and read each result's page in the same call. +- **Ask** the agent a question: it searches, reads pages and answers, in the shape of a schema if you give one, with the pages its answer rests on. (Not live on mesharc.dev yet.) +- **Monitor** a search or a question: asked again hourly, daily or weekly, each answer compared with the last, a webhook told when it changed. (Not live on mesharc.dev yet.) - **Watch** a site over time: projects, scheduled runs, and a change record — pages added, removed, modified, field by field. Python 3.10 or newer. One dependency (`httpx`). Fully typed. @@ -159,14 +161,141 @@ print(out["engine"], out["cached"], out["creditsUsed"]) again = arc.get_search(out["id"]) # a search started earlier, by id for s in arc.searches(q="python"): # the workspace's searches, newest first print(s["id"], s["query"], s["status"], s["resultCount"], s["creditsUsed"]) + +news = arc.web_search("python release", news=True, freshness="day") # the engines' news results +for hit in news["data"]: + print(hit["publisher"], hit["age"], hit["title"]) # who published it, and how long ago ('20h') +more = arc.web_search("python packaging guide", page=2) # results 11-20 ``` `web_search` waits for the results and returns the whole envelope, the hits under `data`. A search is `queued`, `running`, `done`, `blocked` or `error`: `blocked` — every engine refused — is returned, not raised; `error` raises `MeshArcError`. A search that scrapes stays `running` until its pages land. `scrape=` also takes a dict, sent as given (`{"formats": ["markdown", "links"], "maxCredits": 20}`); `country=` and `lang=` say where and in what language to search; `wait=False` returns at once, and `timeout_s` is how long the API holds the request (60 s by default, 120 at most) before the client polls. -A search needs a key that can write, and it spends credits. The results page costs the engine that read it; a results page every engine refused is free. An equal search — same query, `country`, `lang`, `freshness` and domains — within an hour of a finished one comes from the cache (`cached` is true) with no charge for the results page. Scraped pages are charged, cached or not. +`news=True` searches the engines' news results rather than the web's (one or the other per search); each news hit also carries `publisher` and `age`, as the engine put it (`"20h"`). `page=` is which results page, 1 to 10: page 2 is results 11–20, and each page is its own search with its own charge. **`news=` and `page=` need an API with those features**: they are on the API's feature branch, not yet on mesharc.dev. + +A search needs a key that can write, and it spends credits. The results page costs the engine that read it; a results page every engine refused is free. An equal search — same query, `news`, `page`, `country`, `lang`, `freshness` and domains — within an hour of a finished one (ten minutes for news) comes from the cache (`cached` is true) with no charge for the results page. Scraped pages are charged, cached or not. `arc.search(project_id, q)` is something else: it searches the pages a project has stored, not the web (see below). +## Asking the agent + +**The agent route (`/agent`) is not live on mesharc.dev yet**; web search is. The client and the MCP tools are ready for it. The agent's webhook, `connection_id`, `continue_()` and `field_sources` need an API with those features too: they are on the API's feature branch, not yet on mesharc.dev. + +The agent takes a question, searches the web, maps sites and reads pages on its own, and answers with the pages it used. Give it a schema and the answer comes back in that shape: + +```python +run = arc.agent( + "What does each plan on example.com cost per month?", + urls=["https://example.com/pricing"], # pages to start from, up to 20; omit to let it search + allowed_domains=["example.com"], # keep it to these sites, up to 20 + schema={ # a JSON Schema of type object or array + "type": "object", + "properties": {"plans": {"type": "array", "items": {"type": "object", "properties": { + "name": {"type": "string"}, "monthly": {"type": "number"}}}}}, + }, + max_credits=500, # the most the run may spend: 1 to 100,000, 2,000 by default + max_steps=20, # the most steps it may take: 1 to 100, 40 by default +) + +out = run.wait() # blocks until it finishes; the envelope +print(out["status"], out["creditsUsed"], out["steps"]) +print(run.data) # {'plans': [...]}, matching the schema +for src in run.sources: # the pages the answer rests on + print(src["url"], src["title"]) +print(run.field_sources) # {'plans[0].monthly': {'url': ..., 'pageId': ...}, ...} +``` + +`agent` returns an `AgentRun` at once, queued or running; the prompt is up to 10,000 characters. `timeout_s=` asks the API to hold the request for the answer (120 s at most). Without a schema the answer is `{"text": ...}`. `field_sources` says where each value of the answer came from: its path (`"plans[0].monthly"`, or `"[2].name"` for a list answer) to `{url, pageId}`, a page the run read. + +```python +run = arc.agent( + "What does each plan on example.com cost per month?", + webhook={"url": "https://hooks.example.com/mesharc", "events": ["agent.completed", "agent.failed"]}, + connection_id="conn_...", # run on one of the workspace's own LLM connections +) +print(run.webhook_secret) # once: the secret the webhook's messages are signed with +``` + +`webhook=` is a URL, or `{"url", "events", "metadata"}`, told about `agent.started`, `agent.action`, `agent.completed`, `agent.failed` and `agent.cancelled` (all five by default); its signing secret comes back once, as `run.webhook_secret`; the handle `continue_()` returns carries the stopped run's secret on (the new run keeps the same webhook), and a run reopened with `get_agent` has none. `connection_id=` runs the agent on one of the workspace's own LLM connections — its model, its key, its bill. + +To watch it work, follow the trace: + +```python +run = arc.agent("Who maintains the httpx library, and where is it developed?") +for event in run.trace(): # follows the run until it ends + print(event["seq"], event["kind"], event["text"]) +print(run.wait()["data"]) # {'text': '...'} +``` + +`kind` is `start`, `resume`, `continue`, `model`, `search`, `fetch`, `render`, `map`, `select`, `extract`, `tool`, `busy` or `finish`. `trace(after=seq)` resumes after an event already seen; `follow=False` yields what exists and returns. + +```python +run.cancel() # {'id': ..., 'status': 'cancelled'} if it was queued, 'cancelling' if running +run.refresh() # the run as it stands now + +again = arc.get_agent(run.id) # a run started earlier, by id +for r in arc.agent_runs(status="done"): # the workspace's runs, newest first + print(r["id"], r["status"], r["creditsUsed"], r["prompt"]) +from datetime import date +for r in arc.agent_runs(model="openai:gpt-5.4-mini", since="2026-10-01", until=date.today()): + print(r["id"], r["model"], r["createdAt"]) +``` + +A run is `queued`, `running`, `done`, `error`, `cancelled` or `credit_limit`. `wait()` returns a run that is `done`, `cancelled` or stopped at `credit_limit` — the run reached `max_credits`, and what it had found is under `data["partial"]` — and raises `MeshArcError` for `error`, with the API's text. A cancel stops a queued run at once and a running one before its next step; what it used stays charged. + +`agent_runs` filters by `status`, by `model` (as runs name it) and by when the run was made: at or after `since`, before `until`, each an ISO 8601 date or date-time string or a `date` / `datetime`. A date means its midnight UTC; a date-time without a zone is read as UTC (so a naive `datetime.now()` is your local time read as UTC — pass `datetime.now(timezone.utc)` instead). + +A run that stopped at `credit_limit` can be carried on with a new budget: + +```python +out = run.wait() +if out["status"] == "credit_limit": + more = run.continue_(max_credits=1_000) # a new run, on the same thread + print(more.continues_run_id) # run.id + print(more.wait()["data"]) # the full answer, replacing the partial one +``` + +`continue_()` returns an `AgentRun` on the new run, which resumes from where the stopped one was, keeps the pages it read (not paid for again) and its webhook, whose secret the new handle's `webhook_secret` carries on. `max_credits` and `max_steps` are the new run's own; left out, they are the stopped run's. The stopped run's `continued_by` names the new one once refreshed. A run that did not stop at its credit limit, was already continued or has no saved progress raises `MeshArcError` 409 with code `conflict`; one past its keep date, 410 `expired`. + +A run needs a key that can write, and it spends credits: each page it reads is charged as a read (a refused page is free), and the model's tokens at the model provider's price plus 20% — or 1 credit per 1,000 tokens on the workspace's own connection — all within `max_credits`. A run is kept 7 days; after that `get_agent` raises `MeshArcError` with status 410 and code `expired`, and `trace()` ends quietly. + +## Monitors: a search or a question, asked again on a schedule + +**Monitors (`arc.monitors`, the `/monitors` route) need an API with that feature**: it is on the API's feature branch, not yet on mesharc.dev. + +```python +monitor = arc.monitors.create( + "search", # or "agent" + {"query": "mesharc", "freshness": "day"}, # a POST /search or POST /agent body, in the API's own names + "daily", # hourly | daily | weekly + name="Mentions", + webhook="https://hooks.example.com/mesharc", # told search.changed / agent.changed +) +print(monitor["webhookSecret"]) # once + +for r in arc.monitors.runs(monitor["id"]): # newest first + print(r["createdAt"], r["status"], r["changed"], r["summary"]) + +press = arc.monitors.create("search", {"query": "mesharc", "sources": ["news"]}, "hourly") # a news monitor +arc.monitors.update(monitor["id"], schedule="weekly") # only what is not None is sent +arc.monitors.update(monitor["id"], webhook="") # the empty string removes the webhook +``` + +Each run is the ordinary search or agent run, charged as one, and is compared with the last run that answered: new, dropped and moved results for a search, the answer's added, changed and removed values for an agent run. A run's `refId` is the search or agent run it made (`arc.get_search` / `arc.get_agent`). + +The `request` is the body of a POST /search or POST /agent in the API's own (camelCase) names — `{"query": ..., "includeDomains": [...]}`, `{"prompt": ..., "schema": ..., "maxCredits": ...}` — not `web_search`'s or `agent`'s Python arguments. So a news monitor says `"sources": ["news"]`: `web_search(news=True)` maps its argument to that, and `monitors.create` does not. A monitored search reads its results page only, so `scrape` is refused. `baseline_id=` takes a finished search or agent run with the same request as the first run, not paid for again (for a search, the baseline's `limit` must be at least the monitor's); without it the first run starts now. + +| Method | What it does | +|---|---| +| `monitors.create(kind, request, schedule, name=None, webhook=None, baseline_id=None, idempotency_key=None)` | Keep a search or agent request on a schedule | +| `monitors.list(kind=None)` · `monitors.get(id)` | The workspace's monitors, each with its `lastRun`; one monitor | +| `monitors.update(id, name=None, schedule=None, status=None, webhook=None)` | Change what is not None; `status` is `active` or `paused`, `webhook=""` removes the webhook | +| `monitors.pause(id)` · `monitors.resume(id)` | Stop and restart the schedule; a slot missed while paused runs once on resuming | +| `monitors.run(id, idempotency_key=None)` | Run it now; 409 `conflict` while a run is under way | +| `monitors.runs(id, limit=25)` | Its runs, with `changed`, `summary` and `diff`, following `next` | +| `monitors.delete(id)` | The monitor and its runs; the searches and agent runs it made stay | + +Creating and running a monitor spend credits. Every call but `monitors.list`, `monitors.get` and `monitors.runs` needs a key that can write. A webhook's secret comes back once, as `webhookSecret`, on the call that set it. `arc.monitor()` is something else: the workspace's job queue, what is queued and running (see below). + ## Watching a site: projects and runs ```python @@ -206,7 +335,7 @@ arc.export(project["id"], "pages.csv", dataset="pages", fmt="csv") ```python me = arc.me() # the workspace, its plan and limits, credits used and remaining, what this key may do usage = arc.usage() # pages per day, this month by engine -monitor = arc.monitor() # what is queued and running +queue = arc.monitor() # the job queue: what is queued and running (not arc.monitors) meta = arc.meta() # verdict meanings, engine costs, the config defaults keys = arc.keys() key = arc.create_key("ci", scopes=["read", "write"], projects=[project["id"]], expires_in_days=90) # key["key"], once @@ -234,27 +363,28 @@ except MeshArcError as exc: | `forbidden` | 403 | The key's scopes do not allow it | | `not_found` | 404 | No such thing — or not one this key may see | | `conflict` | 409 | The request contradicts current state | +| `expired` | 410 | An agent run past its keep date (7 days) | | `rate_limited` | 429 | Over the key's rate limit; `X-RateLimit-Reset` says when | | `internal` | 500 | Quote `request_id` to support | `request_id` is the id the API put on the response and in its own logs, so a support conversation starts from one string. -Two more cases: a network failure or a request that hits `timeout` raises `MeshArcError` with `status == 0` and `code` `network` or `timeout`; a job the client stopped waiting for raises `MeshArcTimeoutError` — both a `MeshArcError` and a `TimeoutError` — which carries `job_id` so you can poll it later (`arc.get_crawl(id)`, `arc.batch(id)`, `arc.get_search(id)`). +Two more cases: a network failure or a request that hits `timeout` raises `MeshArcError` with `status == 0` and `code` `network` or `timeout`; a job the client stopped waiting for raises `MeshArcTimeoutError` — both a `MeshArcError` and a `TimeoutError` — which carries `job_id` so you can poll it later (`arc.get_crawl(id)`, `arc.batch(id)`, `arc.get_search(id)`, `arc.get_agent(id)`). ## Idempotency and timeouts -- `scrape`, `scrape_one`, `crawl` and `web_search` take `idempotency_key=`: send the same key again within 24 hours and you get the first answer back rather than a second job. The MCP server's `search_web` tool sends none, on purpose: an equal search within the hour of a finished one comes from the cache anyway. -- Waiting calls take `wait=`, `poll=` (seconds between polls) and `timeout=` (seconds before `TimeoutError`). `wait=False` returns the envelope at once; the default polls every 3 s for up to an hour. +- `scrape`, `scrape_one`, `crawl`, `web_search`, `agent`, `AgentRun.continue_`, `monitors.create` and `monitors.run` take `idempotency_key=`: send the same key again within 24 hours and you get the first answer back rather than a second job. The MCP server's `search_web` tool sends none, on purpose: an equal search within the hour of a finished one comes from the cache anyway. Its `run_agent` and `continue_agent` tools send their own, made from their inputs and the ten-minute window, so a retry in the same ten minutes lands on the same run and a re-run later is a new one. +- Waiting calls take `poll=` (seconds between polls) and `timeout=` (seconds before `TimeoutError`), and most take `wait=`: `wait=False` returns the envelope at once. The defaults differ: `scrape`, `batch`, `crawl` (and `Crawl.wait`, `Crawl.pages`) and `runs.start` / `runs.wait` poll every 3 s for up to an hour; `scrape_one` and `web_search` every 2 s for up to ten minutes; `map` / `map_details` and `extract` every 2 s for up to five minutes; `AgentRun.wait` and `AgentRun.trace` every 2 s for up to an hour. - `timeout_s` on a single scrape is how long the API itself holds the request open (60 s by default, 120 at most); a slower page comes back as an id and is polled. - `MeshArc(..., timeout=150.0)` is the HTTP timeout per request. A request is retried on 429, 502, 503, 504 and network failures when it is safe to repeat — a GET, a DELETE, or a POST with an idempotency key — up to `max_retries` times (2), honouring `Retry-After`. ## Credits -Every response says what it cost: `credits` on a page, `creditsUsed` on a job envelope, `X-MeshArc-Credits` on the HTTP response. A page costs the engine that read it — a plain fetch 1, a render 4 — and a refused page or a 404 costs nothing. A web search's results page costs the engine that read it, and nothing when every engine refused; an equal search (same query, country, lang, freshness and domains) within an hour of a finished one comes from the cache with no charge for the results page, while the pages it scrapes are charged either way. The schedule and the plans are at [mesharc.dev/docs/billing](https://mesharc.dev/docs/billing). +Every response says what it cost: `credits` on a page, `creditsUsed` on a job envelope, `X-MeshArc-Credits` on the HTTP response. A page costs the engine that read it — a plain fetch 1, a render 4 — and a refused page or a 404 costs nothing. A web search's results page costs the engine that read it, and nothing when every engine refused; an equal search (same query, news or web, results page, country, lang, freshness and domains) within an hour of a finished one (ten minutes for news) comes from the cache with no charge for the results page, while the pages it scrapes are charged either way. An agent run pays for each page it reads as a read (a refused page is free) and for the model's tokens at the model provider's price plus 20% (1 credit per 1,000 tokens on the workspace's own LLM connection), within its `max_credits` (2,000 by default); `creditsUsed` counts both. A monitor's runs are charged as the searches and agent runs they are. The schedule and the plans are at [mesharc.dev/docs/billing](https://mesharc.dev/docs/billing). ## The MCP server -The package also ships MeshArc as an MCP server, so Claude Desktop, Claude Code, Cursor and any MCP client can scrape, crawl, map, search the web and read change records as tools. Python 3.10+. +The package also ships MeshArc as an MCP server, so Claude Desktop, Claude Code, Cursor and any MCP client can scrape, crawl, map, search the web, hand a question to the agent and read change records as tools. Python 3.10+. There is a hosted one, so most people need install nothing: @@ -275,13 +405,15 @@ MESHARC_API_KEY=mesharc_... mesharc-mcp # serves over stdio claude mcp add mesharc -e MESHARC_API_KEY=mesharc_... -- mesharc-mcp ``` -Tools: `scrape_urls`, `map_site`, `crawl_site`, `search_web`, `list_projects`, `get_project`, `create_project`, `update_project`, `delete_project`, `start_run`, `list_runs`, `list_pages`, `get_changes`, `get_job`, `cancel_job`. `get_project` shows a project's webhook and its recent deliveries, and `update_project` sets it and sends it a test (`test_webhook=true`). Every tool is a call through this client, and each one's answer is described by an output schema. A tool that fails answers `{error, status}`, with the API's `code` and `request_id` as well when it gave them. +Tools: `scrape_urls`, `map_site`, `crawl_site`, `search_web`, `list_projects`, `get_project`, `create_project`, `update_project`, `delete_project`, `start_run`, `list_runs`, `list_pages`, `get_changes`, `get_job`, `cancel_job`, `run_agent` and `continue_agent` (the last two not live on mesharc.dev yet: they need the API's agent features, on its feature branch). `get_project` shows a project's webhook and its recent deliveries, and `update_project` sets it and sends it a test (`test_webhook=true`). Every tool is a call through this client, and each one's answer is described by an output schema. A tool that fails answers `{error, status}`, with the API's `code` and `request_id` as well when it gave them. + +A result with many pages in it is a map, not the territory: a crawl answers with an index of the pages read (up to 500, fewer if the budget needs the room), an excerpt of as many as a 60,000-character budget pays for (fifty at most), and a count of each page's links rather than the links. Ask for the one page you want on its own with `get_job(kind="crawl", id=…, url=…)`, or carry on through the crawl with `cursor=`. A multi-URL scrape past its budget counts the rest by status and names any that did not come back ok. One page asked for on its own -- `list_pages` with `url`, or `scrape_urls` with a single url (with `full=true` for every format) -- comes back whole, capped at 12,000 characters. A `search_web` answer keeps within the same 60,000 characters, a scraped result's page as an excerpt; `scrape_urls` with `full=true` reads one in full. So does an agent's answer: past the budget its `fieldSources` (where each value of the answer came from) are left out first, then its sources are cut, to the first 50 (fewer if they would take more than half the room), and only then is `data` replaced by its JSON text, cut, with a note saying where the full answer is -- `GET /api/v1/agent/{id}`, or `get_agent(id)` in this client. -A result with many pages in it is a map, not the territory: a crawl answers with an index of the pages read (up to 500, fewer if the budget needs the room), an excerpt of as many as a 60,000-character budget pays for (fifty at most), and a count of each page's links rather than the links. Ask for the one page you want on its own with `get_job(kind="crawl", id=…, url=…)`, or carry on through the crawl with `cursor=`. A multi-URL scrape past its budget counts the rest by status and names any that did not come back ok. One page asked for on its own -- `list_pages` with `url`, or `scrape_urls` with a single url (with `full=true` for every format) -- comes back whole, capped at 12,000 characters. A `search_web` answer keeps within the same 60,000 characters, a scraped result's page as an excerpt; `scrape_urls` with `full=true` reads one in full. +Hosted, a connection can be approved read-only, and read-only cuts in a place worth knowing: it reads the whole workspace, but it cannot reach the site. Anything that fetches is a write, because it leaves the workspace and usually spends its credits -- `map_site` reads only a site's sitemaps, at a credit per sitemap file, and it is gated with the rest. `list_projects`, `get_project`, `list_runs`, `list_pages`, `get_changes` and `get_job` work; the eleven that fetch or write -- `scrape_urls`, `map_site`, `crawl_site`, `search_web`, `run_agent`, `continue_agent`, `create_project`, `update_project`, `delete_project`, `start_run`, `cancel_job` -- answer `{"code": "read_only"}` with what to do about it, until the app is reconnected with 'Also allow changes' ticked (write access). `delete_project` needs more than write: an admin API key, which a connected app never holds, so it only works run locally with such a key. -Hosted, a connection can be approved read-only, and read-only cuts in a place worth knowing: it reads the whole workspace, but it cannot reach the site. Anything that fetches is a write, because it leaves the workspace and usually spends its credits -- `map_site` reads only a site's sitemaps, at a credit per sitemap file, and it is gated with the rest. `list_projects`, `get_project`, `list_runs`, `list_pages`, `get_changes` and `get_job` work; the nine that fetch or write -- `scrape_urls`, `map_site`, `crawl_site`, `search_web`, `create_project`, `update_project`, `delete_project`, `start_run`, `cancel_job` -- answer `{"code": "read_only"}` with what to do about it, until the app is reconnected with 'Also allow changes' ticked (write access). `delete_project` needs more than write: an admin API key, which a connected app never holds, so it only works run locally with such a key. +Hosted, a tool that would hold a connection open for minutes — a crawl, a run you asked to wait for, a multi-URL scrape, a web search — hands back a job after `MESHARC_MCP_WAIT` seconds (25 by default, because many MCP hosts time a tool call out sooner). The work carries on server-side; `get_job` picks it up and `cancel_job` stops a crawl, run, batch or agent run. A search that scrapes, with its results in and its pages still landing, answers with the results and a job for the pages. Run locally, those tools block as they always have. -Hosted, a tool that would hold a connection open for minutes — a crawl, a run you asked to wait for, a multi-URL scrape, a web search — hands back a job after `MESHARC_MCP_WAIT` seconds (25 by default, because many MCP hosts time a tool call out sooner). The work carries on server-side; `get_job` picks it up and `cancel_job` stops a crawl, run or batch. A search that scrapes, with its results in and its pages still landing, answers with the results and a job for the pages. Run locally, those tools block as they always have. +`run_agent` does not wait at all, hosted or local: an agent works for minutes, so it hands back a job at once, with what the run has spent so far. `get_job(kind="agent", id=…)` follows it -- every 30 to 60 seconds -- and answers with the result once it finishes; `cancel_job(kind="agent", id=…)` stops it before its next step, and what it used stays charged. A run is kept 7 days; after that `get_job` answers `status: "expired"`. A retry that lands on a run already finished answers with its result. A run that stopped at its budget (`status: "credit_limit"`, what it had found under `data.partial`) is carried on by `continue_agent(id=…, max_credits=…)`: a new run on the same thread that resumes where it stopped and keeps the pages it read, not paid for again. It hands back the new run's job the same way. A run that did not stop at its limit is refused with code `conflict`; asked again for a run already carried on — a retry past the ten-minute key window included — it answers with the run that carried it on, and starts nothing. ### Hosting it yourself diff --git a/mesharc/__init__.py b/mesharc/__init__.py index 8d0fbab..cae9bc4 100644 --- a/mesharc/__init__.py +++ b/mesharc/__init__.py @@ -26,13 +26,14 @@ import os import random import time +from datetime import date from typing import Any, Dict, Iterable, Iterator, List, Optional, Union from urllib.parse import parse_qs, urlparse import httpx __version__ = "0.6.0" -__all__ = ["MeshArc", "MeshArcError", "MeshArcTimeoutError", "Crawl"] +__all__ = ["MeshArc", "MeshArcError", "MeshArcTimeoutError", "Crawl", "AgentRun"] DEFAULT_BASE = "https://api.mesharc.dev" DEFAULT_TIMEOUT = 150.0 @@ -79,6 +80,11 @@ def _cursor_of(next_url: str) -> str: return (parse_qs(urlparse(next_url).query).get("cursor") or [""])[0] +def _iso(when: Union[str, date]) -> str: + """A date or date-time as the API reads it: ISO 8601, a string as given.""" + return when.isoformat() if isinstance(when, date) else when + + class _Http: """The transport: one httpx client, bearer auth, JSON errors, retries.""" @@ -236,6 +242,112 @@ def cancel(self, project_id: str, run_id: str) -> Json: return self._h("POST", f"/projects/{project_id}/runs/{run_id}/cancel") +class _Monitors: + """A search or an agent request, kept and run again on a schedule: ``arc.monitors``. + + Not to be confused with ``arc.monitor()``, which is the workspace's job queue. + """ + + def __init__(self, http: _Http) -> None: + self._h = http + + def create(self, kind: str, request: Json, schedule: str, name: Optional[str] = None, + webhook: Union[str, Json, None] = None, baseline_id: Optional[str] = None, + idempotency_key: Optional[str] = None) -> Json: + """Keep a search or an agent request and run it on a schedule. + + ``kind`` is ``search`` or ``agent``; ``request`` is the body of a + POST /search or POST /agent in the API's own (camelCase) names + (``{"query": ..., "freshness": "day"}``, ``{"prompt": ..., + "schema": ..., "maxCredits": ...}``), checked as those check it; + ``schedule`` is ``hourly``, ``daily`` or ``weekly``. ``name`` is the + query or prompt when left out. A monitored search reads its results + page only, so ``scrape`` is refused. A news monitor's request says + so in the API's name, ``"sources": ["news"]``: ``web_search(news=True)`` + maps its argument to that, and ``create`` does not. Each run is the + ordinary search or agent run, charged as one, and is compared with + the last run that answered: new, dropped and moved results, or the + answer's added, changed and removed values. + + ``webhook`` is a URL, or ``{"url", "events", "metadata"}``, told + ``search.changed`` / ``agent.changed`` when a run found something + different; the secret its messages are signed with comes back once, + as ``webhookSecret``. ``baseline_id`` is a finished search or agent + run with the same request, taken as the first run and not paid for + again; without it the first run starts now. A search baseline's + ``limit`` must be at least the monitor's. + + Spends credits and needs a key that can write. Returns the monitor. + """ + body: Json = {"kind": kind, "request": request, "schedule": schedule} + for key, value in (("name", name), ("webhook", webhook), ("baselineId", baseline_id)): + if value is not None: + body[key] = value + return self._h("POST", "/monitors", json=body, idempotency_key=idempotency_key) + + def list(self, kind: Optional[str] = None) -> List[Json]: + """The workspace's monitors, newest first, each with its last run under + ``lastRun``. ``kind`` keeps ``search`` or ``agent`` ones.""" + return self._h("GET", "/monitors", params={"kind": kind} if kind else None)["data"] + + def get(self, monitor_id: str) -> Json: + """One monitor, with its last run.""" + return self._h("GET", f"/monitors/{monitor_id}") + + def update(self, monitor_id: str, name: Optional[str] = None, schedule: Optional[str] = None, + status: Optional[str] = None, webhook: Union[str, Json, None] = None) -> Json: + """Change a monitor's ``name``, ``schedule``, ``status`` (``active`` or + ``paused``) or ``webhook``. Only the ones that are not None are sent; + ``webhook=""`` removes the webhook, and a new one's secret comes back + once, as ``webhookSecret``. Needs a key that can write.""" + body: Json = {key: value for key, value in (("name", name), ("schedule", schedule), ("status", status), + ("webhook", webhook)) if value is not None} + return self._h("PATCH", f"/monitors/{monitor_id}", json=body) + + def pause(self, monitor_id: str) -> Json: + """No more scheduled runs until it is resumed; a run under way finishes.""" + return self._h("POST", f"/monitors/{monitor_id}/pause") + + def resume(self, monitor_id: str) -> Json: + """Scheduled again: a slot that passed while it was paused runs once, at the next tick.""" + return self._h("POST", f"/monitors/{monitor_id}/resume") + + def run(self, monitor_id: str, idempotency_key: Optional[str] = None) -> Json: + """Run it now, paused or not; the schedule counts on from this run. + + Spends credits and needs a key that can write. Returns the new run, + ``running`` (``error`` with the reason under ``error`` when it could + not start); its ``refId`` is the search or agent run it made + (``get_search`` / ``get_agent``). Raises ``MeshArcError`` 409 (code + ``conflict``) while a run is under way. + """ + return self._h("POST", f"/monitors/{monitor_id}/run", idempotency_key=idempotency_key) + + def runs(self, monitor_id: str, limit: int = 25) -> Iterator[Json]: + """Every run of a monitor, newest first, each with what changed since + the run before that answered (``changed``, ``summary``, ``diff``). + ``limit`` is the page size; follows the ``next`` link until the + list ends. + """ + cursor = "" + while True: + params: Dict[str, Any] = {"limit": limit} + if cursor: + params["cursor"] = cursor + page = self._h("GET", f"/monitors/{monitor_id}/runs", params=params) + for row in page.get("data") or []: + yield row + cursor = _cursor_of(page["next"]) if page.get("next") else "" + if not cursor: + return + + def delete(self, monitor_id: str) -> Json: + """The monitor and its runs. The searches and agent runs it made stay + in the workspace's history; an agent run it has under way is + stopped. Returns ``{id, deleted: true}``.""" + return self._h("DELETE", f"/monitors/{monitor_id}") + + class Crawl: """A crawl started by ``arc.crawl(url)``: a handle on a running job. @@ -329,6 +441,178 @@ def cancel(self) -> Json: return self.envelope +class AgentRun: + """An agent run started by ``arc.agent(prompt)``: a handle on the job. + + ``wait()`` blocks until it finishes and returns the envelope, the answer + under ``data``; ``trace()`` yields what the agent does as it does it; + ``cancel()`` stops it; ``continue_()`` carries on a run that stopped at + its credit limit. ``envelope`` is the last envelope seen. + """ + + def __init__(self, http: _Http, envelope: Json) -> None: + self._h = http + self.id: str = envelope["id"] + self.envelope: Json = envelope + # Kept apart from the envelope: the API sends it once, on the POST, + # and a refresh() would otherwise lose it. + self._webhook_secret: str = envelope.get("webhookSecret") or "" + + def __repr__(self) -> str: + return f"" + + @property + def status(self) -> str: + return self.envelope.get("status", "queued") + + @property + def data(self) -> Any: + """The answer: JSON matching the schema, ``{"text": ...}`` without + one, ``{"partial": ...}`` when the run hit its credit limit.""" + return self.envelope.get("data") + + @property + def sources(self) -> List[Json]: + """The pages the answer rests on, as ``{url, title, pageId}``.""" + return self.envelope.get("sources") or [] + + @property + def field_sources(self) -> Json: + """Where each value of ``data`` came from: its path (``"plans[0].price"``, + or ``"[2].name"`` for a list answer) to ``{url, pageId}``, a page + this run read. ``{}`` when the API said nothing.""" + return self.envelope.get("fieldSources") or {} + + @property + def webhook_secret(self) -> str: + """Returned once, at creation: the secret the run's webhook messages + are signed with. A run made by ``continue_()`` carries on its + stopped run's webhook and secret, so its handle has the stopped + handle's. ``""`` for a run started without a webhook, or one + reopened with ``get_agent``.""" + return self.envelope.get("webhookSecret") or self._webhook_secret + + @property + def continues_run_id(self) -> Optional[str]: + """The run this one carried on, when it was made by ``continue_()``.""" + return self.envelope.get("continuesRunId") or None + + @property + def continued_by(self) -> Optional[str]: + """The run that carried this one on: its answer replaces this one's partial.""" + return self.envelope.get("continuedBy") or None + + def refresh(self) -> AgentRun: + """Read the run as it stands now. Returns the handle.""" + self.envelope = self._h("GET", f"/agent/{self.id}") + return self + + def wait(self, poll: float = 2.0, timeout: float = 3600) -> Json: + """Block until the run finishes. Returns the envelope. + + A run that is ``done``, ``cancelled`` or stopped at ``credit_limit`` + (with what it had under ``data.partial``) is returned; ``error`` + raises ``MeshArcError`` with the API's text. + """ + deadline = time.time() + timeout + while True: + status = self.refresh().status + if status == "error": + raise MeshArcError(502, self.envelope.get("error") or f"agent {status}", "job_failed") + if not _running(status): + return self.envelope + if time.time() >= deadline: + raise MeshArcTimeoutError(f"agent {self.id} is still {status} after {timeout}s", self.id) + time.sleep(poll) + self._h.pace() + + def cancel(self) -> Json: + """Stop the run. Returns the API's answer, ``{id, status}``: a queued + run is ``cancelled``, a running one ``cancelling`` (it stops before + its next step), a finished one keeps its status. ``refresh()`` + reads where it ended.""" + return self._h("DELETE", f"/agent/{self.id}") + + def continue_(self, max_credits: Optional[int] = None, max_steps: Optional[int] = None, timeout_s: float = 0, + idempotency_key: Optional[str] = None) -> AgentRun: + """Carry on a run that stopped at ``credit_limit``, with a new budget. + + Returns an ``AgentRun`` on the new run, queued or running unless + ``timeout_s`` asks the API to hold the request (120 at most). It + resumes from where this one stopped, keeps the pages it read (not + paid for again) and its webhook, whose secret the new handle's + ``webhook_secret`` carries on from this one, and answers in full; its + ``continues_run_id`` is this run, and this run's ``continued_by`` + the new one once refreshed. ``max_credits`` and ``max_steps`` are + the new run's own; left out, they are this run's. + + Spends credits and needs a key that can write. Raises + ``MeshArcError`` 409 (code ``conflict``) for a run that did not stop + at its credit limit, was already continued or has no saved + progress, 404 for no such run and 410 (``expired``) past its keep + date. + """ + body: Json = {} + if max_credits is not None: + body["maxCredits"] = max_credits + if max_steps is not None: + body["maxSteps"] = max_steps + if timeout_s > 0: + body["timeout"] = min(timeout_s, 120) + run = AgentRun(self._h, self._h("POST", f"/agent/{self.id}/continue", json=body, + idempotency_key=idempotency_key)) + # The API carries the webhook and its secret over to the new run, but + # the continue answer does not repeat the secret: keep this run's. + run._webhook_secret = run._webhook_secret or self.webhook_secret + return run + + def trace(self, after: int = 0, follow: bool = True, poll: float = 2.0, + timeout: float = 3600) -> Iterator[Json]: + """What the agent did, step by step, as ``{seq, t, kind, text, ...}``. + + ``kind`` is start, resume, continue, model, search, fetch, render, + map, select, extract, tool, busy or finish. ``after`` is the ``seq`` + of the last event already seen, to resume after it. With ``follow`` + this keeps reading until the run ends, polling every ``poll`` + seconds; ``follow=False`` yields what exists and returns. A run + that has expired ends the walk quietly. + """ + deadline = time.time() + timeout + while True: + live = True + if follow: + # Read the status before the trace, so the events written + # just before the run ended are drained, not missed. + try: + live = _running(self.refresh().status) + except MeshArcError as exc: + if exc.status == 410: + return + raise + while True: + try: + page = self._h("GET", f"/agent/{self.id}/trace", params={"after": after, "limit": 500}) + except MeshArcError as exc: + if exc.status == 410: + return + raise + rows = page.get("data") or [] + for event in rows: + yield event + last = page.get("last") + moved = last is not None and last != after + if last is not None: + after = last + if len(rows) < 500 or not moved: + break + if not follow or not live: + return + if time.time() >= deadline: + raise MeshArcTimeoutError(f"agent {self.id} is still {self.status} after {timeout}s", self.id) + time.sleep(poll) + self._h.pace() + + class MeshArc: """One client, one API key, one workspace. @@ -348,6 +632,7 @@ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None self._h = _Http(key, base, timeout, max_retries) self.projects = _Projects(self._h) self.runs = _Runs(self._h) + self.monitors = _Monitors(self._h) def extract(self, url: str, config: Optional[Json] = None, wait: bool = True, poll: float = 2.0, timeout: float = 300) -> Json: @@ -492,14 +777,19 @@ def web_search(self, query: str, limit: Optional[int] = None, country: Optional[ include_domains: Optional[Iterable[str]] = None, exclude_domains: Optional[Iterable[str]] = None, scrape: Union[bool, Json, None] = None, destination: Optional[str] = None, wait: bool = True, timeout_s: Optional[float] = None, poll: float = 2.0, timeout: float = 600, - idempotency_key: Optional[str] = None) -> Json: + idempotency_key: Optional[str] = None, news: bool = False, page: Optional[int] = None) -> Json: """Search the web. Returns the whole envelope, the hits under ``data``. Needs an API key that can write, and spends credits: a results page - every engine refused is free, an equal search (same query, - ``country``, ``lang``, ``freshness`` and domains) within the hour of - a finished one comes from the cache with no results-page charge, and - scraped pages are charged even then. ``freshness`` is ``hour``, ``day``, + every engine refused is free, an equal search (same query, ``news``, + ``page``, ``country``, ``lang``, ``freshness`` and domains) within the + hour of a finished one -- ten minutes for news -- comes from the + cache with no results-page charge, and scraped pages are charged + even then. ``news=True`` searches the engines' news results instead + of the web's, and each hit also carries ``publisher`` and ``age`` + (as the engine put it, e.g. ``"20h"``). ``page`` is which results + page, 1 to 10: page 2 is results 11-20, each page its own search + and its own charge. ``freshness`` is ``hour``, ``day``, ``week``, ``month`` or ``year``. ``scrape=True`` also fetches each hit as markdown; a dict such as ``{"formats": ["markdown", "links"], "maxCredits": 20}`` is sent as given. The formats are ``markdown``, @@ -515,8 +805,10 @@ def web_search(self, query: str, limit: Optional[int] = None, country: Optional[ queued or running, unless ``timeout_s`` is given. """ body: Json = {"query": query} - for key, value in (("limit", limit), ("country", country), ("lang", lang), ("freshness", freshness), - ("destination", destination)): + if news: + body["sources"] = ["news"] + for key, value in (("limit", limit), ("page", page), ("country", country), ("lang", lang), + ("freshness", freshness), ("destination", destination)): if value is not None: body[key] = value if include_domains is not None: @@ -568,6 +860,93 @@ def searches(self, q: Optional[str] = None, limit: int = 25) -> Iterator[Json]: if not cursor: return + def agent(self, prompt: str, urls: Optional[Iterable[str]] = None, schema: Optional[Json] = None, + max_credits: Optional[int] = None, max_steps: Optional[int] = None, + allowed_domains: Optional[Iterable[str]] = None, webhook: Union[str, Json, None] = None, + connection_id: Optional[str] = None, timeout_s: float = 0, + idempotency_key: Optional[str] = None) -> AgentRun: + """Hand a question to the agent: it searches, reads pages and answers. + + Needs an API key that can write, and spends credits: pages as they + are read (a refused page is free) and the model's tokens at the + model provider's price plus 20%; ``max_credits`` caps the run (2000 + by default on the API). ``connection_id`` runs it on one of the + workspace's own LLM connections instead -- its model, its key, its + bill -- and the tokens then cost 1 credit per 1,000. + ``urls`` are pages to start from, ``allowed_domains`` keeps the run + to those sites, ``max_steps`` caps its turns (40 by default). + ``schema`` is a JSON Schema whose type is ``object`` or ``array``; + the answer under ``data`` then matches it, and without one it is + ``{"text": ...}``. + + ``webhook`` is a URL, or ``{"url", "events", "metadata"}``, told + about ``agent.started``, ``agent.action``, ``agent.completed``, + ``agent.failed`` and ``agent.cancelled`` (all five by default); the + secret its messages are signed with comes back once, as + ``AgentRun.webhook_secret``. + + Returns an ``AgentRun`` at once, queued or running, unless + ``timeout_s`` asks the API to hold the request for the answer (120 + at most); ``AgentRun.wait()`` waits for it. + """ + body: Json = {"prompt": prompt} + if urls is not None: + body["urls"] = list(urls) + if schema is not None: + body["schema"] = schema + if max_credits is not None: + body["maxCredits"] = max_credits + if max_steps is not None: + body["maxSteps"] = max_steps + if allowed_domains is not None: + body["allowedDomains"] = list(allowed_domains) + if webhook is not None: + body["webhook"] = webhook + if connection_id is not None: + body["connectionId"] = connection_id + if timeout_s > 0: + body["timeout"] = min(timeout_s, 120) + return AgentRun(self._h, self._h("POST", "/agent", json=body, idempotency_key=idempotency_key)) + + def get_agent(self, run_id: str) -> AgentRun: + """A handle on an agent run started earlier or elsewhere. + + A run past its keep date (7 days by default) is gone: the API + answers 410 and this raises ``MeshArcError`` with status 410 and + code ``expired``. + """ + return AgentRun(self._h, self._h("GET", f"/agent/{run_id}")) + + def agent_runs(self, status: Optional[str] = None, limit: int = 25, model: Optional[str] = None, + since: Union[str, date, None] = None, until: Union[str, date, None] = None) -> Iterator[Json]: + """Every agent run of the workspace, as summaries. + + ``status`` filters the list (queued, running, done, error, + cancelled, credit_limit); ``model`` keeps one model's runs, named + as runs name it (``"openai:gpt-5.4-mini"``); ``since`` and ``until`` + keep the runs made at or after ``since`` and before ``until``, each + an ISO 8601 date or date-time string or a ``date`` / ``datetime``. + A date means its midnight UTC; a date-time without a zone is read + as UTC (so a naive ``datetime.now()`` is your local time read as + UTC). + ``limit`` is the page size, sent again with the filters on every + page. Follows the ``next`` link until the list ends. + """ + filters: Dict[str, Any] = {k: v for k, v in (("status", status), ("model", model), + ("since", _iso(since) if since else None), + ("until", _iso(until) if until else None)) if v} + cursor = "" + while True: + params: Dict[str, Any] = {"limit": limit, **filters} + if cursor: + params["cursor"] = cursor + page = self._h("GET", "/agent", params=params) + for row in page.get("data") or []: + yield row + cursor = _cursor_of(page["next"]) if page.get("next") else "" + if not cursor: + return + def pages(self, project_id: str, run_id: Optional[str] = None) -> Json: """The pages of a run (the latest finished run by default).""" return self._h("GET", f"/projects/{project_id}/pages", params={"run_id": run_id} if run_id else None) @@ -674,6 +1053,7 @@ def billing(self) -> Json: return self._h("GET", "/me/billing") def monitor(self) -> Json: + """What is queued and running in the workspace: the job queue, not ``arc.monitors``.""" return self._h("GET", "/me/monitor") def meta(self) -> Json: diff --git a/mesharc/mcp.py b/mesharc/mcp.py index 51e4c83..333dd2e 100644 --- a/mesharc/mcp.py +++ b/mesharc/mcp.py @@ -86,6 +86,8 @@ "The project tools are for a site watched over time: its runs, its pages, search inside a run, " "and its change record. search_web searches the web for pages whose URLs you do not know; " "list_pages with q searches only inside a project's run. " + "run_agent hands a research question to an agent that searches and reads pages on its own, " + "returning a job for get_job; continue_agent carries on a run that stopped at its credit limit. " "A long tool may hand back a job: get_job follows it and cancel_job stops it. " "Blocked pages are reported as blocked, never as missing." ) @@ -96,7 +98,7 @@ # on a second server later, built with auth in its constructor. The registry is # what carries the descriptions across: a description is the only thing an # assistant has to pick a tool by, and re-registering without them would leave -# fifteen nameless verbs. +# seventeen nameless verbs. _TOOLS: "list[tuple]" = [] # What each tool does to the world, in the hints MCP clients read. A tool that @@ -366,9 +368,10 @@ class ChangesOut(TypedDict, total=False): @with_config(ConfigDict(extra="allow")) class JobOut(TypedDict, total=False): """A job's state, or its result once finished: a crawl gives crawl_site's answer, a batch scrape_urls', - a search search_web's, a run the run record, and `url` one page in full.""" + a search search_web's, an agent run run_agent's, a run the run record, and `url` one page in full.""" status: Annotated[Any, Field(description=( - "'running' while it goes on; otherwise the finished job's status. On an error, the HTTP status code."))] + "'running' while it goes on; otherwise the finished job's status; 'expired' for an agent run past its " + "keep date. On an error, the HTTP status code."))] job: Annotated[Any, Field(description="While running: {kind, id, project_id} to call again with.")] counts: Annotated[Any, Field(description="Pages by outcome so far.")] crawl_id: Annotated[Any, Field(description="A crawl: its id.")] @@ -376,9 +379,22 @@ class JobOut(TypedDict, total=False): index: Annotated[Any, Field(description="A finished crawl or batch: every page as {url, title, words, status}.")] cursor: Annotated[Any, Field(description="A crawl with more pages: pass it back for the next window.")] markdown: Annotated[Any, Field(description="With url: that page's markdown, capped at 12,000 characters.")] - id: Annotated[Any, Field(description="A run or a search: its id.")] + id: Annotated[Any, Field(description="A run, a search or an agent run: its id.")] results: Annotated[Any, Field(description=( "A search: the ranked results, as search_web returns them, with page excerpts when it scraped."))] + data: Annotated[Any, Field(description=( + "A finished agent run: its answer, JSON matching the schema or {text}; {partial} at credit_limit."))] + fieldSources: Annotated[Any, Field(description=( + "A finished agent run: where each value of data came from, its path (e.g. 'plans[0].price') to {url, " + "pageId}; left out first when the answer is too long."))] + sources: Annotated[Any, Field(description=( + "A finished agent run: the pages its answer rests on, each {url, title, pageId}."))] + creditsUsed: Annotated[Any, Field(description="An agent run: credits spent so far, pages and model tokens.")] + budget: Annotated[Any, Field(description="An agent run: the most it may spend.")] + steps: Annotated[Any, Field(description="An agent run: the steps it has taken.")] + stopReason: Annotated[Any, Field(description=( + "A finished agent run: why it stopped early, e.g. credit_limit, step_limit or cancelled; empty when it " + "finished on its own."))] note: Annotated[Any, Field(description="What to do next.")] error: Annotated[Any, Field(description=_ERR)] code: Annotated[Any, Field(description=_CODE)] @@ -388,12 +404,12 @@ class JobOut(TypedDict, total=False): @with_config(ConfigDict(extra="allow")) class CancelOut(TypedDict, total=False): """What a cancel did.""" - kind: Annotated[Any, Field(description="crawl, run or batch.")] + kind: Annotated[Any, Field(description="crawl, run, batch or agent.")] id: Annotated[Any, Field(description="The job's id.")] project_id: Annotated[Any, Field(description="A run: its project.")] outcome: Annotated[Any, Field(description=( - "'cancelled' (it had not started), 'cancelling' (it stops after the page in hand), or the status of a job " - "that had already ended."))] + "'cancelled' (it had not started), 'cancelling' (it stops after the page in hand, or an agent run after " + "the step under way), or the status of a job that had already ended."))] note: Annotated[Any, Field(description="What a stop keeps and drops.")] error: Annotated[Any, Field(description=_ERR)] status: Annotated[Any, Field(description="Only on a failure: the HTTP status code.")] @@ -427,6 +443,42 @@ class SearchWebOut(TypedDict, total=False): detail: Annotated[Any, Field(description=_DETAIL)] +@with_config(ConfigDict(extra="allow")) +class AgentOut(TypedDict, total=False): + """An agent run: its answer and the pages it rests on once finished, or a job while it works.""" + id: Annotated[Any, Field(description="The run's id: for get_job and cancel_job with kind 'agent'.")] + status: Annotated[Any, Field(description=( + "queued/running with a `job` while it works; done, credit_limit (stopped at its budget, data.partial " + "holds what it found, continue_agent carries it on), cancelled or error once finished; 'expired' past " + "its keep date. On an error, the HTTP status code."))] + prompt: Annotated[Any, Field(description="The question as it was asked.")] + data: Annotated[Any, Field(description=( + "The answer: JSON matching the schema, or {text} without one; {partial} at credit_limit. When the answer " + "is too long for 60,000 characters, a string of its JSON text, cut, and the note says where the whole " + "answer is."))] + fieldSources: Annotated[Any, Field(description=( + "Where each value of data came from: its path (e.g. 'plans[0].price', or '[2].name' for a list answer) " + "to {url, pageId}, a page the run read. The first thing left out when the answer is too long for 60,000 " + "characters, and the note says so."))] + sources: Annotated[Any, Field(description=( + "The pages the answer rests on, each {url, title, pageId}; the first 50 when the answer had to be cut."))] + creditsUsed: Annotated[Any, Field(description="Credits spent so far: pages read plus the model's tokens.")] + budget: Annotated[Any, Field(description=( + "The most the run may spend: max_credits, or less when the workspace had less left (budgetLimited)."))] + budgetLimited: Annotated[Any, Field(description=( + "true when the budget was lowered to what the workspace had left when the run started."))] + steps: Annotated[Any, Field(description="The steps the agent has taken.")] + stopReason: Annotated[Any, Field(description=( + "Why it stopped early, e.g. credit_limit, step_limit or cancelled; empty when it finished on its own."))] + job: Annotated[Any, Field(description=( + "While it works: {kind: 'agent', id} for get_job and cancel_job."))] + note: Annotated[Any, Field(description="What to do next, and what was cut and where to get it whole.")] + error: Annotated[Any, Field(description=( + "When the call failed, or the run ended in error: what went wrong, in words an assistant can act on."))] + code: Annotated[Any, Field(description=_CODE)] + detail: Annotated[Any, Field(description=_DETAIL)] + + def tool(**how): """Declare a tool, and remember how it was declared.""" def register(fn): @@ -636,18 +688,22 @@ def _excerpts(rows, how, reserve=0): return [_summary_page(p, cap, how) for p in shown], index, cap, len(index) -def _still_running(kind, job_id, counts=None, project_id=None): +def _still_running(kind, job_id, counts=None, project_id=None, extra=None): """What a long tool answers when its budget ran out. The job is not cancelled -- it goes on server-side, and `get_job` picks it up. Saying so matters: an assistant told only "running" would start the - work again. + work again. `extra` is merged into the answer, for a kind with more to say + while it runs (an agent run's spend so far). """ job = {"kind": kind, "id": job_id} if project_id: job["project_id"] = project_id - return {"status": "running", "job": job, "counts": counts or {}, - "note": "call get_job with this job to check; it keeps running server-side"} + out = {"status": "running", "job": job, "counts": counts or {}, + "note": "call get_job with this job to check; it keeps running server-side"} + if extra: + out.update(extra) + return out def _walk(job, deadline=None, **how): @@ -838,7 +894,7 @@ def _scopes(): return list(getattr(at, "scopes", None) or []) if at else [] -# Nine of the fifteen tools need write, including every one that fetches a +# Eleven of the seventeen tools need write, including every one that fetches a # page: fetching spends the workspace's credits, so it is not a read however # it reads to an assistant asking for one URL. The API answers "this needs the # member role", which is true and tells an assistant nothing it can act on -- @@ -1174,6 +1230,242 @@ def go(): return _safe(go) +# An agent run's answer past RESULT_BUDGET: the sources it keeps first. +AGENT_SOURCES_CAP = 50 +AGENT_POLL = ("an agent works for minutes: call get_job with this job every 30 to 60 seconds; it keeps running " + "server-side, and cancel_job stops it") +AGENT_ACCEPTING = ("an identical run_agent call is still being accepted; call run_agent again in a few seconds " + "to get its job") +AGENT_CONTINUE_ACCEPTING = ("an identical continue_agent call is still being accepted; call continue_agent again in " + "a few seconds to get the new run's job") +AGENT_EXPIRED = "this agent run is past its keep date (7 days); its answer is no longer kept" +AGENT_CANCEL_NOTE = ("a queued run stops at once; a running one stops before its next step, the step under way " + "finishing first. Pages and model tokens already used stay charged.") + + +def _key_in_flight(exc): + """A 409 saying the first request with this idempotency key is still being + accepted: code in_flight, or only the message on an API from before that + code. Every other 409 is a refusal about the run.""" + return exc.status == 409 and (exc.code == "in_flight" or "Idempotency-Key" in str(exc.detail or "")) + + +def _agent_answer(env): + """An agent run as run_agent and get_job both answer it: the job while it + works, with what it has spent so far, and the result once finished.""" + if _running(env.get("status")): + return _still_running("agent", env.get("id", ""), extra={ + "budget": env.get("budget"), "creditsUsed": env.get("creditsUsed"), "steps": env.get("steps"), + "note": AGENT_POLL}) + return _agent_result(env) + + +def _agent_result(env): + """A finished agent run, shaped once, inside RESULT_BUDGET. + + The answer is what was asked for, so it is cut last. `fieldSources` goes + first -- it says where each value came from, which the sources mostly say + too -- then the sources go down to the first fifty, and only then is + `data` replaced by its JSON text, cut to what the rest leaves. Either way + the note says where the whole answer is. + """ + rid = env.get("id", "") + status = env.get("status") + sources = env.get("sources") if isinstance(env.get("sources"), list) else [] + field_sources = env.get("fieldSources") if isinstance(env.get("fieldSources"), dict) else {} + data = env.get("data") + out = {"id": rid, "status": status, "prompt": env.get("prompt", ""), "data": data, + "fieldSources": field_sources, "sources": sources, + "creditsUsed": env.get("creditsUsed"), "budget": env.get("budget"), + "budgetLimited": env.get("budgetLimited"), "steps": env.get("steps"), + "stopReason": env.get("stopReason", "")} + if env.get("error"): + out["error"] = env["error"] + notes = [] + if status == "credit_limit" and env.get("continuedBy"): + # Already carried on: a second continue is refused, and the answer + # that replaces this partial is the other run's. + notes.append(f"the run stopped at its credit budget and was carried on by run {env['continuedBy']}: " + "get_job with that id for its answer") + elif status == "credit_limit": + notes.append("the run stopped at its credit budget before it finished: data.partial holds what it had " + f"found by then, and continue_agent with id='{rid}' and a new max_credits carries it on " + "from where it stopped") + elif status == "error": + notes.append("the run failed and error says why; pages and model tokens it used stay charged") + out["note"] = ". ".join(notes) + if _cost(out) <= RESULT_BUDGET: + return out + + whole = f"the full answer is at GET /api/v1/agent/{rid} and via the SDK's get_agent('{rid}')" + # Only a map that was there is said to be left out. + dropped = bool(field_sources) + + def shape(kept, value, data_cut): + cuts = [] + if dropped: + cuts.append("fieldSources is left out") + if len(kept) < len(sources): + cuts.append(f"sources lists the first {len(kept)} of {len(sources)}") + if data_cut: + cuts.append("data is the answer's JSON text, cut") + said = ", ".join(cuts[:-1]) + " and " + cuts[-1] if len(cuts) > 1 else "".join(cuts) + shaped = {**out, "sources": kept, "data": value, + "note": ". ".join(notes + [said + f" to stay inside {RESULT_BUDGET:,} characters; " + whole])} + shaped.pop("fieldSources") + return shaped + + if dropped: + shaped = shape(sources, data, False) + if _cost(shaped) <= RESULT_BUDGET: + return shaped + kept = sources + if len(sources) > AGENT_SOURCES_CAP: + kept = sources[:AGENT_SOURCES_CAP] + # However long their titles, the sources leave half the budget to the answer. + while kept and _cost(shape(kept, "", True)) > RESULT_BUDGET // 2: + kept = kept[:len(kept) // 2] + shaped = shape(kept, data, False) + if _cost(shaped) <= RESULT_BUDGET: + return shaped + # The longest cut that fits, found by halving: a character weighs one to + # six in the answer's JSON (quotes, backslashes, anything not ASCII), so + # one measured step can overshoot by most of the answer. + text = json.dumps(data, ensure_ascii=False) + + def cut_at(n): + return shape(kept, text[:n] + f"… [{len(text) - n} more characters]", True) + + lo, hi = 0, len(text) + while lo < hi: + mid = (lo + hi + 1) // 2 + if _cost(cut_at(mid)) <= RESULT_BUDGET: + lo = mid + else: + hi = mid - 1 + return cut_at(lo) + + +@tool(title="Run a research agent", + annotations=_acts(), + description="Hand a research question to an agent that searches the web, maps sites and reads pages on its " + "own, then answers with the sources it used.\n" + "Use it for questions that need several pages or sites to answer. When you know what to fetch, " + "search_web and scrape_urls are quicker and cheaper.\n" + "Inputs: prompt says what to find, e.g. 'the monthly price of each plan on example.com'. urls " + "gives pages to start from, e.g. ['https://example.com/pricing']. schema shapes the answer, e.g. " + "{\"type\": \"object\", \"properties\": {\"plans\": {\"type\": \"array\"}}}; without one the " + "answer is {\"text\": ...}. allowed_domains=['example.com'] keeps it to those sites.\n" + "Cost: pages are charged as they are read, and a page the site refuses is free; the model's " + "tokens at the model provider's price plus 20%. max_credits caps the whole run, 2,000 by " + "default; a run that reaches it stops with what it found, and continue_agent carries it on.\n" + "Timing: it returns at once with a job, as an agent works for minutes. Call " + "get_job(kind='agent', id=...) every 30 to 60 seconds; cancel_job stops it. Calling run_agent " + "again with the same inputs in the same ten-minute window returns that run, not a new one.\n" + "Needs write access; a read-only connection is refused with code 'read_only'.") +def run_agent(prompt: Annotated[str, Field( + description="What the agent should find out, 1 to 10,000 characters, as a question or a " + "task.")], + urls: Annotated[list[str] | None, Field( + description="Up to 20 absolute http(s) URLs for the agent to start from; omit to let it " + "search.")] = None, + schema: Annotated[dict | None, Field( + description="A JSON Schema object whose type is object or array; the answer then matches " + "it. Omit for a text answer, {\"text\": ...}.")] = None, + allowed_domains: Annotated[list[str] | None, Field( + description="Up to 20 sites the agent stays within, e.g. ['example.com']; omit for the " + "whole web.")] = None, + max_credits: Annotated[int | None, Field( + description="The most the run may spend, 1 to 100,000 credits; omit for 2,000.")] = None, + max_steps: Annotated[int | None, Field( + description="The most steps the agent may take, 1 to 100; omit for 40.")] = None) -> AgentOut: + def go(): + # The window is part of the key: a retry in the same ten minutes lands + # on the same run, and a deliberate re-run later is a new one. + key = _key("agent", prompt, urls, schema, allowed_domains, max_credits, max_steps, + int(time.time() // 600)) + with _client() as s: + try: + run = s.agent(prompt, urls=urls, schema=schema, max_credits=max_credits, max_steps=max_steps, + allowed_domains=allowed_domains, idempotency_key=key) + except MeshArcError as exc: + # The first request with this key is still being accepted, and + # this answer carries no run id: asking again shortly gets it. + # The API says so with code in_flight (an API from before that + # code said it only in the message); any other 409 is a + # refusal, and goes back as an error. + if _key_in_flight(exc): + return {"status": "running", "note": AGENT_ACCEPTING} + raise + # A replayed key answers with the first stored envelope, which may + # be long out of date: read the run as it stands. + run.refresh() + return _agent_answer(run.envelope) + return _safe(go) + + +@tool(title="Continue a stopped agent run", + annotations=_acts(), + description="Carry on an agent run that stopped at its credit limit (status credit_limit) with a new budget. " + "It starts a new run on the same thread that resumes where the stopped one was, keeps the pages " + "it read and answers in full.\n" + "Use it when run_agent or get_job answered status 'credit_limit' and data.partial is not enough. " + "A run that finished, failed or was cancelled cannot be carried on: ask run_agent again.\n" + "Inputs: id is the stopped run's id, from its answer. max_credits and max_steps are the new run's " + "own, e.g. max_credits=4000; omitted, they are the stopped run's.\n" + "Cost: the pages the stopped run read are not paid for again. New pages are charged as they are " + "read, a refused page free, and the model's tokens at the model provider's price plus 20%, " + "within max_credits.\n" + "Timing: it returns at once with the new run's job, as run_agent does. Call " + "get_job(kind='agent', id=...) with the new id every 30 to 60 seconds; cancel_job stops it. " + "Calling continue_agent again for a run already carried on returns the run that carried it on, " + "not a new one.\n" + "Refusals: code conflict for a run that did not stop at its credit limit (or was carried on " + "meanwhile, by another call), not_found for an unknown id, expired past its 7-day keep date.\n" + "Needs write access; a read-only connection is refused with code 'read_only'.") +def continue_agent(id: Annotated[str, Field( + description="The id of the run that stopped at its credit limit, from run_agent's or " + "get_job's answer.")], + max_credits: Annotated[int | None, Field( + description="The most the new run may spend, 1 to 100,000 credits; omit for the stopped " + "run's.")] = None, + max_steps: Annotated[int | None, Field( + description="The most steps the new run may take, 1 to 100; omit for the stopped " + "run's.")] = None) -> AgentOut: + def go(): + # Keyed as run_agent is: a retry in the same ten minutes lands on the + # same new run. Later, the stopped run names the run that carried it + # on, and that run is the answer. + key = _key("continue_agent", id, max_credits, max_steps, int(time.time() // 600)) + with _client() as s: + stopped = s.get_agent(id) + if stopped.continued_by: + # Already carried on -- by this tool's own earlier call, its + # key since past the ten-minute window, or elsewhere. A second + # continue would be refused; the run that carried it on is + # the answer. + carried = s.get_agent(stopped.continued_by) + out = _agent_answer(carried.envelope) + said = f"run {id} was already carried on by run {stopped.continued_by}; this is that run" + out["note"] = ". ".join(n for n in (said, out.get("note")) if n) + return out + try: + run = stopped.continue_(max_credits=max_credits, max_steps=max_steps, idempotency_key=key) + except MeshArcError as exc: + # Only the key still being accepted is answered as running. + # The API's other 409s -- a run that did not stop at its + # limit, or was already carried on -- are refusals about the + # run, and go back as errors. + if _key_in_flight(exc): + return {"status": "running", "note": AGENT_CONTINUE_ACCEPTING} + raise + # A replayed key answers with the first stored envelope: read the + # new run as it stands. + run.refresh() + return _agent_answer(run.envelope) + return _safe(go) + + @tool(title="List all projects", annotations=READS_STORED, description="List every project in the workspace, the sites it watches over time, each with its id, name, " @@ -1601,30 +1893,33 @@ def go(): return _safe(go) -JobKind = Annotated[Literal["crawl", "run", "batch"], Field( - description="'crawl' (crawl_site), 'run' (start_run) or 'batch' (scrape_urls with several URLs).")] +JobKind = Annotated[Literal["crawl", "run", "batch", "agent"], Field( + description="'crawl' (crawl_site), 'run' (start_run), 'batch' (scrape_urls with several URLs) or 'agent' " + "(run_agent or continue_agent).")] JobId = Annotated[str, Field(description="The job's id.")] JobProject = Annotated[str | None, Field(description="A run's project id; required for a run, ignored otherwise.")] @tool(title="Follow a long-running job", annotations=READS_STORED, - description="Check on a crawl, run, batch or web search a tool handed back as a job, and return its result " - "once finished.\n" + description="Check on a crawl, run, batch, web search or agent run a tool handed back as a job, and return " + "its result once finished.\n" "Call it when a tool answered with status 'running' and a job; repeating that tool is not needed. " - "To stop a crawl, run or batch use cancel_job. For a project's stored pages use list_pages.\n" + "To stop a crawl, run, batch or agent run use cancel_job. For a project's stored pages use " + "list_pages.\n" "Inputs: kind, id and project_id come from the 'job' object; a crawl_site crawl_id is a crawl's " "id. For a crawl, url reads one page in full, even mid-crawl, and cursor from the last answer " "reads the next window.\n" "Behaviour: it answers at once and never waits, so call it again every 10 to 30 seconds while the " - "status is 'running'. A finished crawl answers as crawl_site does (up to 50 pages excerpted per " - "window), a batch as scrape_urls, a search as search_web, a run with its record. A scraping " - "search whose pages are still landing answers with its results and the job. A crawl expires a day " - "after it started unless kept, then answers 404.\n" + "status is 'running', or every 30 to 60 for an agent run. A finished crawl answers as crawl_site " + "does (up to 50 pages excerpted per window), a batch as scrape_urls, a search as search_web, an " + "agent run as run_agent, a run with its record. A scraping search whose pages are still landing " + "answers with its results and the job. A crawl expires a day after it started unless kept, then " + "answers 404; an agent run is kept 7 days, then answers with status 'expired'.\n" "Read-only and free: it reads stored data and never fetches.") -def get_job(kind: Annotated[Literal["crawl", "run", "batch", "search"], Field( - description="'crawl' (crawl_site), 'run' (start_run), 'batch' (scrape_urls with several URLs) or " - "'search' (search_web).")], +def get_job(kind: Annotated[Literal["crawl", "run", "batch", "search", "agent"], Field( + description="'crawl' (crawl_site), 'run' (start_run), 'batch' (scrape_urls with several URLs), " + "'search' (search_web) or 'agent' (run_agent or continue_agent).")], id: JobId, project_id: JobProject = None, url: Annotated[str | None, Field( @@ -1682,7 +1977,17 @@ def go(): # go back now, with the job, rather than a bare "running". return _search_result(out, running=True) if out.get("data") else _still_running("search", id) return _search_result(out) - return {"error": f"kind must be crawl, run, batch or search, not {kind!r}", "code": "validation"} + if kind == "agent": + try: + run = s.get_agent(id) + except MeshArcError as exc: + # Past its keep date the run is gone, which is an answer about + # the run, not a failure of this call. + if exc.status == 410: + return {"status": "expired", "id": id, "note": AGENT_EXPIRED} + raise + return _agent_answer(run.envelope) + return {"error": f"kind must be crawl, run, batch, search or agent, not {kind!r}", "code": "validation"} return _safe(go) @@ -1693,13 +1998,14 @@ def go(): @tool(title="Cancel a running job", annotations=_acts(destructive=True, idempotent=True, open_world=False), - description="Stop a crawl, run or batch that is still going.\n" + description="Stop a crawl, run, batch or agent run that is still going.\n" "Use it when the work should not finish: a crawl started too wide, a run blocking the next " - "start_run (which answers 409 meanwhile), a batch of the wrong URLs. To check a job without " - "stopping it use get_job.\n" + "start_run (which answers 409 meanwhile), a batch of the wrong URLs, an agent run on the wrong " + "question. To check a job without stopping it use get_job.\n" "Inputs: kind and id come from the 'job' object, or a crawl_site crawl_id with kind='crawl'. A " "run also needs project_id; list_runs shows which run is still going.\n" - "Effect: queued work is dropped and the page in hand finishes. Pages already read stay. A stop " + "Effect: queued work is dropped and the page in hand finishes; an agent run stops before its " + "next step, and the pages and model tokens it used stay charged. Pages already read stay. A stop " "cannot be undone; run the work again to resume. A finished job is left alone, so asking twice is " "harmless.\n" "Needs write access; a read-only connection is refused with code 'read_only'.") @@ -1742,11 +2048,16 @@ def go(): s.cancel_batch(id) return {"kind": "batch", "id": id, "outcome": "cancelled" if status == "queued" else "cancelling", "note": CANCEL_NOTE} - return {"error": f"kind must be crawl, run or batch, not {kind!r}", "code": "validation"} + if kind == "agent": + # The API says what it did: cancelled (queued), cancelling (the + # step under way finishes first), or a finished run's status. + answer = s.get_agent(id).cancel() or {} + return {"kind": "agent", "id": id, "outcome": answer.get("status"), "note": AGENT_CANCEL_NOTE} + return {"error": f"kind must be crawl, run, batch or agent, not {kind!r}", "code": "validation"} return _safe(go) -# Stdio's server, built now that the fifteen are declared. Hosted mode +# Stdio's server, built now that the seventeen are declared. Hosted mode # builds its own in `authorize`, because auth is set in the constructor. server = _build() @@ -1873,7 +2184,7 @@ def _transport_security(public_url): def authorize(issuer, public, secret): - """The hosted server: the same fifteen tools, with OAuth attached. + """The hosted server: the same seventeen tools, with OAuth attached. A server of its own rather than the stdio one with auth bolted on after the fact. `token_verifier` and `auth` are constructor arguments, and diff --git a/tests/test_behaviour.py b/tests/test_behaviour.py index 319160f..8a0b4b7 100644 --- a/tests/test_behaviour.py +++ b/tests/test_behaviour.py @@ -1,10 +1,11 @@ import pathlib +from datetime import date, datetime, timezone import httpx import pytest import mesharc -from mesharc import Crawl, MeshArc, MeshArcError, MeshArcTimeoutError +from mesharc import AgentRun, Crawl, MeshArc, MeshArcError, MeshArcTimeoutError class FakeHttp: @@ -21,9 +22,10 @@ def pace(self, floor=3): pass -def client(replies): +def client(replies, http_class=None): arc = MeshArc.__new__(MeshArc) - arc._h = FakeHttp(replies) + arc._h = (http_class or FakeHttp)(replies) + arc.monitors = mesharc._Monitors(arc._h) return arc, arc._h @@ -205,5 +207,396 @@ def test_the_mcp_server_offers_the_verbs_and_the_config_tools(): source = (pathlib.Path(mesharc.__file__).parent / "mcp.py").read_text(encoding="utf-8") for name in ("scrape_urls", "map_site", "crawl_site", "search_web", "list_projects", "get_changes", "get_job", "cancel_job", "list_runs", "list_pages", "start_run", "get_project", - "create_project", "update_project", "delete_project"): + "create_project", "update_project", "delete_project", "run_agent", "continue_agent"): assert f"def {name}(" in source, name + + +class RaisingHttp(FakeHttp): + """A FakeHttp whose replies may be errors, raised when their turn comes.""" + + def __call__(self, method, path, idempotency_key=None, **kw): + reply = super().__call__(method, path, idempotency_key, **kw) + if isinstance(reply, Exception): + raise reply + return reply + + +def expired(): + return MeshArcError(410, "this run has expired", "expired") + + +def test_an_agent_run_sends_camelcase_and_caps_the_hold(): + arc, http = client([{"id": "a1", "status": "queued"}]) + run = arc.agent("who makes it?", urls=("https://a.test/",), schema={"type": "object"}, max_credits=50, + max_steps=5, allowed_domains=["a.test"], timeout_s=300, idempotency_key="k-a") + assert isinstance(run, AgentRun) + assert (run.id, run.status) == ("a1", "queued") + assert http.calls == [("POST", "/agent", {}, { + "prompt": "who makes it?", "urls": ["https://a.test/"], "schema": {"type": "object"}, "maxCredits": 50, + "maxSteps": 5, "allowedDomains": ["a.test"], "timeout": 120}, "k-a")] + + +def test_an_agent_run_leaves_out_what_was_not_given_and_sends_no_hold_by_default(): + arc, http = client([{"id": "a2", "status": "queued"}, {"id": "a3", "status": "done"}]) + arc.agent("q") + assert http.calls[0][3] == {"prompt": "q"} + assert http.calls[0][4] is None + run = arc.agent("q", timeout_s=30) + assert http.calls[1][3] == {"prompt": "q", "timeout": 30} + assert run.status == "done" + + +def test_an_agent_run_is_polled_until_done(): + done = {"id": "a1", "status": "done", "data": {"text": "MeshArc"}, "sources": [{"url": "https://a.test/"}]} + arc, http = client([{"id": "a1", "status": "queued"}, {"id": "a1", "status": "running"}, done]) + run = arc.agent("q") + assert run.wait(poll=0) == done + assert [c[:2] for c in http.calls[1:]] == [("GET", "/agent/a1"), ("GET", "/agent/a1")] + assert run.data == {"text": "MeshArc"} + assert run.sources == [{"url": "https://a.test/"}] + + +@pytest.mark.parametrize("status", ["credit_limit", "cancelled"]) +def test_an_agent_run_that_stopped_short_is_returned_not_raised(status): + end = {"id": "a1", "status": status, "data": {"partial": "so far"}} + http = FakeHttp([{"id": "a1", "status": "running"}, end]) + assert AgentRun(http, {"id": "a1", "status": "queued"}).wait(poll=0) == end + assert len(http.calls) == 2 + + +def test_an_agent_run_that_ends_in_error_raises_with_the_apis_text(): + http = FakeHttp([{"id": "a1", "status": "error", "error": "the model refused"}]) + with pytest.raises(MeshArcError) as exc: + AgentRun(http, {"id": "a1", "status": "running"}).wait(poll=0) + assert (exc.value.status, exc.value.code, exc.value.detail) == (502, "job_failed", "the model refused") + + +def test_an_agent_run_still_running_at_the_deadline_raises_a_timeout_naming_it(): + http = FakeHttp([{"id": "a9", "status": "running"}]) + with pytest.raises(MeshArcTimeoutError) as exc: + AgentRun(http, {"id": "a9", "status": "queued"}).wait(poll=0, timeout=0) + assert exc.value.job_id == "a9" + assert "agent a9 is still running" in str(exc.value) + + +def test_cancelling_an_agent_run_returns_the_apis_answer_and_leaves_the_envelope(): + http = FakeHttp([{"id": "a1", "status": "cancelling"}]) + run = AgentRun(http, {"id": "a1", "status": "running"}) + assert run.cancel() == {"id": "a1", "status": "cancelling"} + assert http.calls == [("DELETE", "/agent/a1", {}, {}, None)] + assert run.status == "running" + + +def test_an_agent_run_is_asked_for_by_id(): + arc, http = client([{"id": "a1", "status": "done"}]) + run = arc.get_agent("a1") + assert (run.id, run.status) == ("a1", "done") + assert http.calls == [("GET", "/agent/a1", {}, {}, None)] + + +def test_an_expired_agent_run_raises_410_unchanged(): + arc = MeshArc.__new__(MeshArc) + arc._h = RaisingHttp([expired()]) + with pytest.raises(MeshArcError) as exc: + arc.get_agent("a1") + assert (exc.value.status, exc.value.code) == (410, "expired") + + +def test_the_agent_list_follows_next_and_sends_status_and_limit_on_every_page(): + arc, http = client([{"data": [{"id": "a"}, {"id": "b"}], "next": "/api/v1/agent?limit=2&status=done&cursor=17"}, + {"data": [{"id": "c"}], "next": None}]) + assert [row["id"] for row in arc.agent_runs(status="done", limit=2)] == ["a", "b", "c"] + assert [c[:3] for c in http.calls] == [("GET", "/agent", {"limit": 2, "status": "done"}), + ("GET", "/agent", {"limit": 2, "status": "done", "cursor": "17"})] + + +def test_the_trace_refreshes_before_each_drain_and_ends_after_the_drain_that_follows_the_end(): + http = FakeHttp([{"id": "a1", "status": "running"}, + {"data": [{"seq": 1, "kind": "start"}, {"seq": 2, "kind": "search"}], "last": 2}, + {"id": "a1", "status": "done"}, + {"data": [{"seq": 3, "kind": "finish"}], "last": 3}, + {"unexpected": True}]) + run = AgentRun(http, {"id": "a1", "status": "queued"}) + assert [e["seq"] for e in run.trace(poll=0)] == [1, 2, 3] + assert [c[:3] for c in http.calls] == [("GET", "/agent/a1", {}), + ("GET", "/agent/a1/trace", {"after": 0, "limit": 500}), + ("GET", "/agent/a1", {}), + ("GET", "/agent/a1/trace", {"after": 2, "limit": 500})] + assert run.status == "done" + + +def test_the_trace_without_follow_drains_once_reading_on_while_pages_come_back_full(): + full = [{"seq": n} for n in range(1, 501)] + http = FakeHttp([{"data": full, "last": 500}, {"data": [{"seq": 501}], "last": 501}, {"unexpected": True}]) + run = AgentRun(http, {"id": "a1", "status": "running"}) + assert len(list(run.trace(after=0, follow=False))) == 501 + assert [c[:3] for c in http.calls] == [("GET", "/agent/a1/trace", {"after": 0, "limit": 500}), + ("GET", "/agent/a1/trace", {"after": 500, "limit": 500})] + + +def test_the_trace_resumes_after_the_seq_it_is_given(): + http = FakeHttp([{"data": [], "last": 7}]) + assert list(AgentRun(http, {"id": "a1", "status": "done"}).trace(after=7, follow=False)) == [] + assert http.calls[0][2] == {"after": 7, "limit": 500} + + +def test_the_trace_of_an_expired_run_ends_quietly(): + http = RaisingHttp([{"id": "a1", "status": "running"}, {"data": [{"seq": 1}], "last": 1}, expired()]) + assert [e["seq"] for e in AgentRun(http, {"id": "a1", "status": "running"}).trace(poll=0)] == [1] + http = RaisingHttp([{"id": "a1", "status": "running"}, expired()]) + assert list(AgentRun(http, {"id": "a1", "status": "running"}).trace(poll=0)) == [] + + +def test_the_trace_still_running_at_the_deadline_raises_a_timeout_naming_it(): + http = FakeHttp([{"id": "a4", "status": "running"}, {"data": [], "last": 0}]) + with pytest.raises(MeshArcTimeoutError) as exc: + list(AgentRun(http, {"id": "a4", "status": "queued"}).trace(poll=0, timeout=0)) + assert exc.value.job_id == "a4" + + +def test_an_agent_run_sends_its_webhook_and_connection_and_keeps_the_secret_past_a_refresh(): + hook = {"url": "https://hooks.test/agent", "events": ["agent.completed"], "metadata": {"team": "a"}} + arc, http = client([{"id": "a5", "status": "queued", "webhookSecret": "whsec_1"}, {"id": "a5", "status": "running"}]) + run = arc.agent("q", webhook=hook, connection_id="conn_9") + assert http.calls[0][3] == {"prompt": "q", "webhook": hook, "connectionId": "conn_9"} + assert run.webhook_secret == "whsec_1" + run.refresh() + assert "webhookSecret" not in run.envelope + assert run.webhook_secret == "whsec_1" + + +def test_an_agent_runs_webhook_may_be_a_bare_url(): + arc, http = client([{"id": "a6", "status": "queued", "webhookSecret": "whsec_2"}]) + assert arc.agent("q", webhook="https://hooks.test/a").webhook_secret == "whsec_2" + assert http.calls[0][3] == {"prompt": "q", "webhook": "https://hooks.test/a"} + + +def test_an_agent_run_without_a_webhook_or_links_answers_empty_not_missing(): + run = AgentRun(FakeHttp([]), {"id": "a7", "status": "done", "fieldSources": None, "continuesRunId": None}) + assert run.webhook_secret == "" + assert run.field_sources == {} + assert (run.continues_run_id, run.continued_by) == (None, None) + assert AgentRun(FakeHttp([]), {"id": "a8"}).field_sources == {} + + +def test_an_agent_runs_field_sources_and_thread_links_are_read_from_the_envelope(): + sources = {"plans[0].price": {"url": "https://a.test/pricing", "pageId": "p1"}, + "[2].name": {"url": "https://a.test/", "pageId": "p2"}} + run = AgentRun(FakeHttp([]), {"id": "a9", "status": "credit_limit", "fieldSources": sources, + "threadId": "a1", "continuesRunId": "a1", "continuedBy": "a10"}) + assert run.field_sources == sources + assert (run.continues_run_id, run.continued_by) == ("a1", "a10") + + +def test_continuing_a_run_posts_its_new_budget_and_hands_back_the_new_run(): + http = FakeHttp([{"id": "a2", "status": "running", "continuesRunId": "a1", "threadId": "a1"}]) + stopped = AgentRun(http, {"id": "a1", "status": "credit_limit", "data": {"partial": "so far"}}) + run = stopped.continue_(max_credits=800, max_steps=10, timeout_s=300, idempotency_key="k-c") + assert isinstance(run, AgentRun) and run is not stopped + assert (run.id, run.status, run.continues_run_id) == ("a2", "running", "a1") + assert http.calls == [("POST", "/agent/a1/continue", {}, {"maxCredits": 800, "maxSteps": 10, "timeout": 120}, + "k-c")] + assert stopped.status == "credit_limit" + + +def test_continuing_a_run_keeps_the_stopped_runs_webhook_secret(): + """The API carries the webhook and its secret over to the new run, but the + continue answer does not repeat the secret: the new handle keeps the old one's.""" + arc, http = client([{"id": "a1", "status": "queued", "webhookSecret": "whsec_1"}, + {"id": "a1", "status": "credit_limit"}, + {"id": "a2", "status": "running", "continuesRunId": "a1"}, + {"id": "a2", "status": "running"}]) + stopped = arc.agent("q", webhook="https://hooks.test/a") + stopped.refresh() + assert "webhookSecret" not in stopped.envelope + more = stopped.continue_() + assert more.webhook_secret == "whsec_1" + more.refresh() + assert more.webhook_secret == "whsec_1", "kept past a refresh, as the first handle's is" + + +def test_continuing_a_run_without_a_webhook_or_from_a_reopened_handle_has_no_secret(): + http = FakeHttp([{"id": "a2", "status": "queued"}]) + assert AgentRun(http, {"id": "a1", "status": "credit_limit"}).continue_().webhook_secret == "" + + +def test_continuing_a_run_leaves_out_what_was_not_given(): + http = FakeHttp([{"id": "a2", "status": "queued"}]) + AgentRun(http, {"id": "a1", "status": "credit_limit"}).continue_() + assert http.calls == [("POST", "/agent/a1/continue", {}, {}, None)] + + +@pytest.mark.parametrize("error", [ + MeshArcError(409, "only a run stopped at its credit limit can be continued; this one is done", "conflict"), + MeshArcError(409, "this run was already continued, by run a2", "conflict"), + MeshArcError(404, "agent run not found", "not_found"), + MeshArcError(410, "this run is past its keep date; its saved progress is gone", "expired"), +]) +def test_continuing_a_run_the_api_refuses_raises_its_error_unchanged(error): + http = RaisingHttp([error]) + with pytest.raises(MeshArcError) as exc: + AgentRun(http, {"id": "a1", "status": "credit_limit"}).continue_() + assert exc.value is error + assert len(http.calls) == 1 + + +def test_the_agent_list_sends_model_since_and_until_on_every_page(): + arc, http = client([{"data": [{"id": "a"}], "next": "/api/v1/agent?limit=1&cursor=1&model=openai%3Agpt-5.4-mini"}, + {"data": [{"id": "b"}], "next": None}]) + rows = arc.agent_runs(model="openai:gpt-5.4-mini", since="2026-10-01", until="2026-10-08T12:00:00Z", limit=1) + assert [row["id"] for row in rows] == ["a", "b"] + wanted = {"limit": 1, "model": "openai:gpt-5.4-mini", "since": "2026-10-01", "until": "2026-10-08T12:00:00Z"} + assert [c[:3] for c in http.calls] == [("GET", "/agent", wanted), ("GET", "/agent", {**wanted, "cursor": "1"})] + + +def test_the_agent_list_sends_a_date_or_datetime_as_iso_8601(): + arc, http = client([{"data": [], "next": None}]) + list(arc.agent_runs(status="done", since=date(2026, 10, 1), + until=datetime(2026, 10, 8, 9, 30, tzinfo=timezone.utc))) + assert http.calls[0][2] == {"limit": 25, "status": "done", "since": "2026-10-01", + "until": "2026-10-08T09:30:00+00:00"} + + +def test_the_agent_list_sends_no_filter_it_was_not_given(): + arc, http = client([{"data": [], "next": None}]) + assert list(arc.agent_runs()) == [] + assert http.calls[0][2] == {"limit": 25} + + +def test_the_agent_list_sends_no_since_or_until_for_an_empty_string(): + arc, http = client([{"data": [], "next": None}]) + assert list(arc.agent_runs(since="", until="")) == [] + assert http.calls[0][2] == {"limit": 25} + + +def test_a_naive_datetime_is_sent_without_a_zone_for_the_api_to_read_as_utc(): + assert mesharc._iso(datetime(2026, 10, 8, 9, 30)) == "2026-10-08T09:30:00" + assert mesharc._iso(date(2026, 10, 8)) == "2026-10-08" + assert mesharc._iso("2026-10-08T09:30:00+02:00") == "2026-10-08T09:30:00+02:00" + + +def test_a_news_search_sends_sources_news_and_the_results_page(): + hit = {"url": "https://n.test/a", "title": "A", "publisher": "N", "age": "20h"} + arc, http = client([{"id": "s10", "status": "done", "data": [hit]}]) + out = arc.web_search("mesh arc", news=True, page=2) + assert (out["data"][0]["publisher"], out["data"][0]["age"]) == ("N", "20h") + assert http.calls[0][3] == {"query": "mesh arc", "sources": ["news"], "page": 2, "timeout": 60} + + +def test_a_web_search_sends_no_sources_or_page_unless_asked(): + arc, http = client([{"id": "s11", "status": "done", "data": []}]) + arc.web_search("q", news=False) + assert http.calls[0][3] == {"query": "q", "timeout": 60} + + +def test_a_monitor_is_created_with_the_apis_names_and_an_idempotency_key(): + monitor = {"id": "m1", "kind": "search", "status": "active", "webhookSecret": "whsec_m"} + arc, http = client([monitor]) + request = {"query": "mesh arc", "freshness": "day"} + hook = {"url": "https://hooks.test/m", "events": ["search.changed"]} + assert arc.monitors.create("search", request, "daily", name="Mentions", webhook=hook, baseline_id="s1", + idempotency_key="k-m") == monitor + assert http.calls == [("POST", "/monitors", {}, {"kind": "search", "request": request, "schedule": "daily", + "name": "Mentions", "webhook": hook, "baselineId": "s1"}, "k-m")] + + +def test_a_monitor_leaves_out_what_was_not_given(): + arc, http = client([{"id": "m2", "kind": "agent"}]) + arc.monitors.create("agent", {"prompt": "q"}, "weekly") + assert http.calls == [("POST", "/monitors", {}, {"kind": "agent", "request": {"prompt": "q"}, + "schedule": "weekly"}, None)] + + +def test_the_monitor_list_unwraps_data_and_filters_by_kind(): + arc, http = client([{"data": [{"id": "m1"}, {"id": "m2"}]}, {"data": []}]) + assert [m["id"] for m in arc.monitors.list()] == ["m1", "m2"] + assert arc.monitors.list(kind="agent") == [] + assert [c[:3] for c in http.calls] == [("GET", "/monitors", {}), ("GET", "/monitors", {"kind": "agent"})] + + +def test_a_monitor_is_read_paused_resumed_and_deleted_by_id(): + arc, http = client([{"id": "m1", "status": "active"}, {"id": "m1", "status": "paused"}, + {"id": "m1", "status": "active"}, {"id": "m1", "deleted": True}]) + assert arc.monitors.get("m1")["status"] == "active" + assert arc.monitors.pause("m1")["status"] == "paused" + assert arc.monitors.resume("m1")["status"] == "active" + assert arc.monitors.delete("m1") == {"id": "m1", "deleted": True} + assert [c[:2] for c in http.calls] == [("GET", "/monitors/m1"), ("POST", "/monitors/m1/pause"), + ("POST", "/monitors/m1/resume"), ("DELETE", "/monitors/m1")] + + +def test_updating_a_monitor_sends_only_the_given_fields_and_an_empty_webhook_to_remove_it(): + arc, http = client([{"id": "m1"}, {"id": "m1", "webhook": None}]) + arc.monitors.update("m1", schedule="hourly", status="paused") + arc.monitors.update("m1", webhook="") + assert [c[:4] for c in http.calls] == [("PATCH", "/monitors/m1", {}, {"schedule": "hourly", "status": "paused"}), + ("PATCH", "/monitors/m1", {}, {"webhook": ""})] + + +def test_updating_a_monitor_sends_nothing_for_a_none_and_keeps_an_empty_webhook(): + """None means "leave it": a caller passing a variable that is None must not + clear the field. Only the empty string removes the webhook.""" + arc, http = client([{"id": "m1"}, {"id": "m1"}]) + arc.monitors.update("m1", name=None, schedule=None, status=None, webhook=None) + arc.monitors.update("m1", name="Mentions", schedule=None, status=None, webhook="") + assert [c[:4] for c in http.calls] == [("PATCH", "/monitors/m1", {}, {}), + ("PATCH", "/monitors/m1", {}, {"name": "Mentions", "webhook": ""})] + + +def test_updating_a_monitor_takes_only_its_four_fields(): + arc, http = client([{"id": "m1"}]) + with pytest.raises(TypeError): + arc.monitors.update("m1", schedul="hourly") + assert http.calls == [], "a misspelt field is refused here, not sent" + + +def test_running_a_monitor_now_answers_the_new_run(): + run = {"id": "r1", "monitorId": "m1", "trigger": "manual", "status": "running", "refId": "s9"} + arc, http = client([run]) + assert arc.monitors.run("m1", idempotency_key="k-r") == run + assert http.calls == [("POST", "/monitors/m1/run", {}, {}, "k-r")] + + +def test_running_a_monitor_while_a_run_is_under_way_raises_the_apis_409(): + arc, _ = client([MeshArcError(409, "a run of this monitor is under way", "conflict")], RaisingHttp) + with pytest.raises(MeshArcError) as exc: + arc.monitors.run("m1") + assert (exc.value.status, exc.value.code) == (409, "conflict") + + +@pytest.mark.parametrize("call", [ + lambda arc: arc.monitors.get("nope"), + lambda arc: arc.monitors.update("nope", name="x"), + lambda arc: arc.monitors.pause("nope"), + lambda arc: arc.monitors.resume("nope"), + lambda arc: arc.monitors.run("nope"), + lambda arc: arc.monitors.delete("nope"), + lambda arc: list(arc.monitors.runs("nope")), +]) +def test_a_missing_monitor_raises_404_unchanged(call): + arc, _ = client([MeshArcError(404, "monitor not found", "not_found")], RaisingHttp) + with pytest.raises(MeshArcError) as exc: + call(arc) + assert (exc.value.status, exc.value.code) == (404, "not_found") + + +def test_the_monitor_run_list_follows_next_and_sends_limit_on_every_page(): + arc, http = client([{"data": [{"id": "r1", "changed": True}, {"id": "r2"}], + "next": "/api/v1/monitors/m1/runs?limit=2&cursor=2"}, + {"data": [{"id": "r3"}], "next": None}]) + assert [row["id"] for row in arc.monitors.runs("m1", limit=2)] == ["r1", "r2", "r3"] + assert [c[:3] for c in http.calls] == [("GET", "/monitors/m1/runs", {"limit": 2}), + ("GET", "/monitors/m1/runs", {"limit": 2, "cursor": "2"})] + + +def test_the_monitors_namespace_is_set_up_by_the_client_and_leaves_the_job_queue_alone(): + arc = MeshArc("mesharc_test") + assert isinstance(arc.monitors, mesharc._Monitors) + assert arc.monitors._h is arc._h, "one transport for the whole client" + old = ("create_monitor", "get_monitor", "update_monitor", "pause_monitor", "resume_monitor", "run_monitor", + "monitor_runs", "delete_monitor") + assert not any(hasattr(arc, name) for name in old) + arc.close() + arc, http = client([{"queued": 0, "running": 1}]) + assert arc.monitor() == {"queued": 0, "running": 1} + assert http.calls == [("GET", "/me/monitor", {}, {}, None)] diff --git a/tests/test_client.py b/tests/test_client.py index 42c0804..3ce59f8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,11 +1,12 @@ """The client against a scripted transport: no network, no key.""" import json +from datetime import datetime, timezone import httpx import pytest -from mesharc import Crawl, MeshArc, MeshArcError, __version__ +from mesharc import AgentRun, Crawl, MeshArc, MeshArcError, __version__ def scripted(*responses): @@ -139,3 +140,70 @@ def test_cancel_batch_deletes_the_scrape(): arc = scripted((204, None)) assert arc.cancel_batch("b1") is None assert (arc.calls[0].method, arc.calls[0].url.path) == ("DELETE", "/api/v1/scrape/b1") + + +def test_an_agent_run_posts_its_body_with_the_key_and_is_polled_by_id(): + arc = scripted((202, {"id": "a1", "kind": "agent", "status": "running", "data": None, "next": "/api/v1/agent/a1"}), + {"id": "a1", "kind": "agent", "status": "done", "data": {"text": "MeshArc"}, + "sources": [{"url": "https://a.test/", "title": "A", "pageId": "p1"}], "next": None}) + run = arc.agent("who makes it?", max_credits=100, allowed_domains=["a.test"], timeout_s=10, + idempotency_key="agent-1") + assert run.status == "running" + assert run.wait(poll=0)["data"] == {"text": "MeshArc"} + assert run.sources[0]["pageId"] == "p1" + post, poll = arc.calls + assert (post.method, post.url.path) == ("POST", "/api/v1/agent") + assert post.headers["Idempotency-Key"] == "agent-1" + assert json.loads(post.content) == {"prompt": "who makes it?", "maxCredits": 100, "allowedDomains": ["a.test"], + "timeout": 10} + assert (poll.method, str(poll.url)) == ("GET", "https://api.mesharc.dev/api/v1/agent/a1") + + +def test_an_expired_agent_run_is_a_410_error_and_ends_a_trace_quietly(): + arc = scripted((410, {"error": "this run has expired", "code": "expired"})) + with pytest.raises(MeshArcError) as exc: + arc.get_agent("a1") + assert (exc.value.status, exc.value.code) == (410, "expired") + arc = scripted({"id": "a1", "status": "running"}, (410, {"error": "this run has expired", "code": "expired"})) + run = arc.get_agent("a1") + assert list(run.trace(poll=0)) == [] + assert str(arc.calls[1].url) == "https://api.mesharc.dev/api/v1/agent/a1" + + +def test_continuing_a_run_posts_json_and_a_409_is_raised_with_its_code(): + arc = scripted((202, {"id": "a2", "status": "running", "continuesRunId": "a1"}), + (409, {"error": "this run was already continued, by run a2", "code": "conflict", "request_id": "req_c"})) + first = AgentRun(arc._h, {"id": "a1", "status": "credit_limit"}).continue_(max_credits=500) + assert (first.id, first.continues_run_id) == ("a2", "a1") + post = arc.calls[0] + assert (post.method, post.url.path) == ("POST", "/api/v1/agent/a1/continue") + assert json.loads(post.content) == {"maxCredits": 500} + with pytest.raises(MeshArcError) as exc: + AgentRun(arc._h, {"id": "a1", "status": "credit_limit"}).continue_() + assert (exc.value.status, exc.value.code, exc.value.request_id) == (409, "conflict", "req_c") + assert len(arc.calls) == 2 + + +def test_the_monitors_namespace_sends_its_bodies_as_json_with_the_key(): + arc = scripted((201, {"id": "m1", "kind": "search", "webhookSecret": "whsec_m"}), + {"id": "m1"}, {"id": "m1", "webhook": None}, + (202, {"id": "r1", "status": "running"})) + assert arc.monitors.create("search", {"query": "mesh arc", "sources": ["news"]}, "hourly", + idempotency_key="mon-1")["webhookSecret"] == "whsec_m" + arc.monitors.update("m1", name=None, schedule="daily", status=None, webhook=None) + arc.monitors.update("m1", webhook="") + arc.monitors.run("m1", idempotency_key="run-1") + create, update, remove, run = arc.calls + assert (create.method, create.url.path, create.headers["Idempotency-Key"]) == ("POST", "/api/v1/monitors", "mon-1") + assert json.loads(create.content) == {"kind": "search", "request": {"query": "mesh arc", "sources": ["news"]}, + "schedule": "hourly"} + assert (update.method, update.url.path) == ("PATCH", "/api/v1/monitors/m1") + assert json.loads(update.content) == {"schedule": "daily"}, "a None is not sent as null" + assert json.loads(remove.content) == {"webhook": ""}, "the empty string is what removes the webhook" + assert (run.method, run.url.path, run.headers["Idempotency-Key"]) == ("POST", "/api/v1/monitors/m1/run", "run-1") + + +def test_the_agent_list_puts_a_datetimes_zone_in_the_query_intact(): + arc = scripted({"data": [], "next": None}) + assert list(arc.agent_runs(since=datetime(2026, 10, 8, 9, 30, tzinfo=timezone.utc))) == [] + assert arc.calls[0].url.params["since"] == "2026-10-08T09:30:00+00:00" diff --git a/tests/test_definitions.py b/tests/test_definitions.py index 9f0b3d1..96ce8c1 100644 --- a/tests/test_definitions.py +++ b/tests/test_definitions.py @@ -16,7 +16,7 @@ # find and bump. EXPECTED = {"scrape_urls", "map_site", "crawl_site", "search_web", "list_projects", "get_project", "create_project", "update_project", "delete_project", "list_runs", "list_pages", - "get_changes", "start_run", "get_job", "cancel_job"} + "get_changes", "start_run", "get_job", "cancel_job", "run_agent", "continue_agent"} # The tools that only read what the workspace has stored. Everything else # reaches a site or changes the workspace, and a read-only connection is @@ -32,6 +32,7 @@ def test_every_tool_is_listed(): # 0.4.0 folded five tools into the siblings they overlapped and added # delete_project, nineteen becoming fifteen; web search made sixteen in # 0.5.0, and 0.6.0 folded get_page into list_pages(url=) for fifteen. + # run_agent makes sixteen and continue_agent seventeen. assert set(_tools()) == EXPECTED assert READ_ONLY < EXPECTED @@ -73,9 +74,9 @@ def enum(tool, param): options = [schema] + list(schema.get("anyOf") or []) return next(o["enum"] for o in options if "enum" in o) - assert enum("get_job", "kind") == ["crawl", "run", "batch", "search"] + assert enum("get_job", "kind") == ["crawl", "run", "batch", "search", "agent"] # A web search cannot be stopped: the API has no cancel for one. - assert enum("cancel_job", "kind") == ["crawl", "run", "batch"] + assert enum("cancel_job", "kind") == ["crawl", "run", "batch", "agent"] assert enum("list_pages", "mode") == ["content", "selector"] for tool in ("create_project", "update_project"): assert enum(tool, "schedule") == ["manual", "hourly", "daily", "weekly"] diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index 8d51fd0..4c1b576 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -25,7 +25,7 @@ EXPECTED = frozenset({ "scrape_urls", "map_site", "crawl_site", "search_web", "list_projects", "get_project", "create_project", "update_project", "delete_project", "list_runs", "list_pages", - "get_changes", "start_run", "get_job", "cancel_job", + "get_changes", "start_run", "get_job", "cancel_job", "run_agent", "continue_agent", }) diff --git a/tests/test_results.py b/tests/test_results.py index af4864d..543f73b 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -591,3 +591,487 @@ def test_get_job_follows_a_search(monkeypatch): lambda: _Searches(_search(status="error", error="every engine failed"))) out = mcp_mod.get_job("search", "s1") assert out == {"error": "every engine failed", "status": 502, "code": "job_failed"} + + +# --- run_agent, and an agent run through get_job and cancel_job ---------- + +class _AgentRun: + """A handle as `MeshArc.agent` and `get_agent` return one: `refresh` + reads the next envelope the test scripted, as the API would.""" + + def __init__(self, arc, envelope): + self.arc, self.id, self.envelope = arc, envelope["id"], envelope + + @property + def continued_by(self): + return self.envelope.get("continuedBy") or None + + def refresh(self): + self.arc.calls.append(("refresh", self.id)) + if self.arc.now: + self.envelope = self.arc.now.pop(0) + return self + + def cancel(self): + self.arc.calls.append(("cancel", self.id)) + return self.arc.cancel_answer + + def continue_(self, max_credits=None, max_steps=None, timeout_s=0, idempotency_key=None): + self.arc.calls.append(("continue", self.id, idempotency_key, + {"max_credits": max_credits, "max_steps": max_steps})) + if self.arc.continue_raises is not None: + raise self.arc.continue_raises + return _AgentRun(self.arc, dict(self.arc.continued)) + + +class _Agents: + """Enough of a client for run_agent, continue_agent and the agent branches + of get_job and cancel_job. `posted` is what POST answers, `continued` what + POST .../continue answers, `now` what each later read finds; `raises` is + raised by the POST or the read instead, `continue_raises` by the continue.""" + + def __init__(self, posted=None, now=(), raises=None, cancel_answer=None, continued=None, + continue_raises=None): + self.posted, self.now, self.raises = posted, list(now), raises + self.cancel_answer = cancel_answer + self.continued, self.continue_raises = continued, continue_raises + self.calls = [] + + def agent(self, prompt, idempotency_key=None, **kw): + self.calls.append(("agent", prompt, idempotency_key, kw)) + if self.raises is not None: + raise self.raises + return _AgentRun(self, dict(self.posted)) + + def get_agent(self, id): + self.calls.append(("get_agent", id)) + if self.raises is not None: + raise self.raises + return _AgentRun(self, self.now.pop(0)) + + def __enter__(self): + return self + + def __exit__(self, *_exc): + return None + + +def _agent_env(status="running", **extra): + return {"id": "a1", "kind": "agent", "status": status, "prompt": "find it", "params": {}, "data": None, + "sources": [], "creditsUsed": 12, "budget": 2000, "budgetLimited": False, + "tokens": {"in": 100, "out": 20}, "steps": 3, "model": "m", "stopReason": "", "error": "", + "next": "/api/v1/agent/a1", **extra} + + +def test_run_agent_keys_a_retry_onto_the_same_run_for_ten_minutes(monkeypatch): + clock = [600 * 1000 + 10] + monkeypatch.setattr(mcp_mod.time, "time", lambda: clock[0]) + arc = _Agents(posted=_agent_env("queued")) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + + def key(prompt="find it", **kw): + arc.now = [_agent_env("running")] + mcp_mod.run_agent(prompt, **kw) + return arc.calls[-2][2] + + first = key(urls=["https://x.test/"], max_credits=50) + assert first.startswith("mcp-") + clock[0] += 500 + assert key(urls=["https://x.test/"], max_credits=50) == first, "a retry in the same window is the same run" + assert key(urls=["https://x.test/"], max_credits=60) != first, "different inputs are a different run" + assert key("find something else", urls=["https://x.test/"], max_credits=50) != first + clock[0] += 100 + assert key(urls=["https://x.test/"], max_credits=50) != first, "a re-run in a later window is a new run" + # Every input reaches the client. + _name, prompt, _key, kw = arc.calls[-2] + assert prompt == "find it" and kw["urls"] == ["https://x.test/"] and kw["max_credits"] == 50 + + +def test_run_agent_reads_the_run_after_starting_it_and_hands_back_a_job(monkeypatch): + arc = _Agents(posted=_agent_env("queued", creditsUsed=0, steps=0), + now=[_agent_env("running", creditsUsed=40, steps=2)]) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + out = mcp_mod.run_agent("find it", schema={"type": "object"}, allowed_domains=["x.test"], max_steps=10) + assert [c[0] for c in arc.calls] == ["agent", "refresh"] + assert arc.calls[0][3]["schema"] == {"type": "object"} and arc.calls[0][3]["allowed_domains"] == ["x.test"] + assert arc.calls[0][3]["max_steps"] == 10 + assert out["status"] == "running" and out["job"] == {"kind": "agent", "id": "a1"} + assert out["budget"] == 2000 and out["creditsUsed"] == 40 and out["steps"] == 2 + assert "get_job" in out["note"] and "30 to 60 seconds" in out["note"] + + +def test_a_replayed_run_that_has_finished_answers_with_its_result(monkeypatch): + """A replayed key answers with the first stored envelope -- here a stale + 'queued' -- so the run is read again rather than reported as queued.""" + done = _agent_env("done", data={"text": "the answer"}, creditsUsed=90, steps=7, + sources=[{"url": "https://x.test/a", "title": "A", "pageId": "p1"}]) + arc = _Agents(posted=_agent_env("queued"), now=[done]) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + out = mcp_mod.run_agent("find it") + assert out["status"] == "done" and "job" not in out + assert out["data"] == {"text": "the answer"} and out["sources"] == done["sources"] + assert out["creditsUsed"] == 90 and out["steps"] == 7 and out["id"] == "a1" + assert "error" not in out, "an empty error is not an error" + + +@pytest.mark.parametrize("detail,code", [ + # The API's own code for it, and an API from before that code, which said so only in the message. + ("a request with this Idempotency-Key is still running", "in_flight"), + ("a request with this Idempotency-Key is still running", "conflict"), +]) +def test_a_key_still_being_accepted_is_answered_not_raised(monkeypatch, detail, code): + from mesharc import MeshArcError + arc = _Agents(raises=MeshArcError(409, detail, code)) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + out = mcp_mod.run_agent("find it") + assert out == {"status": "running", "note": mcp_mod.AGENT_ACCEPTING} + assert "run_agent again" in out["note"] + + +def test_run_agent_passes_on_a_409_that_is_not_a_key_in_flight(monkeypatch): + """A 409 the API means as a refusal is not "still being accepted": + answering it as running would have an assistant ask again for ever.""" + from mesharc import MeshArcError + arc = _Agents(raises=MeshArcError(409, "the workspace already has an agent run going", "conflict", "req_7")) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + out = mcp_mod.run_agent("find it") + assert out == {"error": "the workspace already has an agent run going", "status": 409, "code": "conflict", + "request_id": "req_7"} + + +def test_get_job_follows_an_agent_run(monkeypatch): + def poll(envelope): + monkeypatch.setattr(mcp_mod, "_client", lambda: _Agents(now=[envelope])) + return mcp_mod.get_job("agent", "a1") + + out = poll(_agent_env("running", creditsUsed=55, steps=4)) + assert out["status"] == "running" and out["job"] == {"kind": "agent", "id": "a1"} + assert out["creditsUsed"] == 55 and out["steps"] == 4 and out["budget"] == 2000 + + sources = [{"url": f"https://x.test/{i}", "title": f"T{i}", "pageId": f"p{i}"} for i in range(3)] + out = poll(_agent_env("done", data={"plans": [{"name": "Pro", "price": 20}]}, sources=sources)) + assert out["status"] == "done" and out["data"] == {"plans": [{"name": "Pro", "price": 20}]} + assert out["sources"] == sources and "job" not in out and out["note"] == "" + + out = poll(_agent_env("credit_limit", data={"partial": "half of it"}, stopReason="credit_limit")) + assert out["status"] == "credit_limit" and out["data"] == {"partial": "half of it"} + assert "data.partial" in out["note"] and "max_credits" in out["note"] + assert "continue_agent with id='a1'" in out["note"], "the note names the tool and the run to carry on" + assert "run_agent" not in out["note"], "starting over would pay for the pages again" + assert out["stopReason"] == "credit_limit" + + +def test_a_stopped_run_already_carried_on_points_at_the_run_that_did(monkeypatch): + stopped = _agent_env("credit_limit", data={"partial": "half"}, stopReason="credit_limit", continuedBy="a2") + monkeypatch.setattr(mcp_mod, "_client", lambda: _Agents(now=[stopped])) + out = mcp_mod.get_job("agent", "a1") + assert "carried on by run a2" in out["note"] and "get_job" in out["note"] + assert "continue_agent" not in out["note"], "a second continue is refused, so it is not suggested" + + +def test_an_expired_agent_run_is_an_answer_not_an_error(monkeypatch): + from mesharc import MeshArcError + arc = _Agents(raises=MeshArcError(410, "this agent run has expired", "expired")) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + out = mcp_mod.get_job("agent", "a1") + assert out == {"status": "expired", "id": "a1", "note": mcp_mod.AGENT_EXPIRED} + assert "7 days" in out["note"] + + +def test_an_agent_run_that_failed_keeps_the_apis_words(monkeypatch): + failed = _agent_env("error", error="the model provider refused the request") + monkeypatch.setattr(mcp_mod, "_client", lambda: _Agents(now=[failed])) + out = mcp_mod.get_job("agent", "a1") + assert out["status"] == "error" and out["error"] == "the model provider refused the request" + assert "job" not in out + + +@pytest.mark.parametrize("chars", ["x", "é\"\\"], ids=["plain", "escaped"]) +def test_a_huge_agent_answer_stays_inside_the_budget(monkeypatch, chars): + """Five hundred sources and an answer of a few hundred thousand + characters: the sources go down to fifty first, then the answer is cut, + and the note says where the whole of it is. Quotes, backslashes and + anything not ASCII weigh more in JSON than they count in the text.""" + text = chars * (200_000 // len(chars)) + sources = [{"url": f"https://www.example-company.com/products/item-{i}", "title": f"Item {i} | Example", + "pageId": f"{i:032x}"} for i in range(500)] + huge = _agent_env("done", data={"text": text}, sources=sources) + monkeypatch.setattr(mcp_mod, "_client", lambda: _Agents(now=[huge])) + out = mcp_mod.get_job("agent", "a1") + size = len(json.dumps(out)) + assert size <= mcp_mod.RESULT_BUDGET, f"{size:,} characters against a promise of {mcp_mod.RESULT_BUDGET:,}" + assert len(out["sources"]) == mcp_mod.AGENT_SOURCES_CAP and out["sources"] == sources[:50] + assert isinstance(out["data"], str) and out["data"].startswith('{"text": "') + assert "more characters" in out["data"] + assert size > mcp_mod.RESULT_BUDGET - 1_000, "the cut answer uses the room it has" + assert "GET /api/v1/agent/a1" in out["note"] and "get_agent" in out["note"] + assert "first 50 of 500" in out["note"] + + +def test_many_sources_alone_are_cut_and_the_answer_kept(monkeypatch): + sources = [{"url": f"https://www.example-company.com/products/category/item-{i}", + "title": "A long product title " * 4, "pageId": f"{i:032x}"} for i in range(800)] + many = _agent_env("done", data={"text": "short"}, sources=sources) + monkeypatch.setattr(mcp_mod, "_client", lambda: _Agents(now=[many])) + out = mcp_mod.get_job("agent", "a1") + assert len(json.dumps(out)) <= mcp_mod.RESULT_BUDGET + assert out["data"] == {"text": "short"}, "the answer is cut last" + assert len(out["sources"]) == 50 and "first 50 of 800" in out["note"] and "get_agent" in out["note"] + + +def _field_sources(n): + return {f"plans[{i}].price": {"url": f"https://www.example-company.com/pricing/plan-{i}", + "pageId": f"{i:032x}"} for i in range(n)} + + +def test_a_finished_answer_says_where_each_value_came_from(monkeypatch): + fields = _field_sources(2) + done = _agent_env("done", data={"plans": [{"price": 20}, {"price": 40}]}, fieldSources=fields, + sources=[{"url": "https://www.example-company.com/pricing/plan-0", "title": "P", + "pageId": f"{0:032x}"}]) + monkeypatch.setattr(mcp_mod, "_client", lambda: _Agents(now=[done])) + out = mcp_mod.get_job("agent", "a1") + assert out["fieldSources"] == fields and out["note"] == "" + # A run the API said nothing about answers an empty map, not a missing one. + monkeypatch.setattr(mcp_mod, "_client", lambda: _Agents(now=[_agent_env("done", fieldSources=None)])) + assert mcp_mod.get_job("agent", "a1")["fieldSources"] == {} + + +def test_field_sources_are_left_out_first_when_the_answer_is_too_long(monkeypatch): + """The answer and its sources fit; the map of where each value came from + tips it over, so the map goes and nothing else is cut.""" + sources = [{"url": f"https://x.test/{i}", "title": f"T{i}", "pageId": f"p{i}"} for i in range(80)] + data = {"text": "y" * 30_000} + fields = _field_sources(400) + assert len(json.dumps(fields)) > 30_000, "the map alone has to tip the answer over" + env = _agent_env("done", data=data, sources=sources, fieldSources=fields) + monkeypatch.setattr(mcp_mod, "_client", lambda: _Agents(now=[env])) + out = mcp_mod.get_job("agent", "a1") + assert len(json.dumps(out)) <= mcp_mod.RESULT_BUDGET + assert "fieldSources" not in out + assert out["data"] == data and out["sources"] == sources, "the answer and all 80 sources are kept" + assert "fieldSources is left out" in out["note"] and "sources lists" not in out["note"] + assert "GET /api/v1/agent/a1" in out["note"] and "get_agent('a1')" in out["note"] + + +def test_a_huge_answer_with_field_sources_loses_them_and_then_the_rest(monkeypatch): + sources = [{"url": f"https://www.example-company.com/products/item-{i}", "title": f"Item {i}", + "pageId": f"{i:032x}"} for i in range(500)] + huge = _agent_env("done", data={"text": "x" * 200_000}, sources=sources, fieldSources=_field_sources(400)) + monkeypatch.setattr(mcp_mod, "_client", lambda: _Agents(now=[huge])) + out = mcp_mod.get_job("agent", "a1") + assert len(json.dumps(out)) <= mcp_mod.RESULT_BUDGET + assert "fieldSources" not in out and len(out["sources"]) == 50 and isinstance(out["data"], str) + assert ("fieldSources is left out, sources lists the first 50 of 500 and data is the answer's JSON text, cut" + in out["note"]) + + +def test_cancel_job_stops_an_agent_run_and_passes_on_the_apis_status(monkeypatch): + for status in ("cancelling", "cancelled", "done"): + arc = _Agents(now=[_agent_env("running")], cancel_answer={"id": "a1", "status": status}) + monkeypatch.setattr(mcp_mod, "_client", lambda arc=arc: arc) + out = mcp_mod.cancel_job("agent", "a1") + assert arc.calls == [("get_agent", "a1"), ("cancel", "a1")] + assert out["kind"] == "agent" and out["id"] == "a1" and out["outcome"] == status + assert "stay charged" in out["note"] and "next step" in out["note"] + + +def test_run_agent_answers_through_the_server_inside_its_output_schema(monkeypatch): + """Through call_tool, so the answer is checked against the schema the + tool declares -- and that schema describes an error too.""" + import asyncio + arc = _Agents(posted=_agent_env("queued"), now=[_agent_env("running")]) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + got = asyncio.run(mcp_mod.server.call_tool("run_agent", {"prompt": "find it"})) + assert got.structured_content["job"] == {"kind": "agent", "id": "a1"} + + tool = {t.name: t for t in asyncio.run(mcp_mod.server.list_tools())}["run_agent"] + schema = tool.output_schema + assert schema["additionalProperties"] is True and not schema.get("required") + for field in ("id", "status", "data", "fieldSources", "sources", "creditsUsed", "budget", "steps", "job", + "note", "error"): + assert (schema["properties"][field].get("description") or "").strip(), field + assert tool.annotations.read_only_hint is False and tool.annotations.destructive_hint is False + assert tool.annotations.idempotent_hint is False and tool.annotations.open_world_hint is True + + +# --- continue_agent ------------------------------------------------------------ + +def _stopped(**extra): + return _agent_env("credit_limit", data={"partial": "half"}, stopReason="credit_limit", **extra) + + +def _continued(status="queued", **extra): + return {**_agent_env(status, **extra), "id": "a2", "continuesRunId": "a1", "next": "/api/v1/agent/a2"} + + +def test_continue_agent_starts_the_new_run_and_hands_back_its_job(monkeypatch): + arc = _Agents(now=[_stopped(), _continued("running", creditsUsed=5, steps=1)], continued=_continued()) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + out = mcp_mod.continue_agent("a1", max_credits=4000, max_steps=20) + assert [c[0] for c in arc.calls] == ["get_agent", "continue", "refresh"] + assert arc.calls[1][1] == "a1" and arc.calls[1][3] == {"max_credits": 4000, "max_steps": 20} + assert arc.calls[2] == ("refresh", "a2"), "the new run is read as it stands, not the stopped one" + assert out["status"] == "running" and out["job"] == {"kind": "agent", "id": "a2"} + assert out["creditsUsed"] == 5 and out["steps"] == 1 and "get_job" in out["note"] + + +def test_continue_agent_leaves_out_the_budget_it_was_not_given(monkeypatch): + arc = _Agents(now=[_stopped(), _continued("running")], continued=_continued()) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + mcp_mod.continue_agent("a1") + assert arc.calls[1][3] == {"max_credits": None, "max_steps": None}, "the stopped run's own then apply" + + +def test_continue_agent_keys_a_retry_onto_the_same_run_for_ten_minutes(monkeypatch): + clock = [600 * 1000 + 10] + monkeypatch.setattr(mcp_mod.time, "time", lambda: clock[0]) + arc = _Agents(continued=_continued()) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + + def key(run_id="a1", **kw): + arc.now = [_stopped(), _continued("running")] + mcp_mod.continue_agent(run_id, **kw) + return arc.calls[-2][2] + + first = key(max_credits=4000) + assert first.startswith("mcp-") + clock[0] += 500 + assert key(max_credits=4000) == first, "a retry in the same window is the same new run" + assert key(max_credits=5000) != first, "a different budget is a different request" + assert key(max_credits=4000, max_steps=10) != first + assert key("a9", max_credits=4000) != first, "another run is another key" + clock[0] += 100 + assert key(max_credits=4000) != first, "a later window is a new key" + + +def test_continue_agent_answers_a_finished_new_run_with_its_result(monkeypatch): + done = _continued("done", data={"text": "all of it"}, fieldSources={"text": {"url": "https://x.test/", + "pageId": "p1"}}) + arc = _Agents(now=[_stopped(), done], continued=_continued()) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + out = mcp_mod.continue_agent("a1") + assert out["status"] == "done" and out["id"] == "a2" and out["data"] == {"text": "all of it"} + assert out["fieldSources"] == {"text": {"url": "https://x.test/", "pageId": "p1"}} and "job" not in out + + +@pytest.mark.parametrize("detail,code", [ + # The API's own code for it, and an API from before that code, which said so only in the message. + ("a request with this Idempotency-Key is still running", "in_flight"), + ("a request with this Idempotency-Key is still running", "conflict"), +]) +def test_continue_agent_while_the_same_key_is_still_being_accepted(monkeypatch, detail, code): + from mesharc import MeshArcError + arc = _Agents(now=[_stopped()], continue_raises=MeshArcError(409, detail, code)) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + out = mcp_mod.continue_agent("a1") + assert out == {"status": "running", "note": mcp_mod.AGENT_CONTINUE_ACCEPTING} + assert "continue_agent again" in out["note"] + + +@pytest.mark.parametrize("detail", [ + "only a run stopped at its credit limit can be continued; this one is done", + "this run was already continued, by run a2", + "this run has no saved progress to continue from", +]) +def test_continue_agent_passes_on_a_conflict_about_the_run(monkeypatch, detail): + """The same status and code as a key in flight, but a refusal: answering + it as running would have an assistant ask again for ever.""" + from mesharc import MeshArcError + arc = _Agents(now=[_stopped()], continue_raises=MeshArcError(409, detail, "conflict", "req_9")) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + out = mcp_mod.continue_agent("a1") + assert out == {"error": detail, "status": 409, "code": "conflict", "request_id": "req_9"} + + +@pytest.mark.parametrize("carried", [_continued("running", creditsUsed=7, steps=2), + _continued("done", data={"text": "all of it"})]) +def test_continue_agent_answers_with_the_run_that_already_carried_it_on(monkeypatch, carried): + """Already carried on -- this tool's own call, retried past the ten-minute + key window, or another caller's: the run that did is the answer, and + nothing is posted, as a second continue would only be refused.""" + arc = _Agents(now=[_stopped(continuedBy="a2"), carried], continued=_continued()) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + out = mcp_mod.continue_agent("a1", max_credits=4000) + assert arc.calls == [("get_agent", "a1"), ("get_agent", "a2")], "read, not continued again" + assert out["status"] == carried["status"] + assert out["note"].startswith("run a1 was already carried on by run a2; this is that run") + if carried["status"] == "running": + assert out["job"] == {"kind": "agent", "id": "a2"} and "get_job" in out["note"] + assert out["creditsUsed"] == 7 and out["steps"] == 2 + else: + assert out["id"] == "a2" and out["data"] == {"text": "all of it"} and "job" not in out + + +@pytest.mark.parametrize("status,code,detail", [ + (404, "not_found", "agent run not found"), + (410, "expired", "this agent run's results are past their keep date"), +]) +def test_continue_agent_answers_an_unknown_or_expired_run_with_its_code(monkeypatch, status, code, detail): + from mesharc import MeshArcError + arc = _Agents(raises=MeshArcError(status, detail, code)) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + out = mcp_mod.continue_agent("a1") + assert out == {"error": detail, "status": status, "code": code} + assert [c[0] for c in arc.calls] == ["get_agent"], "nothing is continued" + # Past its keep date at the continue itself, too. + arc = _Agents(now=[_stopped()], continue_raises=MeshArcError(status, detail, code)) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + assert mcp_mod.continue_agent("a1") == {"error": detail, "status": status, "code": code} + + +def test_continue_agent_on_a_read_only_connection_says_so(monkeypatch): + from mesharc import MeshArcError + monkeypatch.setattr(mcp_mod, "_scopes", lambda: ["read"]) + arc = _Agents(now=[_stopped()], continue_raises=MeshArcError(403, "this needs the member role", "forbidden")) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + out = mcp_mod.continue_agent("a1") + assert out["code"] == "read_only" and out["status"] == 403 and "write access" in out["error"] + + +def test_continue_agent_answers_through_the_server_inside_its_output_schema(monkeypatch): + import asyncio + arc = _Agents(now=[_stopped(), _continued("running")], continued=_continued()) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + got = asyncio.run(mcp_mod.server.call_tool("continue_agent", {"id": "a1", "max_credits": 4000})) + assert got.structured_content["job"] == {"kind": "agent", "id": "a2"} + + tools = {t.name: t for t in asyncio.run(mcp_mod.server.list_tools())} + tool, run = tools["continue_agent"], tools["run_agent"] + assert tool.output_schema == run.output_schema, "both answer an agent run the same way" + assert tool.annotations == run.annotations + assert set(tool.input_schema["properties"]) == {"id", "max_credits", "max_steps"} + assert tool.input_schema["required"] == ["id"] + + +# --- a webhook signing secret never reaches an assistant through an agent tool ------- + +SECRET = "whsec_agent_live_123" + + +@pytest.mark.parametrize("status", ["running", "done"]) +def test_no_agent_tool_hands_back_a_webhook_secret(monkeypatch, status): + """The API returns a run's signing secret once, on the POST that made it, + for a program that verifies signatures. Here every envelope carries one, + the POST's and every read's, and no agent tool's answer may.""" + secret = {"webhookSecret": SECRET} + extra = dict(secret, data={"text": "the answer"}) if status == "done" else secret + + def check(out): + assert out.get("status") == status, out + assert "webhookSecret" not in json.dumps(out) and SECRET not in json.dumps(out), out + + arc = _Agents(posted=_agent_env("queued", **secret), now=[_agent_env(status, **extra)]) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + check(mcp_mod.run_agent("find it")) + + arc = _Agents(now=[_stopped(**secret), _continued(status, **extra)], continued=_continued("queued", **secret)) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + check(mcp_mod.continue_agent("a1")) + + monkeypatch.setattr(mcp_mod, "_client", lambda: _Agents(now=[_agent_env(status, **extra)])) + check(mcp_mod.get_job("agent", "a1")) + # A stopped run read through get_job, too. + monkeypatch.setattr(mcp_mod, "_client", lambda: _Agents(now=[_stopped(**secret)])) + out = mcp_mod.get_job("agent", "a1") + assert out["status"] == "credit_limit" and SECRET not in json.dumps(out) and "webhookSecret" not in out From 91bf33ac2f2a20ed92d28b567381633bddccf688 Mon Sep 17 00:00:00 2001 From: Siddharth-DWT Date: Mon, 12 Oct 2026 11:35:44 +0530 Subject: [PATCH 3/3] Review fixes: run_agent stays on its starting sites, cancel_agent, one pager, a stricter wait - MCP run_agent given urls and no allowed_domains is held to the URLs' sites (host, lower case, without www), as the app does: a page the agent reads could tell it to go elsewhere. allowed_domains given wins, [] is no limit, and with no urls there is none. The client's agent() is unchanged. - MeshArc.cancel_agent(run_id) stops an agent run by its id in one request; MCP cancel_job(kind="agent") uses it instead of reading the run first, and AgentRun.cancel() shares it. - One cursor pager (_paged) for searches(), agent_runs() and monitors.runs(), instead of three copies of the loop. - AgentRun.wait() returns only a done, cancelled or credit_limit run; any other end raises MeshArcError 502 job_failed, as the Node client. - pytest 247 pass (4 new); ruff and mypy clean. README and CHANGELOG. --- CHANGELOG.md | 5 ++- README.md | 4 ++- mesharc/__init__.py | 77 +++++++++++++++++++---------------------- mesharc/mcp.py | 30 +++++++++++++--- tests/test_behaviour.py | 17 +++++++++ tests/test_results.py | 27 +++++++++++++-- 6 files changed, 110 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19d915d..53decf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,10 @@ All notable changes to this package are recorded here. The format follows ### Added -- **The client hands a question to the agent, which searches, reads pages and answers on its own.** `agent(prompt, urls=, schema=, max_credits=, max_steps=, allowed_domains=, timeout_s=0, idempotency_key=)` returns an `AgentRun` at once; `timeout_s` asks the API to hold the request for the answer, 120 s at most. `AgentRun.wait(poll=2.0, timeout=3600)` returns the envelope once the run is `done`, `cancelled` or `credit_limit` (what it had found under `data.partial`) and raises `MeshArcError` for `error`; `refresh()` reads it as it stands, `cancel()` stops it (`cancelled` when queued, `cancelling` when running, which stops before its next step), and `trace(after=0, follow=True)` yields what the agent does, step by step. `data` is JSON matching the schema, or `{"text": ...}` without one, and `sources` the pages it rests on. `get_agent(id)` reopens a run, and raises `MeshArcError` 410 with code `expired` once it is past its 7 days; `agent_runs(status=None, limit=25)` walks the workspace's runs, following `next`. It needs a key that can write, and spends credits: pages as they are read (a refused page is free) and the model's tokens at the model provider's price plus 20%, within `max_credits` (2,000 by default). **This needs an API that has the `/agent` route**, which is not live on mesharc.dev yet. +- **`cancel_agent(run_id)` stops an agent run by its id**, with no handle needed: what `AgentRun.cancel()` does, in one request. `cancel_job(kind="agent")` in MCP uses it, so it no longer reads the run first. +- **The MCP `run_agent` tool keeps an agent given starting `urls` on their sites** unless `allowed_domains` says otherwise -- each URL's host, lower case, without `www`, the API keeping its subdomains -- as the app does, since a page the agent reads could tell it to go elsewhere. `allowed_domains=[]` lets it read any site; with no `urls` there is no limit. The client's `agent()` is unchanged: it sends what it is given. + +- **The client hands a question to the agent, which searches, reads pages and answers on its own.** `agent(prompt, urls=, schema=, max_credits=, max_steps=, allowed_domains=, timeout_s=0, idempotency_key=)` returns an `AgentRun` at once; `timeout_s` asks the API to hold the request for the answer, 120 s at most. `AgentRun.wait(poll=2.0, timeout=3600)` returns the envelope once the run is `done`, `cancelled` or `credit_limit` (what it had found under `data.partial`) and raises `MeshArcError` 502 `job_failed` for `error` and for any other end it does not know, as the Node client does; `refresh()` reads it as it stands, `cancel()` stops it (`cancelled` when queued, `cancelling` when running, which stops before its next step), and `trace(after=0, follow=True)` yields what the agent does, step by step. `data` is JSON matching the schema, or `{"text": ...}` without one, and `sources` the pages it rests on. `get_agent(id)` reopens a run, and raises `MeshArcError` 410 with code `expired` once it is past its 7 days; `agent_runs(status=None, limit=25)` walks the workspace's runs, following `next`. It needs a key that can write, and spends credits: pages as they are read (a refused page is free) and the model's tokens at the model provider's price plus 20%, within `max_credits` (2,000 by default). **This needs an API that has the `/agent` route**, which is not live on mesharc.dev yet. - **An agent run can report to a webhook and run on the workspace's own model.** `agent()` takes `webhook=` (a URL, or `{url, events, metadata}`, told about `agent.started`, `agent.action`, `agent.completed`, `agent.failed` and `agent.cancelled`) and `connection_id=` (one of the workspace's LLM connections, whose tokens then cost 1 credit per 1,000). `AgentRun.webhook_secret` is the secret the messages are signed with, returned once on the POST and kept by the handle past `refresh()`, and by the handle `continue_()` returns; `""` without a webhook. **These need an API with the agent's webhook and connection features**, which are on the API's feature branch, not yet on mesharc.dev. - **`AgentRun.field_sources` says where each value of the answer came from**: its path (`"plans[0].price"`, `"[2].name"` for a list answer) to `{url, pageId}`, a page the run read; `{}` when the API said nothing. **This needs an API with field sources**, on the API's feature branch, not yet on mesharc.dev. - **`AgentRun.continue_(max_credits=None, max_steps=None, timeout_s=0, idempotency_key=None)` carries on a run that stopped at `credit_limit`**, with a new budget. It returns an `AgentRun` on the new run, which resumes from where the stopped one was, keeps the pages it read (not paid for again) and its webhook, and answers in full. The API carries the webhook's secret over without repeating it, so the new handle's `webhook_secret` is the stopped handle's. `continues_run_id` and `continued_by` link the two runs. A run that did not stop at its limit, was already continued or has no saved progress raises `MeshArcError` 409 `conflict`; one past its keep date, 410 `expired`. **This needs an API that has the `/agent/{id}/continue` route**, on the API's feature branch, not yet on mesharc.dev. diff --git a/README.md b/README.md index 9c89ec9..544ab57 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ for r in arc.agent_runs(model="openai:gpt-5.4-mini", since="2026-10-01", until=d print(r["id"], r["model"], r["createdAt"]) ``` -A run is `queued`, `running`, `done`, `error`, `cancelled` or `credit_limit`. `wait()` returns a run that is `done`, `cancelled` or stopped at `credit_limit` — the run reached `max_credits`, and what it had found is under `data["partial"]` — and raises `MeshArcError` for `error`, with the API's text. A cancel stops a queued run at once and a running one before its next step; what it used stays charged. +A run is `queued`, `running`, `done`, `error`, `cancelled` or `credit_limit`. `wait()` returns a run that is `done`, `cancelled` or stopped at `credit_limit` — the run reached `max_credits`, and what it had found is under `data["partial"]` — and raises `MeshArcError` 502 `job_failed` for `error`, with the API's text -- and for any other end it does not know, as the Node client does. A cancel stops a queued run at once and a running one before its next step; what it used stays charged. `arc.cancel_agent(id)` does the same by the run's id, with no handle needed. `agent_runs` filters by `status`, by `model` (as runs name it) and by when the run was made: at or after `since`, before `until`, each an ISO 8601 date or date-time string or a `date` / `datetime`. A date means its midnight UTC; a date-time without a zone is read as UTC (so a naive `datetime.now()` is your local time read as UTC — pass `datetime.now(timezone.utc)` instead). @@ -413,6 +413,8 @@ Hosted, a connection can be approved read-only, and read-only cuts in a place wo Hosted, a tool that would hold a connection open for minutes — a crawl, a run you asked to wait for, a multi-URL scrape, a web search — hands back a job after `MESHARC_MCP_WAIT` seconds (25 by default, because many MCP hosts time a tool call out sooner). The work carries on server-side; `get_job` picks it up and `cancel_job` stops a crawl, run, batch or agent run. A search that scrapes, with its results in and its pages still landing, answers with the results and a job for the pages. Run locally, those tools block as they always have. +`run_agent` keeps an agent given starting `urls` on those URLs' sites unless `allowed_domains` says otherwise, as the app does: a page it reads could tell it to go elsewhere. `allowed_domains=[]` lets it read any site, and with no `urls` there is nothing to hold it to. (The client's `agent()` sends what it is given, as the API takes it: no `allowed_domains` is no limit.) + `run_agent` does not wait at all, hosted or local: an agent works for minutes, so it hands back a job at once, with what the run has spent so far. `get_job(kind="agent", id=…)` follows it -- every 30 to 60 seconds -- and answers with the result once it finishes; `cancel_job(kind="agent", id=…)` stops it before its next step, and what it used stays charged. A run is kept 7 days; after that `get_job` answers `status: "expired"`. A retry that lands on a run already finished answers with its result. A run that stopped at its budget (`status: "credit_limit"`, what it had found under `data.partial`) is carried on by `continue_agent(id=…, max_credits=…)`: a new run on the same thread that resumes where it stopped and keeps the pages it read, not paid for again. It hands back the new run's job the same way. A run that did not stop at its limit is refused with code `conflict`; asked again for a run already carried on — a retry past the ten-minute key window included — it answers with the run that carried it on, and starts nothing. ### Hosting it yourself diff --git a/mesharc/__init__.py b/mesharc/__init__.py index cae9bc4..9a3133a 100644 --- a/mesharc/__init__.py +++ b/mesharc/__init__.py @@ -85,6 +85,23 @@ def _iso(when: Union[str, date]) -> str: return when.isoformat() if isinstance(when, date) else when +def _paged(http: Any, path: str, params: Dict[str, Any]) -> Iterator[Json]: + """Every row of a cursor-paged list: ``params`` on every request, and + the cursor from each page's ``next`` link until there is none.""" + cursor = "" + while True: + page = http("GET", path, params={**params, **({"cursor": cursor} if cursor else {})}) + for row in page.get("data") or []: + yield row + cursor = _cursor_of(page["next"]) if page.get("next") else "" + if not cursor: + return + + +def _cancel_agent(http: Any, run_id: str) -> Json: + return http("DELETE", f"/agent/{run_id}") + + class _Http: """The transport: one httpx client, bearer auth, JSON errors, retries.""" @@ -329,17 +346,7 @@ def runs(self, monitor_id: str, limit: int = 25) -> Iterator[Json]: ``limit`` is the page size; follows the ``next`` link until the list ends. """ - cursor = "" - while True: - params: Dict[str, Any] = {"limit": limit} - if cursor: - params["cursor"] = cursor - page = self._h("GET", f"/monitors/{monitor_id}/runs", params=params) - for row in page.get("data") or []: - yield row - cursor = _cursor_of(page["next"]) if page.get("next") else "" - if not cursor: - return + yield from _paged(self._h, f"/monitors/{monitor_id}/runs", {"limit": limit}) def delete(self, monitor_id: str) -> Json: """The monitor and its runs. The searches and agent runs it made stay @@ -511,16 +518,18 @@ def wait(self, poll: float = 2.0, timeout: float = 3600) -> Json: """Block until the run finishes. Returns the envelope. A run that is ``done``, ``cancelled`` or stopped at ``credit_limit`` - (with what it had under ``data.partial``) is returned; ``error`` - raises ``MeshArcError`` with the API's text. + (with what it had under ``data.partial``) is returned. Any other end + -- ``error``, or a status this client does not know -- raises + ``MeshArcError`` 502 ``job_failed`` with the API's text, as the Node + client does. """ deadline = time.time() + timeout while True: status = self.refresh().status - if status == "error": - raise MeshArcError(502, self.envelope.get("error") or f"agent {status}", "job_failed") - if not _running(status): + if status in ("done", "cancelled", "credit_limit"): return self.envelope + if not _running(status): + raise MeshArcError(502, self.envelope.get("error") or f"agent {status}", "job_failed") if time.time() >= deadline: raise MeshArcTimeoutError(f"agent {self.id} is still {status} after {timeout}s", self.id) time.sleep(poll) @@ -531,7 +540,7 @@ def cancel(self) -> Json: run is ``cancelled``, a running one ``cancelling`` (it stops before its next step), a finished one keeps its status. ``refresh()`` reads where it ended.""" - return self._h("DELETE", f"/agent/{self.id}") + return _cancel_agent(self._h, self.id) def continue_(self, max_credits: Optional[int] = None, max_steps: Optional[int] = None, timeout_s: float = 0, idempotency_key: Optional[str] = None) -> AgentRun: @@ -846,19 +855,7 @@ def searches(self, q: Optional[str] = None, limit: int = 25) -> Iterator[Json]: again with ``q`` on every page. Follows the ``next`` link until the list ends. """ - cursor = "" - while True: - params: Dict[str, Any] = {"limit": limit} - if q: - params["q"] = q - if cursor: - params["cursor"] = cursor - page = self._h("GET", "/search", params=params) - for row in page.get("data") or []: - yield row - cursor = _cursor_of(page["next"]) if page.get("next") else "" - if not cursor: - return + yield from _paged(self._h, "/search", {"limit": limit, **({"q": q} if q else {})}) def agent(self, prompt: str, urls: Optional[Iterable[str]] = None, schema: Optional[Json] = None, max_credits: Optional[int] = None, max_steps: Optional[int] = None, @@ -917,6 +914,14 @@ def get_agent(self, run_id: str) -> AgentRun: """ return AgentRun(self._h, self._h("GET", f"/agent/{run_id}")) + def cancel_agent(self, run_id: str) -> Json: + """Stop an agent run by its id, with no handle needed: what + ``AgentRun.cancel()`` does. Returns ``{id, status}``: ``cancelled`` + for a queued run, ``cancelling`` for a running one (it stops before + its next step), a finished run's own status otherwise -- one past its + keep date included. Pages and model tokens it used stay charged.""" + return _cancel_agent(self._h, run_id) + def agent_runs(self, status: Optional[str] = None, limit: int = 25, model: Optional[str] = None, since: Union[str, date, None] = None, until: Union[str, date, None] = None) -> Iterator[Json]: """Every agent run of the workspace, as summaries. @@ -935,17 +940,7 @@ def agent_runs(self, status: Optional[str] = None, limit: int = 25, model: Optio filters: Dict[str, Any] = {k: v for k, v in (("status", status), ("model", model), ("since", _iso(since) if since else None), ("until", _iso(until) if until else None)) if v} - cursor = "" - while True: - params: Dict[str, Any] = {"limit": limit, **filters} - if cursor: - params["cursor"] = cursor - page = self._h("GET", "/agent", params=params) - for row in page.get("data") or []: - yield row - cursor = _cursor_of(page["next"]) if page.get("next") else "" - if not cursor: - return + yield from _paged(self._h, "/agent", {"limit": limit, **filters}) def pages(self, project_id: str, run_id: Optional[str] = None) -> Json: """The pages of a run (the latest finished run by default).""" diff --git a/mesharc/mcp.py b/mesharc/mcp.py index 333dd2e..46f60fe 100644 --- a/mesharc/mcp.py +++ b/mesharc/mcp.py @@ -1243,6 +1243,19 @@ def go(): "finishing first. Pages and model tokens already used stay charged.") +def _start_sites(urls): + """The sites of the URLs an agent starts from, as allowed_domains takes + them: each host, lower case, without www (the API keeps its subdomains).""" + from urllib.parse import urlparse + sites = [] + for url in urls or []: + host = (urlparse(url).hostname or "").lower() + host = host[4:] if host.startswith("www.") else host + if host and host not in sites: + sites.append(host) + return sites + + def _key_in_flight(exc): """A 409 saying the first request with this idempotency key is still being accepted: code in_flight, or only the message on an API from before that @@ -1355,7 +1368,9 @@ def cut_at(n): "Inputs: prompt says what to find, e.g. 'the monthly price of each plan on example.com'. urls " "gives pages to start from, e.g. ['https://example.com/pricing']. schema shapes the answer, e.g. " "{\"type\": \"object\", \"properties\": {\"plans\": {\"type\": \"array\"}}}; without one the " - "answer is {\"text\": ...}. allowed_domains=['example.com'] keeps it to those sites.\n" + "answer is {\"text\": ...}. allowed_domains=['example.com'] keeps it to those sites; with urls " + "and no allowed_domains it stays on the sites of urls, as a page could tell it to go elsewhere, " + "and allowed_domains=[] lets it read any site.\n" "Cost: pages are charged as they are read, and a page the site refuses is free; the model's " "tokens at the model provider's price plus 20%. max_credits caps the whole run, 2,000 by " "default; a run that reaches it stops with what it found, and continue_agent carries it on.\n" @@ -1373,8 +1388,9 @@ def run_agent(prompt: Annotated[str, Field( description="A JSON Schema object whose type is object or array; the answer then matches " "it. Omit for a text answer, {\"text\": ...}.")] = None, allowed_domains: Annotated[list[str] | None, Field( - description="Up to 20 sites the agent stays within, e.g. ['example.com']; omit for the " - "whole web.")] = None, + description="Up to 20 sites the agent stays within, e.g. ['example.com']. Omitted, it stays " + "on the sites of urls (or reads any site when there are none); [] lets it read " + "any site.")] = None, max_credits: Annotated[int | None, Field( description="The most the run may spend, 1 to 100,000 credits; omit for 2,000.")] = None, max_steps: Annotated[int | None, Field( @@ -1384,10 +1400,14 @@ def go(): # on the same run, and a deliberate re-run later is a new one. key = _key("agent", prompt, urls, schema, allowed_domains, max_credits, max_steps, int(time.time() // 600)) + # Held to where it starts unless told otherwise: an agent reads pages + # it did not choose, and one of them could send it elsewhere. The app + # does the same; [] given on purpose is no limit. + domains = allowed_domains if allowed_domains is not None else (_start_sites(urls) or None) with _client() as s: try: run = s.agent(prompt, urls=urls, schema=schema, max_credits=max_credits, max_steps=max_steps, - allowed_domains=allowed_domains, idempotency_key=key) + allowed_domains=domains, idempotency_key=key) except MeshArcError as exc: # The first request with this key is still being accepted, and # this answer carries no run id: asking again shortly gets it. @@ -2051,7 +2071,7 @@ def go(): if kind == "agent": # The API says what it did: cancelled (queued), cancelling (the # step under way finishes first), or a finished run's status. - answer = s.get_agent(id).cancel() or {} + answer = s.cancel_agent(id) or {} return {"kind": "agent", "id": id, "outcome": answer.get("status"), "note": AGENT_CANCEL_NOTE} return {"error": f"kind must be crawl, run, batch or agent, not {kind!r}", "code": "validation"} return _safe(go) diff --git a/tests/test_behaviour.py b/tests/test_behaviour.py index 8a0b4b7..a80cdfd 100644 --- a/tests/test_behaviour.py +++ b/tests/test_behaviour.py @@ -279,6 +279,23 @@ def test_an_agent_run_still_running_at_the_deadline_raises_a_timeout_naming_it() assert "agent a9 is still running" in str(exc.value) +@pytest.mark.parametrize("status", ["expired", "something_new"]) +def test_an_agent_run_that_ends_any_other_way_raises_rather_than_passing_as_an_answer(status): + """Only done, cancelled and credit_limit are answers; anything else that is + not running -- an expired run, a status this client does not know -- is a + failure, as the Node client has it.""" + http = FakeHttp([{"id": "a1", "status": status}]) + with pytest.raises(MeshArcError) as exc: + AgentRun(http, {"id": "a1", "status": "running"}).wait(poll=0) + assert (exc.value.status, exc.value.code, exc.value.detail) == (502, "job_failed", f"agent {status}") + + +def test_an_agent_run_is_cancelled_by_its_id_without_reading_it_first(): + arc, http = client([{"id": "a1", "status": "cancelling"}]) + assert arc.cancel_agent("a1") == {"id": "a1", "status": "cancelling"} + assert http.calls == [("DELETE", "/agent/a1", {}, {}, None)] + + def test_cancelling_an_agent_run_returns_the_apis_answer_and_leaves_the_envelope(): http = FakeHttp([{"id": "a1", "status": "cancelling"}]) run = AgentRun(http, {"id": "a1", "status": "running"}) diff --git a/tests/test_results.py b/tests/test_results.py index 543f73b..411df13 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -649,6 +649,10 @@ def get_agent(self, id): raise self.raises return _AgentRun(self, self.now.pop(0)) + def cancel_agent(self, id): + self.calls.append(("cancel_agent", id)) + return self.cancel_answer + def __enter__(self): return self @@ -700,6 +704,24 @@ def test_run_agent_reads_the_run_after_starting_it_and_hands_back_a_job(monkeypa assert "get_job" in out["note"] and "30 to 60 seconds" in out["note"] +def test_run_agent_stays_on_the_sites_it_starts_from_unless_told_otherwise(monkeypatch): + """A page the agent reads could tell it to go elsewhere, so a run given + starting URLs is held to their sites by default -- as the app holds it. + allowed_domains given wins, [] included (no limit); no URLs, no limit.""" + def sent(**kw): + arc = _Agents(posted=_agent_env("queued"), now=[_agent_env("running")]) + monkeypatch.setattr(mcp_mod, "_client", lambda: arc) + mcp_mod.run_agent("find it", **kw) + return arc.calls[0][3]["allowed_domains"] + + urls = ["https://www.Shop.test/a", "https://shop.test/b", "http://docs.shop.test/", "https://other.test:8443/x"] + assert sent(urls=urls) == ["shop.test", "docs.shop.test", "other.test"], "hosts, lower case, without www, once" + assert sent(urls=urls, allowed_domains=["x.test"]) == ["x.test"] + assert sent(urls=urls, allowed_domains=[]) == [], "[] on purpose is no limit, and is sent as such" + assert sent() is None and sent(urls=[]) is None, "no starting URL: nothing to hold it to" + assert sent(urls=["not a url"]) is None + + def test_a_replayed_run_that_has_finished_answers_with_its_result(monkeypatch): """A replayed key answers with the first stored envelope -- here a stale 'queued' -- so the run is read again rather than reported as queued.""" @@ -868,10 +890,11 @@ def test_a_huge_answer_with_field_sources_loses_them_and_then_the_rest(monkeypat def test_cancel_job_stops_an_agent_run_and_passes_on_the_apis_status(monkeypatch): for status in ("cancelling", "cancelled", "done"): - arc = _Agents(now=[_agent_env("running")], cancel_answer={"id": "a1", "status": status}) + arc = _Agents(cancel_answer={"id": "a1", "status": status}) monkeypatch.setattr(mcp_mod, "_client", lambda arc=arc: arc) out = mcp_mod.cancel_job("agent", "a1") - assert arc.calls == [("get_agent", "a1"), ("cancel", "a1")] + # One request: the run is stopped by its id, not read first. + assert arc.calls == [("cancel_agent", "a1")] assert out["kind"] == "agent" and out["id"] == "a1" and out["outcome"] == status assert "stay charged" in out["note"] and "next step" in out["note"]