From 7cd19aa2712b66d0dcd1e88b16159b5b9b61ef98 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Tue, 29 Sep 2026 22:28:07 -0700 Subject: [PATCH 1/9] Add prospecting.update_plan for PATCH /prospecting/runs/{run_id}/plan Managed prospecting runs now let a caller pick contact, company-check and search engines and a credit limit on a proposed plan, and report candidate_limit and credit_limit stop reasons. The request model is hand-added in generated form until the deployed spec carries it. --- CHANGELOG.md | 1 + README.md | 8 ++- packages/discolike/README.md | 2 + .../src/discolike/_generated/requests.py | 38 +++++++++++++ packages/discolike/src/discolike/requests.py | 2 + .../src/discolike/resources/prospecting.py | 28 ++++++++++ packages/discolike/tests/test_gen_requests.py | 2 +- packages/discolike/tests/test_prospecting.py | 56 +++++++++++++++++++ 8 files changed, 134 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f060a0..b2ce76b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - **Breaking:** the polling deadline on `Job.wait()`, `EmailJob.wait()` and `EmailBatch.results()` (sync and async) is renamed from `timeout=` to `max_wait=`, and `signup()` / `async_signup()` take `request_timeout=` instead of `timeout=`. Behavior is unchanged; the client constructor, `with_options(timeout=...)` and the CLI's `--timeout` keep their names. - SDK: response enums such as `JobStatus` are open, so a value a newer API adds parses instead of failing. Compare against the documented values and treat anything else as unknown. - SDK/CLI: managed prospecting. `client.prospecting` starts, reads, lists, approves, messages, renames, cancels, deletes and waits on runs (sync and async), with typed plans, chat, progress and saved contact lists (`saved_query_ids`, split into parts for large results). `ProspectingBrief.checkpoints="ask"` pauses the run at a pilot, a drifting search, a shortfall or the target for you to answer; `customer_domains` groups your customers into segments and finds lookalikes of each, picked on approval with `seed_segments`. `rename()` retitles a run in any status with `ProspectingRunUpdate(title=...)` and returns its `list()` summary; result lists it already saved keep their names. `cancel()` stops a run and keeps it; `delete()` cancels an active run, then removes it from `list()` and `get()` (charges already incurred stay). The CLI adds `prospecting start/status/list/approve/message/cancel/wait`, asks at checkpoints on a terminal, and exits 7 with the question on stderr under `--no-input`. +- SDK: `client.prospecting.update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` (sync and async) changes a proposed plan's contact engine, company-check engine, search provider (`"none"` skips web research) and `max_credits` (`0` removes the limit) before approval, then returns the run with the re-estimated plan at a new `plan_version`. Prospecting `stop_reason` gains `candidate_limit` (candidate cap reached) and `credit_limit` (stopped at `max_credits`, results kept); `candidates_exhausted` now means only that the search ran dry. `stop_reason` stays an open string. Runs without a chosen contact engine research on DiscoLike Groove, or find indexed contacts only with no search provider. ## 0.4.1 (2026-09-23) diff --git a/README.md b/README.md index dd04922..3fe74e7 100644 --- a/README.md +++ b/README.md @@ -431,7 +431,7 @@ REST starts in `drafting`, then waits at `proposed` for approval. Review the pla ```python from discolike.requests import ( ProspectingApproveRequest, ProspectingBrief, ProspectingGetParams, - ProspectingListParams, ProspectingMessageRequest, ProspectingRunUpdate, + ProspectingListParams, ProspectingMessageRequest, ProspectingPlanSettings, ProspectingRunUpdate, ) run = client.prospecting.start( @@ -453,6 +453,8 @@ page = client.prospecting.get( run.run_id, ProspectingGetParams(offset=0, limit=100, events_after=run.next_event_seq, messages_after=run.next_message_seq), ) +# Pick engines or a credit limit from the plan card's options before approving; approve the returned plan_version. +# run = client.prospecting.update_plan(run.run_id, ProspectingPlanSettings(plan_version=run.plan_version, max_credits=5000)) # client.prospecting.rename(run.run_id, ProspectingRunUpdate(title="Logistics ops leaders")) # Any status. # client.prospecting.cancel(run.run_id) # Stop the run; it and its results stay readable. # client.prospecting.delete(run.run_id) # Cancel if active, then remove it from list() and get(). @@ -462,8 +464,10 @@ The async client exposes the same methods with `await`. Starts and messages requ `wait()` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`. Inspect `status`, `stop_reason`, and `error`; completion does not guarantee the target was reached. A local timeout stops polling only. Partial results remain available. Use `get()` with event and message cursors to receive the agent's reply after sending a message; `reply_pending` indicates a pending reply. After a "segment these" request on a finished run it stays true past the acknowledgement until the segments message is posted, which can take more than an hour, and clears on its own after about 90 minutes if grouping stops without an outcome. A `needs_input` question can be answered with `message()`. -Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and checks again, then stops with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief. A search drifting off target is dropped, a run short of candidates finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`). The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. +Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and checks again, then stops with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief. A search drifting off target is dropped, a run whose search runs dry finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`). The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. Initial planning extracts company counts and contacts per company from the brief. Omitted settings keep that inference available, falling back to 1,000 companies and 1 contact per company. Explicit settings, including explicit defaults, override the text. Targets support 1–10,000 companies and 1–5 contacts per company. Candidate and action caps default to automatic (`0`); explicit maxima are 100,000 candidates and 10,000 actions. Result pages support up to 500 rows; recent-run lists support up to 50. Approved runs expose a stable `saved_query_id` for saved results. +Run controls: `update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan before approval. `contact_integration_id`, `validation_integration_id` and `search_provider_id` take an option id from the plan message's `contact_engine`, `company_check_engine` and `search_provider` (`data.contact_engine.options` and so on); omitted fields keep their choice, `search_provider_id="none"` skips web research, and `max_credits` sets the most credits the run may spend (`0` removes the limit). The plan is re-estimated and posted again at a new `plan_version`, and that is the version to approve. An id outside the plan's options is a 422; a run that is not awaiting approval, or a stale `plan_version`, is a 409. With no engine chosen, contact research runs on DiscoLike Groove when you have a search provider and finds indexed contacts only when you have none. New `stop_reason` values, all plain strings: `candidate_limit` (the run reached its candidate cap; raise `max_candidates` or ask for fewer companies), `credit_limit` (the run stopped at its `max_credits` and kept its results), and `candidates_exhausted` now means only that the search ran dry. An automatic candidate cap may grow once from the run's measured yield; a `max_candidates` you set never does. Interrupted indexed contact searches are rerun rather than skipped. + Existing processing charges and configured BYOK/BYOS integrations apply. Wizard interpretation, segmentation, and prompt preparation use platform credentials. Agent coordination and independent contact qualification use your contact LLM integration; native contacts use your validation LLM integration or organization default, so this workflow requires a customer LLM even with native extraction. Missing keys and provider errors never fall back to platform keys. Limits bound work, not provider dollar spend. Email finder outcomes are exposed; raw email verification is not a public API. diff --git a/packages/discolike/README.md b/packages/discolike/README.md index 76052d0..5fbf4d7 100644 --- a/packages/discolike/README.md +++ b/packages/discolike/README.md @@ -128,6 +128,8 @@ Two errors are specific to the native engine: a 400 `ValidationError` when the I `wait()` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`; timeout stops local polling only. Use `message(run_id, ProspectingMessageRequest(text="..."), idempotency_key="...")` to steer or answer a question, and `get(run_id, ProspectingGetParams(events_after=..., messages_after=...))` for new events and replies. `list(ProspectingListParams(limit=20))` lists recent organization runs, up to 50. `rename(run_id, ProspectingRunUpdate(title="..."))` retitles a run in any status and returns its `list()` summary (titles are 1-80 characters after trimming; result lists it already saved keep their names). `cancel(run_id)` stops a run and returns it; `delete(run_id)` cancels an active run, then removes it so `list()` omits it and `get()` raises `NotFoundError` (charges already incurred stay). The async client has the same methods with `await`. Import these request models from `discolike.requests`. +`update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan's `contact_integration_id`, `validation_integration_id`, `search_provider_id` (`"none"` skips web research) and `max_credits` (`0` removes the limit) using option ids from the plan message's `contact_engine`, `company_check_engine` and `search_provider`, then returns the run with a re-estimated plan at a new `plan_version` to approve. An unknown option id is a 422; a run not awaiting approval or a stale `plan_version` is a 409. `stop_reason` also reports `candidate_limit` and `credit_limit`; `candidates_exhausted` means only that the search ran dry. + Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and continues the run with a notice. Only if the re-pilot fit is still under 20% does it stop with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief in that case. If sharpening itself fails, the run continues on the original criteria. A search drifting off target is dropped, a run short of candidates finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`). The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. Omit target counts to infer them from the brief; explicit values override the text. Work caps default to automatic (`0`). Partial results and `saved_query_id` remain available after stopping. Large results are split into several saved contact lists rather than being cut off; `saved_query_ids` carries every list for the run in order, with `saved_query_id` always the first entry, and the parts are final once the run reaches a terminal status. Customer integration charges apply; work caps do not cap provider dollar spend. diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index c1a38ee..45cc305 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -2956,6 +2956,44 @@ class ProspectingGetParams(DiscolikeRequest): messages_after: Annotated[int | None, Field(ge=0, title="Messages After")] = 0 +class ProspectingPlanSettings(DiscolikeRequest): + """Engine choices for a proposed plan; each omitted field keeps its current choice.""" + + plan_version: Annotated[int, Field(ge=1, title="Plan Version")] + contact_integration_id: Annotated[ + str | None, + Field( + description="Contact research engine: an option id from the plan's contact_engine.", + max_length=128, + title="Contact Integration Id", + ), + ] = None + validation_integration_id: Annotated[ + str | None, + Field( + description="Company check engine: an option id from the plan's company_check_engine.", + max_length=128, + title="Validation Integration Id", + ), + ] = None + search_provider_id: Annotated[ + str | None, + Field( + description="Search provider: an option id from the plan's search_provider; 'none' skips web research.", + max_length=128, + title="Search Provider Id", + ), + ] = None + max_credits: Annotated[ + int | None, + Field( + description="Most credits (billed records) the run may spend before it stops; 0 removes the limit.", + ge=0, + title="Max Credits", + ), + ] = None + + class ProspectingRunUpdate(DiscolikeRequest): title: Annotated[str, Field(max_length=80, min_length=1, title="Title")] diff --git a/packages/discolike/src/discolike/requests.py b/packages/discolike/src/discolike/requests.py index 39ef20f..1154d79 100644 --- a/packages/discolike/src/discolike/requests.py +++ b/packages/discolike/src/discolike/requests.py @@ -33,6 +33,7 @@ from discolike._generated.requests import ProspectingGetParams from discolike._generated.requests import ProspectingListParams from discolike._generated.requests import ProspectingMessageRequest +from discolike._generated.requests import ProspectingPlanSettings from discolike._generated.requests import ProspectingRunUpdate from discolike._generated.requests import QueriesListParams from discolike._generated.requests import SaveResultsRequest @@ -76,6 +77,7 @@ "ProspectingGetParams", "ProspectingListParams", "ProspectingMessageRequest", + "ProspectingPlanSettings", "ProspectingRunUpdate", "QueriesListParams", "SaveResultsRequest", diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index d49499e..a1aa15d 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -18,6 +18,7 @@ from discolike.requests import ProspectingGetParams from discolike.requests import ProspectingListParams from discolike.requests import ProspectingMessageRequest +from discolike.requests import ProspectingPlanSettings from discolike.requests import ProspectingRunUpdate from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource @@ -58,6 +59,7 @@ class ProspectingRunBrief(DiscolikeModel): segment: bool | None = None checkpoints: Literal["ask", "auto"] | str | None = None selected_seed_segments: list[int] | None = None + max_credits: int | None = None class ProspectingEvent(DiscolikeModel): @@ -232,6 +234,19 @@ def cancel(self, run_id: str | UUID) -> ProspectingRun: response = self._transport.request("POST", _path(run_id) + "/cancel") return ProspectingRun.model_validate(response.json()) + @api_route("PATCH", "/prospecting/runs/{run_id}/plan") + def update_plan(self, run_id: str | UUID, request: ProspectingPlanSettings) -> ProspectingRun: + """Choose engines and a credit limit for a proposed plan; returns the run with the re-posted plan. + + Fields left unset keep their current choice; ids come from the latest plan message's + contact_engine, company_check_engine and search_provider options, and search_provider_id="none" + skips web research. max_credits=0 removes the limit. Pass the current plan_version: the plan is + re-estimated at a new plan_version, which is the one to approve. A 422 means an id is not one of the + plan's options, a 409 that the run is not awaiting approval or plan_version is stale. + """ + response = self._transport.request("PATCH", _path(run_id) + "/plan", json_body=request.to_wire()) + return ProspectingRun.model_validate(response.json()) + @api_route("PATCH", "/prospecting/runs/{run_id}") def rename(self, run_id: str | UUID, request: ProspectingRunUpdate) -> ProspectingRunSummary: """Rename the run in any status; returns its summary as list() shows it. @@ -324,6 +339,19 @@ async def cancel(self, run_id: str | UUID) -> ProspectingRun: response = await self._transport.request("POST", _path(run_id) + "/cancel") return ProspectingRun.model_validate(response.json()) + @api_route("PATCH", "/prospecting/runs/{run_id}/plan") + async def update_plan(self, run_id: str | UUID, request: ProspectingPlanSettings) -> ProspectingRun: + """Choose engines and a credit limit for a proposed plan; returns the run with the re-posted plan. + + Fields left unset keep their current choice; ids come from the latest plan message's + contact_engine, company_check_engine and search_provider options, and search_provider_id="none" + skips web research. max_credits=0 removes the limit. Pass the current plan_version: the plan is + re-estimated at a new plan_version, which is the one to approve. A 422 means an id is not one of the + plan's options, a 409 that the run is not awaiting approval or plan_version is stale. + """ + response = await self._transport.request("PATCH", _path(run_id) + "/plan", json_body=request.to_wire()) + return ProspectingRun.model_validate(response.json()) + @api_route("PATCH", "/prospecting/runs/{run_id}") async def rename(self, run_id: str | UUID, request: ProspectingRunUpdate) -> ProspectingRunSummary: """Rename the run in any status; returns its summary as list() shows it. diff --git a/packages/discolike/tests/test_gen_requests.py b/packages/discolike/tests/test_gen_requests.py index 694a1af..5204595 100644 --- a/packages/discolike/tests/test_gen_requests.py +++ b/packages/discolike/tests/test_gen_requests.py @@ -274,5 +274,5 @@ def test_compare_prints_a_diff_and_returns_one_on_drift(gen, capsys) -> None: def test_collect_routes_covers_every_stamped_sync_route(gen) -> None: routes = gen.collect_routes() - assert len(routes) == 56 + assert len(routes) == 57 assert all(not route.class_name.startswith("Async") for route in routes) diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index e4c8e8a..5b8c890 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -9,6 +9,7 @@ import discolike.resources.prospecting as module from discolike import CHECKPOINT_STOP_REASONS +from discolike import DiscolikeError from discolike import JobTimeoutError from discolike import NotFoundError from discolike.requests import ProspectingApproveRequest @@ -16,6 +17,7 @@ from discolike.requests import ProspectingGetParams from discolike.requests import ProspectingListParams from discolike.requests import ProspectingMessageRequest +from discolike.requests import ProspectingPlanSettings from discolike.requests import ProspectingRunUpdate from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -191,6 +193,60 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert json.loads(seen[0].content) == {"title": "Renamed"} +def test_update_plan_patches_only_the_set_fields(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json=payload("proposed") | {"plan_version": 2}) + + with make_client(handler) as client: + run = client.prospecting.update_plan( + RUN_ID, ProspectingPlanSettings(plan_version=1, search_provider_id="none", max_credits=0) + ) + assert run.plan_version == 2 + assert [(r.method, r.url.path) for r in seen] == [("PATCH", f"/v1/prospecting/runs/{RUN_ID}/plan")] + assert json.loads(seen[0].content) == {"plan_version": 1, "search_provider_id": "none", "max_credits": 0} + + +@pytest.mark.parametrize("status", [409, 422]) +def test_update_plan_surfaces_rejections(make_client: ClientFactory, status: int) -> None: + with ( + make_client(lambda request: httpx2.Response(status, json={"detail": "rejected"})) as client, + pytest.raises(DiscolikeError), + ): + client.prospecting.update_plan(RUN_ID, ProspectingPlanSettings(plan_version=1, contact_integration_id="x")) + + +def test_update_plan_validates_locally() -> None: + with pytest.raises(ValidationError): + ProspectingPlanSettings(plan_version=1, max_credits=-1) + with pytest.raises(ValidationError): + ProspectingPlanSettings(plan_version=0) + + +async def test_async_update_plan_patches_the_plan(make_async_client: AsyncClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json=payload("proposed") | {"plan_version": 3}) + + async with make_async_client(handler) as client: + run = await client.prospecting.update_plan(RUN_ID, ProspectingPlanSettings(plan_version=2, max_credits=500)) + assert run.plan_version == 3 + assert [(r.method, r.url.path) for r in seen] == [("PATCH", f"/v1/prospecting/runs/{RUN_ID}/plan")] + assert json.loads(seen[0].content) == {"plan_version": 2, "max_credits": 500} + + +@pytest.mark.parametrize("reason", ["candidate_limit", "credit_limit", "a_reason_added_later"]) +def test_stop_reason_stays_an_open_string(make_client: ClientFactory, reason: str) -> None: + with make_client( + lambda request: httpx2.Response(200, json=payload("completed") | {"stop_reason": reason}) + ) as client: + assert client.prospecting.get(RUN_ID).stop_reason == reason + + def test_wait_returns_a_proposed_plan(make_client: ClientFactory) -> None: with make_client(lambda request: httpx2.Response(200, json=payload("proposed"))) as client: assert client.prospecting.wait(RUN_ID).status == "proposed" From de47e961f741d2c303c0399e7bbc07a151125ecf Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Tue, 29 Sep 2026 22:49:27 -0700 Subject: [PATCH 2/9] Take the prospecting run limit in dollars, not records The API now prices a run's limit at the plan's per-record rate, so the plan settings take max_spend_usd in place of max_credits. --- CHANGELOG.md | 2 +- README.md | 4 ++-- packages/discolike/README.md | 2 +- .../src/discolike/_generated/requests.py | 10 +++++----- .../src/discolike/resources/prospecting.py | 19 ++++++++++++------- packages/discolike/tests/test_prospecting.py | 10 +++++----- 6 files changed, 26 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ce76b..8c02486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - **Breaking:** the polling deadline on `Job.wait()`, `EmailJob.wait()` and `EmailBatch.results()` (sync and async) is renamed from `timeout=` to `max_wait=`, and `signup()` / `async_signup()` take `request_timeout=` instead of `timeout=`. Behavior is unchanged; the client constructor, `with_options(timeout=...)` and the CLI's `--timeout` keep their names. - SDK: response enums such as `JobStatus` are open, so a value a newer API adds parses instead of failing. Compare against the documented values and treat anything else as unknown. - SDK/CLI: managed prospecting. `client.prospecting` starts, reads, lists, approves, messages, renames, cancels, deletes and waits on runs (sync and async), with typed plans, chat, progress and saved contact lists (`saved_query_ids`, split into parts for large results). `ProspectingBrief.checkpoints="ask"` pauses the run at a pilot, a drifting search, a shortfall or the target for you to answer; `customer_domains` groups your customers into segments and finds lookalikes of each, picked on approval with `seed_segments`. `rename()` retitles a run in any status with `ProspectingRunUpdate(title=...)` and returns its `list()` summary; result lists it already saved keep their names. `cancel()` stops a run and keeps it; `delete()` cancels an active run, then removes it from `list()` and `get()` (charges already incurred stay). The CLI adds `prospecting start/status/list/approve/message/cancel/wait`, asks at checkpoints on a terminal, and exits 7 with the question on stderr under `--no-input`. -- SDK: `client.prospecting.update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` (sync and async) changes a proposed plan's contact engine, company-check engine, search provider (`"none"` skips web research) and `max_credits` (`0` removes the limit) before approval, then returns the run with the re-estimated plan at a new `plan_version`. Prospecting `stop_reason` gains `candidate_limit` (candidate cap reached) and `credit_limit` (stopped at `max_credits`, results kept); `candidates_exhausted` now means only that the search ran dry. `stop_reason` stays an open string. Runs without a chosen contact engine research on DiscoLike Groove, or find indexed contacts only with no search provider. +- SDK: `client.prospecting.update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` (sync and async) changes a proposed plan's contact engine, company-check engine, search provider (`"none"` skips web research) and `max_spend_usd` (a USD spending limit at the plan's per-record rate; `0` removes it) before approval, then returns the run with the re-estimated plan at a new `plan_version`. Prospecting `stop_reason` gains `candidate_limit` (candidate cap reached) and `credit_limit` (stopped at the spending limit, results kept); `candidates_exhausted` now means only that the search ran dry. `stop_reason` stays an open string. Runs without a chosen contact engine research on DiscoLike Groove, or find indexed contacts only with no search provider. ## 0.4.1 (2026-09-23) diff --git a/README.md b/README.md index 3fe74e7..21e0e75 100644 --- a/README.md +++ b/README.md @@ -454,7 +454,7 @@ page = client.prospecting.get( ProspectingGetParams(offset=0, limit=100, events_after=run.next_event_seq, messages_after=run.next_message_seq), ) # Pick engines or a credit limit from the plan card's options before approving; approve the returned plan_version. -# run = client.prospecting.update_plan(run.run_id, ProspectingPlanSettings(plan_version=run.plan_version, max_credits=5000)) +# run = client.prospecting.update_plan(run.run_id, ProspectingPlanSettings(plan_version=run.plan_version, max_spend_usd=25)) # client.prospecting.rename(run.run_id, ProspectingRunUpdate(title="Logistics ops leaders")) # Any status. # client.prospecting.cancel(run.run_id) # Stop the run; it and its results stay readable. # client.prospecting.delete(run.run_id) # Cancel if active, then remove it from list() and get(). @@ -468,6 +468,6 @@ Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its dec Initial planning extracts company counts and contacts per company from the brief. Omitted settings keep that inference available, falling back to 1,000 companies and 1 contact per company. Explicit settings, including explicit defaults, override the text. Targets support 1–10,000 companies and 1–5 contacts per company. Candidate and action caps default to automatic (`0`); explicit maxima are 100,000 candidates and 10,000 actions. Result pages support up to 500 rows; recent-run lists support up to 50. Approved runs expose a stable `saved_query_id` for saved results. -Run controls: `update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan before approval. `contact_integration_id`, `validation_integration_id` and `search_provider_id` take an option id from the plan message's `contact_engine`, `company_check_engine` and `search_provider` (`data.contact_engine.options` and so on); omitted fields keep their choice, `search_provider_id="none"` skips web research, and `max_credits` sets the most credits the run may spend (`0` removes the limit). The plan is re-estimated and posted again at a new `plan_version`, and that is the version to approve. An id outside the plan's options is a 422; a run that is not awaiting approval, or a stale `plan_version`, is a 409. With no engine chosen, contact research runs on DiscoLike Groove when you have a search provider and finds indexed contacts only when you have none. New `stop_reason` values, all plain strings: `candidate_limit` (the run reached its candidate cap; raise `max_candidates` or ask for fewer companies), `credit_limit` (the run stopped at its `max_credits` and kept its results), and `candidates_exhausted` now means only that the search ran dry. An automatic candidate cap may grow once from the run's measured yield; a `max_candidates` you set never does. Interrupted indexed contact searches are rerun rather than skipped. +Run controls: `update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan before approval. `contact_integration_id`, `validation_integration_id` and `search_provider_id` take an option id from the plan message's `contact_engine`, `company_check_engine` and `search_provider` (`data.contact_engine.options` and so on); omitted fields keep their choice, `search_provider_id="none"` skips web research, and `max_spend_usd` sets the most the run may spend on DiscoLike records, in USD at your plan's per-record rate (`0` removes the limit; a plan with no per-record price is a 422). The plan is re-estimated and posted again at a new `plan_version`, and that is the version to approve. An id outside the plan's options is a 422; a run that is not awaiting approval, or a stale `plan_version`, is a 409. With no engine chosen, contact research runs on DiscoLike Groove when you have a search provider and finds indexed contacts only when you have none. New `stop_reason` values, all plain strings: `candidate_limit` (the run reached its candidate cap; raise `max_candidates` or ask for fewer companies), `credit_limit` (the run stopped at its spending limit and kept its results), and `candidates_exhausted` now means only that the search ran dry. An automatic candidate cap may grow once from the run's measured yield; a `max_candidates` you set never does. Interrupted indexed contact searches are rerun rather than skipped. Existing processing charges and configured BYOK/BYOS integrations apply. Wizard interpretation, segmentation, and prompt preparation use platform credentials. Agent coordination and independent contact qualification use your contact LLM integration; native contacts use your validation LLM integration or organization default, so this workflow requires a customer LLM even with native extraction. Missing keys and provider errors never fall back to platform keys. Limits bound work, not provider dollar spend. Email finder outcomes are exposed; raw email verification is not a public API. diff --git a/packages/discolike/README.md b/packages/discolike/README.md index 5fbf4d7..21455bb 100644 --- a/packages/discolike/README.md +++ b/packages/discolike/README.md @@ -128,7 +128,7 @@ Two errors are specific to the native engine: a 400 `ValidationError` when the I `wait()` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`; timeout stops local polling only. Use `message(run_id, ProspectingMessageRequest(text="..."), idempotency_key="...")` to steer or answer a question, and `get(run_id, ProspectingGetParams(events_after=..., messages_after=...))` for new events and replies. `list(ProspectingListParams(limit=20))` lists recent organization runs, up to 50. `rename(run_id, ProspectingRunUpdate(title="..."))` retitles a run in any status and returns its `list()` summary (titles are 1-80 characters after trimming; result lists it already saved keep their names). `cancel(run_id)` stops a run and returns it; `delete(run_id)` cancels an active run, then removes it so `list()` omits it and `get()` raises `NotFoundError` (charges already incurred stay). The async client has the same methods with `await`. Import these request models from `discolike.requests`. -`update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan's `contact_integration_id`, `validation_integration_id`, `search_provider_id` (`"none"` skips web research) and `max_credits` (`0` removes the limit) using option ids from the plan message's `contact_engine`, `company_check_engine` and `search_provider`, then returns the run with a re-estimated plan at a new `plan_version` to approve. An unknown option id is a 422; a run not awaiting approval or a stale `plan_version` is a 409. `stop_reason` also reports `candidate_limit` and `credit_limit`; `candidates_exhausted` means only that the search ran dry. +`update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan's `contact_integration_id`, `validation_integration_id`, `search_provider_id` (`"none"` skips web research) and `max_spend_usd` (a USD spending limit at the plan's per-record rate; `0` removes it) using option ids from the plan message's `contact_engine`, `company_check_engine` and `search_provider`, then returns the run with a re-estimated plan at a new `plan_version` to approve. An unknown option id is a 422; a run not awaiting approval or a stale `plan_version` is a 409. `stop_reason` also reports `candidate_limit` and `credit_limit`; `candidates_exhausted` means only that the search ran dry. Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and continues the run with a notice. Only if the re-pilot fit is still under 20% does it stop with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief in that case. If sharpening itself fails, the run continues on the original criteria. A search drifting off target is dropped, a run short of candidates finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`). The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 45cc305..0f2abe8 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -2984,12 +2984,12 @@ class ProspectingPlanSettings(DiscolikeRequest): title="Search Provider Id", ), ] = None - max_credits: Annotated[ - int | None, + max_spend_usd: Annotated[ + float | None, Field( - description="Most credits (billed records) the run may spend before it stops; 0 removes the limit.", - ge=0, - title="Max Credits", + description="Most the run may spend on DiscoLike records, in USD at your plan's per-record rate, before it stops; 0 removes the limit.", + ge=0.0, + title="Max Spend Usd", ), ] = None diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index a1aa15d..8c07e3e 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -59,7 +59,8 @@ class ProspectingRunBrief(DiscolikeModel): segment: bool | None = None checkpoints: Literal["ask", "auto"] | str | None = None selected_seed_segments: list[int] | None = None - max_credits: int | None = None + max_records: int | None = None + max_spend_usd: float | None = None class ProspectingEvent(DiscolikeModel): @@ -236,13 +237,15 @@ def cancel(self, run_id: str | UUID) -> ProspectingRun: @api_route("PATCH", "/prospecting/runs/{run_id}/plan") def update_plan(self, run_id: str | UUID, request: ProspectingPlanSettings) -> ProspectingRun: - """Choose engines and a credit limit for a proposed plan; returns the run with the re-posted plan. + """Choose engines and a spending limit for a proposed plan; returns the run with the re-posted plan. Fields left unset keep their current choice; ids come from the latest plan message's contact_engine, company_check_engine and search_provider options, and search_provider_id="none" - skips web research. max_credits=0 removes the limit. Pass the current plan_version: the plan is + skips web research. max_spend_usd (USD at the plan's per-record rate) of 0 removes the limit; + engines left unset are re-chosen around the picks. Pass the current plan_version: the plan is re-estimated at a new plan_version, which is the one to approve. A 422 means an id is not one of the - plan's options, a 409 that the run is not awaiting approval or plan_version is stale. + plan's options or the plan has no per-record price, a 409 that the run is not awaiting approval or + plan_version is stale. """ response = self._transport.request("PATCH", _path(run_id) + "/plan", json_body=request.to_wire()) return ProspectingRun.model_validate(response.json()) @@ -341,13 +344,15 @@ async def cancel(self, run_id: str | UUID) -> ProspectingRun: @api_route("PATCH", "/prospecting/runs/{run_id}/plan") async def update_plan(self, run_id: str | UUID, request: ProspectingPlanSettings) -> ProspectingRun: - """Choose engines and a credit limit for a proposed plan; returns the run with the re-posted plan. + """Choose engines and a spending limit for a proposed plan; returns the run with the re-posted plan. Fields left unset keep their current choice; ids come from the latest plan message's contact_engine, company_check_engine and search_provider options, and search_provider_id="none" - skips web research. max_credits=0 removes the limit. Pass the current plan_version: the plan is + skips web research. max_spend_usd (USD at the plan's per-record rate) of 0 removes the limit; + engines left unset are re-chosen around the picks. Pass the current plan_version: the plan is re-estimated at a new plan_version, which is the one to approve. A 422 means an id is not one of the - plan's options, a 409 that the run is not awaiting approval or plan_version is stale. + plan's options or the plan has no per-record price, a 409 that the run is not awaiting approval or + plan_version is stale. """ response = await self._transport.request("PATCH", _path(run_id) + "/plan", json_body=request.to_wire()) return ProspectingRun.model_validate(response.json()) diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index 5b8c890..cc41409 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -202,11 +202,11 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: run = client.prospecting.update_plan( - RUN_ID, ProspectingPlanSettings(plan_version=1, search_provider_id="none", max_credits=0) + RUN_ID, ProspectingPlanSettings(plan_version=1, search_provider_id="none", max_spend_usd=0) ) assert run.plan_version == 2 assert [(r.method, r.url.path) for r in seen] == [("PATCH", f"/v1/prospecting/runs/{RUN_ID}/plan")] - assert json.loads(seen[0].content) == {"plan_version": 1, "search_provider_id": "none", "max_credits": 0} + assert json.loads(seen[0].content) == {"plan_version": 1, "search_provider_id": "none", "max_spend_usd": 0} @pytest.mark.parametrize("status", [409, 422]) @@ -220,7 +220,7 @@ def test_update_plan_surfaces_rejections(make_client: ClientFactory, status: int def test_update_plan_validates_locally() -> None: with pytest.raises(ValidationError): - ProspectingPlanSettings(plan_version=1, max_credits=-1) + ProspectingPlanSettings(plan_version=1, max_spend_usd=-1) with pytest.raises(ValidationError): ProspectingPlanSettings(plan_version=0) @@ -233,10 +233,10 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=payload("proposed") | {"plan_version": 3}) async with make_async_client(handler) as client: - run = await client.prospecting.update_plan(RUN_ID, ProspectingPlanSettings(plan_version=2, max_credits=500)) + run = await client.prospecting.update_plan(RUN_ID, ProspectingPlanSettings(plan_version=2, max_spend_usd=25.0)) assert run.plan_version == 3 assert [(r.method, r.url.path) for r in seen] == [("PATCH", f"/v1/prospecting/runs/{RUN_ID}/plan")] - assert json.loads(seen[0].content) == {"plan_version": 2, "max_credits": 500} + assert json.loads(seen[0].content) == {"plan_version": 2, "max_spend_usd": 25.0} @pytest.mark.parametrize("reason", ["candidate_limit", "credit_limit", "a_reason_added_later"]) From 1097239d505c7fcd6a762a9661e289dcc858a672 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Tue, 29 Sep 2026 23:04:48 -0700 Subject: [PATCH 3/9] Say the prospecting spending limit covers per-call fees --- CHANGELOG.md | 2 +- README.md | 2 +- packages/discolike/README.md | 2 +- packages/discolike/src/discolike/_generated/requests.py | 2 +- packages/discolike/src/discolike/resources/prospecting.py | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c02486..8399a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - **Breaking:** the polling deadline on `Job.wait()`, `EmailJob.wait()` and `EmailBatch.results()` (sync and async) is renamed from `timeout=` to `max_wait=`, and `signup()` / `async_signup()` take `request_timeout=` instead of `timeout=`. Behavior is unchanged; the client constructor, `with_options(timeout=...)` and the CLI's `--timeout` keep their names. - SDK: response enums such as `JobStatus` are open, so a value a newer API adds parses instead of failing. Compare against the documented values and treat anything else as unknown. - SDK/CLI: managed prospecting. `client.prospecting` starts, reads, lists, approves, messages, renames, cancels, deletes and waits on runs (sync and async), with typed plans, chat, progress and saved contact lists (`saved_query_ids`, split into parts for large results). `ProspectingBrief.checkpoints="ask"` pauses the run at a pilot, a drifting search, a shortfall or the target for you to answer; `customer_domains` groups your customers into segments and finds lookalikes of each, picked on approval with `seed_segments`. `rename()` retitles a run in any status with `ProspectingRunUpdate(title=...)` and returns its `list()` summary; result lists it already saved keep their names. `cancel()` stops a run and keeps it; `delete()` cancels an active run, then removes it from `list()` and `get()` (charges already incurred stay). The CLI adds `prospecting start/status/list/approve/message/cancel/wait`, asks at checkpoints on a terminal, and exits 7 with the question on stderr under `--no-input`. -- SDK: `client.prospecting.update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` (sync and async) changes a proposed plan's contact engine, company-check engine, search provider (`"none"` skips web research) and `max_spend_usd` (a USD spending limit at the plan's per-record rate; `0` removes it) before approval, then returns the run with the re-estimated plan at a new `plan_version`. Prospecting `stop_reason` gains `candidate_limit` (candidate cap reached) and `credit_limit` (stopped at the spending limit, results kept); `candidates_exhausted` now means only that the search ran dry. `stop_reason` stays an open string. Runs without a chosen contact engine research on DiscoLike Groove, or find indexed contacts only with no search provider. +- SDK: `client.prospecting.update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` (sync and async) changes a proposed plan's contact engine, company-check engine, search provider (`"none"` skips web research) and `max_spend_usd` (a USD spending limit on records and per-request fees at the plan's rates; `0` removes it) before approval, then returns the run with the re-estimated plan at a new `plan_version`. Prospecting `stop_reason` gains `candidate_limit` (candidate cap reached) and `credit_limit` (stopped at the spending limit, results kept); `candidates_exhausted` now means only that the search ran dry. `stop_reason` stays an open string. Runs without a chosen contact engine research on DiscoLike Groove, or find indexed contacts only with no search provider. ## 0.4.1 (2026-09-23) diff --git a/README.md b/README.md index 21e0e75..274f54d 100644 --- a/README.md +++ b/README.md @@ -468,6 +468,6 @@ Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its dec Initial planning extracts company counts and contacts per company from the brief. Omitted settings keep that inference available, falling back to 1,000 companies and 1 contact per company. Explicit settings, including explicit defaults, override the text. Targets support 1–10,000 companies and 1–5 contacts per company. Candidate and action caps default to automatic (`0`); explicit maxima are 100,000 candidates and 10,000 actions. Result pages support up to 500 rows; recent-run lists support up to 50. Approved runs expose a stable `saved_query_id` for saved results. -Run controls: `update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan before approval. `contact_integration_id`, `validation_integration_id` and `search_provider_id` take an option id from the plan message's `contact_engine`, `company_check_engine` and `search_provider` (`data.contact_engine.options` and so on); omitted fields keep their choice, `search_provider_id="none"` skips web research, and `max_spend_usd` sets the most the run may spend on DiscoLike records, in USD at your plan's per-record rate (`0` removes the limit; a plan with no per-record price is a 422). The plan is re-estimated and posted again at a new `plan_version`, and that is the version to approve. An id outside the plan's options is a 422; a run that is not awaiting approval, or a stale `plan_version`, is a 409. With no engine chosen, contact research runs on DiscoLike Groove when you have a search provider and finds indexed contacts only when you have none. New `stop_reason` values, all plain strings: `candidate_limit` (the run reached its candidate cap; raise `max_candidates` or ask for fewer companies), `credit_limit` (the run stopped at its spending limit and kept its results), and `candidates_exhausted` now means only that the search ran dry. An automatic candidate cap may grow once from the run's measured yield; a `max_candidates` you set never does. Interrupted indexed contact searches are rerun rather than skipped. +Run controls: `update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan before approval. `contact_integration_id`, `validation_integration_id` and `search_provider_id` take an option id from the plan message's `contact_engine`, `company_check_engine` and `search_provider` (`data.contact_engine.options` and so on); omitted fields keep their choice, `search_provider_id="none"` skips web research, and `max_spend_usd` sets the most the run may spend on DiscoLike records and per-request fees, in USD at your plan's rates (`0` removes the limit; a plan with no per-record price is a 422). The plan is re-estimated and posted again at a new `plan_version`, and that is the version to approve. An id outside the plan's options is a 422; a run that is not awaiting approval, or a stale `plan_version`, is a 409. With no engine chosen, contact research runs on DiscoLike Groove when you have a search provider and finds indexed contacts only when you have none. New `stop_reason` values, all plain strings: `candidate_limit` (the run reached its candidate cap; raise `max_candidates` or ask for fewer companies), `credit_limit` (the run stopped at its spending limit and kept its results), and `candidates_exhausted` now means only that the search ran dry. An automatic candidate cap may grow once from the run's measured yield; a `max_candidates` you set never does. Interrupted indexed contact searches are rerun rather than skipped. Existing processing charges and configured BYOK/BYOS integrations apply. Wizard interpretation, segmentation, and prompt preparation use platform credentials. Agent coordination and independent contact qualification use your contact LLM integration; native contacts use your validation LLM integration or organization default, so this workflow requires a customer LLM even with native extraction. Missing keys and provider errors never fall back to platform keys. Limits bound work, not provider dollar spend. Email finder outcomes are exposed; raw email verification is not a public API. diff --git a/packages/discolike/README.md b/packages/discolike/README.md index 21455bb..769d3b1 100644 --- a/packages/discolike/README.md +++ b/packages/discolike/README.md @@ -128,7 +128,7 @@ Two errors are specific to the native engine: a 400 `ValidationError` when the I `wait()` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`; timeout stops local polling only. Use `message(run_id, ProspectingMessageRequest(text="..."), idempotency_key="...")` to steer or answer a question, and `get(run_id, ProspectingGetParams(events_after=..., messages_after=...))` for new events and replies. `list(ProspectingListParams(limit=20))` lists recent organization runs, up to 50. `rename(run_id, ProspectingRunUpdate(title="..."))` retitles a run in any status and returns its `list()` summary (titles are 1-80 characters after trimming; result lists it already saved keep their names). `cancel(run_id)` stops a run and returns it; `delete(run_id)` cancels an active run, then removes it so `list()` omits it and `get()` raises `NotFoundError` (charges already incurred stay). The async client has the same methods with `await`. Import these request models from `discolike.requests`. -`update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan's `contact_integration_id`, `validation_integration_id`, `search_provider_id` (`"none"` skips web research) and `max_spend_usd` (a USD spending limit at the plan's per-record rate; `0` removes it) using option ids from the plan message's `contact_engine`, `company_check_engine` and `search_provider`, then returns the run with a re-estimated plan at a new `plan_version` to approve. An unknown option id is a 422; a run not awaiting approval or a stale `plan_version` is a 409. `stop_reason` also reports `candidate_limit` and `credit_limit`; `candidates_exhausted` means only that the search ran dry. +`update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan's `contact_integration_id`, `validation_integration_id`, `search_provider_id` (`"none"` skips web research) and `max_spend_usd` (a USD spending limit on records and per-request fees at the plan's rates; `0` removes it) using option ids from the plan message's `contact_engine`, `company_check_engine` and `search_provider`, then returns the run with a re-estimated plan at a new `plan_version` to approve. An unknown option id is a 422; a run not awaiting approval or a stale `plan_version` is a 409. `stop_reason` also reports `candidate_limit` and `credit_limit`; `candidates_exhausted` means only that the search ran dry. Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and continues the run with a notice. Only if the re-pilot fit is still under 20% does it stop with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief in that case. If sharpening itself fails, the run continues on the original criteria. A search drifting off target is dropped, a run short of candidates finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`). The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 0f2abe8..424d0fd 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -2987,7 +2987,7 @@ class ProspectingPlanSettings(DiscolikeRequest): max_spend_usd: Annotated[ float | None, Field( - description="Most the run may spend on DiscoLike records, in USD at your plan's per-record rate, before it stops; 0 removes the limit.", + description="Most the run may spend on DiscoLike records and per-call fees, in USD at your plan's rates, before it stops; 0 removes the limit.", ge=0.0, title="Max Spend Usd", ), diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index 8c07e3e..02f6f5f 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -241,7 +241,7 @@ def update_plan(self, run_id: str | UUID, request: ProspectingPlanSettings) -> P Fields left unset keep their current choice; ids come from the latest plan message's contact_engine, company_check_engine and search_provider options, and search_provider_id="none" - skips web research. max_spend_usd (USD at the plan's per-record rate) of 0 removes the limit; + skips web research. max_spend_usd (USD for records and per-call fees at the plan's rates) of 0 removes the limit; engines left unset are re-chosen around the picks. Pass the current plan_version: the plan is re-estimated at a new plan_version, which is the one to approve. A 422 means an id is not one of the plan's options or the plan has no per-record price, a 409 that the run is not awaiting approval or @@ -348,7 +348,7 @@ async def update_plan(self, run_id: str | UUID, request: ProspectingPlanSettings Fields left unset keep their current choice; ids come from the latest plan message's contact_engine, company_check_engine and search_provider options, and search_provider_id="none" - skips web research. max_spend_usd (USD at the plan's per-record rate) of 0 removes the limit; + skips web research. max_spend_usd (USD for records and per-call fees at the plan's rates) of 0 removes the limit; engines left unset are re-chosen around the picks. Pass the current plan_version: the plan is re-estimated at a new plan_version, which is the one to approve. A 422 means an id is not one of the plan's options or the plan has no per-record price, a 409 that the run is not awaiting approval or From 1305698d6c3ce10b25515a518cba5878be8eb1e6 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Tue, 29 Sep 2026 23:48:22 -0700 Subject: [PATCH 4/9] Drop the spending limit from the prospecting run brief The API keeps the limit in run state, so run briefs never carry it; only the plan settings take max_spend_usd. --- packages/discolike/src/discolike/resources/prospecting.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index 02f6f5f..e3406d9 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -59,8 +59,6 @@ class ProspectingRunBrief(DiscolikeModel): segment: bool | None = None checkpoints: Literal["ask", "auto"] | str | None = None selected_seed_segments: list[int] | None = None - max_records: int | None = None - max_spend_usd: float | None = None class ProspectingEvent(DiscolikeModel): From 3ed840baa86d7d4eadc0041aebd253d68b29e4ec Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Tue, 29 Sep 2026 22:35:42 -0700 Subject: [PATCH 5/9] Add prospecting.answer_intake for the guided intake card Runs can now ask a short option card before planning. answer_intake posts the picked values / free text per question key through the existing messages route, so older SDKs and servers are unaffected. ProspectingMessageRequest.text becomes optional. --- CHANGELOG.md | 4 ++ README.md | 2 + .../discolike-cli/tests/test_sdk_parity.py | 1 + .../src/discolike/_generated/requests.py | 21 ++++++-- packages/discolike/src/discolike/requests.py | 2 + .../src/discolike/resources/prospecting.py | 35 +++++++++++++ packages/discolike/tests/test_prospecting.py | 51 +++++++++++++++++++ 7 files changed, 112 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8399a44..c2a7fe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,11 @@ - **Breaking:** the polling deadline on `Job.wait()`, `EmailJob.wait()` and `EmailBatch.results()` (sync and async) is renamed from `timeout=` to `max_wait=`, and `signup()` / `async_signup()` take `request_timeout=` instead of `timeout=`. Behavior is unchanged; the client constructor, `with_options(timeout=...)` and the CLI's `--timeout` keep their names. - SDK: response enums such as `JobStatus` are open, so a value a newer API adds parses instead of failing. Compare against the documented values and treat anything else as unknown. - SDK/CLI: managed prospecting. `client.prospecting` starts, reads, lists, approves, messages, renames, cancels, deletes and waits on runs (sync and async), with typed plans, chat, progress and saved contact lists (`saved_query_ids`, split into parts for large results). `ProspectingBrief.checkpoints="ask"` pauses the run at a pilot, a drifting search, a shortfall or the target for you to answer; `customer_domains` groups your customers into segments and finds lookalikes of each, picked on approval with `seed_segments`. `rename()` retitles a run in any status with `ProspectingRunUpdate(title=...)` and returns its `list()` summary; result lists it already saved keep their names. `cancel()` stops a run and keeps it; `delete()` cancels an active run, then removes it from `list()` and `get()` (charges already incurred stay). The CLI adds `prospecting start/status/list/approve/message/cancel/wait`, asks at checkpoints on a terminal, and exits 7 with the question on stderr under `--no-input`. +<<<<<<< HEAD - SDK: `client.prospecting.update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` (sync and async) changes a proposed plan's contact engine, company-check engine, search provider (`"none"` skips web research) and `max_spend_usd` (a USD spending limit on records and per-request fees at the plan's rates; `0` removes it) before approval, then returns the run with the re-estimated plan at a new `plan_version`. Prospecting `stop_reason` gains `candidate_limit` (candidate cap reached) and `credit_limit` (stopped at the spending limit, results kept); `candidates_exhausted` now means only that the search ran dry. `stop_reason` stays an open string. Runs without a chosen contact engine research on DiscoLike Groove, or find indexed contacts only with no search provider. +======= +- SDK: `client.prospecting.answer_intake(run_id, answers, idempotency_key=..., summary=None)` (sync and async) answers the guided-intake card a run asks before it drafts a plan. `answers` maps a question key (`company_activity`, `industry`, `geography`, `company_size`, `persona_roles`, `list_size`) to `IntakeAnswer(values=[...], other="...")`; the card's fields are on the `kind="question"` message's `data`. `ProspectingMessageRequest.text` is now optional, so a message can carry `intake` alone. +>>>>>>> 783249d (Add prospecting.answer_intake for the guided intake card) ## 0.4.1 (2026-09-23) diff --git a/README.md b/README.md index 274f54d..729d08d 100644 --- a/README.md +++ b/README.md @@ -466,6 +466,8 @@ The async client exposes the same methods with `await`. Starts and messages requ Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and checks again, then stops with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief. A search drifting off target is dropped, a run whose search runs dry finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`). The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. +Intake: a run can pause on a `kind="question"` message whose `data` lists a few option fields (keys `company_activity`, `industry`, `geography`, `company_size`, `persona_roles`, `list_size`). Answer it with `client.prospecting.answer_intake(run.run_id, {"company_activity": IntakeAnswer(values=["sell"]), "geography": IntakeAnswer(other="Ohio")}, idempotency_key=...)`: `values` are the option values you picked and `other` is free text. An answer sent after the card was superseded returns 409. Typing free text with `message()` instead still works. + Initial planning extracts company counts and contacts per company from the brief. Omitted settings keep that inference available, falling back to 1,000 companies and 1 contact per company. Explicit settings, including explicit defaults, override the text. Targets support 1–10,000 companies and 1–5 contacts per company. Candidate and action caps default to automatic (`0`); explicit maxima are 100,000 candidates and 10,000 actions. Result pages support up to 500 rows; recent-run lists support up to 50. Approved runs expose a stable `saved_query_id` for saved results. Run controls: `update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan before approval. `contact_integration_id`, `validation_integration_id` and `search_provider_id` take an option id from the plan message's `contact_engine`, `company_check_engine` and `search_provider` (`data.contact_engine.options` and so on); omitted fields keep their choice, `search_provider_id="none"` skips web research, and `max_spend_usd` sets the most the run may spend on DiscoLike records and per-request fees, in USD at your plan's rates (`0` removes the limit; a plan with no per-record price is a 422). The plan is re-estimated and posted again at a new `plan_version`, and that is the version to approve. An id outside the plan's options is a 422; a run that is not awaiting approval, or a stale `plan_version`, is a 409. With no engine chosen, contact research runs on DiscoLike Groove when you have a search provider and finds indexed contacts only when you have none. New `stop_reason` values, all plain strings: `candidate_limit` (the run reached its candidate cap; raise `max_candidates` or ask for fewer companies), `credit_limit` (the run stopped at its spending limit and kept its results), and `candidates_exhausted` now means only that the search ran dry. An automatic candidate cap may grow once from the run's measured yield; a `max_candidates` you set never does. Interrupted indexed contact searches are rerun rather than skipped. diff --git a/packages/discolike-cli/tests/test_sdk_parity.py b/packages/discolike-cli/tests/test_sdk_parity.py index ba1b1c3..a4a86e5 100644 --- a/packages/discolike-cli/tests/test_sdk_parity.py +++ b/packages/discolike-cli/tests/test_sdk_parity.py @@ -25,6 +25,7 @@ "ContactsCountParams": frozenset({"icp_text"}), "ContactFilters": frozenset({"icp_text"}), "FindEmailBatchRequest": frozenset({"source_query_id", "refs", "round"}), + "ProspectingMessageRequest": frozenset({"intake"}), } # ``discolike bulk`` takes the full vocabulary through --params-file / --param and manages the paging # fields itself; its flags are the handful a volume run needs, not one per SDK field. diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 424d0fd..59771da 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -2915,10 +2915,6 @@ class ProspectingListParams(DiscolikeRequest): ] = None -class ProspectingMessageRequest(DiscolikeRequest): - text: Annotated[str, Field(max_length=4000, min_length=1, title="Text")] - - class ProspectingBrief(DiscolikeRequest): brief: Annotated[str, Field(max_length=4000, min_length=10, title="Brief")] domains: Annotated[list[str] | None, Field(max_length=1000, title="Domains")] = None @@ -3217,6 +3213,11 @@ class BulkContactMatchQueryItem(DiscolikeRequest): ] = None +class IntakeAnswer(DiscolikeRequest): + values: Annotated[list[str] | None, Field(title="Values")] = None + other: Annotated[str | None, Field(max_length=200, title="Other")] = None + + class BulkContactMatchRequest(DiscolikeRequest): queries: Annotated[ list[BulkContactMatchQueryItem], @@ -3245,3 +3246,15 @@ class ProspectingApproveRequest(DiscolikeRequest): checkpoints: Literal["ask", "auto"] | None = None seed_segments: Annotated[list[int] | None, Field(min_length=1, title="Seed Segments")] = None segment: Annotated[bool | None, Field(title="Segment")] = None + + +class ProspectingMessageRequest(DiscolikeRequest): + text: Annotated[str | None, Field(max_length=4000, min_length=1, title="Text")] = None + intake: Annotated[ + dict[ + Literal["company_activity", "industry", "geography", "company_size", "persona_roles", "list_size"], + IntakeAnswer, + ] + | None, + Field(title="Intake"), + ] = None diff --git a/packages/discolike/src/discolike/requests.py b/packages/discolike/src/discolike/requests.py index 1154d79..7104691 100644 --- a/packages/discolike/src/discolike/requests.py +++ b/packages/discolike/src/discolike/requests.py @@ -24,6 +24,7 @@ from discolike._generated.requests import DiscoverParams from discolike._generated.requests import FindEmailBatchRequest from discolike._generated.requests import FindEmailRequest +from discolike._generated.requests import IntakeAnswer from discolike._generated.requests import LLMProviderCreateRequest from discolike._generated.requests import LLMProviderUpdateRequest from discolike._generated.requests import MatchBulkParams @@ -68,6 +69,7 @@ "DiscoverParams", "FindEmailBatchRequest", "FindEmailRequest", + "IntakeAnswer", "LLMProviderCreateRequest", "LLMProviderUpdateRequest", "MatchBulkParams", diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index e3406d9..b86f32c 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -13,6 +13,7 @@ from discolike._exceptions import JobTimeoutError from discolike._models import DiscolikeModel +from discolike.requests import IntakeAnswer from discolike.requests import ProspectingApproveRequest from discolike.requests import ProspectingBrief from discolike.requests import ProspectingGetParams @@ -34,6 +35,8 @@ Literal["plan", "discover", "validate", "contacts", "generate", "verify", "segment", "seed_segment"] | str ) +IntakeKey = Literal["company_activity", "industry", "geography", "company_size", "persona_roles", "list_size"] + class ProspectingPlan(DiscolikeModel): company_queries: list[dict[str, Any]] = Field(default_factory=list) @@ -163,6 +166,12 @@ class ProspectingRun(DiscolikeModel): ) +def _intake_request(*, answers: dict[IntakeKey, IntakeAnswer], summary: str | None) -> ProspectingMessageRequest: + if summary is None: + return ProspectingMessageRequest(intake=answers) + return ProspectingMessageRequest(text=summary, intake=answers) + + def _key(value: str) -> str: if not value.strip() or len(value) > 128: raise ValueError("idempotency_key must contain 1-128 characters") @@ -215,6 +224,18 @@ def message( ) return ProspectingMessage.model_validate(response.json()) + @api_route("POST", "/prospecting/runs/{run_id}/messages") + def answer_intake( + self, + run_id: str | UUID, + answers: dict[IntakeKey, IntakeAnswer], + *, + idempotency_key: str, + summary: str | None = None, + ) -> ProspectingMessage: + """Answer the intake card on a run; `answers` maps each question key to its picked `values` and/or `other` text.""" + return self.message(run_id, _intake_request(answers=answers, summary=summary), idempotency_key=idempotency_key) + @api_route("POST", "/prospecting/runs") def start(self, request: ProspectingBrief, *, idempotency_key: str) -> ProspectingRun: """Draft a plan for approval; retain the key when retrying this submission.""" @@ -323,6 +344,20 @@ async def message( ) return ProspectingMessage.model_validate(response.json()) + @api_route("POST", "/prospecting/runs/{run_id}/messages") + async def answer_intake( + self, + run_id: str | UUID, + answers: dict[IntakeKey, IntakeAnswer], + *, + idempotency_key: str, + summary: str | None = None, + ) -> ProspectingMessage: + """Answer the intake card on a run; `answers` maps each question key to its picked `values` and/or `other` text.""" + return await self.message( + run_id, _intake_request(answers=answers, summary=summary), idempotency_key=idempotency_key + ) + @api_route("POST", "/prospecting/runs") async def start(self, request: ProspectingBrief, *, idempotency_key: str) -> ProspectingRun: response = await self._transport.request( diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index cc41409..84e3ed2 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -12,6 +12,7 @@ from discolike import DiscolikeError from discolike import JobTimeoutError from discolike import NotFoundError +from discolike.requests import IntakeAnswer from discolike.requests import ProspectingApproveRequest from discolike.requests import ProspectingBrief from discolike.requests import ProspectingGetParams @@ -485,3 +486,53 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert (run.brief.customer_domains, run.brief.selected_seed_segments) == (customers, [1]) with pytest.raises(ValidationError): ProspectingBrief(brief="Lookalikes of our customers", customer_domains=["acme.com"] * 1001) + + +def test_answer_intake_posts_answers_with_key(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(202, json=message_payload()) + + with make_client(handler) as client: + client.prospecting.answer_intake( + RUN_ID, {"company_activity": IntakeAnswer(values=["sell"])}, idempotency_key="k" + ) + message = client.prospecting.answer_intake( + RUN_ID, + {"company_activity": IntakeAnswer(values=["sell"]), "geography": IntakeAnswer(other="Ohio")}, + idempotency_key="k2", + summary="Sellers in Ohio", + ) + assert seen[0].url.path == f"/v1/prospecting/runs/{RUN_ID}/messages" + assert seen[0].headers["Idempotency-Key"] == "k" + assert json.loads(seen[0].content) == {"intake": {"company_activity": {"values": ["sell"]}}} + assert json.loads(seen[1].content) == { + "text": "Sellers in Ohio", + "intake": {"company_activity": {"values": ["sell"]}, "geography": {"other": "Ohio"}}, + } + assert message.seq == 8 + + +async def test_async_answer_intake(make_async_client: AsyncClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(202, json=message_payload()) + + async with make_async_client(handler) as client: + message = await client.prospecting.answer_intake( + RUN_ID, {"list_size": IntakeAnswer(values=["1000"])}, idempotency_key="async-k" + ) + assert seen[0].headers["Idempotency-Key"] == "async-k" + assert json.loads(seen[0].content) == {"intake": {"list_size": {"values": ["1000"]}}} + assert message.seq == 8 + + +def test_answer_intake_rejects_unknown_key_and_long_other() -> None: + with pytest.raises(ValidationError): + ProspectingMessageRequest.model_validate({"intake": {"bogus": {"values": ["x"]}}}) + with pytest.raises(ValidationError): + IntakeAnswer(other="x" * 201) From ece47f78e61a43a46b5fbc421a07399ec5b3c78a Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Tue, 29 Sep 2026 22:37:21 -0700 Subject: [PATCH 6/9] Say where IntakeAnswer lives and that invalid answers 422 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 729d08d..d69e039 100644 --- a/README.md +++ b/README.md @@ -466,7 +466,7 @@ The async client exposes the same methods with `await`. Starts and messages requ Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and checks again, then stops with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief. A search drifting off target is dropped, a run whose search runs dry finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`). The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. -Intake: a run can pause on a `kind="question"` message whose `data` lists a few option fields (keys `company_activity`, `industry`, `geography`, `company_size`, `persona_roles`, `list_size`). Answer it with `client.prospecting.answer_intake(run.run_id, {"company_activity": IntakeAnswer(values=["sell"]), "geography": IntakeAnswer(other="Ohio")}, idempotency_key=...)`: `values` are the option values you picked and `other` is free text. An answer sent after the card was superseded returns 409. Typing free text with `message()` instead still works. +Intake: a run can pause on a `kind="question"` message whose `data` lists a few option fields (keys `company_activity`, `industry`, `geography`, `company_size`, `persona_roles`, `list_size`). Answer it with `client.prospecting.answer_intake(run.run_id, {"company_activity": IntakeAnswer(values=["sell"]), "geography": IntakeAnswer(other="Ohio")}, idempotency_key=...)`: `values` are the option values you picked and `other` is free text. `IntakeAnswer` comes from `discolike.requests`. Invalid answers return 422; an answer sent after the card was superseded returns 409. Typing free text with `message()` instead still works. Initial planning extracts company counts and contacts per company from the brief. Omitted settings keep that inference available, falling back to 1,000 companies and 1 contact per company. Explicit settings, including explicit defaults, override the text. Targets support 1–10,000 companies and 1–5 contacts per company. Candidate and action caps default to automatic (`0`); explicit maxima are 100,000 candidates and 10,000 actions. Result pages support up to 500 rows; recent-run lists support up to 50. Approved runs expose a stable `saved_query_id` for saved results. From cd826a9ea9f4c6385691ffd8b88738c6aaebb178 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Wed, 30 Sep 2026 00:24:20 -0700 Subject: [PATCH 7/9] Resolve changelog merge of run controls and intake --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2a7fe3..b2aba89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,8 @@ - **Breaking:** the polling deadline on `Job.wait()`, `EmailJob.wait()` and `EmailBatch.results()` (sync and async) is renamed from `timeout=` to `max_wait=`, and `signup()` / `async_signup()` take `request_timeout=` instead of `timeout=`. Behavior is unchanged; the client constructor, `with_options(timeout=...)` and the CLI's `--timeout` keep their names. - SDK: response enums such as `JobStatus` are open, so a value a newer API adds parses instead of failing. Compare against the documented values and treat anything else as unknown. - SDK/CLI: managed prospecting. `client.prospecting` starts, reads, lists, approves, messages, renames, cancels, deletes and waits on runs (sync and async), with typed plans, chat, progress and saved contact lists (`saved_query_ids`, split into parts for large results). `ProspectingBrief.checkpoints="ask"` pauses the run at a pilot, a drifting search, a shortfall or the target for you to answer; `customer_domains` groups your customers into segments and finds lookalikes of each, picked on approval with `seed_segments`. `rename()` retitles a run in any status with `ProspectingRunUpdate(title=...)` and returns its `list()` summary; result lists it already saved keep their names. `cancel()` stops a run and keeps it; `delete()` cancels an active run, then removes it from `list()` and `get()` (charges already incurred stay). The CLI adds `prospecting start/status/list/approve/message/cancel/wait`, asks at checkpoints on a terminal, and exits 7 with the question on stderr under `--no-input`. -<<<<<<< HEAD - SDK: `client.prospecting.update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` (sync and async) changes a proposed plan's contact engine, company-check engine, search provider (`"none"` skips web research) and `max_spend_usd` (a USD spending limit on records and per-request fees at the plan's rates; `0` removes it) before approval, then returns the run with the re-estimated plan at a new `plan_version`. Prospecting `stop_reason` gains `candidate_limit` (candidate cap reached) and `credit_limit` (stopped at the spending limit, results kept); `candidates_exhausted` now means only that the search ran dry. `stop_reason` stays an open string. Runs without a chosen contact engine research on DiscoLike Groove, or find indexed contacts only with no search provider. -======= - SDK: `client.prospecting.answer_intake(run_id, answers, idempotency_key=..., summary=None)` (sync and async) answers the guided-intake card a run asks before it drafts a plan. `answers` maps a question key (`company_activity`, `industry`, `geography`, `company_size`, `persona_roles`, `list_size`) to `IntakeAnswer(values=[...], other="...")`; the card's fields are on the `kind="question"` message's `data`. `ProspectingMessageRequest.text` is now optional, so a message can carry `intake` alone. ->>>>>>> 783249d (Add prospecting.answer_intake for the guided intake card) ## 0.4.1 (2026-09-23) From ee03bbf2537c711519340d7dfc03bc792de3c026 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Wed, 30 Sep 2026 00:25:11 -0700 Subject: [PATCH 8/9] Regenerate request models from the dev spec and declare ProspectingInFlight.progress --- CHANGELOG.md | 1 + packages/discolike/src/discolike/_generated/requests.py | 2 -- packages/discolike/src/discolike/resources/prospecting.py | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2aba89..06e925e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - SDK/CLI: managed prospecting. `client.prospecting` starts, reads, lists, approves, messages, renames, cancels, deletes and waits on runs (sync and async), with typed plans, chat, progress and saved contact lists (`saved_query_ids`, split into parts for large results). `ProspectingBrief.checkpoints="ask"` pauses the run at a pilot, a drifting search, a shortfall or the target for you to answer; `customer_domains` groups your customers into segments and finds lookalikes of each, picked on approval with `seed_segments`. `rename()` retitles a run in any status with `ProspectingRunUpdate(title=...)` and returns its `list()` summary; result lists it already saved keep their names. `cancel()` stops a run and keeps it; `delete()` cancels an active run, then removes it from `list()` and `get()` (charges already incurred stay). The CLI adds `prospecting start/status/list/approve/message/cancel/wait`, asks at checkpoints on a terminal, and exits 7 with the question on stderr under `--no-input`. - SDK: `client.prospecting.update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` (sync and async) changes a proposed plan's contact engine, company-check engine, search provider (`"none"` skips web research) and `max_spend_usd` (a USD spending limit on records and per-request fees at the plan's rates; `0` removes it) before approval, then returns the run with the re-estimated plan at a new `plan_version`. Prospecting `stop_reason` gains `candidate_limit` (candidate cap reached) and `credit_limit` (stopped at the spending limit, results kept); `candidates_exhausted` now means only that the search ran dry. `stop_reason` stays an open string. Runs without a chosen contact engine research on DiscoLike Groove, or find indexed contacts only with no search provider. - SDK: `client.prospecting.answer_intake(run_id, answers, idempotency_key=..., summary=None)` (sync and async) answers the guided-intake card a run asks before it drafts a plan. `answers` maps a question key (`company_activity`, `industry`, `geography`, `company_size`, `persona_roles`, `list_size`) to `IntakeAnswer(values=[...], other="...")`; the card's fields are on the `kind="question"` message's `data`. `ProspectingMessageRequest.text` is now optional, so a message can carry `intake` alone. +- SDK: `ProspectingInFlight` gains `progress` (0-100 or `None`) for the running item. ## 0.4.1 (2026-09-23) diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 59771da..34b1468 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -2953,8 +2953,6 @@ class ProspectingGetParams(DiscolikeRequest): class ProspectingPlanSettings(DiscolikeRequest): - """Engine choices for a proposed plan; each omitted field keeps its current choice.""" - plan_version: Annotated[int, Field(ge=1, title="Plan Version")] contact_integration_id: Annotated[ str | None, diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index b86f32c..fe0c1bb 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -88,6 +88,7 @@ class ProspectingInFlight(DiscolikeModel): plan_version: int state: Literal["dispatching", "running"] | str started_at: datetime + progress: int | None = None class ProspectingRunSummary(DiscolikeModel): From 06525d0c9ff07587214f9db07f33a9d96da86c36 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Wed, 30 Sep 2026 23:10:21 -0700 Subject: [PATCH 9/9] Track the phased prospecting contract now live on api-dev The platform added run shape and provider-spend controls to the plan card, top-up rounds that ask before buying more companies, and two new stop reasons. The dev spec renamed the start body to ProspectingStartBrief, which made gen_requests drop ProspectingBrief and ProspectingPlanSettings entirely, so the generator now maps that schema back onto the name callers import. Everything is additive: new request fields are optional and only sent when set, new response fields default so older servers still parse, and phase/deliverable/goal stay open strings. CHECKPOINT_STOP_REASONS gains top_up because an auto run also pauses there when the next round would pass a spending limit, and the CLI's wait loop keys off that set. update_plan's docs now say the plan message is rewritten in place under the same seq: a client following messages_after would never see the re-estimated card otherwise. --- CHANGELOG.md | 1 + README.md | 10 +- packages/discolike-cli/README.md | 4 +- .../src/discolike_cli/prospecting.py | 15 +- .../tests/test_prospecting_cli.py | 27 +++ packages/discolike/README.md | 6 +- .../src/discolike/_generated/requests.py | 182 +++++++++++------- .../src/discolike/resources/prospecting.py | 64 ++++-- packages/discolike/tests/test_prospecting.py | 97 +++++++++- scripts/gen_requests.py | 13 +- 10 files changed, 320 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06e925e..ef6ccfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - SDK: `client.prospecting.update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` (sync and async) changes a proposed plan's contact engine, company-check engine, search provider (`"none"` skips web research) and `max_spend_usd` (a USD spending limit on records and per-request fees at the plan's rates; `0` removes it) before approval, then returns the run with the re-estimated plan at a new `plan_version`. Prospecting `stop_reason` gains `candidate_limit` (candidate cap reached) and `credit_limit` (stopped at the spending limit, results kept); `candidates_exhausted` now means only that the search ran dry. `stop_reason` stays an open string. Runs without a chosen contact engine research on DiscoLike Groove, or find indexed contacts only with no search provider. - SDK: `client.prospecting.answer_intake(run_id, answers, idempotency_key=..., summary=None)` (sync and async) answers the guided-intake card a run asks before it drafts a plan. `answers` maps a question key (`company_activity`, `industry`, `geography`, `company_size`, `persona_roles`, `list_size`) to `IntakeAnswer(values=[...], other="...")`; the card's fields are on the `kind="question"` message's `data`. `ProspectingMessageRequest.text` is now optional, so a message can carry `intake` alone. - SDK: `ProspectingInFlight` gains `progress` (0-100 or `None`) for the running item. +- SDK/CLI: prospecting run shape and limits. `ProspectingPlanSettings` gains `target_companies`, `contacts_per_company`, `max_provider_spend_usd` (USD on your own AI and search provider keys; `0` removes it), `deliverable` (`"leads"`, or `"accounts"` for the checked companies only) and `goal` (`"leads"`, or `"companies"` to work only `target_companies` matching companies), all optional. `ProspectingBrief` takes `deliverable` and `goal` too, sent only when set, and the CLI's `prospecting start` gains `--deliverable` and `--goal`. `update_plan()` rewrites the latest plan message in place under the same `seq`, so read the plan from the run it returns. `ProspectingRun` gains `pipeline_phase` (`"companies"`, `"people"` or `None`) and `provider_cost_usd`, and its `brief` reports `deliverable` and `goal`. `CHECKPOINT_STOP_REASONS` gains `top_up`: a run asks before another round of companies (replies `continue`, `raise`, `shrink`, `finish`), and an auto run asks too when that round would pass a spending limit. `stop_reason` gains `provider_limit` and `companies_worked`. A saved contact list now carries every fit company as a row with its fit columns (`ICP Fit`, `Confidence`, `Reasoning`, `Segment`, `Customer segment`); a company with no contact has `contacts: []`, and an accounts run saves company rows only. Contacts per company go up to 10, and intake answers accept the `deliverable` key. ## 0.4.1 (2026-09-23) diff --git a/README.md b/README.md index d69e039..26c5241 100644 --- a/README.md +++ b/README.md @@ -464,12 +464,16 @@ The async client exposes the same methods with `await`. Starts and messages requ `wait()` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`. Inspect `status`, `stop_reason`, and `error`; completion does not guarantee the target was reached. A local timeout stops polling only. Partial results remain available. Use `get()` with event and message cursors to receive the agent's reply after sending a message; `reply_pending` indicates a pending reply. After a "segment these" request on a finished run it stays true past the acknowledgement until the segments message is posted, which can take more than an hour, and clears on its own after about 90 minutes if grouping stops without an outcome. A `needs_input` question can be answered with `message()`. -Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and checks again, then stops with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief. A search drifting off target is dropped, a run whose search runs dry finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`). The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. +Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and checks again, then stops with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief. A search drifting off target is dropped, a run whose search runs dry finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`, `top_up`). The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. + +Top-up rounds: when the companies checked so far yield too few contacts for the target, the run sizes another round of companies. `"ask"` pauses on it with `stop_reason="top_up"`; `"auto"` runs it with a notice unless it would pass the run's DiscoLike credit or AI provider spending limit, in which case it pauses too. The question's suggested replies map to `continue` (run another round), `raise` (raise the limit and run it), `shrink` (run the smaller round the limits leave room for) and `finish`; only the ones that apply are offered. Intake: a run can pause on a `kind="question"` message whose `data` lists a few option fields (keys `company_activity`, `industry`, `geography`, `company_size`, `persona_roles`, `list_size`). Answer it with `client.prospecting.answer_intake(run.run_id, {"company_activity": IntakeAnswer(values=["sell"]), "geography": IntakeAnswer(other="Ohio")}, idempotency_key=...)`: `values` are the option values you picked and `other` is free text. `IntakeAnswer` comes from `discolike.requests`. Invalid answers return 422; an answer sent after the card was superseded returns 409. Typing free text with `message()` instead still works. -Initial planning extracts company counts and contacts per company from the brief. Omitted settings keep that inference available, falling back to 1,000 companies and 1 contact per company. Explicit settings, including explicit defaults, override the text. Targets support 1–10,000 companies and 1–5 contacts per company. Candidate and action caps default to automatic (`0`); explicit maxima are 100,000 candidates and 10,000 actions. Result pages support up to 500 rows; recent-run lists support up to 50. Approved runs expose a stable `saved_query_id` for saved results. +Initial planning extracts company counts and contacts per company from the brief. Omitted settings keep that inference available, falling back to 1,000 companies and 1 contact per company. Explicit settings, including explicit defaults, override the text. Targets support 1–10,000 companies and 1–10 contacts per company. Candidate and action caps default to automatic (`0`); explicit maxima are 100,000 candidates and 10,000 actions. Result pages support up to 500 rows; recent-run lists support up to 50. Approved runs expose a stable `saved_query_id` for saved results. + +Run controls: `update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan before approval. `contact_integration_id`, `validation_integration_id` and `search_provider_id` take an option id from the plan message's `contact_engine`, `company_check_engine` and `search_provider` (`data.contact_engine.options` and so on); omitted fields keep their choice, `search_provider_id="none"` skips web research, and `max_spend_usd` sets the most the run may spend on DiscoLike records and per-request fees, in USD at your plan's rates (`0` removes the limit; a plan with no per-record price is a 422). `max_provider_spend_usd` caps what the run may spend on your own AI and search provider keys, in USD (`0` removes it); once reached the run starts no new work and batches already running finish, so the total can end slightly above it. `target_companies` and `contacts_per_company` resize the plan, and its open limits are re-derived around them. `deliverable` is `"leads"` (find people at the companies) or `"accounts"` (return the checked companies only); `goal` is `"leads"` (keep adding rounds until the contact target is met) or `"companies"` (take `target_companies` matching companies and work only those). The same `deliverable` and `goal` are optional on `ProspectingBrief` at start, both defaulting to `"leads"` server-side, and the run's `brief` reports them. The plan is re-estimated at a new `plan_version`, and that is the version to approve; the latest plan message is rewritten in place under the same `seq`, so read it from the returned run's `messages` rather than past a `messages_after` cursor. An id outside the plan's options is a 422; a run that is not awaiting approval, a stale `plan_version`, or a run that cannot switch to `"accounts"` is a 409. With no engine chosen, contact research runs on DiscoLike Groove when you have a search provider and finds indexed contacts only when you have none. New `stop_reason` values, all plain strings: `candidate_limit` (the run reached its candidate cap; raise `max_candidates` or ask for fewer companies), `credit_limit` (the run stopped at its spending limit and kept its results), `provider_limit` (the run reached its AI provider spending limit), `companies_worked` (a `goal="companies"` run worked through the companies it was asked to take), and `candidates_exhausted` now means only that the search ran dry. A run reports `provider_cost_usd`, what it has spent so far on your AI and search provider keys as the providers report it (`None` when it recorded none; a custom AI endpoint reports no price), and `pipeline_phase`, `"companies"` while a phased run checks companies and `"people"` while it finds people at them (`None` for runs that interleave both). -Run controls: `update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan before approval. `contact_integration_id`, `validation_integration_id` and `search_provider_id` take an option id from the plan message's `contact_engine`, `company_check_engine` and `search_provider` (`data.contact_engine.options` and so on); omitted fields keep their choice, `search_provider_id="none"` skips web research, and `max_spend_usd` sets the most the run may spend on DiscoLike records and per-request fees, in USD at your plan's rates (`0` removes the limit; a plan with no per-record price is a 422). The plan is re-estimated and posted again at a new `plan_version`, and that is the version to approve. An id outside the plan's options is a 422; a run that is not awaiting approval, or a stale `plan_version`, is a 409. With no engine chosen, contact research runs on DiscoLike Groove when you have a search provider and finds indexed contacts only when you have none. New `stop_reason` values, all plain strings: `candidate_limit` (the run reached its candidate cap; raise `max_candidates` or ask for fewer companies), `credit_limit` (the run stopped at its spending limit and kept its results), and `candidates_exhausted` now means only that the search ran dry. An automatic candidate cap may grow once from the run's measured yield; a `max_candidates` you set never does. Interrupted indexed contact searches are rerun rather than skipped. +Saved results: each saved contact list (`saved_query_ids`) holds one row per fit company, carrying its `ICP Fit`, `Confidence`, `Reasoning`, `Segment` and `Customer segment` columns where known, with the people found there under `contacts`. A fit company with no contact found is still saved, with `contacts: []`, and an `"accounts"` run saves company rows only. An automatic candidate cap may grow once from the run's measured yield; a `max_candidates` you set never does. Interrupted indexed contact searches are rerun rather than skipped. Existing processing charges and configured BYOK/BYOS integrations apply. Wizard interpretation, segmentation, and prompt preparation use platform credentials. Agent coordination and independent contact qualification use your contact LLM integration; native contacts use your validation LLM integration or organization default, so this workflow requires a customer LLM even with native extraction. Missing keys and provider errors never fall back to platform keys. Limits bound work, not provider dollar spend. Email finder outcomes are exposed; raw email verification is not a public API. diff --git a/packages/discolike-cli/README.md b/packages/discolike-cli/README.md index 7f17a91..7c40e7e 100644 --- a/packages/discolike-cli/README.md +++ b/packages/discolike-cli/README.md @@ -65,9 +65,9 @@ discolike prospecting cancel RUN_ID `wait` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`. A timeout stops polling only. Inspect status and stop reason; completion does not guarantee full coverage. Message replies arrive through `status --messages-after`; follow `next_message_seq` and `reply_pending`. A "segment these" request keeps `reply_pending` true until the segments message is posted, which can take more than an hour. If grouping stops without an outcome, the flag clears on its own after about 90 minutes. -`start` and `approve` default to pausing at checkpoints, like the web chat: a pilot check on large lists (`pilot`), a search drifting off target (`tail_quality`), candidates running out short of the target (`short`), and the target being met (`target_reached`). Pass `--auto` to never pause; a poor pilot is then sharpened once and the run continues with a notice, stopping with `pilot_failed` only if the re-pilot fit is still under 20%. If sharpening itself fails, the run continues on the original criteria. On a terminal, `wait` shows the question, any sample companies and numbered replies at a checkpoint, sends your pick or your own text, and keeps waiting. Without a terminal, or with `--no-input`, it prints the run on stdout, a `needs_input` envelope (`message`, `stop_reason`, `suggested_replies`, `sample`) on stderr, and exits 7; answer with `prospecting message --text ""` and run `wait` again. +`start` and `approve` default to pausing at checkpoints, like the web chat: a pilot check on large lists (`pilot`), a search drifting off target (`tail_quality`), candidates running out short of the target (`short`), the target being met (`target_reached`), and another round of companies when the ones checked yield too few contacts (`top_up`). Pass `--auto` to never pause (except at a `top_up` round that would pass a spending limit); a poor pilot is then sharpened once and the run continues with a notice, stopping with `pilot_failed` only if the re-pilot fit is still under 20%. If sharpening itself fails, the run continues on the original criteria. On a terminal, `wait` shows the question, any sample companies and numbered replies at a checkpoint, sends your pick or your own text, and keeps waiting. Without a terminal, or with `--no-input`, it prints the run on stdout, a `needs_input` envelope (`message`, `stop_reason`, `suggested_replies`, `sample`) on stderr, and exits 7; answer with `prospecting message --text ""` and run `wait` again. -Omit `--target-companies` and `--contacts-per-company` to infer counts from the brief (fallback 25 and 2). Explicit values override the text. `--max-candidates` and `--max-actions` are automatic when omitted or `0`; their maxima are 100,000 and 10,000. Targets allow up to 10,000 companies, status pages up to 500 rows, and lists up to 50 runs. Work caps do not cap provider charges. +Omit `--target-companies` and `--contacts-per-company` to infer counts from the brief (fallback 1,000 and 1; up to 10 contacts per company). Explicit values override the text. `--deliverable accounts` returns the checked companies only, with no people; `--goal companies` takes `--target-companies` matching companies and works only those instead of adding rounds until the contact target is met. Both default to `leads`. `--max-candidates` and `--max-actions` are automatic when omitted or `0`; their maxima are 100,000 and 10,000. Targets allow up to 10,000 companies, status pages up to 500 rows, and lists up to 50 runs. Work caps do not cap provider charges. ### Conventions diff --git a/packages/discolike-cli/src/discolike_cli/prospecting.py b/packages/discolike-cli/src/discolike_cli/prospecting.py index fa668cb..4dcf559 100644 --- a/packages/discolike-cli/src/discolike_cli/prospecting.py +++ b/packages/discolike-cli/src/discolike_cli/prospecting.py @@ -30,7 +30,12 @@ app = typer.Typer(help="Run managed prospecting; processing and provider charges apply.") -AUTO_HELP = "Never pause to ask: a poor pilot is sharpened once and the run continues; it only stops if the re-pilot fit is still under 20%. Default: pause at checkpoints." +AUTO_HELP = "Never pause to ask: a poor pilot is sharpened once and the run continues; it only stops if the re-pilot fit is still under 20%. A top_up round that would pass a spending limit still asks. Default: pause at checkpoints." +DELIVERABLE_HELP = "leads (default) finds people at the companies; accounts returns the checked companies only." +GOAL_HELP = ( + "leads (default) keeps adding rounds until the target is met; companies works only --target-companies " + "matching companies." +) CUSTOMERS_FILE_HELP = ( "Your customers (CSV with a 'domain' column, or one per line): grouped into segments, lookalikes of each are " "found. Not with --domain or --company-name." @@ -44,7 +49,7 @@ WAIT_HELP = ( "Return the first page on proposed, needs_input, completed, failed, or cancelled. Approve proposed plans; " "timeout stops polling only.\n\n" - "At a checkpoint (stop_reason pilot, tail_quality, short or target_reached) a terminal shows the question, " + "At a checkpoint (stop_reason pilot, tail_quality, short, target_reached or top_up) a terminal shows the question, " "any sample companies and numbered replies, sends your pick or your own text, and keeps waiting. Without a " "terminal, or with --no-input, it prints the run on stdout and a needs_input envelope with the question and " f"suggested_replies on stderr, then exits {NEEDS_INPUT_EXIT_CODE}; answer with `prospecting message --text " @@ -181,7 +186,7 @@ def start_command( None, "--contacts-per-company", min=1, - max=5, + max=10, help="Override contacts per company; otherwise inferred, default 1.", ), max_candidates: int | None = typer.Option( @@ -195,6 +200,8 @@ def start_command( search_provider_id: str | None = typer.Option(None, "--search-provider-id"), segment: bool = typer.Option(False, "--segment/--no-segment"), auto: bool = typer.Option(False, "--auto", help=AUTO_HELP), + deliverable: str | None = typer.Option(None, "--deliverable", help=DELIVERABLE_HELP), + goal: str | None = typer.Option(None, "--goal", help=GOAL_HELP), ) -> None: """Draft a plan. Wait for proposed, review it, then approve its plan version.""" from discolike_cli.main import get_client @@ -217,6 +224,8 @@ def start_command( search_provider_id=search_provider_id, segment=segment, checkpoints=_checkpoints(auto=auto), + deliverable=deliverable, + goal=goal, ), ) emit(get_client(ctx).prospecting.start(request, idempotency_key=idempotency_key)) diff --git a/packages/discolike-cli/tests/test_prospecting_cli.py b/packages/discolike-cli/tests/test_prospecting_cli.py index bdd8b3f..7dfa73e 100644 --- a/packages/discolike-cli/tests/test_prospecting_cli.py +++ b/packages/discolike-cli/tests/test_prospecting_cli.py @@ -49,6 +49,33 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert json.loads(result.stdout)["run_id"] == RUN_ID +def test_start_forwards_deliverable_and_goal(install_build_client: Callable[[Handler], None]) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(json.loads(request.content)) + return httpx2.Response(202, json=run_payload("queued")) + + install_build_client(handler) + result = runner.invoke( + app, + [ + "prospecting", + "start", + "--brief", + "US logistics companies, accounts only", + "--idempotency-key", + "accounts", + "--deliverable", + "accounts", + "--goal", + "companies", + ], + ) + assert result.exit_code == 0, result.output + assert (seen[0]["deliverable"], seen[0]["goal"]) == ("accounts", "companies") + + def test_status_paginates(install_build_client: Callable[[Handler], None]) -> None: def handler(request: httpx2.Request) -> httpx2.Response: assert request.url.params["offset"] == "100" diff --git a/packages/discolike/README.md b/packages/discolike/README.md index 769d3b1..633534a 100644 --- a/packages/discolike/README.md +++ b/packages/discolike/README.md @@ -128,11 +128,11 @@ Two errors are specific to the native engine: a 400 `ValidationError` when the I `wait()` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`; timeout stops local polling only. Use `message(run_id, ProspectingMessageRequest(text="..."), idempotency_key="...")` to steer or answer a question, and `get(run_id, ProspectingGetParams(events_after=..., messages_after=...))` for new events and replies. `list(ProspectingListParams(limit=20))` lists recent organization runs, up to 50. `rename(run_id, ProspectingRunUpdate(title="..."))` retitles a run in any status and returns its `list()` summary (titles are 1-80 characters after trimming; result lists it already saved keep their names). `cancel(run_id)` stops a run and returns it; `delete(run_id)` cancels an active run, then removes it so `list()` omits it and `get()` raises `NotFoundError` (charges already incurred stay). The async client has the same methods with `await`. Import these request models from `discolike.requests`. -`update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan's `contact_integration_id`, `validation_integration_id`, `search_provider_id` (`"none"` skips web research) and `max_spend_usd` (a USD spending limit on records and per-request fees at the plan's rates; `0` removes it) using option ids from the plan message's `contact_engine`, `company_check_engine` and `search_provider`, then returns the run with a re-estimated plan at a new `plan_version` to approve. An unknown option id is a 422; a run not awaiting approval or a stale `plan_version` is a 409. `stop_reason` also reports `candidate_limit` and `credit_limit`; `candidates_exhausted` means only that the search ran dry. +`update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan's `contact_integration_id`, `validation_integration_id`, `search_provider_id` (`"none"` skips web research) and `max_spend_usd` (a USD spending limit on records and per-request fees at the plan's rates; `0` removes it) using option ids from the plan message's `contact_engine`, `company_check_engine` and `search_provider`, then returns the run with a re-estimated plan at a new `plan_version` to approve. It also takes `max_provider_spend_usd` (USD on your own AI and search provider keys; `0` removes it), `target_companies`, `contacts_per_company`, `deliverable` (`"leads"` or `"accounts"`, the checked companies only) and `goal` (`"leads"`, or `"companies"` to work only `target_companies` matching companies); `deliverable` and `goal` are optional on `ProspectingBrief` too. The latest plan message is rewritten in place under the same `seq`, so read it from the returned run's `messages`. An unknown option id is a 422; a run not awaiting approval, a stale `plan_version` or a run that cannot switch to `"accounts"` is a 409. `stop_reason` also reports `candidate_limit`, `credit_limit`, `provider_limit` (AI provider spending limit reached) and `companies_worked` (a `goal="companies"` run finished its companies); `candidates_exhausted` means only that the search ran dry. Runs report `provider_cost_usd` and `pipeline_phase` (`"companies"`, `"people"` or `None`). -Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and continues the run with a notice. Only if the re-pilot fit is still under 20% does it stop with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief in that case. If sharpening itself fails, the run continues on the original criteria. A search drifting off target is dropped, a run short of candidates finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`). The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. +Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and continues the run with a notice. Only if the re-pilot fit is still under 20% does it stop with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief in that case. If sharpening itself fails, the run continues on the original criteria. A search drifting off target is dropped, a run short of candidates finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`, `top_up`). `top_up` asks before another round of companies when the ones checked yield too few contacts; auto runs that round with a notice unless it would pass a spending limit, and then pauses too. Its replies map to `continue`, `raise` (raise the limit), `shrink` (a smaller round) and `finish`. The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. -Omit target counts to infer them from the brief; explicit values override the text. Work caps default to automatic (`0`). Partial results and `saved_query_id` remain available after stopping. Large results are split into several saved contact lists rather than being cut off; `saved_query_ids` carries every list for the run in order, with `saved_query_id` always the first entry, and the parts are final once the run reaches a terminal status. Customer integration charges apply; work caps do not cap provider dollar spend. +Omit target counts to infer them from the brief; explicit values override the text. Work caps default to automatic (`0`). Partial results and `saved_query_id` remain available after stopping. Large results are split into several saved contact lists rather than being cut off; `saved_query_ids` carries every list for the run in order, with `saved_query_id` always the first entry, and the parts are final once the run reaches a terminal status. Each list has one row per fit company with its `ICP Fit`, `Confidence`, `Reasoning`, `Segment` and `Customer segment` columns and its people under `contacts`; a company with no contact found has `contacts: []`, and an `"accounts"` run saves company rows only. Customer integration charges apply; work caps do not cap provider dollar spend, `max_provider_spend_usd` does. ## Links diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 34b1468..6935a6b 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -2915,36 +2915,6 @@ class ProspectingListParams(DiscolikeRequest): ] = None -class ProspectingBrief(DiscolikeRequest): - brief: Annotated[str, Field(max_length=4000, min_length=10, title="Brief")] - domains: Annotated[list[str] | None, Field(max_length=1000, title="Domains")] = None - company_names: Annotated[list[str] | None, Field(max_length=100, title="Company Names")] = None - customer_domains: Annotated[ - list[str] | None, - Field( - description="Your customers' domains, grouped into segments; lookalikes of each are found. Cannot be combined with domains or company_names.", - max_length=1000, - title="Customer Domains", - ), - ] = None - exclude_domains: Annotated[list[str] | None, Field(max_length=1000, title="Exclude Domains")] = None - target_companies: Annotated[int | None, Field(ge=1, le=10000, title="Target Companies")] = 1000 - contacts_per_company: Annotated[int | None, Field(ge=1, le=5, title="Contacts Per Company")] = 1 - max_candidates: Annotated[int | None, Field(ge=0, le=100000, title="Max Candidates")] = 0 - max_actions: Annotated[int | None, Field(ge=0, le=10000, title="Max Actions")] = 0 - validation_integration_id: Annotated[str | None, Field(max_length=128, title="Validation Integration Id")] = None - contact_integration_id: Annotated[str | None, Field(max_length=128, title="Contact Integration Id")] = None - search_provider_id: Annotated[str | None, Field(max_length=128, title="Search Provider Id")] = None - segment: Annotated[bool | None, Field(title="Segment")] = False - checkpoints: Annotated[ - Literal["ask", "auto"] | None, - Field( - description="ask: pause at checkpoints (pilot, tail_quality, short, target_reached) with status needs_input and a question to answer through message(). auto: never pause; a poor pilot is sharpened once and the run continues with a notice, stopping with stop_reason pilot_failed only if the re-pilot fit is still under 20%.", - title="Checkpoints", - ), - ] = "auto" - - class ProspectingGetParams(DiscolikeRequest): offset: Annotated[int | None, Field(ge=0, title="Offset")] = 0 limit: Annotated[int | None, Field(ge=1, le=500, title="Limit")] = 100 @@ -2952,42 +2922,6 @@ class ProspectingGetParams(DiscolikeRequest): messages_after: Annotated[int | None, Field(ge=0, title="Messages After")] = 0 -class ProspectingPlanSettings(DiscolikeRequest): - plan_version: Annotated[int, Field(ge=1, title="Plan Version")] - contact_integration_id: Annotated[ - str | None, - Field( - description="Contact research engine: an option id from the plan's contact_engine.", - max_length=128, - title="Contact Integration Id", - ), - ] = None - validation_integration_id: Annotated[ - str | None, - Field( - description="Company check engine: an option id from the plan's company_check_engine.", - max_length=128, - title="Validation Integration Id", - ), - ] = None - search_provider_id: Annotated[ - str | None, - Field( - description="Search provider: an option id from the plan's search_provider; 'none' skips web research.", - max_length=128, - title="Search Provider Id", - ), - ] = None - max_spend_usd: Annotated[ - float | None, - Field( - description="Most the run may spend on DiscoLike records and per-call fees, in USD at your plan's rates, before it stops; 0 removes the limit.", - ge=0.0, - title="Max Spend Usd", - ), - ] = None - - class ProspectingRunUpdate(DiscolikeRequest): title: Annotated[str, Field(max_length=80, min_length=1, title="Title")] @@ -3250,9 +3184,123 @@ class ProspectingMessageRequest(DiscolikeRequest): text: Annotated[str | None, Field(max_length=4000, min_length=1, title="Text")] = None intake: Annotated[ dict[ - Literal["company_activity", "industry", "geography", "company_size", "persona_roles", "list_size"], + Literal[ + "deliverable", "company_activity", "industry", "geography", "company_size", "persona_roles", "list_size" + ], IntakeAnswer, ] | None, Field(title="Intake"), ] = None + + +class ProspectingBrief(DiscolikeRequest): + brief: Annotated[str, Field(max_length=4000, min_length=10, title="Brief")] + domains: Annotated[list[str] | None, Field(max_length=1000, title="Domains")] = None + company_names: Annotated[list[str] | None, Field(max_length=100, title="Company Names")] = None + customer_domains: Annotated[ + list[str] | None, + Field( + description="Your customers' domains, grouped into segments; lookalikes of each are found. Cannot be combined with domains or company_names.", + max_length=1000, + title="Customer Domains", + ), + ] = None + exclude_domains: Annotated[list[str] | None, Field(max_length=1000, title="Exclude Domains")] = None + target_companies: Annotated[int | None, Field(ge=1, le=10000, title="Target Companies")] = 1000 + contacts_per_company: Annotated[int | None, Field(ge=1, le=10, title="Contacts Per Company")] = 1 + max_candidates: Annotated[int | None, Field(ge=0, le=100000, title="Max Candidates")] = 0 + max_actions: Annotated[int | None, Field(ge=0, le=10000, title="Max Actions")] = 0 + validation_integration_id: Annotated[str | None, Field(max_length=128, title="Validation Integration Id")] = None + contact_integration_id: Annotated[str | None, Field(max_length=128, title="Contact Integration Id")] = None + search_provider_id: Annotated[str | None, Field(max_length=128, title="Search Provider Id")] = None + segment: Annotated[bool | None, Field(title="Segment")] = False + deliverable: Annotated[ + Literal["leads", "accounts"] | None, + Field(description="leads finds people at the companies; accounts returns the checked companies only."), + ] = "leads" + goal: Annotated[ + Literal["leads", "companies"] | None, + Field( + description="leads keeps adding rounds until the target is met; companies takes target_companies matching companies and works only those." + ), + ] = "leads" + checkpoints: Annotated[ + Literal["ask", "auto"] | None, + Field( + description="ask: pause at checkpoints (pilot, tail_quality, short, target_reached, top_up) with status needs_input and a question to answer through message(). auto: never pause; a poor pilot is sharpened once and the run continues with a notice, stopping with stop_reason pilot_failed only if the re-pilot fit is still under 20%. Either mode pauses at top_up when another round would pass a spending limit.", + title="Checkpoints", + ), + ] = "auto" + + +class ProspectingPlanSettings(DiscolikeRequest): + plan_version: Annotated[int, Field(ge=1, title="Plan Version")] + contact_integration_id: Annotated[ + str | None, + Field( + description="Contact research engine: an option id from the plan's contact_engine.", + max_length=128, + title="Contact Integration Id", + ), + ] = None + validation_integration_id: Annotated[ + str | None, + Field( + description="Company check engine: an option id from the plan's company_check_engine.", + max_length=128, + title="Validation Integration Id", + ), + ] = None + search_provider_id: Annotated[ + str | None, + Field( + description="Search provider: an option id from the plan's search_provider; 'none' skips web research.", + max_length=128, + title="Search Provider Id", + ), + ] = None + max_spend_usd: Annotated[ + float | None, + Field( + description="Most the run may spend on DiscoLike records and per-call fees, in USD at your plan's rates, before it stops; 0 removes the limit.", + ge=0.0, + title="Max Spend Usd", + ), + ] = None + target_companies: Annotated[ + int | None, + Field( + description="How many qualified companies the run aims for; the plan's limits are re-derived around it.", + ge=1, + le=10000, + title="Target Companies", + ), + ] = None + contacts_per_company: Annotated[ + int | None, + Field( + description="Most contacts to find at each qualified company.", + ge=1, + le=10, + title="Contacts Per Company", + ), + ] = None + deliverable: Annotated[ + Literal["leads", "accounts"] | None, + Field(description="leads finds people at the companies; accounts returns the checked companies only."), + ] = None + goal: Annotated[ + Literal["leads", "companies"] | None, + Field( + description="leads keeps adding rounds until the target is met; companies takes target_companies matching companies and works only those." + ), + ] = None + max_provider_spend_usd: Annotated[ + float | None, + Field( + description="Most the run may spend on your own AI and search provider keys, in USD, before it stops starting new work; batches already running finish, so the total can end slightly above it. 0 removes the limit.", + ge=0.0, + title="Max Provider Spend Usd", + ), + ] = None diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index fe0c1bb..454c102 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -26,7 +26,7 @@ from discolike.resources._base import api_route WAIT_STATUSES = frozenset({"proposed", "completed", "needs_input", "failed", "cancelled"}) -CHECKPOINT_STOP_REASONS = frozenset({"pilot", "tail_quality", "short", "target_reached"}) +CHECKPOINT_STOP_REASONS = frozenset({"pilot", "tail_quality", "short", "target_reached", "top_up"}) # Response enums stay open (`| str`) so a value the platform adds later never fails parsing in released SDKs. ProspectingStatus = ( Literal["drafting", "proposed", "queued", "running", "needs_input", "completed", "failed", "cancelled"] | str @@ -35,7 +35,11 @@ Literal["plan", "discover", "validate", "contacts", "generate", "verify", "segment", "seed_segment"] | str ) -IntakeKey = Literal["company_activity", "industry", "geography", "company_size", "persona_roles", "list_size"] +IntakeKey = Literal[ + "deliverable", "company_activity", "industry", "geography", "company_size", "persona_roles", "list_size" +] +ProspectingDeliverable = Literal["leads", "accounts"] | str +ProspectingGoal = Literal["leads", "companies"] | str class ProspectingPlan(DiscolikeModel): @@ -62,6 +66,14 @@ class ProspectingRunBrief(DiscolikeModel): segment: bool | None = None checkpoints: Literal["ask", "auto"] | str | None = None selected_seed_segments: list[int] | None = None + deliverable: ProspectingDeliverable = Field( + default="leads", description="leads finds people at the companies; accounts returns the checked companies only." + ) + goal: ProspectingGoal = Field( + default="leads", + description="leads keeps adding rounds until the target is met; companies works only target_companies " + "matching companies.", + ) class ProspectingEvent(DiscolikeModel): @@ -140,13 +152,25 @@ class ProspectingRun(DiscolikeModel): saved_query_ids: list[UUID] = Field( default_factory=list, description="Every saved contact list for this run, in order. Large results are split across several " - "lists; the first is saved_query_id. Parts are final once the run reaches a terminal status.", + "lists; the first is saved_query_id. Parts are final once the run reaches a terminal status. Each row is " + "a fit company with its ICP Fit, Confidence, Reasoning, Segment and Customer segment columns and its " + "contacts; a company with no contact has contacts=[], and an accounts run saves company rows only.", ) messages: list[ProspectingMessage] = Field(default_factory=list) next_message_seq: int = 0 in_flight: list[ProspectingInFlight] = Field(default_factory=list) fit_companies: int = 0 emails_found: int = 0 + pipeline_phase: Literal["companies", "people"] | str | None = Field( + default=None, + description="Which phase a phased run is in: checking companies, or finding people at them. " + "None for runs that interleave both.", + ) + provider_cost_usd: float | None = Field( + default=None, + description="What this run has spent so far on your own AI and search provider keys (USD), as the " + "providers report it; a custom AI endpoint reports no price. None when the run recorded no provider cost.", + ) reply_pending: bool = Field( default=False, description="The agent still owes a reply to a user message. After a 'segment these' request on a " @@ -257,15 +281,19 @@ def cancel(self, run_id: str | UUID) -> ProspectingRun: @api_route("PATCH", "/prospecting/runs/{run_id}/plan") def update_plan(self, run_id: str | UUID, request: ProspectingPlanSettings) -> ProspectingRun: - """Choose engines and a spending limit for a proposed plan; returns the run with the re-posted plan. + """Change a proposed plan's engines, size, deliverable and spending limits; returns the re-estimated run. Fields left unset keep their current choice; ids come from the latest plan message's contact_engine, company_check_engine and search_provider options, and search_provider_id="none" - skips web research. max_spend_usd (USD for records and per-call fees at the plan's rates) of 0 removes the limit; - engines left unset are re-chosen around the picks. Pass the current plan_version: the plan is - re-estimated at a new plan_version, which is the one to approve. A 422 means an id is not one of the - plan's options or the plan has no per-record price, a 409 that the run is not awaiting approval or - plan_version is stale. + skips web research. max_spend_usd (USD for records and per-call fees at the plan's rates) and + max_provider_spend_usd (USD on your own AI and search provider keys) each take 0 to remove the limit; + engines left unset are re-chosen around the picks. target_companies and contacts_per_company resize + the plan, deliverable="accounts" returns checked companies only, and goal="companies" works only + target_companies matching companies. Pass the current plan_version: the plan is re-estimated at a new + plan_version, which is the one to approve, and the latest plan message is rewritten in place (same seq), + so read it from this response's messages rather than past a messages_after cursor. A 422 means an id is + not one of the plan's options or the plan has no per-record price, a 409 that the run is not awaiting + approval, plan_version is stale, or the run cannot switch to accounts. """ response = self._transport.request("PATCH", _path(run_id) + "/plan", json_body=request.to_wire()) return ProspectingRun.model_validate(response.json()) @@ -295,7 +323,7 @@ def wait(self, run_id: str | UUID, *, max_wait: float = 3600, poll_interval: flo Inspect status and stop_reason; completed does not guarantee the target was met. A run in checkpoints="ask" mode returns needs_input with a stop_reason in CHECKPOINT_STOP_REASONS; answer the latest kind="question" message through message(), - then wait again. + then wait again. Either mode pauses at top_up when another round would pass a spending limit. Timeout stops local polling only. Fetch subsequent pages with get(). """ deadline = _deadline(max_wait, poll_interval) @@ -378,15 +406,19 @@ async def cancel(self, run_id: str | UUID) -> ProspectingRun: @api_route("PATCH", "/prospecting/runs/{run_id}/plan") async def update_plan(self, run_id: str | UUID, request: ProspectingPlanSettings) -> ProspectingRun: - """Choose engines and a spending limit for a proposed plan; returns the run with the re-posted plan. + """Change a proposed plan's engines, size, deliverable and spending limits; returns the re-estimated run. Fields left unset keep their current choice; ids come from the latest plan message's contact_engine, company_check_engine and search_provider options, and search_provider_id="none" - skips web research. max_spend_usd (USD for records and per-call fees at the plan's rates) of 0 removes the limit; - engines left unset are re-chosen around the picks. Pass the current plan_version: the plan is - re-estimated at a new plan_version, which is the one to approve. A 422 means an id is not one of the - plan's options or the plan has no per-record price, a 409 that the run is not awaiting approval or - plan_version is stale. + skips web research. max_spend_usd (USD for records and per-call fees at the plan's rates) and + max_provider_spend_usd (USD on your own AI and search provider keys) each take 0 to remove the limit; + engines left unset are re-chosen around the picks. target_companies and contacts_per_company resize + the plan, deliverable="accounts" returns checked companies only, and goal="companies" works only + target_companies matching companies. Pass the current plan_version: the plan is re-estimated at a new + plan_version, which is the one to approve, and the latest plan message is rewritten in place (same seq), + so read it from this response's messages rather than past a messages_after cursor. A 422 means an id is + not one of the plan's options or the plan has no per-record price, a 409 that the run is not awaiting + approval, plan_version is stale, or the run cannot switch to accounts. """ response = await self._transport.request("PATCH", _path(run_id) + "/plan", json_body=request.to_wire()) return ProspectingRun.model_validate(response.json()) diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index 84e3ed2..47cd2a1 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -210,6 +210,48 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert json.loads(seen[0].content) == {"plan_version": 1, "search_provider_id": "none", "max_spend_usd": 0} +def test_update_plan_sends_run_shape_and_provider_limit(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json=payload("proposed") | {"plan_version": 2}) + + settings = ProspectingPlanSettings( + plan_version=1, + target_companies=250, + contacts_per_company=10, + max_provider_spend_usd=12.5, + deliverable="accounts", + goal="companies", + ) + with make_client(handler) as client: + client.prospecting.update_plan(RUN_ID, settings) + assert json.loads(seen[0].content) == { + "plan_version": 1, + "target_companies": 250, + "contacts_per_company": 10, + "max_provider_spend_usd": 12.5, + "deliverable": "accounts", + "goal": "companies", + } + + +@pytest.mark.parametrize( + "values", + [ + {"deliverable": "people"}, + {"goal": "accounts"}, + {"max_provider_spend_usd": -1}, + {"target_companies": 0}, + {"contacts_per_company": 11}, + ], +) +def test_update_plan_rejects_bad_run_shape_locally(values: dict) -> None: + with pytest.raises(ValidationError): + ProspectingPlanSettings.model_validate({"plan_version": 1} | values) + + @pytest.mark.parametrize("status", [409, 422]) def test_update_plan_surfaces_rejections(make_client: ClientFactory, status: int) -> None: with ( @@ -240,7 +282,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert json.loads(seen[0].content) == {"plan_version": 2, "max_spend_usd": 25.0} -@pytest.mark.parametrize("reason", ["candidate_limit", "credit_limit", "a_reason_added_later"]) +@pytest.mark.parametrize( + "reason", ["candidate_limit", "credit_limit", "provider_limit", "companies_worked", "a_reason_added_later"] +) def test_stop_reason_stays_an_open_string(make_client: ClientFactory, reason: str) -> None: with make_client( lambda request: httpx2.Response(200, json=payload("completed") | {"stop_reason": reason}) @@ -385,6 +429,18 @@ def test_request_defaults_preserve_explicit_quantity_intent() -> None: assert (ProspectingListParams().limit, ProspectingListParams().before) == (20, None) +def test_deliverable_and_goal_are_sent_only_when_set() -> None: + brief = "US logistics companies and operations leaders" + assert ProspectingBrief(brief=brief).to_wire() == {"brief": brief} + assert ProspectingBrief(brief=brief, deliverable="accounts", goal="companies").to_wire() == { + "brief": brief, + "deliverable": "accounts", + "goal": "companies", + } + with pytest.raises(ValidationError): + ProspectingBrief.model_validate({"brief": brief, "deliverable": "people"}) + + def test_checkpoints_default_to_the_server_mode_and_send_only_when_set() -> None: brief = "US logistics companies and operations leaders" assert ProspectingBrief(brief=brief).checkpoints == "auto" @@ -417,6 +473,40 @@ def test_wait_returns_at_a_checkpoint_with_its_question(make_client: ClientFacto assert run.messages[-1].data == question["data"] +def test_a_top_up_round_is_a_checkpoint(make_client: ClientFactory) -> None: + replies = ["Raise the limit and run it", "Run a smaller round (40 companies)", "Finish with 60 found"] + question = message_payload() | { + "role": "agent", + "kind": "question", + "content": "60 of 100 companies had reachable people. Another round needs about 70 more matching companies.", + "data": {"reason": "top_up", "suggested_replies": replies, "sample": []}, + } + paused = payload("needs_input") | {"stop_reason": "top_up", "messages": [question]} + with make_client(lambda request: httpx2.Response(200, json=paused)) as client: + run = client.prospecting.wait(RUN_ID) + assert run.stop_reason in CHECKPOINT_STOP_REASONS + assert run.messages[-1].data == question["data"] + + +def test_a_run_reports_its_phase_provider_cost_and_shape(make_client: ClientFactory) -> None: + phased = payload("running") | { + "pipeline_phase": "people", + "provider_cost_usd": 3.25, + "brief": {"brief": "US logistics companies", "deliverable": "accounts", "goal": "companies"}, + } + with make_client(lambda request: httpx2.Response(200, json=phased)) as client: + run = client.prospecting.get(RUN_ID) + assert (run.pipeline_phase, run.provider_cost_usd) == ("people", 3.25) + assert (run.brief.deliverable, run.brief.goal) == ("accounts", "companies") + + +def test_a_run_from_before_phases_defaults_its_new_fields(make_client: ClientFactory) -> None: + with make_client(lambda request: httpx2.Response(200, json=payload("running"))) as client: + run = client.prospecting.get(RUN_ID) + assert (run.pipeline_phase, run.provider_cost_usd) == (None, None) + assert (run.brief.deliverable, run.brief.goal) == ("leads", "leads") + + def test_a_failed_pilot_carries_its_sample(make_client: ClientFactory) -> None: sample = [{"domain": "example.com", "name": "Example", "company_fit": "No", "reason": "Sells software"}] stopped = payload("completed") | {"stop_reason": "pilot_failed", "pilot_sample": sample} @@ -531,6 +621,11 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert message.seq == 8 +def test_intake_accepts_a_deliverable_answer() -> None: + request = ProspectingMessageRequest(intake={"deliverable": IntakeAnswer(values=["accounts"])}) + assert request.to_wire() == {"intake": {"deliverable": {"values": ["accounts"]}}} + + def test_answer_intake_rejects_unknown_key_and_long_other() -> None: with pytest.raises(ValidationError): ProspectingMessageRequest.model_validate({"intake": {"bogus": {"values": ["x"]}}}) diff --git a/scripts/gen_requests.py b/scripts/gen_requests.py index 236c5b6..82f8d94 100644 --- a/scripts/gen_requests.py +++ b/scripts/gen_requests.py @@ -89,10 +89,10 @@ _CHECKPOINT_MODES = ["ask", "auto"] _BRIEF_CHECKPOINTS_DESCRIPTION = ( - "ask: pause at checkpoints (pilot, tail_quality, short, target_reached) with status needs_input and a " - "question to answer through message(). auto: never pause; a poor pilot is sharpened once and the run " + "ask: pause at checkpoints (pilot, tail_quality, short, target_reached, top_up) with status needs_input and " + "a question to answer through message(). auto: never pause; a poor pilot is sharpened once and the run " "continues with a notice, stopping with stop_reason pilot_failed only if the re-pilot fit is still " - "under 20%." + "under 20%. Either mode pauses at top_up when another round would pass a spending limit." ) # Properties the SDK ships before the deployed spec has them. Merged in only while the spec @@ -120,6 +120,11 @@ } +# The platform names the REST start body ProspectingStartBrief; the SDK keeps the ProspectingBrief name +# callers already import. +SCHEMA_RENAMES: dict[str, str] = {"ProspectingStartBrief": "ProspectingBrief"} + + @dataclass(frozen=True) class Route: class_name: str @@ -192,7 +197,7 @@ def request_schemas(*, spec: dict[str, Any], routes: list[Route]) -> dict[str, d raise SystemExit(f"{route.http_method} {route.path} is not in the spec; run scripts/check_contract.py") body_name = _body_ref_name(operation) if body_name is not None: - requested[body_name] = copy.deepcopy(schemas[body_name]) + requested[SCHEMA_RENAMES.get(body_name, body_name)] = copy.deepcopy(schemas[body_name]) continue synthesized = _synthesize_params_schema(operation) if synthesized is not None: