diff --git a/CHANGELOG.md b/CHANGELOG.md index f9978ec..53decf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ All notable changes to this package are recorded here. The format follows ## Unreleased +### Added + +- **`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. +- `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..544ab57 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` 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). + +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,17 @@ 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. + +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. -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 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 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. +`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.) -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..9a3133a 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,28 @@ 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 + + +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.""" @@ -236,6 +259,102 @@ 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. + """ + 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 + 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 +448,180 @@ 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. 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 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) + 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 _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: + """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 +641,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 +786,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 +814,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: @@ -554,19 +855,92 @@ 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, + 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 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. + + ``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} + 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).""" @@ -674,6 +1048,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..46f60fe 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,262 @@ 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 _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 + 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; 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" + "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']. 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( + 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)) + # 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=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 +1913,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 +1997,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 +2018,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 +2068,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.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) -# 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 +2204,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..a80cdfd 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,413 @@ 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) + + +@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"}) + 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..411df13 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -591,3 +591,510 @@ 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 cancel_agent(self, id): + self.calls.append(("cancel_agent", id)) + return self.cancel_answer + + 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_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.""" + 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(cancel_answer={"id": "a1", "status": status}) + monkeypatch.setattr(mcp_mod, "_client", lambda arc=arc: arc) + out = mcp_mod.cancel_job("agent", "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"] + + +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