From 466280ca0eab20c074affe173e35459aee479dbd Mon Sep 17 00:00:00 2001 From: Siddharth-DWT Date: Wed, 14 Oct 2026 16:53:27 +0530 Subject: [PATCH 1/2] Follow-ups, tables, monitors in full, triggers, proposals, sub-accounts, and four monitor MCP tools Client - agent(): login_id, data_connections, proposals (sent only when given); AgentRun.follow_up(); conversations() and conversation(); estimate(). - monitors: budget, alerts, add_to_project, rows, destination and sentiment on create/update; overview(), rows(), rows_page(), row(), row_events(), export_rows(). - arc.agent_batches (preview, start, get, rows, wait, list, cancel, export), arc.triggers, arc.proposals, arc.sub_accounts, arc.connections and arc.logins. - MeshArc(tenant=...) and as_sub_account(id): X-MeshArc-Tenant on every request; TENANT_HEADER. MCP - create_monitor, list_monitors, pause_monitor and get_monitor_rows (21 tools; list_monitors and get_monitor_rows read-only). - create_project takes max_credits_per_run and max_credits_per_month. ruff clean, mypy clean, pytest 277 passed. Live against the local API with every new call (agent follow-up, a two-row table, an agent monitor's records, sub-accounts, the MCP tools). Unreleased: the API routes are on Seam-be's feature branch, not yet on mesharc.dev. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 + README.md | 150 ++++++++- mesharc/__init__.py | 595 ++++++++++++++++++++++++++++++--- mesharc/mcp.py | 264 ++++++++++++++- tests/test_client_workspace.py | 237 +++++++++++++ tests/test_definitions.py | 11 +- tests/test_mcp_http.py | 1 + tests/test_monitor_tools.py | 174 ++++++++++ 8 files changed, 1371 insertions(+), 71 deletions(-) create mode 100644 tests/test_client_workspace.py create mode 100644 tests/test_monitor_tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 53decf6..747160d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ All notable changes to this package are recorded here. The format follows ### Added +- **Follow-ups and conversations.** `AgentRun.follow_up(prompt, ...)` asks the next question in a run's conversation -- it takes what `agent()` takes, answers after everything asked and found before it, and may read again, free, any page the conversation read -- and returns its `AgentRun`. `conversations(q=None, limit=25)` walks the workspace's conversations, the latest message first; `conversation(id)` reads one with every run in it. A message that is not the conversation's latest is a 409 `conflict`. **These need an API with follow-ups**, on the API's feature branch, not yet on mesharc.dev. +- **`agent()` takes `login_id=`, `data_connections=` and `proposals=`**: a saved login its site is read with; up to two of the workspace's data-provider connections (People Data Labs, Financial Modeling Prep), which the agent looks company and financial data up with on your own key, at no MeshArc credits, each record one of its `sources` as `{url, title, provider}`; and leave to propose things to watch, for a member to approve. Each is sent only when given. `trace()` knows the `lookup` step. **These need an API with saved logins, data providers and proposals**, on the API's feature branch. +- **`arc.agent_batches`: the agent over every row of a table.** `preview`, `start`, `get`, `rows`, `wait`, `list`, `cancel` and `export` -- one agent run a row, its prompt and starting URLs filled from the row's columns as `{{column}}`, its answer the row's new columns; `max_credits_per_row` caps each row and the batch never spends more than rows x that cap. **This needs an API with `/agent/batch`**, on the API's feature branch. +- **Monitors in full.** `monitors.create` and `update` take `budget` (per run and per month, in credits), `alerts` (email and a chat webhook), `add_to_project`, `rows` (follow a list answer record by record), `destination` and `sentiment` (a news monitor's tone, scored by your LLM connection); `{}`, `""` and `False` are sent and clear a setting, only `None` is left out. New: `monitors.overview()`, `rows()`, `rows_page()`, `row()`, `row_events()` and `export_rows()`. +- **`arc.triggers`** (`create`, `list`, `get`, `update`, `delete`, `fires`, `preview`): a project's change or a monitor's run starts an agent run, a re-crawl, a monitor run or a re-check. **`arc.proposals`** (`list`, `approve`, `dismiss`): what agents proposed to watch. **`estimate(kind, schedule, ...)`**: about what a crawl, search or agent on a schedule costs a month, before it is saved. +- **Sub-accounts.** `arc.sub_accounts` (`list`, `create`, `get`, `update`, `keys`, `create_key`, `revoke_key`) makes and manages workspaces for your customers on a Scale or Enterprise plan. `arc.as_sub_account(id)`, or `MeshArc(key, tenant=id)`, is a client whose every request carries `X-MeshArc-Tenant` and acts on that sub-account. `TENANT_HEADER` names the header. +- **`arc.connections`** (`providers`, `list`, `create`, `get`, `update`, `delete`, `test`) and **`arc.logins`** (`list`, `update`, `test`, `delete`): the workspace's connections -- a data provider among them -- and its saved logins. Admin keys only. +- **Four MCP tools: `create_monitor`, `list_monitors`, `pause_monitor` and `get_monitor_rows`**, twenty-one in all. `create_monitor` keeps a web search (news too) or an agent question on a schedule, an agent one held to its starting sites as `run_agent` is, with an optional monthly cap and record-by-record following, and sends its own idempotency key from its inputs and the ten-minute window. `list_monitors` lists them, or reads one with its latest runs; `pause_monitor` pauses and resumes one; `get_monitor_rows` reads an agent monitor's records and what moved, within 60,000 characters. `list_monitors` and `get_monitor_rows` only read and work on a read-only connection; the other two need write access. **These need an API with monitors**, on the API's feature branch. +- **The MCP `create_project` tool takes `max_credits_per_run` and `max_credits_per_month`**: a run stops at its cap, keeping what it read, and scheduled runs are skipped once the month has spent its cap. On a kept crawl (`crawl_id`) they are set on the project it becomes. + - **`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. diff --git a/README.md b/README.md index 544ab57..1a5745f 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ for event in run.trace(): # follows the run until it ends 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. +`kind` is `start`, `resume`, `continue`, `model`, `search`, `fetch`, `render`, `map`, `select`, `extract`, `lookup` (a data provider's record), `propose`, `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 @@ -258,9 +258,41 @@ if out["status"] == "credit_limit": 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. +### Follow-ups and conversations + +**Not live on mesharc.dev yet:** everything in this section needs an API with these features, which are on the API's feature branch. + +```python +first = arc.agent("Which plans does example.com sell?", urls=["https://example.com/pricing"]) +first.wait() +nxt = first.follow_up("And which of them include SSO?") # the same conversation +print(nxt.wait()["data"]) + +for c in arc.conversations(q="example.com"): # the latest message first + print(c["id"], c["messages"], c["title"]) +thread = arc.conversation(first.id) # every run in it, oldest first, under "runs" +``` + +A follow-up answers after everything asked and found in the conversation before it, and may read again, free, any page the conversation read. Its request is its own — `follow_up` takes what `agent` takes — and it pays for its own work. Only the conversation's latest message can be followed, once it has finished: `MeshArcError` 409 `conflict` otherwise (the error names the latest), or past 30 messages. + +### Saved logins, data providers and proposals + +**Not live on mesharc.dev yet:** everything in this section needs an API with these features, which are on the API's feature branch. + +```python +run = arc.agent( + "How many employees does Stripe have, and what was Apple's revenue last year?", + data_connections=["conn_pdl", "conn_fmp"], # the workspace's data providers, one per provider + login_id=None, # a saved login: its site read signed in + proposals=True, # it may propose things to watch +) +``` + +`data_connections=` names up to two of the workspace's data-provider connections (`arc.connections`, below): People Data Labs for company data, Financial Modeling Prep for financial data. The agent may then look things up on your own key — the provider bills it, MeshArc charges no credits — and each record found is one of `run.sources`, as `{url, title, provider}`, `url` being the request it came from (your key is never in it). `login_id=` is one of the workspace's saved logins (`arc.logins`): pages on its site are read signed in, every other site signed out; logins are switched on per deployment. `proposals=True` lets the agent propose crawling a site or keeping a search or question on a schedule; nothing is made until a member approves it (`arc.proposals`). + ## 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. +**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. So do their caps, alerts, records and tone, below. ```python monitor = arc.monitors.create( @@ -286,16 +318,120 @@ The `request` is the body of a POST /search or POST /agent in the API's own (cam | 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.create(kind, request, schedule, name=None, webhook=None, baseline_id=None, idempotency_key=None, budget=None, alerts=None, add_to_project=None, rows=None, destination=None, sentiment=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.overview(kind=None)` | Everything on a schedule — projects and monitors — with next and last run, this month's spend, an estimate and caps | +| `monitors.update(id, name=None, schedule=None, status=None, webhook=None, budget=, alerts=, add_to_project=, rows=, destination=, sentiment=)` | 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 | +| `monitors.rows(id, active=None, q=None)` · `monitors.row(id, key)` · `monitors.row_events(id, run_id=None, kind=None)` · `monitors.export_rows(id, path, dataset="rows", fmt="csv")` | An agent monitor's records, one record with its history, what moved, and all of it as a file | + +```python +plans = arc.monitors.create( + "agent", {"prompt": "List example.com's plans with their monthly price", "urls": ["https://example.com/pricing"], + "allowedDomains": ["example.com"]}, "weekly", + budget={"perRun": 300, "perMonth": 1500}, # credits; 0 or left out is no cap + alerts={"email": True, "chatUrl": "https://hooks.slack.com/services/..."}, + rows={"path": "plans", "key": "name"}, # follow the answer's list, plan by plan +) +for row in arc.monitors.rows(plans["id"]): + print(row["key"], row["attrs"], row["sources"]) # its columns, and the page each was read on +for event in arc.monitors.row_events(plans["id"]): # added, changed (before and after), gone + print(event["kind"], event["key"]) +``` + +`budget` caps each run (`perRun` lowers an agent monitor's `maxCredits`) and the month (a run due past `perMonth` is skipped, and the webhook hears `budget.reached` once a month). `alerts` sends the workspace's run emails and a chat message (Slack, Discord, Teams or Google Chat incoming webhook) when a run finds something. A search monitor's `add_to_project` adds its new results on that project's site to the project; an agent monitor's `rows` follows a list answer record by record, and `destination` writes the records out after each run. A news monitor's `sentiment={"connectionId": ..., "only": ["negative"]}` scores each run's new results with one of the workspace's LLM connections (1 credit per 1,000 tokens) and keeps chat and email to those tones. `update` replaces `budget`, `alerts` and `sentiment` whole (`{}` for none); `add_to_project=""` and `destination=""` remove them. 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). +## The agent over a table + +**Not live on mesharc.dev yet:** everything in this section needs an API with these features, which are on the API's feature branch. + +```python +table = "Company,Website\nAcme,acme.com\nGlobex,globex.com\n" +ask = dict(csv=table, max_credits_per_row=300, urls=["{{Website}}"], + schema={"type": "object", "properties": {"plan": {"type": "string"}, "price_month": {"type": "number"}}}) +print(arc.agent_batches.preview("What does {{Company}} charge for its cheapest plan?", **ask)["estimate"]) +batch = arc.agent_batches.start("What does {{Company}} charge for its cheapest plan?", **ask, concurrency=3) +arc.agent_batches.wait(batch["id"]) +for row in arc.agent_batches.rows(batch["id"]): + print(row["values"]["Company"], row["status"], row["data"]) +arc.agent_batches.export(batch["id"], "prices.csv") +``` + +One agent run a row: the prompt and starting URLs filled from the row's columns, written `{{column}}`, and the answer — shaped by `schema`, of type object — the row's new columns. Send the table as `csv` or as `rows`, a list of objects. `max_credits_per_row` is required: each row stops there and pays only for what it used, and the batch never spends more than rows × the cap, or your balance when it started if that was less — a row it can no longer afford is `skipped`, free. Without `allowed_domains` each row stays on its own URLs' sites; `[]` lifts the limit. `preview` checks everything and estimates the cost without starting anything. `login_id` and `data_connections` are as `agent()` takes them, for every row. + +| Method | What it does | +|---|---| +| `agent_batches.preview(prompt, csv=, rows=, max_credits_per_row=, ...)` | What would run, the rows that cannot and why, the first prompts, and the estimate; free | +| `agent_batches.start(prompt, ..., webhook=None, idempotency_key=None)` | Start it; `webhookSecret` comes back once, and the webhook hears `agent.batch.finished` once | +| `agent_batches.get(id, offset=0, limit=50, status=None)` · `agent_batches.rows(id, status=None)` · `agent_batches.wait(id)` | One page of rows; every row; block until it ends | +| `agent_batches.list()` · `agent_batches.cancel(id)` · `agent_batches.export(id, path, fmt="csv")` | The batches; stop one; the table with its answers as a file (a CSV cell that would start a formula is written as text) | + +A batch is kept 7 days, then `get` raises `MeshArcError` 410 `expired`. + +## Triggers, proposals and what a month costs + +**Not live on mesharc.dev yet:** everything in this section needs an API with these features, which are on the API's feature branch. + +```python +arc.triggers.create( + {"kind": "project", "id": project["id"]}, + {"type": "agent", "prompt": "Summarise what changed on {{url}}", "maxCredits": 300}, + when={"kinds": ["modified"], "paths": ["/pricing*"]}, + limits={"cooldownMinutes": 60, "maxPerDay": 5}, +) +for p in arc.proposals.list(): # what agents proposed to watch, waiting + print(p["kind"], p["name"], p["estimate"]) + arc.proposals.approve(p["id"]) # or arc.proposals.dismiss(p["id"]) +print(arc.estimate("agent", "weekly", max_credits=300)) # about what a month costs, before saving +``` + +| Method | What it does | +|---|---| +| `triggers.create(source, action, when=None, limits=None, name=None, status=None)` | Start an agent run, a re-crawl, a monitor run or a re-check when a project's changes or a monitor's run match | +| `triggers.list(source_id=None, target_id=None)` · `triggers.get(id)` · `triggers.update(id, ...)` · `triggers.delete(id)` | The triggers | +| `triggers.fires(id)` · `triggers.preview(id)` | What each source run matched and started (kept 90 days); what the latest run would make of it, firing nothing | +| `proposals.list(status="proposed", run_id=None)` · `proposals.approve(id)` · `proposals.dismiss(id)` | Approve makes the project or monitor (a monitor's first run starts now) | +| `estimate(kind, schedule, project_id=, monitor_id=, max_pages=, max_tier=, max_credits=, month_cap=)` | About how many credits a crawl, search or agent on a schedule costs a month; `upTo` when nothing has run yet | + +## Sub-accounts + +**Not live on mesharc.dev yet:** everything in this section needs an API with these features, which are on the API's feature branch. + +A workspace on Scale or Enterprise can make workspaces of its own for its customers: each with its own keys, projects and data, spending this workspace's credits under a cap of its own. Every call needs an admin key. + +```python +acme = arc.sub_accounts.create("Acme Ltd", monthly_cap=50_000, external_id="cust-42", idempotency_key="cust-42") +key = arc.sub_accounts.create_key(acme["id"], name="acme", scopes=["read", "write"]) # key["key"], once: for Acme + +with arc.as_sub_account(acme["id"]) as acme_arc: # your key, acting on Acme: X-MeshArc-Tenant on every request + acme_arc.scrape("https://example.com") +arc.sub_accounts.update(acme["id"], suspended=True, reason="unpaid") # refuses its keys, cancels what it runs +``` + +| Method | What it does | +|---|---| +| `sub_accounts.list()` · `sub_accounts.get(id)` | `{data, allowed, spent, remaining}`; one with its month, spend by kind, charges, and your keys' access | +| `sub_accounts.create(name, monthly_cap=None, external_id=None, idempotency_key=None)` · `sub_accounts.update(id, name=, monthly_cap=, suspended=, reason=)` | Make one; rename, cap (0: none), suspend and lift | +| `sub_accounts.keys(id)` · `sub_accounts.create_key(id, ...)` · `sub_accounts.revoke_key(id, key_id)` | Its own keys, for your customer | +| `as_sub_account(id)` · `MeshArc(key, tenant=id)` | A client whose every request acts on that sub-account | + +## Connections and saved logins + +**Not live on mesharc.dev yet:** everything in this section needs an API with these features, which are on the API's feature branch. + +```python +pdl = arc.connections.create("pdl", name="Company data", secrets={"api_key": "..."}) +print(arc.connections.test(pdl["id"])) # {status: healthy | failing, error, detail} +print(arc.logins.list()["enabled"]) # saved logins: made in the app, from a live browser +``` + +`connections` is the workspace's connections — destinations, models and data providers: `providers()`, `list()`, `create(provider, name=, config=, secrets=)`, `get(id)`, `update(id, ...)`, `delete(id)` and `test(id)`. Secrets are sent and never returned. `logins` lists the workspace's saved logins (`list()`), and `update(id, name=, expired=)`, `test(id)` and `delete(id)` them; a login is saved in the app, from a live browser, and its session is never returned. Both need an admin key. + ## Watching a site: projects and runs ```python @@ -373,7 +509,7 @@ Two more cases: a network failure or a request that hits `timeout` raises `MeshA ## Idempotency and timeouts -- `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. +- `scrape`, `scrape_one`, `crawl`, `web_search`, `agent`, `AgentRun.continue_`, `AgentRun.follow_up`, `monitors.create`, `monitors.run`, `agent_batches.start` and `sub_accounts.create` 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`, `continue_agent` and `create_monitor` 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`. @@ -405,11 +541,11 @@ 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`, `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. +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`, `continue_agent`, `create_monitor`, `list_monitors`, `pause_monitor` and `get_monitor_rows` (the last six not live on mesharc.dev yet: they need the API's agent and monitor features, on its feature branch). `create_monitor` keeps a web search or an agent question on a schedule, each run compared with the last; `list_monitors` shows them and their runs, `pause_monitor` pauses and resumes one, and `get_monitor_rows` reads the records an agent monitor follows. `create_project` takes `max_credits_per_run` and `max_credits_per_month`. `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. +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`, `get_job`, `list_monitors` and `get_monitor_rows` work; the thirteen that fetch or write -- `scrape_urls`, `map_site`, `crawl_site`, `search_web`, `run_agent`, `continue_agent`, `create_monitor`, `pause_monitor`, `create_project`, `update_project`, `delete_project`, `start_run`, `cancel_job` -- answer `{"code": "read_only"}` with what to do about it, until the app is reconnected with 'Also allow changes' ticked (write access). `delete_project` needs more than write: an admin API key, which a connected app never holds, so it only works run locally with such a key. Hosted, a tool that would hold a connection open for minutes — a crawl, a run you asked to wait for, a multi-URL scrape, a web search — hands back a job after `MESHARC_MCP_WAIT` seconds (25 by default, because many MCP hosts time a tool call out sooner). The work carries on server-side; `get_job` picks it up and `cancel_job` stops a crawl, run, batch or agent run. A search that scrapes, with its results in and its pages still landing, answers with the results and a job for the pages. Run locally, those tools block as they always have. diff --git a/mesharc/__init__.py b/mesharc/__init__.py index 9a3133a..a60ea90 100644 --- a/mesharc/__init__.py +++ b/mesharc/__init__.py @@ -34,6 +34,7 @@ __version__ = "0.6.0" __all__ = ["MeshArc", "MeshArcError", "MeshArcTimeoutError", "Crawl", "AgentRun"] +TENANT_HEADER = "X-MeshArc-Tenant" DEFAULT_BASE = "https://api.mesharc.dev" DEFAULT_TIMEOUT = 150.0 @@ -102,15 +103,66 @@ def _cancel_agent(http: Any, run_id: str) -> Json: return http("DELETE", f"/agent/{run_id}") +def _some(**fields: Any) -> Json: + """The fields that are not None, as a request body or query: what was + left out is left to the API's default (or, in a PATCH, left as it is).""" + return {k: v for k, v in fields.items() if v is not None} + + +def _agent_body(prompt: str, urls: Optional[Iterable[str]], schema: Optional[Json], max_credits: Optional[int], + max_steps: Optional[int], allowed_domains: Optional[Iterable[str]], webhook: Union[str, Json, None], + connection_id: Optional[str], login_id: Optional[str], data_connections: Optional[Iterable[str]], + proposals: Optional[bool], timeout_s: float) -> Json: + """A POST /agent body (and a follow-up's) in the API's own names, naming only what was given.""" + body: Json = {"prompt": prompt} + body.update(_some(schema=schema, maxCredits=max_credits, maxSteps=max_steps, webhook=webhook, + connectionId=connection_id, loginId=login_id, proposals=proposals)) + if urls is not None: + body["urls"] = list(urls) + if allowed_domains is not None: + body["allowedDomains"] = list(allowed_domains) + if data_connections is not None: + body["dataConnections"] = list(data_connections) + if timeout_s > 0: + body["timeout"] = min(timeout_s, 120) + return body + + +def _offset_rows(http: Any, path: str, params: Dict[str, Any], limit: int) -> Iterator[Json]: + """Every row of an offset-paged list, ``limit`` at a time, until a page + comes back short.""" + offset = 0 + while True: + page = http("GET", path, params={**params, "offset": offset, "limit": limit}) + rows = page.get("data") or [] + for row in rows: + yield row + if len(rows) < limit: + return + offset += len(rows) + + +def _download(http: Any, method: str, path: str, dest: str, **kw: Any) -> str: + """Stream an API file to ``dest``. Returns ``dest``.""" + with http.stream(method, path, **kw) as r: + if r.status_code >= 400: + r.read() + raise http._error(r) + with open(dest, "wb") as fh: + for chunk in r.iter_bytes(): + fh.write(chunk) + return dest + + class _Http: """The transport: one httpx client, bearer auth, JSON errors, retries.""" - def __init__(self, api_key: str, base_url: str, timeout: float, max_retries: int) -> None: - self._c = httpx.Client( - base_url=base_url.rstrip("/") + "/api/v1", - headers={"Authorization": f"Bearer {api_key}", "User-Agent": f"mesharc-python/{__version__}"}, - timeout=timeout, - ) + def __init__(self, api_key: str, base_url: str, timeout: float, max_retries: int, tenant: str = "") -> None: + headers = {"Authorization": f"Bearer {api_key}", "User-Agent": f"mesharc-python/{__version__}"} + if tenant: + # Every request acts on that sub-account of the key's workspace. + headers[TENANT_HEADER] = tenant + self._c = httpx.Client(base_url=base_url.rstrip("/") + "/api/v1", headers=headers, timeout=timeout) self._max_retries = max(0, int(max_retries)) # What the last response said about the key's rate limit, so a # polling loop can slow down before it is refused rather than after. @@ -270,7 +322,9 @@ def __init__(self, http: _Http) -> None: 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: + idempotency_key: Optional[str] = None, budget: Optional[Json] = None, alerts: Optional[Json] = None, + add_to_project: Optional[str] = None, rows: Union[bool, Json, None] = None, + destination: Optional[str] = None, sentiment: Union[bool, Json, None] = 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 @@ -294,12 +348,25 @@ def create(self, kind: str, request: Json, schedule: str, name: Optional[str] = again; without it the first run starts now. A search baseline's ``limit`` must be at least the monitor's. + ``budget`` is ``{"perRun", "perMonth"}`` in credits (0 or left out: + no cap): ``perRun`` lowers an agent monitor's ``maxCredits``, and a + run due past ``perMonth`` is skipped, the webhook told + ``budget.reached`` once a month. ``alerts`` is ``{"email", + "chatUrl"}``: the workspace's run emails and a Slack, Discord, Teams + or Google Chat incoming webhook. ``add_to_project`` (a search + monitor's) adds its new results on that project's site to the + project. ``rows`` (an agent monitor's) follows a list answer record + by record -- ``True``, or ``{"path", "key", "confirmAfter"}`` -- read + with ``rows()``; ``destination`` writes those records out after each + run. ``sentiment`` (a news monitor's) is ``{"connectionId", "only"}``: + each run's new results scored positive, neutral or negative by that + LLM connection, on your key at 1 credit per 1,000 tokens. + 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 + body.update(_some(name=name, webhook=webhook, baselineId=baseline_id, budget=budget, alerts=alerts, + addToProject=add_to_project, rows=rows, destination=destination, sentiment=sentiment)) return self._h("POST", "/monitors", json=body, idempotency_key=idempotency_key) def list(self, kind: Optional[str] = None) -> List[Json]: @@ -312,13 +379,20 @@ def get(self, monitor_id: str) -> Json: 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: + status: Optional[str] = None, webhook: Union[str, Json, None] = None, + budget: Optional[Json] = None, alerts: Optional[Json] = None, add_to_project: Optional[str] = None, + rows: Union[bool, Json, None] = None, destination: Optional[str] = None, + sentiment: Union[bool, 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} + ``paused``), ``webhook`` or any setting ``create`` takes. Only the + ones that are not None are sent; ``webhook=""`` removes the webhook, + and a new one's secret comes back once, as ``webhookSecret``. + ``budget``, ``alerts`` and ``sentiment`` are replaced whole (``{}`` + for none); ``add_to_project=""`` and ``destination=""`` remove them; + a new ``rows`` path or key starts the records again. Needs a key that + can write.""" + body = _some(name=name, schedule=schedule, status=status, webhook=webhook, budget=budget, alerts=alerts, + addToProject=add_to_project, rows=rows, destination=destination, sentiment=sentiment) return self._h("PATCH", f"/monitors/{monitor_id}", json=body) def pause(self, monitor_id: str) -> Json: @@ -354,6 +428,360 @@ def delete(self, monitor_id: str) -> Json: stopped. Returns ``{id, deleted: true}``.""" return self._h("DELETE", f"/monitors/{monitor_id}") + def overview(self, kind: Optional[str] = None) -> Json: + """Everything on a schedule -- projects and monitors -- as the Monitor + screen lists it: each with its next and last run, this month's + spend, an estimate of the month and its caps; ``counts`` by kind. + ``kind`` keeps ``crawl``, ``search`` or ``agent``.""" + return self._h("GET", "/monitors/overview", params=_some(kind=kind) or None) + + def rows(self, monitor_id: str, active: Optional[bool] = None, q: Optional[str] = None, + limit: int = 100) -> Iterator[Json]: + """The records an agent monitor's answers list, as they stand, each + with the page every value was read on. ``active=False`` lists the + records gone; ``q`` keeps those holding it. ``limit`` is the page + size (1,000 at most).""" + params = _some(active=None if active is None else str(bool(active)).lower(), q=q) + yield from _offset_rows(self._h, f"/monitors/{monitor_id}/rows", params, limit) + + def rows_page(self, monitor_id: str, active: Optional[bool] = None, q: Optional[str] = None, + offset: int = 0, limit: int = 100) -> Json: + """One page of ``rows()``, as the API answers it: ``{data, total}``, + ``total`` counting every record that matches.""" + params = _some(active=None if active is None else str(bool(active)).lower(), q=q) + return self._h("GET", f"/monitors/{monitor_id}/rows", params={**params, "offset": offset, "limit": limit}) + + def row(self, monitor_id: str, key: str) -> Json: + """One record: what it is now, the page each value was read on, and every event that touched it.""" + return self._h("GET", f"/monitors/{monitor_id}/rows/{key}") + + def row_events(self, monitor_id: str, run_id: Optional[str] = None, kind: Optional[str] = None, + limit: int = 200) -> List[Json]: + """What moved, newest first: records added, changed (one entry per + column, both values), gone and withheld. ``run_id`` keeps one + monitor run's; ``kind`` one kind.""" + return self._h("GET", f"/monitors/{monitor_id}/rows/events", + params={"limit": limit, **_some(run_id=run_id, kind=kind)})["data"] + + def export_rows(self, monitor_id: str, path: str, dataset: str = "rows", fmt: str = "csv") -> str: + """Its records (``rows``) or everything that moved (``row-events``) + to ``path``, as csv or jsonl. A CSV cell that would start a formula + is written as text. Returns ``path``.""" + return _download(self._h, "GET", f"/monitors/{monitor_id}/rows/export", path, + params={"dataset": dataset, "format": fmt}) + + +class _AgentBatches: + """The agent over every row of a table: ``arc.agent_batches``. + + One agent run a row, its prompt (and starting URLs) filled from the + row's columns, written ``{{column}}``, and its answer the row's new + columns. Each row pays only for what it uses, up to + ``max_credits_per_row``; the batch never spends more than rows x that + cap, or the balance when that is less -- a row it cannot afford is + skipped, free. + """ + + def __init__(self, http: _Http) -> None: + self._h = http + + @staticmethod + def _body(prompt: str, csv: Optional[str], rows: Optional[List[Json]], max_credits_per_row: Optional[int], + urls: Optional[Iterable[str]], schema: Optional[Json], max_steps: Optional[int], + allowed_domains: Optional[Iterable[str]], concurrency: Optional[int], name: Optional[str], + connection_id: Optional[str], login_id: Optional[str], + data_connections: Optional[Iterable[str]]) -> Json: + body: Json = {"prompt": prompt} + body.update(_some(csv=csv, rows=rows, maxCreditsPerRow=max_credits_per_row, schema=schema, + maxSteps=max_steps, concurrency=concurrency, name=name, connectionId=connection_id, + loginId=login_id)) + if urls is not None: + body["urls"] = list(urls) + if allowed_domains is not None: + body["allowedDomains"] = list(allowed_domains) + if data_connections is not None: + body["dataConnections"] = list(data_connections) + return body + + def preview(self, prompt: str, csv: Optional[str] = None, rows: Optional[List[Json]] = None, + max_credits_per_row: Optional[int] = None, urls: Optional[Iterable[str]] = None, + schema: Optional[Json] = None, max_steps: Optional[int] = None, + allowed_domains: Optional[Iterable[str]] = None, concurrency: Optional[int] = None, + name: Optional[str] = None, connection_id: Optional[str] = None, login_id: Optional[str] = None, + data_connections: Optional[Iterable[str]] = None) -> Json: + """What ``start`` would do, without starting it or spending anything: + the rows that can run and those that cannot (and why), the first + prompts as their rows will ask them, and an estimate against the + balance.""" + return self._h("POST", "/agent/batch/preview", json=self._body( + prompt, csv, rows, max_credits_per_row, urls, schema, max_steps, allowed_domains, concurrency, name, + connection_id, login_id, data_connections)) + + def start(self, prompt: str, csv: Optional[str] = None, rows: Optional[List[Json]] = None, + max_credits_per_row: Optional[int] = None, urls: Optional[Iterable[str]] = None, + schema: Optional[Json] = None, max_steps: Optional[int] = None, + allowed_domains: Optional[Iterable[str]] = None, concurrency: Optional[int] = None, + name: Optional[str] = None, connection_id: Optional[str] = None, login_id: Optional[str] = None, + data_connections: Optional[Iterable[str]] = None, webhook: Union[str, Json, None] = None, + idempotency_key: Optional[str] = None) -> Json: + """Start one agent run a row of the table. + + The table is ``csv`` (its first line names the columns) or ``rows``, + a list of objects. ``prompt`` and ``urls`` name columns as + ``{{column}}``; ``schema`` (type ``object``) gives the answer's + columns. ``max_credits_per_row`` is required by the API. Without + ``allowed_domains`` each row is held to its own URLs' sites; ``[]`` + lifts the limit. ``concurrency`` rows run at once (3 by default). + ``login_id`` and ``data_connections`` are as ``agent()`` takes them, + for every row. ``webhook`` is told ``agent.batch.finished`` once; its + secret comes back once, as ``webhookSecret``. + + Spends credits and needs a key that can write. Returns the batch, + with ``counts`` by row status; ``wait()`` waits for it. + """ + body = self._body(prompt, csv, rows, max_credits_per_row, urls, schema, max_steps, allowed_domains, + concurrency, name, connection_id, login_id, data_connections) + if webhook is not None: + body["webhook"] = webhook + return self._h("POST", "/agent/batch", json=body, idempotency_key=idempotency_key) + + def get(self, batch_id: str, offset: int = 0, limit: int = 50, status: Optional[str] = None) -> Json: + """A batch and one page of its rows in table order (200 at most), each + with its status, answer, sources, credits and run. ``status`` keeps + one status. Raises ``MeshArcError`` 410 (``expired``) past its keep + date.""" + return self._h("GET", f"/agent/batch/{batch_id}", + params={"offset": offset, "limit": limit, **_some(status=status)}) + + def rows(self, batch_id: str, status: Optional[str] = None, limit: int = 200) -> Iterator[Json]: + """Every row of a batch, in table order; ``status`` keeps one status.""" + offset = 0 + while True: + page = self.get(batch_id, offset=offset, limit=limit, status=status) + rows = page.get("data") or [] + for row in rows: + yield row + if not page.get("more") or not rows: + return + offset += len(rows) + + def wait(self, batch_id: str, poll: float = 5.0, timeout: float = 3600) -> Json: + """Block until every row has ended. Returns the batch (its first rows under ``data``).""" + deadline = time.time() + timeout + while True: + batch = self.get(batch_id, limit=1) + if not _running(batch.get("status")): + return batch + if time.time() >= deadline: + raise MeshArcTimeoutError(f"agent batch {batch_id} is still {batch.get('status')} after {timeout}s", + batch_id) + time.sleep(poll) + self._h.pace() + + def list(self, limit: int = 25) -> Iterator[Json]: + """The workspace's batches, newest first, without their rows.""" + yield from _paged(self._h, "/agent/batch", {"limit": limit}) + + def cancel(self, batch_id: str) -> Json: + """Stop it: rows not started are cancelled, running ones stop before + their next step. What rows used stays charged; their answers are kept.""" + return self._h("DELETE", f"/agent/batch/{batch_id}") + + def export(self, batch_id: str, path: str, fmt: str = "csv") -> str: + """The table as sent, each answer value a new column, then each row's + credits, sources, error and run, to ``path`` as csv or jsonl. A CSV + cell that would start a formula is written as text. Returns ``path``.""" + return _download(self._h, "GET", f"/agent/batch/{batch_id}/export", path, params={"format": fmt}) + + +class _Triggers: + """A change or a new result starts work: ``arc.triggers``.""" + + def __init__(self, http: _Http) -> None: + self._h = http + + def create(self, source: Json, action: Json, when: Optional[Json] = None, limits: Optional[Json] = None, + name: Optional[str] = None, status: Optional[str] = None) -> Json: + """Start work when a source matches. + + ``source`` is ``{"kind": "project" | "monitor", "id"}``. ``when`` is + a project's ``{kinds, paths, fields, minWords, significant}`` or a + monitor's ``{events}``. ``action`` is ``{"type": "agent", "prompt", + ...}`` (a template: ``{{url}}``, ``{{before}}``, ``{{after}}``, + ``{{changes}}``, ``{{source}}``), ``{"type": "recrawl"}``, or + ``{"type": "monitor" | "recheck", "monitorId"}``. ``limits`` is + ``{cooldownMinutes, maxPerDay, perMonth}``. It looks from now on. + Needs a key that can write.""" + body: Json = {"source": source, "action": action} + body.update(_some(when=when, limits=limits, name=name, status=status)) + return self._h("POST", "/triggers", json=body) + + def list(self, source_id: Optional[str] = None, target_id: Optional[str] = None) -> List[Json]: + """The workspace's triggers, newest first; ``source_id`` keeps one + project's or monitor's, ``target_id`` those whose action runs a monitor.""" + return self._h("GET", "/triggers", params=_some(source_id=source_id, target_id=target_id) or None)["data"] + + def get(self, trigger_id: str) -> Json: + return self._h("GET", f"/triggers/{trigger_id}") + + def update(self, trigger_id: str, name: Optional[str] = None, when: Optional[Json] = None, + action: Optional[Json] = None, limits: Optional[Json] = None, status: Optional[str] = None) -> Json: + """Its name, condition, action, limits or status (``active`` / + ``paused``); only what is not None is sent. Resumed, it looks from then on.""" + return self._h("PATCH", f"/triggers/{trigger_id}", + json=_some(name=name, when=when, action=action, limits=limits, status=status)) + + def delete(self, trigger_id: str) -> Json: + """The trigger and its firing history. What it started stays.""" + return self._h("DELETE", f"/triggers/{trigger_id}") + + def fires(self, trigger_id: str, limit: int = 25) -> Iterator[Json]: + """Its firing history, newest first: what each source run matched, and + what it started or why it was skipped. Kept 90 days.""" + yield from _paged(self._h, f"/triggers/{trigger_id}/fires", {"limit": limit}) + + def preview(self, trigger_id: str) -> Json: + """What the source's latest run would make of it -- what matches, and + an agent action's prompt as it would be sent. Fires nothing.""" + return self._h("POST", f"/triggers/{trigger_id}/preview") + + +class _Proposals: + """What agents proposed to watch, for a member to approve: ``arc.proposals``.""" + + def __init__(self, http: _Http) -> None: + self._h = http + + def list(self, status: str = "proposed", run_id: Optional[str] = None) -> List[Json]: + """Newest first: those waiting (``proposed``, the default), the + ``approved``, or all (``""``); ``run_id`` keeps one agent run's.""" + return self._h("GET", "/proposals", params={"status": status, **_some(run_id=run_id)})["data"] + + def approve(self, proposal_id: str) -> Json: + """Make what was proposed: the project, or the monitor, whose first + run starts now. Returns ``{proposal, made: {kind, id}}``. Raises + ``MeshArcError`` 409 when it was decided already, 402 at the project + limit or with no credits left.""" + return self._h("POST", f"/proposals/{proposal_id}/approve") + + def dismiss(self, proposal_id: str) -> Json: + """Not wanted: the proposal is deleted.""" + return self._h("POST", f"/proposals/{proposal_id}/dismiss") + + +class _SubAccounts: + """Workspaces this one makes for its own customers: ``arc.sub_accounts``. + + Each has its own keys, projects and data, spends this workspace's + credits under a cap of its own, and is on this workspace's plan (Scale + and Enterprise). Acting inside one: ``arc.as_sub_account(id)``. Every + call needs an admin key. + """ + + def __init__(self, http: _Http) -> None: + self._h = http + + def list(self) -> Json: + """``{data, allowed, spent, remaining}``: each sub-account with this + month's spend, how many the plan allows, and what they spent in all.""" + return self._h("GET", "/orgs/children") + + def create(self, name: str, monthly_cap: Optional[int] = None, external_id: Optional[str] = None, + idempotency_key: Optional[str] = None) -> Json: + """A sub-account; ``monthly_cap`` in credits (0 or None: no cap of + its own), ``external_id`` your own id for it, echoed back.""" + return self._h("POST", "/orgs/children", idempotency_key=idempotency_key, + json={"name": name, **_some(monthlyCap=monthly_cap, externalId=external_id)}) + + def get(self, child_id: str) -> Json: + """One sub-account: its month, its spend by kind, its latest charges, + and which of your keys acted on it these 30 days.""" + return self._h("GET", f"/orgs/children/{child_id}") + + def update(self, child_id: str, name: Optional[str] = None, monthly_cap: Optional[int] = None, + suspended: Optional[bool] = None, reason: Optional[str] = None) -> Json: + """Rename it, set its cap (0: none), or suspend it -- which refuses its + keys and cancels what it has running -- and lift that.""" + return self._h("PATCH", f"/orgs/children/{child_id}", + json=_some(name=name, monthlyCap=monthly_cap, suspended=suspended, reason=reason)) + + def keys(self, child_id: str) -> List[Json]: + return self._h("GET", f"/orgs/children/{child_id}/keys") + + def create_key(self, child_id: str, name: str = "default", scopes: Optional[List[str]] = None, + expires_in_days: Optional[int] = None, rpm: Optional[int] = None) -> Json: + """A key of the sub-account's own, for your customer: it reaches that + workspace and nothing else. The plaintext is under ``key``, once.""" + return self._h("POST", f"/orgs/children/{child_id}/keys", + json={"name": name, **_some(scopes=scopes, expires_in_days=expires_in_days, rpm=rpm)}) + + def revoke_key(self, child_id: str, key_id: str) -> None: + self._h("DELETE", f"/orgs/children/{child_id}/keys/{key_id}") + + +class _Connections: + """The workspace's connections -- destinations, models and data + providers: ``arc.connections``. Secrets are sent, never returned. + Creating, changing, testing and deleting need an admin key.""" + + def __init__(self, http: _Http) -> None: + self._h = http + + def providers(self) -> Json: + """The catalogue: each provider's kind, fields, secrets and note.""" + return self._h("GET", "/connections/providers") + + def list(self) -> List[Json]: + return self._h("GET", "/connections")["connections"] + + def create(self, provider: str, name: Optional[str] = None, config: Optional[Json] = None, + secrets: Optional[Json] = None) -> Json: + """A connection: ``provider`` from ``providers()`` (``"pdl"`` and + ``"fmp"`` are the data providers the agent reads, with + ``secrets={"api_key": ...}``). Test it with ``test()``.""" + return self._h("POST", "/connections", json={"provider": provider, **_some(name=name, config=config, + secrets=secrets)}) + + def get(self, connection_id: str) -> Json: + return self._h("GET", f"/connections/{connection_id}") + + def update(self, connection_id: str, name: Optional[str] = None, config: Optional[Json] = None, + secrets: Optional[Json] = None) -> Json: + return self._h("PATCH", f"/connections/{connection_id}", json=_some(name=name, config=config, secrets=secrets)) + + def delete(self, connection_id: str) -> None: + self._h("DELETE", f"/connections/{connection_id}") + + def test(self, connection_id: str) -> Json: + """Reach the provider with the stored credentials: ``{status, error, detail}``.""" + return self._h("POST", f"/connections/{connection_id}/test") + + +class _Logins: + """The workspace's saved logins: ``arc.logins``. A login is saved in the + app, from a live browser; here it is listed, renamed, expired, tested + and deleted. Its session is never returned. Every call needs an admin key.""" + + def __init__(self, http: _Http) -> None: + self._h = http + + def list(self) -> Json: + """``{data, enabled, why}``: each login's site, status and use, and + whether logins are switched on where this API runs.""" + return self._h("GET", "/logins") + + def update(self, login_id: str, name: Optional[str] = None, expired: Optional[bool] = None) -> Json: + """Rename it; ``expired=True`` stops using it now.""" + return self._h("PATCH", f"/logins/{login_id}", json=_some(name=name, expired=expired)) + + def delete(self, login_id: str) -> None: + """Delete it: projects and runs that name it stop, saying so.""" + self._h("DELETE", f"/logins/{login_id}") + + def test(self, login_id: str) -> Json: + """One plain fetch of the site's front page with it: ``{status, finalUrl, loginPage, reason}``. Free.""" + return self._h("POST", f"/logins/{login_id}/test") + class Crawl: """A crawl started by ``arc.crawl(url)``: a handle on a running job. @@ -480,7 +908,9 @@ def data(self) -> Any: @property def sources(self) -> List[Json]: - """The pages the answer rests on, as ``{url, title, pageId}``.""" + """What the answer rests on: pages, as ``{url, title, pageId}``, and + data providers' records, as ``{url, title, provider}`` -- ``url`` the + request it came from.""" return self.envelope.get("sources") or [] @property @@ -575,12 +1005,34 @@ def continue_(self, max_credits: Optional[int] = None, max_steps: Optional[int] run._webhook_secret = run._webhook_secret or self.webhook_secret return run + def follow_up(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, login_id: Optional[str] = None, + data_connections: Optional[Iterable[str]] = None, proposals: Optional[bool] = None, + timeout_s: float = 0, idempotency_key: Optional[str] = None) -> AgentRun: + """Ask a follow-up in this run's conversation; returns its ``AgentRun``. + + It answers after everything asked and found in the conversation + before it, and may read again, free, any page the conversation read. + Its request is its own, as ``agent()`` takes one, and it pays for its + own work. This run must be the conversation's latest message, and + finished: ``MeshArcError`` 409 (``conflict``) otherwise -- the error + names the latest -- or past 30 messages; 410 (``expired``) past its + keep date. + """ + body = _agent_body(prompt, urls, schema, max_credits, max_steps, allowed_domains, webhook, connection_id, + login_id, data_connections, proposals, timeout_s) + return AgentRun(self._h, self._h("POST", f"/agent/{self.id}/follow-up", json=body, + idempotency_key=idempotency_key)) + 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`` + map, select, extract, lookup (a data provider's), propose, 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 @@ -629,19 +1081,39 @@ class MeshArc: ``timeout`` is the HTTP timeout per request in seconds; ``max_retries`` how many times a request that is safe to repeat is retried on 429, 502, 503, 504 or a network failure. ``base_url`` is for MeshArc's own - test environments; the hosted API needs nothing there. + test environments; the hosted API needs nothing there. ``tenant`` is + the id of one of the workspace's sub-accounts: every request then acts + on it (``as_sub_account`` makes such a client from this one). """ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, - timeout: float = DEFAULT_TIMEOUT, max_retries: int = DEFAULT_MAX_RETRIES) -> None: + timeout: float = DEFAULT_TIMEOUT, max_retries: int = DEFAULT_MAX_RETRIES, + tenant: Optional[str] = None) -> None: key = api_key or os.environ.get("MESHARC_API_KEY") or "" if not key: raise ValueError("An API key is required: MeshArc('mesharc_...') or set MESHARC_API_KEY.") base = base_url or os.environ.get("MESHARC_API_URL") or DEFAULT_BASE - self._h = _Http(key, base, timeout, max_retries) + # What as_sub_account() makes its client with. + self._key, self._base, self._timeout, self._max_retries = key, base, timeout, max_retries + self.tenant: str = tenant or "" + self._h = _Http(key, base, timeout, max_retries, tenant=self.tenant) self.projects = _Projects(self._h) self.runs = _Runs(self._h) self.monitors = _Monitors(self._h) + self.agent_batches = _AgentBatches(self._h) + self.triggers = _Triggers(self._h) + self.proposals = _Proposals(self._h) + self.sub_accounts = _SubAccounts(self._h) + self.connections = _Connections(self._h) + self.logins = _Logins(self._h) + + def as_sub_account(self, sub_account_id: str) -> "MeshArc": + """A client on the same key that acts on one of this workspace's + sub-accounts: each request carries ``X-MeshArc-Tenant``, and is the + sub-account's, with the key's own scopes and rate limit. Only a key + of the sub-account's parent, not held to some projects, may do this; + any other id is a 404. Each use is logged. Close it when done.""" + return MeshArc(self._key, self._base, self._timeout, self._max_retries, tenant=sub_account_id) def extract(self, url: str, config: Optional[Json] = None, wait: bool = True, poll: float = 2.0, timeout: float = 300) -> Json: @@ -861,7 +1333,8 @@ def agent(self, prompt: str, urls: Optional[Iterable[str]] = None, schema: Optio 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: + idempotency_key: Optional[str] = None, login_id: Optional[str] = None, + data_connections: Optional[Iterable[str]] = None, proposals: Optional[bool] = 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 @@ -882,29 +1355,50 @@ def agent(self, prompt: str, urls: Optional[Iterable[str]] = None, schema: Optio secret its messages are signed with comes back once, as ``AgentRun.webhook_secret``. + ``login_id`` is one of the workspace's saved logins: pages on its + site are read signed in, every other site signed out. + ``data_connections`` names up to two of the workspace's data + providers (one per provider): the agent may then look up company + data (People Data Labs) or financial data (Financial Modeling Prep) + on your own key, at no MeshArc credits, each record a source. + ``proposals=True`` lets it propose things to watch, which wait in + ``arc.proposals`` for a member to approve. + 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. + at most); ``AgentRun.wait()`` waits for it. ``AgentRun.follow_up()`` + asks the next question in the same conversation. """ - 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) + body = _agent_body(prompt, urls, schema, max_credits, max_steps, allowed_domains, webhook, connection_id, + login_id, data_connections, proposals, timeout_s) return AgentRun(self._h, self._h("POST", "/agent", json=body, idempotency_key=idempotency_key)) + def conversations(self, q: Optional[str] = None, limit: int = 25) -> Iterator[Json]: + """The workspace's agent conversations, the latest message first, each + ``{id, title, messages, status, lastRunId, creditsUsed, ...}``; ``q`` + keeps those whose first question holds it. Kept 7 days after the + last message. A table's rows are not listed.""" + yield from _paged(self._h, "/agent/conversations", {"limit": limit, **_some(q=q)}) + + def conversation(self, thread_id: str) -> Json: + """One conversation with every run in it, oldest first under ``runs``, + each as ``get_agent`` reads one. Raises ``MeshArcError`` 410 past its + keep date.""" + return self._h("GET", f"/agent/conversations/{thread_id}") + + def estimate(self, kind: str, schedule: str, project_id: Optional[str] = None, + monitor_id: Optional[str] = None, max_pages: Optional[int] = None, max_tier: Optional[str] = None, + max_credits: Optional[int] = None, month_cap: Optional[int] = None) -> Json: + """About how many credits scheduled work costs a month, before it is + saved: a ``crawl`` (``project_id``, or ``max_pages`` and ``max_tier`` + for one not made yet), a ``search`` or an ``agent`` (``monitor_id``, + or ``max_credits``), on an ``hourly``, ``daily`` or ``weekly`` + schedule, held to ``month_cap``. ``upTo`` says the figure is the most + a run could cost, nothing having run yet.""" + return self._h("GET", "/estimate", params={"kind": kind, "schedule": schedule, **_some( + projectId=project_id, monitorId=monitor_id, maxPages=max_pages, maxTier=max_tier, + maxCredits=max_credits, monthCap=month_cap)}) + def get_agent(self, run_id: str) -> AgentRun: """A handle on an agent run started earlier or elsewhere. @@ -1002,21 +1496,12 @@ def export(self, project_id: str, path: str, dataset: str = "pages", fmt: str = ``urls`` restricts the export to those pages. Returns ``path``. """ if urls: - ctx = self._h.stream("POST", f"/projects/{project_id}/export", - json={"dataset": dataset, "format": fmt, "run_id": run_id, "urls": list(urls)}) - else: - params: Dict[str, Any] = {"dataset": dataset, "format": fmt} - if run_id: - params["run_id"] = run_id - ctx = self._h.stream("GET", f"/projects/{project_id}/export", params=params) - with ctx as r: - if r.status_code >= 400: - r.read() - raise self._h._error(r) - with open(path, "wb") as fh: - for chunk in r.iter_bytes(): - fh.write(chunk) - return path + return _download(self._h, "POST", f"/projects/{project_id}/export", path, + json={"dataset": dataset, "format": fmt, "run_id": run_id, "urls": list(urls)}) + params: Dict[str, Any] = {"dataset": dataset, "format": fmt} + if run_id: + params["run_id"] = run_id + return _download(self._h, "GET", f"/projects/{project_id}/export", path, params=params) def me(self) -> Json: """The workspace, its plan and limits, and what this key may do.""" diff --git a/mesharc/mcp.py b/mesharc/mcp.py index 46f60fe..f37b187 100644 --- a/mesharc/mcp.py +++ b/mesharc/mcp.py @@ -88,6 +88,8 @@ "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. " + "create_monitor keeps a web search or an agent question and runs it again on a schedule, reporting what " + "changed; list_monitors, pause_monitor and get_monitor_rows follow and manage them. " "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." ) @@ -98,7 +100,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 -# seventeen nameless verbs. +# twenty-one nameless verbs. _TOOLS: "list[tuple]" = [] # What each tool does to the world, in the hints MCP clients read. A tool that @@ -479,6 +481,60 @@ class AgentOut(TypedDict, total=False): detail: Annotated[Any, Field(description=_DETAIL)] +@with_config(ConfigDict(extra="allow")) +class MonitorOut(TypedDict, total=False): + """One monitor: a search or an agent question kept on a schedule, and its latest runs.""" + id: Annotated[Any, Field(description="The monitor's id, for list_monitors, pause_monitor and get_monitor_rows.")] + kind: Annotated[Any, Field(description="'search' or 'agent'.")] + name: Annotated[Any, Field(description="Its name; the query or the prompt unless one was given.")] + schedule: Annotated[Any, Field(description="'hourly', 'daily' or 'weekly'.")] + status: Annotated[Any, Field(description="'active' or 'paused'. On an error, the HTTP status code.")] + request: Annotated[Any, Field(description="The search or agent request it runs, in the API's names.")] + budget: Annotated[Any, Field(description="{perRun, perMonth} in credits; 0 is no cap.")] + spentThisMonth: Annotated[Any, Field(description="What its runs cost this calendar month (UTC), in credits.")] + nextRunAt: Annotated[Any, Field(description="When it runs next, ISO 8601; null while paused.")] + lastRun: Annotated[Any, Field(description=( + "Its latest run: {status, changed, summary, credits, refId}; refId is the search or agent run it made."))] + rows: Annotated[Any, Field(description="When it follows its answer as records: {path, key, confirmAfter}.")] + note: Annotated[Any, Field(description="What happens next, or what was left out.")] + error: Annotated[Any, Field(description=_ERR)] + code: Annotated[Any, Field(description=_CODE)] + detail: Annotated[Any, Field(description=_DETAIL)] + + +@with_config(ConfigDict(extra="allow")) +class MonitorsOut(TypedDict, total=False): + """The workspace's monitors.""" + monitors: Annotated[Any, Field(description=( + "Each {id, kind, name, schedule, status, nextRunAt, lastRunAt, spentThisMonth, budget, lastRun: {status, " + "changed, summary}}, newest first. Absent with monitor_id."))] + monitor: Annotated[Any, Field(description=( + "With monitor_id only: the monitor in full -- its request, budget and records setting -- and under `runs` " + "its latest runs, newest first, each {id, status, trigger, changed, summary, credits, refId, createdAt}."))] + error: Annotated[Any, Field(description=_ERR)] + status: Annotated[Any, Field(description="Only on a failure: the HTTP status code.")] + code: Annotated[Any, Field(description=_CODE)] + detail: Annotated[Any, Field(description=_DETAIL)] + + +@with_config(ConfigDict(extra="allow")) +class MonitorRowsOut(TypedDict, total=False): + """The records an agent monitor follows, and what moved among them.""" + monitor_id: Annotated[Any, Field(description="The monitor read.")] + rows: Annotated[Any, Field(description=( + "The records as they stand, each {key, attrs (its columns), sources (the page each was read on), url, " + "active, withheld, firstSeen, lastSeen}, at most `limit`."))] + total: Annotated[Any, Field(description="How many records match, whatever `limit` returned.")] + events: Annotated[Any, Field(description=( + "With changes=true: what moved, newest first -- records added, a column changed (before and after), " + "records gone or withheld -- at most 50."))] + note: Annotated[Any, Field(description="What was left out and how to get it.")] + error: Annotated[Any, Field(description=_ERR)] + status: Annotated[Any, Field(description="Only on a failure: the HTTP status code.")] + 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): @@ -894,7 +950,7 @@ def _scopes(): return list(getattr(at, "scopes", None) or []) if at else [] -# Eleven of the seventeen tools need write, including every one that fetches a +# Thirteen of the twenty-one 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 -- @@ -1486,6 +1542,191 @@ def go(): return _safe(go) +MonitorId = Annotated[str, Field(description="The monitor's id, from list_monitors or create_monitor.")] +# What get_monitor_rows hands back at most: records past it are counted, not shown. +ROWS_CHARS = 60_000 +ROWS_MAX = 500 + + +def _monitor_row(m): + """A monitor as list_monitors shows it: what picks one, not its request.""" + last = m.get("lastRun") or {} + return {**{k: m.get(k) for k in ("id", "kind", "name", "schedule", "status", "nextRunAt", "lastRunAt", + "spentThisMonth", "budget")}, + "lastRun": {k: last.get(k) for k in ("status", "changed", "summary")} if last else None} + + +def _monitor_run(r): + return {k: r.get(k) for k in ("id", "status", "trigger", "changed", "summary", "credits", "refId", "createdAt", + "error") if r.get(k) not in (None, "")} + + +@tool(title="Keep a search or question on a schedule", + annotations=_acts(), + description="Keep a web search or an agent question and run it again on a schedule, each run compared with " + "the last one that answered: new, dropped and moved results for a search, the answer's added, " + "changed and removed values for a question.\n" + "Use it when the person asks to keep watching, monitoring or tracking something on the web -- a " + "brand's mentions, a ranking, a competitor's prices. To watch a whole site's pages use " + "create_project; to ask once use search_web or run_agent.\n" + "Inputs: kind 'search' with query (news=true for news results), or kind 'agent' with prompt and " + "optional urls, schema and allowed_domains (omitted, it stays on the sites of urls, as run_agent " + "does). schedule is hourly, daily or weekly. follow_rows=true follows a list answer record by " + "record (read with get_monitor_rows).\n" + "Cost: each run is charged as the search or agent run it is -- a results page 1 to 4 credits, " + "an agent run up to max_credits -- and the first run starts now. max_credits_per_month skips " + "runs once the month has spent that much.\n" + "Effect: it answers with the monitor; list_monitors shows its runs. pause_monitor stops it.\n" + "Needs write access; a read-only connection is refused with code 'read_only'.") +def create_monitor(kind: Annotated[Literal["search", "agent"], Field( + description="'search' keeps a web search; 'agent' keeps a research question.")], + schedule: Annotated[Literal["hourly", "daily", "weekly"], Field( + description="How often it runs again.")], + query: Annotated[str | None, Field( + description="kind 'search' only: what to search for, as typed into a search engine.")] = None, + news: Annotated[bool, Field( + description="kind 'search' only: true searches news results; default false.")] = False, + prompt: Annotated[str | None, Field( + description="kind 'agent' only: the question, 1 to 10,000 characters.")] = None, + urls: Annotated[list[str] | None, Field( + description="kind 'agent' only: up to 20 absolute URLs it starts from.")] = None, + allowed_domains: Annotated[list[str] | None, Field( + description="kind 'agent' only: sites it stays within; omitted, the sites of urls; [] any " + "site.")] = None, + schema: Annotated[dict | None, Field( + description="kind 'agent' only: a JSON Schema (type object or array) for the answer.")] = None, + max_credits: Annotated[int | None, Field( + description="kind 'agent' only: the most one run may spend, 1 to 100,000; omit for " + "2,000.")] = None, + follow_rows: Annotated[bool, Field( + description="kind 'agent' only: true follows a list answer record by record; default " + "false.")] = False, + max_credits_per_month: Annotated[int | None, Field( + description="The most its runs may spend in a calendar month, in credits; omit or 0 for " + "no cap.", ge=0)] = None, + name: Annotated[str | None, Field( + description="A display name; omit for the query or the prompt.")] = None) -> MonitorOut: + def go(): + if kind == "search": + if not (query or "").strip() or prompt or follow_rows: + return {"error": "a search monitor takes query, not prompt or follow_rows", "code": "validation"} + request: dict = {"query": query} + if news: + request["sources"] = ["news"] + else: + if not (prompt or "").strip() or query: + return {"error": "an agent monitor takes prompt, not query", "code": "validation"} + domains = allowed_domains if allowed_domains is not None else (_start_sites(urls) or None) + request = {"prompt": prompt, **{k: v for k, v in (("urls", urls), ("allowedDomains", domains), + ("schema", schema), ("maxCredits", max_credits)) + if v is not None}} + budget = {"perMonth": max_credits_per_month} if max_credits_per_month else None + # A retry in the same ten minutes lands on the same monitor. + key = _key("monitor", kind, schedule, request, follow_rows, max_credits_per_month, name, + int(time.time() // 600)) + with _client() as s: + m = s.monitors.create(kind, request, schedule, name=name, budget=budget, rows=True if follow_rows else None, + idempotency_key=key) + out = dict(m) + out["note"] = (f"its first run has started; it runs {schedule} from then. list_monitors with monitor_id shows " + f"its runs and what changed; pause_monitor stops it") + return out + return _safe(go) + + +@tool(title="List monitors and their runs", + annotations=READS_STORED, + description="List the workspace's monitors -- searches and agent questions kept on a schedule -- each with " + "its schedule, status, next run, this month's spend and what its last run found; or, with " + "monitor_id, one monitor with its latest runs.\n" + "Call it to find a monitor_id, to see whether a kept search or question found something new, or " + "to check spend. A run's refId is the search or agent run it made: get_job(kind='search' or " + "'agent', id=refId) reads it in full.\n" + "Inputs: kind keeps 'search' or 'agent' ones; monitor_id reads one, with up to `runs` of its " + "runs (10 by default).\n" + "Read-only and free: it reads stored data and never fetches.") +def list_monitors(kind: Annotated[Literal["search", "agent"] | None, Field( + description="Keep only 'search' or 'agent' monitors; omit for both.")] = None, + monitor_id: Annotated[str | None, Field( + description="One monitor's id, to read it with its latest runs.")] = None, + runs: Annotated[int, Field( + description="With monitor_id: how many of its latest runs, 1 to 50; default 10.")] = 10 + ) -> MonitorsOut: + def go(): + with _client() as s: + if monitor_id: + m = dict(s.monitors.get(monitor_id)) + want = max(1, min(int(runs or 10), 50)) + got = [] + for r in s.monitors.runs(monitor_id, limit=want): + got.append(_monitor_run(r)) + if len(got) >= want: + break + m["runs"] = got + return {"monitor": m} + return {"monitors": [_monitor_row(m) for m in s.monitors.list(kind=kind)]} + return _safe(go) + + +@tool(title="Pause or resume a monitor", + annotations=_acts(idempotent=True, open_world=False), + description="Pause a monitor so it makes no more scheduled runs, or resume it.\n" + "Use it when the person wants a kept search or question to stop for now, or to start again. " + "A paused monitor keeps its runs and records; nothing is deleted.\n" + "Inputs: monitor_id from list_monitors. resume=true starts it again: a slot that passed while it " + "was paused runs once, at the next tick.\n" + "Effect: it answers with the monitor. A run under way finishes. Nothing is fetched and no " + "credits are spent by pausing.\n" + "Needs write access; a read-only connection is refused with code 'read_only'.") +def pause_monitor(monitor_id: MonitorId, + resume: Annotated[bool, Field( + description="true resumes a paused monitor; default false pauses it.")] = False) -> MonitorOut: + def go(): + with _client() as s: + return s.monitors.resume(monitor_id) if resume else s.monitors.pause(monitor_id) + return _safe(go) + + +@tool(title="Read a monitor's records", + annotations=READS_STORED, + description="Read the records an agent monitor follows -- one per item its answer lists, e.g. each plan of " + "a pricing page -- as they stand, with the page each value was read on; and, with changes=true, " + "what moved: records added, a column changed (before and after), records gone.\n" + "Use it for a monitor made with follow_rows=true, to answer what changed or what the list is " + "now. list_monitors shows a run's summary; this is the record-level detail.\n" + "Inputs: monitor_id from list_monitors. active=false lists the records gone instead. q keeps " + "records holding it. limit is 1 to 500 (100 by default).\n" + "Read-only and free: it reads stored data and never fetches.") +def get_monitor_rows(monitor_id: MonitorId, + active: Annotated[bool, Field( + description="true (default) lists the records as they stand; false the records " + "gone.")] = True, + q: Annotated[str | None, Field(description="Keep records holding this text.")] = None, + limit: Annotated[int, Field(description="The most records to return, 1 to 500.")] = 100, + changes: Annotated[bool, Field( + description="true also returns what moved, newest first, at most 50.")] = False + ) -> MonitorRowsOut: + def go(): + want = max(1, min(int(limit or 100), ROWS_MAX)) + with _client() as s: + page = s.monitors.rows_page(monitor_id, active=active, q=q, limit=want) + rows, size = [], 0 + for r in page.get("data") or []: + # Kept within what an answer may carry; the rest are counted. + size += len(json.dumps(r, default=str)) + if rows and size > ROWS_CHARS: + break + rows.append(r) + out: dict = {"monitor_id": monitor_id, "rows": rows, "total": page.get("total", len(rows))} + if out["total"] > len(rows): + out["note"] = (f"{len(rows)} of {out['total']} records shown; ask with q to narrow, or export them " + f"from the monitor's page in the MeshArc app") + if changes: + out["events"] = s.monitors.row_events(monitor_id, limit=50) + return out + return _safe(go) + + @tool(title="List all projects", annotations=READS_STORED, description="List every project in the workspace, the sites it watches over time, each with its id, name, " @@ -1642,6 +1883,9 @@ def go(): "all.\n" "Cost: creating reads robots.txt and the sitemaps but no pages, so it is free. Each run spends " "credits per page, and any schedule but 'manual' runs on its own.\n" + "Caps: max_credits_per_run stops a run at that many credits, keeping what it read; " + "max_credits_per_month skips scheduled runs once the month has spent that many. 0 or omitted is " + "no cap.\n" "Refusals: an unknown config key, or a plan already at its project limit (code plan_limit). " "Values above the plan's caps are lowered to them.\n" "Needs write access; a read-only connection is refused with code 'read_only'.") @@ -1658,18 +1902,28 @@ def create_project(seed: Annotated[str | None, Field( "start_run is called.")] = "manual", config: Annotated[dict | None, Field( description="Settings as {key: value}, with a seed only; omit for the defaults.")] - = None) -> ProjectOut: + = None, + max_credits_per_run: Annotated[int | None, Field( + description="The most one run may spend, in credits; omit or 0 for no cap.", ge=0)] = None, + max_credits_per_month: Annotated[int | None, Field( + description="The most its runs may spend in a calendar month, in credits; omit or 0 for no " + "cap.", ge=0)] = None) -> ProjectOut: def go(): if bool(seed) == bool(crawl_id): return {"error": "give either seed (a new project) or crawl_id (keep a crawl), not both or neither", "code": "validation"} + caps = {k: v for k, v in (("max_credits_per_run", max_credits_per_run), + ("max_credits_per_month", max_credits_per_month)) if v is not None} with _client() as s: if crawl_id: if config: return {"error": "a kept crawl keeps the settings it was crawled with; create the project " "without config, then change them with update_project", "code": "validation"} - return s.get_crawl(crawl_id).keep(name=name, schedule=schedule) - return s.projects.create(seed, name=name, schedule=schedule, config=config) + kept = s.get_crawl(crawl_id).keep(name=name, schedule=schedule) + # The caps are budget, not how the crawl read: set on the + # project it became, which changes nothing it stored. + return s.projects.update(kept["id"], config=caps) if caps and kept.get("id") else kept + return s.projects.create(seed, name=name, schedule=schedule, config={**(config or {}), **caps} or None) return _safe(go) diff --git a/tests/test_client_workspace.py b/tests/test_client_workspace.py new file mode 100644 index 0000000..f3cf601 --- /dev/null +++ b/tests/test_client_workspace.py @@ -0,0 +1,237 @@ +"""The client's follow-ups, tables, monitors in full, triggers, proposals, estimate, sub-accounts, +connections and saved logins, against a scripted transport: no network, no key.""" + +import json + +import httpx +import pytest + +from mesharc import TENANT_HEADER, MeshArc, MeshArcError, MeshArcTimeoutError, __version__ + +from test_client import scripted + + +def body(req): + return json.loads(req.content) if req.content else None + + +def run(**over): + return {"id": "r1", "kind": "agent", "status": "queued", **over} + + +# -- the agent --------------------------------------------------------------------------- + +def test_an_agent_run_names_its_login_data_providers_and_proposals_only_when_given(): + arc = scripted(run(), run(id="r2")) + arc.agent("p") + arc.agent("p", login_id="L1", data_connections=("c1", "c2"), proposals=True) + assert body(arc.calls[0]) == {"prompt": "p"} + assert body(arc.calls[1]) == {"prompt": "p", "loginId": "L1", "dataConnections": ["c1", "c2"], "proposals": True} + + +def test_a_follow_up_posts_its_own_request_to_the_runs_conversation(): + arc = scripted(run(status="done"), run(id="r2", followsRunId="r1")) + first = arc.get_agent("r1") + nxt = first.follow_up("and their prices?", max_credits=300, data_connections=["c1"], timeout_s=500, + idempotency_key="f-1") + req = arc.calls[1] + assert (req.method, req.url.path, req.headers["Idempotency-Key"]) == ("POST", "/api/v1/agent/r1/follow-up", "f-1") + assert body(req) == {"prompt": "and their prices?", "maxCredits": 300, "dataConnections": ["c1"], "timeout": 120} + assert (nxt.id, nxt.envelope["followsRunId"]) == ("r2", "r1") + + +def test_a_follow_up_of_a_message_that_is_not_the_latest_is_a_409(): + arc = scripted(run(status="done"), (409, {"error": "this is not the conversation's latest message; follow run r3", + "code": "conflict"})) + with pytest.raises(MeshArcError) as exc: + arc.get_agent("r1").follow_up("x") + assert (exc.value.status, exc.value.code) == (409, "conflict") + + +def test_conversations_follow_the_cursor_and_keep_the_filter(): + arc = scripted({"data": [{"id": "t1"}], "next": "/api/v1/agent/conversations?limit=1&cursor=1&q=acme"}, + {"data": [{"id": "t2"}], "next": None}, {"id": "t1", "runs": []}) + assert [c["id"] for c in arc.conversations(q="acme", limit=1)] == ["t1", "t2"] + assert dict(arc.calls[1].url.params) == {"limit": "1", "q": "acme", "cursor": "1"} + assert arc.conversation("t1") == {"id": "t1", "runs": []} + assert arc.calls[2].url.path == "/api/v1/agent/conversations/t1" + + +# -- tables -------------------------------------------------------------------------------- + +def test_a_table_is_previewed_and_started_with_the_apis_names(): + arc = scripted({"valid": 2}, {"id": "b1", "status": "queued", "webhookSecret": "whsec"}) + kw = dict(csv="co,site\nAcme,acme.test\n", max_credits_per_row=50, urls=["{{site}}"], allowed_domains=[], + data_connections=["c1"], login_id="L1") + arc.agent_batches.preview("about {{co}}", **kw) + out = arc.agent_batches.start("about {{co}}", webhook="https://hook.test/", idempotency_key="b-1", **kw) + want = {"prompt": "about {{co}}", "csv": "co,site\nAcme,acme.test\n", "maxCreditsPerRow": 50, + "urls": ["{{site}}"], "allowedDomains": [], "dataConnections": ["c1"], "loginId": "L1"} + assert (arc.calls[0].url.path, body(arc.calls[0])) == ("/api/v1/agent/batch/preview", want) + assert (arc.calls[1].url.path, body(arc.calls[1])) == ("/api/v1/agent/batch", {**want, "webhook": "https://hook.test/"}) + assert arc.calls[1].headers["Idempotency-Key"] == "b-1" + assert out["webhookSecret"] == "whsec" + + +def test_a_tables_rows_are_read_page_by_page_until_there_is_no_more(): + arc = scripted({"id": "b1", "data": [{"row": 0}, {"row": 1}], "more": "/api/v1/agent/batch/b1?offset=2"}, + {"id": "b1", "data": [{"row": 2}]}) + assert [r["row"] for r in arc.agent_batches.rows("b1", status="done", limit=2)] == [0, 1, 2] + assert dict(arc.calls[1].url.params) == {"offset": "2", "limit": "2", "status": "done"} + + +def test_waiting_for_a_table_polls_until_it_ends_and_times_out_plainly(monkeypatch): + monkeypatch.setattr("time.sleep", lambda s: None) + arc = scripted({"id": "b1", "status": "running"}, {"id": "b1", "status": "done"}) + assert arc.agent_batches.wait("b1", poll=0)["status"] == "done" + arc = scripted({"id": "b1", "status": "running"}) + with pytest.raises(MeshArcTimeoutError) as exc: + arc.agent_batches.wait("b1", poll=0, timeout=0) + assert exc.value.job_id == "b1" + + +def test_an_expired_table_is_a_410_and_a_table_downloads_to_a_file(tmp_path): + arc = scripted((410, {"error": "past its keep date", "code": "expired"})) + with pytest.raises(MeshArcError) as exc: + arc.agent_batches.get("b1") + assert (exc.value.status, exc.value.code) == (410, "expired") + arc = scripted() + arc._h._c = httpx.Client(base_url="https://api.mesharc.dev/api/v1", transport=httpx.MockTransport( + lambda r: httpx.Response(200, content=b"co,plan\nAcme,Pro\n"))) + path = arc.agent_batches.export("b1", str(tmp_path / "t.csv")) + assert open(path, "rb").read() == b"co,plan\nAcme,Pro\n" + + +def test_cancelling_and_listing_tables(): + arc = scripted({"id": "b1", "status": "cancelling"}, {"data": [{"id": "b1"}], "next": None}) + assert arc.agent_batches.cancel("b1")["status"] == "cancelling" + assert arc.calls[0].method == "DELETE" + assert [b["id"] for b in arc.agent_batches.list()] == ["b1"] + + +# -- monitors -------------------------------------------------------------------------------- + +def test_a_monitor_is_made_and_changed_with_its_caps_alerts_records_and_tone(): + arc = scripted({"id": "m1"}, {"id": "m1"}) + arc.monitors.create("agent", {"prompt": "p"}, "daily", budget={"perRun": 100, "perMonth": 2000}, + alerts={"email": True}, rows={"path": "plans", "key": "name"}, destination="d1") + arc.monitors.update("m1", budget={}, add_to_project="", sentiment=False) + assert body(arc.calls[0]) == {"kind": "agent", "request": {"prompt": "p"}, "schedule": "daily", + "budget": {"perRun": 100, "perMonth": 2000}, "alerts": {"email": True}, + "rows": {"path": "plans", "key": "name"}, "destination": "d1"} + # {} and "" and False are sent: they clear a setting. Only None is left out. + assert body(arc.calls[1]) == {"budget": {}, "addToProject": "", "sentiment": False} + + +def test_a_monitors_records_are_paged_by_offset_and_its_events_and_one_record_read(): + arc = scripted({"data": [{"key": "a"}, {"key": "b"}], "total": 3}, {"data": [{"key": "c"}], "total": 3}, + {"data": [{"kind": "added"}]}, {"key": "a", "events": []}, {"data": [], "counts": {}}) + assert [r["key"] for r in arc.monitors.rows("m1", active=False, limit=2)] == ["a", "b", "c"] + assert dict(arc.calls[0].url.params) == {"active": "false", "offset": "0", "limit": "2"} + assert arc.monitors.row_events("m1", run_id="mr1") == [{"kind": "added"}] + assert dict(arc.calls[2].url.params) == {"limit": "200", "run_id": "mr1"} + assert arc.monitors.row("m1", "a")["key"] == "a" + assert arc.monitors.overview(kind="agent") == {"data": [], "counts": {}} + assert arc.calls[4].url.path == "/api/v1/monitors/overview" + + +# -- triggers, proposals, estimate ---------------------------------------------------------------- + +def test_a_trigger_is_made_listed_changed_and_its_fires_followed(): + arc = scripted({"id": "t1"}, {"data": [{"id": "t1"}]}, {"id": "t1", "status": "paused"}, + {"data": [{"id": "f1"}], "next": "/api/v1/triggers/t1/fires?limit=1&cursor=1"}, + {"data": [{"id": "f2"}], "next": None}, {"matched": 1}, {"id": "t1", "deleted": True}) + arc.triggers.create({"kind": "project", "id": "p1"}, {"type": "recrawl"}, limits={"maxPerDay": 2}) + assert body(arc.calls[0]) == {"source": {"kind": "project", "id": "p1"}, "action": {"type": "recrawl"}, + "limits": {"maxPerDay": 2}} + assert arc.triggers.list(source_id="p1") == [{"id": "t1"}] + assert dict(arc.calls[1].url.params) == {"source_id": "p1"} + arc.triggers.update("t1", status="paused") + assert (arc.calls[2].method, body(arc.calls[2])) == ("PATCH", {"status": "paused"}) + assert [f["id"] for f in arc.triggers.fires("t1", limit=1)] == ["f1", "f2"] + assert arc.triggers.preview("t1") == {"matched": 1} + assert arc.triggers.delete("t1")["deleted"] is True + + +def test_proposals_are_listed_waiting_by_default_and_approved_or_dismissed(): + arc = scripted({"data": [{"id": "pr1"}]}, {"data": []}, {"made": {"kind": "monitor", "id": "m1"}}, + (409, {"error": "already decided", "code": "conflict"})) + assert arc.proposals.list() == [{"id": "pr1"}] + assert dict(arc.calls[0].url.params) == {"status": "proposed"} + arc.proposals.list(status="", run_id="r1") + assert dict(arc.calls[1].url.params) == {"status": "", "run_id": "r1"} + assert arc.proposals.approve("pr1")["made"]["id"] == "m1" + with pytest.raises(MeshArcError) as exc: + arc.proposals.dismiss("pr1") + assert exc.value.code == "conflict" + + +def test_the_estimate_sends_the_apis_names_and_only_what_was_given(): + arc = scripted({"perMonth": 120, "upTo": True}) + assert arc.estimate("agent", "weekly", max_credits=300, month_cap=0)["upTo"] is True + assert dict(arc.calls[0].url.params) == {"kind": "agent", "schedule": "weekly", "maxCredits": "300", + "monthCap": "0"} + + +# -- sub-accounts ----------------------------------------------------------------------------------- + +def test_sub_accounts_are_made_capped_suspended_and_given_keys(): + arc = scripted({"id": "s1"}, {"id": "s1", "suspended": True}, {"id": "k1", "key": "mesharc_x"}, (204, None)) + arc.sub_accounts.create("Acme", monthly_cap=5000, external_id="cust-1", idempotency_key="s-1") + assert body(arc.calls[0]) == {"name": "Acme", "monthlyCap": 5000, "externalId": "cust-1"} + assert arc.calls[0].headers["Idempotency-Key"] == "s-1" + arc.sub_accounts.update("s1", suspended=True, reason="unpaid") + assert body(arc.calls[1]) == {"suspended": True, "reason": "unpaid"} + assert arc.sub_accounts.create_key("s1", scopes=["read", "write"])["key"] == "mesharc_x" + assert body(arc.calls[2]) == {"name": "default", "scopes": ["read", "write"]} + assert arc.sub_accounts.revoke_key("s1", "k1") is None + assert (arc.calls[3].method, arc.calls[3].url.path) == ("DELETE", "/api/v1/orgs/children/s1/keys/k1") + + +def test_a_client_for_a_sub_account_sends_the_tenant_header_on_every_request_and_the_parent_does_not(): + arc = MeshArc("mesharc_parent", base_url="https://api.test") + child = arc.as_sub_account("s1") + try: + assert (child.tenant, child._h._c.headers.get(TENANT_HEADER), arc._h._c.headers.get(TENANT_HEADER)) == \ + ("s1", "s1", None) + assert child._h._c.headers["Authorization"] == "Bearer mesharc_parent" + assert str(child._h._c.base_url) == "https://api.test/api/v1/" + assert MeshArc("k", tenant="s2")._h._c.headers[TENANT_HEADER] == "s2" + finally: + child.close() + arc.close() + + +def test_a_tenant_the_key_may_not_act_on_is_a_404(): + arc = scripted((404, {"error": "sub-account not found", "code": "not_found"})) + with pytest.raises(MeshArcError) as exc: + arc.me() + assert exc.value.status == 404 + + +# -- connections and logins ---------------------------------------------------------------------------- + +def test_a_data_provider_is_connected_with_its_key_and_tested(): + arc = scripted({"id": "c1", "kind": "data provider"}, {"status": "failing", "error": "refused the key (401)"}, + {"connections": [{"id": "c1"}], "secretsEncrypted": True}, (204, None)) + arc.connections.create("pdl", name="Company data", secrets={"api_key": "k"}) + assert body(arc.calls[0]) == {"provider": "pdl", "name": "Company data", "secrets": {"api_key": "k"}} + assert arc.connections.test("c1")["status"] == "failing" + assert arc.connections.list() == [{"id": "c1"}] + assert arc.connections.delete("c1") is None + + +def test_saved_logins_are_listed_renamed_expired_tested_and_deleted(): + arc = scripted({"data": [], "enabled": False, "why": "logins are off"}, {"id": "l1", "status": "expired"}, + {"status": 200, "loginPage": False}, (204, None)) + assert arc.logins.list()["enabled"] is False + arc.logins.update("l1", expired=True) + assert body(arc.calls[1]) == {"expired": True} + assert arc.logins.test("l1")["loginPage"] is False + assert arc.logins.delete("l1") is None + + +def test_the_user_agent_still_names_the_package(): + arc = scripted({"id": "ws"}) + arc.me() + assert arc.calls[0].headers["User-Agent"] == f"mesharc-python/{__version__}" diff --git a/tests/test_definitions.py b/tests/test_definitions.py index 96ce8c1..9d80900 100644 --- a/tests/test_definitions.py +++ b/tests/test_definitions.py @@ -16,12 +16,14 @@ # 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", "run_agent", "continue_agent"} + "get_changes", "start_run", "get_job", "cancel_job", "run_agent", "continue_agent", + "create_monitor", "list_monitors", "pause_monitor", "get_monitor_rows"} # The tools that only read what the workspace has stored. Everything else # reaches a site or changes the workspace, and a read-only connection is -# refused it -- the same six the read_only answer is about. -READ_ONLY = {"list_projects", "get_project", "list_runs", "list_pages", "get_changes", "get_job"} +# refused it -- the eight the read_only answer is about. +READ_ONLY = {"list_projects", "get_project", "list_runs", "list_pages", "get_changes", "get_job", "list_monitors", + "get_monitor_rows"} def _tools(): @@ -32,7 +34,8 @@ 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. + # run_agent makes sixteen and continue_agent seventeen; the four monitor + # tools twenty-one. assert set(_tools()) == EXPECTED assert READ_ONLY < EXPECTED diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index 4c1b576..bbeefdd 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -26,6 +26,7 @@ "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", "run_agent", "continue_agent", + "create_monitor", "list_monitors", "pause_monitor", "get_monitor_rows", }) diff --git a/tests/test_monitor_tools.py b/tests/test_monitor_tools.py new file mode 100644 index 0000000..975892b --- /dev/null +++ b/tests/test_monitor_tools.py @@ -0,0 +1,174 @@ +"""The monitor tools and create_project's caps, against a stand-in client: what each sends, and what it answers.""" +import pytest + +mcp_mod = pytest.importorskip("mesharc.mcp", reason="needs the mcp extra") + +from mesharc import MeshArcError # noqa: E402 + + +class _Monitors: + def __init__(self, rows=None, total=None): + self.made, self.calls, self._rows = [], [], rows or [] + self._total = len(self._rows) if total is None else total + + def create(self, kind, request, schedule, **kw): + self.made.append((kind, request, schedule, kw)) + return {"id": "m1", "kind": kind, "schedule": schedule, "status": "active", "webhookSecret": "whsec_x"} + + def list(self, kind=None): + self.calls.append(("list", kind)) + return [{"id": "m1", "kind": "search", "name": "acme", "schedule": "daily", "status": "active", + "request": {"query": "acme"}, "nextRunAt": "2026-10-15T00:00:00Z", "spentThisMonth": 8, + "budget": {"perRun": 0, "perMonth": 0}, + "lastRun": {"status": "done", "changed": True, "summary": "2 new", "diff": {"new": ["x"] * 99}}}] + + def get(self, monitor_id): + return {"id": monitor_id, "kind": "agent", "request": {"prompt": "p"}} + + def runs(self, monitor_id, limit=25): + for i in range(100): + yield {"id": f"r{i}", "status": "done", "changed": i == 0, "summary": "s", "credits": 3, "refId": f"a{i}", + "diff": {"big": "x" * 1000}, "error": ""} + + def pause(self, monitor_id): + self.calls.append(("pause", monitor_id)) + return {"id": monitor_id, "status": "paused"} + + def resume(self, monitor_id): + self.calls.append(("resume", monitor_id)) + return {"id": monitor_id, "status": "active"} + + def rows_page(self, monitor_id, active=None, q=None, offset=0, limit=100): + self.calls.append(("rows", monitor_id, active, q, limit)) + return {"data": self._rows[:limit], "total": self._total} + + def row_events(self, monitor_id, run_id=None, kind=None, limit=200): + return [{"kind": "added", "key": "Pro"}][:limit] + + +class _Projects: + def __init__(self): + self.created, self.updated = [], [] + + def create(self, seed, name=None, schedule="manual", config=None): + self.created.append((seed, config)) + return {"id": "p1", "seed": seed} + + def update(self, project_id, **fields): + self.updated.append((project_id, fields)) + return {"id": project_id, **fields} + + +class _Kept: + def keep(self, name=None, schedule=None): + return {"id": "p9"} + + +class _Client: + def __init__(self, monitors=None): + self.monitors = monitors or _Monitors() + self.projects = _Projects() + + def get_crawl(self, crawl_id): + return _Kept() + + def __enter__(self): + return self + + def __exit__(self, *a): + return None + + +@pytest.fixture +def client(monkeypatch): + c = _Client() + monkeypatch.setattr(mcp_mod, "_client", lambda: c) + return c + + +def test_a_search_monitor_sends_its_query_news_and_monthly_cap_and_never_its_webhook_secret(client): + out = mcp_mod.create_monitor("search", "daily", query="acme ltd", news=True, max_credits_per_month=200) + kind, request, schedule, kw = client.monitors.made[0] + assert (kind, request, schedule) == ("search", {"query": "acme ltd", "sources": ["news"]}, "daily") + assert kw["budget"] == {"perMonth": 200} and kw["rows"] is None and kw["idempotency_key"].startswith("mcp-") + assert "webhookSecret" not in out and out["id"] == "m1" and "first run has started" in out["note"] + + +def test_an_agent_monitor_stays_on_its_starting_sites_unless_told_and_can_follow_rows(client): + mcp_mod.create_monitor("agent", "weekly", prompt="Acme's plans", urls=["https://www.Acme.test/pricing"], + follow_rows=True, max_credits=300) + mcp_mod.create_monitor("agent", "weekly", prompt="p", urls=["https://acme.test/"], allowed_domains=[]) + first, second = client.monitors.made + assert first[1] == {"prompt": "Acme's plans", "urls": ["https://www.Acme.test/pricing"], + "allowedDomains": ["acme.test"], "maxCredits": 300} + assert first[3]["rows"] is True and first[3]["budget"] is None + assert second[1]["allowedDomains"] == [] + + +def test_a_monitor_with_the_wrong_inputs_for_its_kind_is_refused_before_any_call(client): + answers = [mcp_mod.create_monitor("search", "daily", prompt="p"), + mcp_mod.create_monitor("search", "daily", query="q", follow_rows=True), + mcp_mod.create_monitor("agent", "daily", query="q"), + mcp_mod.create_monitor("agent", "daily")] + assert [a["code"] for a in answers] == ["validation"] * 4 and client.monitors.made == [] + + +def test_the_same_monitor_asked_twice_in_ten_minutes_carries_one_key(client): + mcp_mod.create_monitor("search", "daily", query="q") + mcp_mod.create_monitor("search", "daily", query="q") + mcp_mod.create_monitor("search", "hourly", query="q") + keys = [m[3]["idempotency_key"] for m in client.monitors.made] + assert keys[0] == keys[1] != keys[2] + + +def test_list_monitors_shows_what_picks_one_and_drops_the_request_and_the_diff(client): + out = mcp_mod.list_monitors(kind="search") + row = out["monitors"][0] + assert client.monitors.calls == [("list", "search")] + assert "request" not in row and row["lastRun"] == {"status": "done", "changed": True, "summary": "2 new"} + assert row["spentThisMonth"] == 8 + + +def test_one_monitor_comes_with_its_latest_runs_each_without_its_diff(client): + out = mcp_mod.list_monitors(monitor_id="m1", runs=500)["monitor"] + assert out["request"] == {"prompt": "p"} and len(out["runs"]) == 50 + assert out["runs"][0] == {"id": "r0", "status": "done", "changed": True, "summary": "s", "credits": 3, + "refId": "a0"} + + +def test_pause_monitor_pauses_and_resumes(client): + assert mcp_mod.pause_monitor("m1")["status"] == "paused" + assert mcp_mod.pause_monitor("m1", resume=True)["status"] == "active" + assert client.monitors.calls == [("pause", "m1"), ("resume", "m1")] + + +def test_get_monitor_rows_keeps_its_answer_within_its_size_and_says_how_many_are_left(monkeypatch): + rows = [{"key": f"k{i}", "attrs": {"text": "x" * 2000}} for i in range(100)] + c = _Client(_Monitors(rows=rows, total=140)) + monkeypatch.setattr(mcp_mod, "_client", lambda: c) + out = mcp_mod.get_monitor_rows("m1", active=False, q="pro", limit=9999, changes=True) + assert c.monitors.calls[0] == ("rows", "m1", False, "pro", 500) + assert 0 < len(out["rows"]) < 100 and out["total"] == 140 + assert out["note"].startswith(f"{len(out['rows'])} of 140 records shown") + assert out["events"] == [{"kind": "added", "key": "Pro"}] + + +def test_get_monitor_rows_of_an_unknown_monitor_is_an_error_not_a_crash(monkeypatch): + class _Gone(_Monitors): + def rows_page(self, *a, **kw): + raise MeshArcError(404, "monitor not found", "not_found") + + monkeypatch.setattr(mcp_mod, "_client", lambda: _Client(_Gone())) + out = mcp_mod.get_monitor_rows("nope") + assert (out["status"], out["code"]) == (404, "not_found") + + +def test_create_project_puts_its_caps_in_the_config_and_sets_them_on_a_kept_crawl(client): + mcp_mod.create_project(seed="example.com", config={"max_pages": 50}, max_credits_per_run=100, + max_credits_per_month=0) + assert client.projects.created == [("example.com", {"max_pages": 50, "max_credits_per_run": 100, + "max_credits_per_month": 0})] + mcp_mod.create_project(seed="example.org") + assert client.projects.created[1] == ("example.org", None) + out = mcp_mod.create_project(crawl_id="c1", max_credits_per_month=500) + assert client.projects.updated == [("p9", {"config": {"max_credits_per_month": 500}})] and out["id"] == "p9" From 15391d185e96eb33cdbbbcf5afcea1d1dc3f4ae3 Mon Sep 17 00:00:00 2001 From: Siddharth-DWT Date: Thu, 15 Oct 2026 11:48:31 +0530 Subject: [PATCH 2/2] logins on agent runs, follow-ups and tables Left out, the API reads each site the workspace has a saved login for signed in with that site's login ("auto"); "none", or a list of login ids, changes that. login_id / loginId stays as the older form of a list of one. Tests and CHANGELOG updated; checked live against the local API. --- CHANGELOG.md | 1 + mesharc/__init__.py | 43 +++++++++++++++++++++------------- tests/test_client_workspace.py | 10 ++++++++ 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 747160d..f180b3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this package are recorded here. The format follows ### Added +- **The agent's saved logins need no picking.** `agent()`, `AgentRun.follow_up()` and `agent_batches` take `logins`: left out, the API reads each site the workspace has a saved login for signed in with that site's login (`"auto"`), and every other site signed out, so one run can read several sites signed in; `"none"`, or a list of login ids, changes that. `login_id` is the older form of a list of one. **This needs an API with `logins`**, on the API's feature branch. - **Follow-ups and conversations.** `AgentRun.follow_up(prompt, ...)` asks the next question in a run's conversation -- it takes what `agent()` takes, answers after everything asked and found before it, and may read again, free, any page the conversation read -- and returns its `AgentRun`. `conversations(q=None, limit=25)` walks the workspace's conversations, the latest message first; `conversation(id)` reads one with every run in it. A message that is not the conversation's latest is a 409 `conflict`. **These need an API with follow-ups**, on the API's feature branch, not yet on mesharc.dev. - **`agent()` takes `login_id=`, `data_connections=` and `proposals=`**: a saved login its site is read with; up to two of the workspace's data-provider connections (People Data Labs, Financial Modeling Prep), which the agent looks company and financial data up with on your own key, at no MeshArc credits, each record one of its `sources` as `{url, title, provider}`; and leave to propose things to watch, for a member to approve. Each is sent only when given. `trace()` knows the `lookup` step. **These need an API with saved logins, data providers and proposals**, on the API's feature branch. - **`arc.agent_batches`: the agent over every row of a table.** `preview`, `start`, `get`, `rows`, `wait`, `list`, `cancel` and `export` -- one agent run a row, its prompt and starting URLs filled from the row's columns as `{{column}}`, its answer the row's new columns; `max_credits_per_row` caps each row and the batch never spends more than rows x that cap. **This needs an API with `/agent/batch`**, on the API's feature branch. diff --git a/mesharc/__init__.py b/mesharc/__init__.py index a60ea90..e45767c 100644 --- a/mesharc/__init__.py +++ b/mesharc/__init__.py @@ -112,11 +112,11 @@ def _some(**fields: Any) -> Json: def _agent_body(prompt: str, urls: Optional[Iterable[str]], schema: Optional[Json], max_credits: Optional[int], max_steps: Optional[int], allowed_domains: Optional[Iterable[str]], webhook: Union[str, Json, None], connection_id: Optional[str], login_id: Optional[str], data_connections: Optional[Iterable[str]], - proposals: Optional[bool], timeout_s: float) -> Json: + proposals: Optional[bool], timeout_s: float, logins: Union[str, Iterable[str], None] = None) -> Json: """A POST /agent body (and a follow-up's) in the API's own names, naming only what was given.""" body: Json = {"prompt": prompt} body.update(_some(schema=schema, maxCredits=max_credits, maxSteps=max_steps, webhook=webhook, - connectionId=connection_id, loginId=login_id, proposals=proposals)) + connectionId=connection_id, loginId=login_id, proposals=proposals, logins=_logins(logins))) if urls is not None: body["urls"] = list(urls) if allowed_domains is not None: @@ -128,6 +128,11 @@ def _agent_body(prompt: str, urls: Optional[Iterable[str]], schema: Optional[Jso return body +def _logins(logins: Union[str, Iterable[str], None]) -> Union[str, List[str], None]: + """``logins`` as the API takes it: "auto", "none", or a list of login ids.""" + return logins if logins is None or isinstance(logins, str) else list(logins) + + def _offset_rows(http: Any, path: str, params: Dict[str, Any], limit: int) -> Iterator[Json]: """Every row of an offset-paged list, ``limit`` at a time, until a page comes back short.""" @@ -490,11 +495,11 @@ def _body(prompt: str, csv: Optional[str], rows: Optional[List[Json]], max_credi urls: Optional[Iterable[str]], schema: Optional[Json], max_steps: Optional[int], allowed_domains: Optional[Iterable[str]], concurrency: Optional[int], name: Optional[str], connection_id: Optional[str], login_id: Optional[str], - data_connections: Optional[Iterable[str]]) -> Json: + data_connections: Optional[Iterable[str]], logins: Union[str, Iterable[str], None] = None) -> Json: body: Json = {"prompt": prompt} body.update(_some(csv=csv, rows=rows, maxCreditsPerRow=max_credits_per_row, schema=schema, maxSteps=max_steps, concurrency=concurrency, name=name, connectionId=connection_id, - loginId=login_id)) + loginId=login_id, logins=_logins(logins))) if urls is not None: body["urls"] = list(urls) if allowed_domains is not None: @@ -508,14 +513,14 @@ def preview(self, prompt: str, csv: Optional[str] = None, rows: Optional[List[Js schema: Optional[Json] = None, max_steps: Optional[int] = None, allowed_domains: Optional[Iterable[str]] = None, concurrency: Optional[int] = None, name: Optional[str] = None, connection_id: Optional[str] = None, login_id: Optional[str] = None, - data_connections: Optional[Iterable[str]] = None) -> Json: + data_connections: Optional[Iterable[str]] = None, logins: Union[str, Iterable[str], None] = None) -> Json: """What ``start`` would do, without starting it or spending anything: the rows that can run and those that cannot (and why), the first prompts as their rows will ask them, and an estimate against the balance.""" return self._h("POST", "/agent/batch/preview", json=self._body( prompt, csv, rows, max_credits_per_row, urls, schema, max_steps, allowed_domains, concurrency, name, - connection_id, login_id, data_connections)) + connection_id, login_id, data_connections, logins)) def start(self, prompt: str, csv: Optional[str] = None, rows: Optional[List[Json]] = None, max_credits_per_row: Optional[int] = None, urls: Optional[Iterable[str]] = None, @@ -523,7 +528,7 @@ def start(self, prompt: str, csv: Optional[str] = None, rows: Optional[List[Json allowed_domains: Optional[Iterable[str]] = None, concurrency: Optional[int] = None, name: Optional[str] = None, connection_id: Optional[str] = None, login_id: Optional[str] = None, data_connections: Optional[Iterable[str]] = None, webhook: Union[str, Json, None] = None, - idempotency_key: Optional[str] = None) -> Json: + idempotency_key: Optional[str] = None, logins: Union[str, Iterable[str], None] = None) -> Json: """Start one agent run a row of the table. The table is ``csv`` (its first line names the columns) or ``rows``, @@ -532,15 +537,15 @@ def start(self, prompt: str, csv: Optional[str] = None, rows: Optional[List[Json columns. ``max_credits_per_row`` is required by the API. Without ``allowed_domains`` each row is held to its own URLs' sites; ``[]`` lifts the limit. ``concurrency`` rows run at once (3 by default). - ``login_id`` and ``data_connections`` are as ``agent()`` takes them, - for every row. ``webhook`` is told ``agent.batch.finished`` once; its + ``logins``, ``login_id`` and ``data_connections`` are as ``agent()`` + takes them, for every row. ``webhook`` is told ``agent.batch.finished`` once; its secret comes back once, as ``webhookSecret``. Spends credits and needs a key that can write. Returns the batch, with ``counts`` by row status; ``wait()`` waits for it. """ body = self._body(prompt, csv, rows, max_credits_per_row, urls, schema, max_steps, allowed_domains, - concurrency, name, connection_id, login_id, data_connections) + concurrency, name, connection_id, login_id, data_connections, logins) if webhook is not None: body["webhook"] = webhook return self._h("POST", "/agent/batch", json=body, idempotency_key=idempotency_key) @@ -1010,7 +1015,8 @@ def follow_up(self, prompt: str, urls: Optional[Iterable[str]] = None, schema: O allowed_domains: Optional[Iterable[str]] = None, webhook: Union[str, Json, None] = None, connection_id: Optional[str] = None, login_id: Optional[str] = None, data_connections: Optional[Iterable[str]] = None, proposals: Optional[bool] = None, - timeout_s: float = 0, idempotency_key: Optional[str] = None) -> AgentRun: + timeout_s: float = 0, idempotency_key: Optional[str] = None, + logins: Union[str, Iterable[str], None] = None) -> AgentRun: """Ask a follow-up in this run's conversation; returns its ``AgentRun``. It answers after everything asked and found in the conversation @@ -1022,7 +1028,7 @@ def follow_up(self, prompt: str, urls: Optional[Iterable[str]] = None, schema: O keep date. """ body = _agent_body(prompt, urls, schema, max_credits, max_steps, allowed_domains, webhook, connection_id, - login_id, data_connections, proposals, timeout_s) + login_id, data_connections, proposals, timeout_s, logins) return AgentRun(self._h, self._h("POST", f"/agent/{self.id}/follow-up", json=body, idempotency_key=idempotency_key)) @@ -1334,7 +1340,8 @@ def agent(self, prompt: str, urls: Optional[Iterable[str]] = None, schema: Optio 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, login_id: Optional[str] = None, - data_connections: Optional[Iterable[str]] = None, proposals: Optional[bool] = None) -> AgentRun: + data_connections: Optional[Iterable[str]] = None, proposals: Optional[bool] = None, + logins: Union[str, Iterable[str], None] = 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 @@ -1355,8 +1362,12 @@ def agent(self, prompt: str, urls: Optional[Iterable[str]] = None, schema: Optio secret its messages are signed with comes back once, as ``AgentRun.webhook_secret``. - ``login_id`` is one of the workspace's saved logins: pages on its - site are read signed in, every other site signed out. + The workspace's saved logins need no picking: each page on a site + with one is read signed in with that site's login, every other page + signed out, so one run can read several sites signed in. ``logins`` + changes that -- ``"none"``, or a list of login ids to keep it to; + left out, the API's ``"auto"`` -- and ``login_id`` is the older form + of a list of one. ``data_connections`` names up to two of the workspace's data providers (one per provider): the agent may then look up company data (People Data Labs) or financial data (Financial Modeling Prep) @@ -1370,7 +1381,7 @@ def agent(self, prompt: str, urls: Optional[Iterable[str]] = None, schema: Optio asks the next question in the same conversation. """ body = _agent_body(prompt, urls, schema, max_credits, max_steps, allowed_domains, webhook, connection_id, - login_id, data_connections, proposals, timeout_s) + login_id, data_connections, proposals, timeout_s, logins) return AgentRun(self._h, self._h("POST", "/agent", json=body, idempotency_key=idempotency_key)) def conversations(self, q: Optional[str] = None, limit: int = 25) -> Iterator[Json]: diff --git a/tests/test_client_workspace.py b/tests/test_client_workspace.py index f3cf601..c044422 100644 --- a/tests/test_client_workspace.py +++ b/tests/test_client_workspace.py @@ -29,6 +29,16 @@ def test_an_agent_run_names_its_login_data_providers_and_proposals_only_when_giv assert body(arc.calls[1]) == {"prompt": "p", "loginId": "L1", "dataConnections": ["c1", "c2"], "proposals": True} +def test_logins_need_no_picking_and_are_sent_only_to_change_that(): + arc = scripted(run(), run(id="r2"), run(id="r3"), {"valid": 1}) + arc.agent("p") + arc.agent("p", logins="none") + arc.agent("p", logins=(i for i in ["l1", "l2"])) + arc.agent_batches.preview("about {{co}}", csv="co\nAcme\n", max_credits_per_row=10, logins=["l1"]) + assert "logins" not in body(arc.calls[0]) + assert [body(c)["logins"] for c in arc.calls[1:]] == ["none", ["l1", "l2"], ["l1"]] + + def test_a_follow_up_posts_its_own_request_to_the_runs_conversation(): arc = scripted(run(status="done"), run(id="r2", followsRunId="r1")) first = arc.get_agent("r1")