From 41ee19b6b682d1cb01231fbe42f1a0307e3531fb Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 25 Sep 2026 10:46:46 -0700 Subject: [PATCH 01/13] changelog: typed columns without TypeSafe now 400 at submit --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 396de75..9d79971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- SDK/CLI: `discogen.process` / `discogen.process_personas` with `typed_columns=True` now raise a 400 `ValidationError` at submit when the account has no TypeSafe integration, instead of returning a job whose typed cells all read `Error: No TypeSafe integration is configured`. No SDK code change; the server rejects earlier. + ## 0.4.1 (2026-09-23) - CLI: fix `ImportError: cannot import name 'Abort' from 'typer._click.exceptions'` on every command in a fresh 0.4.0 install. Typer 0.27 moved `Abort`; the CLI now imports the public `typer.Abort` and requires `typer>=0.26.1,<0.28`, since it still relies on Typer's vendored Click for its error envelope and help formatting. From 9d436a01de2bc1e5f3ce54e87213f904dadd5227 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 25 Sep 2026 15:27:18 -0700 Subject: [PATCH 02/13] Add managed prospecting SDK and CLI workflows --- CHANGELOG.md | 3 + README.md | 20 +++ .../discolike-cli/src/discolike_cli/main.py | 2 + .../src/discolike_cli/prospecting.py | 92 +++++++++++++ .../tests/test_prospecting_cli.py | 69 ++++++++++ packages/discolike/src/discolike/_client.py | 64 +++++---- .../src/discolike/_generated/requests.py | 24 +++- .../discolike/src/discolike/_transport.py | 20 ++- packages/discolike/src/discolike/requests.py | 4 + .../src/discolike/resources/prospecting.py | 130 ++++++++++++++++++ .../discolike/tests/test_contract_registry.py | 3 +- packages/discolike/tests/test_gen_requests.py | 2 +- packages/discolike/tests/test_prospecting.py | 87 ++++++++++++ scripts/check_contract.py | 9 +- 14 files changed, 491 insertions(+), 38 deletions(-) create mode 100644 packages/discolike-cli/src/discolike_cli/prospecting.py create mode 100644 packages/discolike-cli/tests/test_prospecting_cli.py create mode 100644 packages/discolike/src/discolike/resources/prospecting.py create mode 100644 packages/discolike/tests/test_prospecting.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d79971..cb361fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- CLI: add `prospecting start/status/cancel/wait`, with explicit submission keys, work limits, integration selection, and result pagination. +- SDK: add sync/async `client.prospecting.start/get/cancel/wait` for managed prospecting. Starts require an idempotency key; status responses preserve partial results and support pagination. `wait` stops on needs-input, failed, and cancelled runs as well as completion; inspect `stop_reason` before assuming the target was reached. + - SDK/CLI: `discogen.process` / `discogen.process_personas` with `typed_columns=True` now raise a 400 `ValidationError` at submit when the account has no TypeSafe integration, instead of returning a job whose typed cells all read `Error: No TypeSafe integration is configured`. No SDK code change; the server rejects earlier. ## 0.4.1 (2026-09-23) diff --git a/README.md b/README.md index 37ff589..a52b01e 100644 --- a/README.md +++ b/README.md @@ -421,3 +421,23 @@ Committed request models track the dev spec (`--spec-url https://api.dev.discoli ## License [MIT](LICENSE) + + +### Managed prospecting + +Existing processing charges and configured BYOK/BYOS integrations apply; the coordinator uses platform credentials. Limits bound work, not provider dollar spend. + +```python +from discolike.requests import ProspectingBrief, ProspectingGetParams + +run = client.prospecting.start( + ProspectingBrief(brief="US logistics companies; operations directors", target_companies=10), + idempotency_key="logistics-pilot-2026-09-25", +) +run = client.prospecting.wait(run.run_id) +print(run.status, run.stop_reason, run.accepted_contacts) +page = client.prospecting.get(run.run_id, ProspectingGetParams(offset=100, limit=100)) +# client.prospecting.cancel(run.run_id) +``` + +The async client has the same methods with `await`. Save the run ID and submission key. Reuse the key on retries; a different brief with the same key is rejected. `wait` returns the first page on `completed`, `needs_input`, `failed`, or `cancelled`, and a local timeout leaves server execution running. Partial results remain available. The pilot uses email finder outcomes; it does not expose raw email verification through the public API. diff --git a/packages/discolike-cli/src/discolike_cli/main.py b/packages/discolike-cli/src/discolike_cli/main.py index 6270907..1c1634c 100644 --- a/packages/discolike-cli/src/discolike_cli/main.py +++ b/packages/discolike-cli/src/discolike_cli/main.py @@ -23,6 +23,7 @@ from discolike_cli import email from discolike_cli import enrich from discolike_cli import match +from discolike_cli import prospecting from discolike_cli import providers from discolike_cli import queries from discolike_cli import signup @@ -85,6 +86,7 @@ def get_client(ctx: typer.Context) -> Discolike: app.add_typer(discogen.app, name="discogen") app.add_typer(email.app, name="email") app.add_typer(queries.app, name="queries") +app.add_typer(prospecting.app, name="prospecting") app.add_typer(account.app, name="account") app.add_typer(providers.search_providers_app, name="search-providers") app.add_typer(providers.llm_providers_app, name="llm-providers") diff --git a/packages/discolike-cli/src/discolike_cli/prospecting.py b/packages/discolike-cli/src/discolike_cli/prospecting.py new file mode 100644 index 0000000..6578871 --- /dev/null +++ b/packages/discolike-cli/src/discolike_cli/prospecting.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import typer + +from discolike.requests import ProspectingBrief +from discolike.requests import ProspectingGetParams +from discolike_cli._output import build_request +from discolike_cli._output import emit +from discolike_cli._output import handle_errors +from discolike_cli.discover import _merge_params + +app = typer.Typer(help="Run managed prospecting; processing and provider charges apply.") + + +@app.command("start") +@handle_errors +def start_command( + ctx: typer.Context, + brief: str = typer.Option(..., "--brief", help="Company criteria and buyer roles."), + idempotency_key: str = typer.Option(..., "--idempotency-key", help="Reuse this key when retrying this submission."), + domain: list[str] | None = typer.Option(None, "--domain", help="Starting domain (repeatable)."), + company_name: list[str] | None = typer.Option(None, "--company-name", help="Company to match (repeatable)."), + exclude_domain: list[str] | None = typer.Option(None, "--exclude-domain", help="Suppressed domain (repeatable)."), + target_companies: int = typer.Option(25, "--target-companies", min=1, max=100), + contacts_per_company: int = typer.Option(2, "--contacts-per-company", min=1, max=5), + max_candidates: int = typer.Option(200, "--max-candidates", min=1, max=1000), + max_actions: int = typer.Option(24, "--max-actions", min=1, max=60), + validation_integration_id: str | None = typer.Option(None, "--validation-integration-id"), + contact_integration_id: str | None = typer.Option(None, "--contact-integration-id"), + search_provider_id: str | None = typer.Option(None, "--search-provider-id"), + segment: bool = typer.Option(False, "--segment/--no-segment"), +) -> None: + """Return a run ID immediately. Poll status or wait; limits bound work, not provider dollars.""" + from discolike_cli.main import get_client + + request = build_request( + ProspectingBrief, + _merge_params( + None, + brief=brief, + domains=domain, + company_names=company_name, + exclude_domains=exclude_domain, + target_companies=target_companies, + contacts_per_company=contacts_per_company, + max_candidates=max_candidates, + max_actions=max_actions, + validation_integration_id=validation_integration_id, + contact_integration_id=contact_integration_id, + search_provider_id=search_provider_id, + segment=segment, + ), + ) + emit(get_client(ctx).prospecting.start(request, idempotency_key=idempotency_key)) + + +@app.command("status") +@handle_errors +def status_command( + ctx: typer.Context, + run_id: str = typer.Argument(...), + offset: int = typer.Option(0, "--offset", min=0), + limit: int = typer.Option(100, "--limit", min=1, max=100), +) -> None: + """Retrieve status and one page of partial results.""" + from discolike_cli.main import get_client + + params = build_request(ProspectingGetParams, {"offset": offset, "limit": limit}) + emit(get_client(ctx).prospecting.get(run_id, params)) + + +@app.command("cancel") +@handle_errors +def cancel_command(ctx: typer.Context, run_id: str = typer.Argument(...)) -> None: + """Stop new work; retain partial results and incurred charges.""" + from discolike_cli.main import get_client + + emit(get_client(ctx).prospecting.cancel(run_id)) + + +@app.command("wait") +@handle_errors +def wait_command( + ctx: typer.Context, + run_id: str = typer.Argument(...), + timeout: float = typer.Option(3600, "--timeout", min=0.01), + poll_interval: float = typer.Option(5, "--poll-interval", min=5), +) -> None: + """Return the first page on any terminal status. Inspect stop_reason; timeout leaves the run active.""" + from discolike_cli.main import get_client + + emit(get_client(ctx).prospecting.wait(run_id, timeout=timeout, poll_interval=poll_interval)) diff --git a/packages/discolike-cli/tests/test_prospecting_cli.py b/packages/discolike-cli/tests/test_prospecting_cli.py new file mode 100644 index 0000000..d238892 --- /dev/null +++ b/packages/discolike-cli/tests/test_prospecting_cli.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import json +from collections.abc import Callable + +import httpx2 +from typer.testing import CliRunner + +from discolike_cli.main import app +from discolike_testkit import Handler + +runner = CliRunner() +RUN_ID = "00000000-0000-0000-0000-000000000001" + + +def test_start_forwards_key_and_brief(install_build_client: Callable[[Handler], None]) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(202, json={"run_id": RUN_ID, "status": "queued", "max_actions": 24}) + + install_build_client(handler) + result = runner.invoke( + app, + [ + "prospecting", + "start", + "--brief", + "US logistics companies and operations directors", + "--idempotency-key", + "pilot-key", + "--exclude-domain", + "excluded.com", + "--target-companies", + "10", + ], + ) + assert result.exit_code == 0, result.output + assert seen[0].headers["Idempotency-Key"] == "pilot-key" + assert json.loads(seen[0].content)["exclude_domains"] == ["excluded.com"] + assert json.loads(result.stdout)["run_id"] == RUN_ID + + +def test_status_paginates(install_build_client: Callable[[Handler], None]) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.url.params["offset"] == "100" + assert request.url.params["limit"] == "20" + return httpx2.Response(200, json={"run_id": RUN_ID, "status": "needs_input", "max_actions": 24}) + + install_build_client(handler) + result = runner.invoke(app, ["prospecting", "status", RUN_ID, "--offset", "100", "--limit", "20"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["status"] == "needs_input" + + +def test_cancel_and_wait_preserve_terminal_outcomes(install_build_client: Callable[[Handler], None]) -> None: + methods = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + methods.append(request.method) + return httpx2.Response(200, json={"run_id": RUN_ID, "status": "cancelled", "max_actions": 24}) + + install_build_client(handler) + for command in ("cancel", "wait"): + result = runner.invoke(app, ["prospecting", command, RUN_ID]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["status"] == "cancelled" + assert methods == ["DELETE", "GET"] diff --git a/packages/discolike/src/discolike/_client.py b/packages/discolike/src/discolike/_client.py index fc4998d..dfe1a5b 100644 --- a/packages/discolike/src/discolike/_client.py +++ b/packages/discolike/src/discolike/_client.py @@ -40,6 +40,8 @@ from discolike.resources.enrich import EnrichResource from discolike.resources.match import AsyncMatchResource from discolike.resources.match import MatchResource +from discolike.resources.prospecting import AsyncProspectingResource +from discolike.resources.prospecting import ProspectingResource from discolike.resources.providers import AsyncLLMProvidersResource from discolike.resources.providers import AsyncSearchProvidersResource from discolike.resources.providers import LLMProvidersResource @@ -60,6 +62,22 @@ def _build_auth(*, api_key: str | None, auth: Credential | None) -> DiscolikeAut class Discolike: + def _attach(self, transport: Transport) -> None: + self._transport = transport + self.account = AccountResource(self._transport) + self.companies = CompaniesResource(self._transport) + self.contacts = ContactsResource(self._transport) + self.match = MatchResource(self._transport) + self.discogen = DiscogenResource(self._transport) + self.email = EmailResource(self._transport) + self.prospecting = ProspectingResource(self._transport) + self.queries = QueriesResource(self._transport) + self.search_providers = SearchProvidersResource(self._transport) + self.llm_providers = LLMProvidersResource(self._transport) + self._discovery = DiscoveryResource(self._transport) + self._validate = ValidateResource(self._transport) + self._enrich = EnrichResource(self._transport) + def __init__( self, *, @@ -80,21 +98,6 @@ def __init__( ) ) - def _attach(self, transport: Transport) -> None: - self._transport = transport - self.account = AccountResource(self._transport) - self.companies = CompaniesResource(self._transport) - self.contacts = ContactsResource(self._transport) - self.match = MatchResource(self._transport) - self.discogen = DiscogenResource(self._transport) - self.email = EmailResource(self._transport) - self.queries = QueriesResource(self._transport) - self.search_providers = SearchProvidersResource(self._transport) - self.llm_providers = LLMProvidersResource(self._transport) - self._discovery = DiscoveryResource(self._transport) - self._validate = ValidateResource(self._transport) - self._enrich = EnrichResource(self._transport) - def with_options(self, *, timeout: float | httpx2.Timeout) -> Discolike: """A client view with a different request timeout, sharing this client's connection pool.""" clone = object.__new__(Discolike) @@ -130,6 +133,22 @@ def __exit__(self, *exc_info: object) -> None: class AsyncDiscolike: + def _attach(self, transport: AsyncTransport) -> None: + self._transport = transport + self.account = AsyncAccountResource(self._transport) + self.companies = AsyncCompaniesResource(self._transport) + self.contacts = AsyncContactsResource(self._transport) + self.match = AsyncMatchResource(self._transport) + self.discogen = AsyncDiscogenResource(self._transport) + self.email = AsyncEmailResource(self._transport) + self.prospecting = AsyncProspectingResource(self._transport) + self.queries = AsyncQueriesResource(self._transport) + self.search_providers = AsyncSearchProvidersResource(self._transport) + self.llm_providers = AsyncLLMProvidersResource(self._transport) + self._discovery = AsyncDiscoveryResource(self._transport) + self._validate = AsyncValidateResource(self._transport) + self._enrich = AsyncEnrichResource(self._transport) + def __init__( self, *, @@ -150,21 +169,6 @@ def __init__( ) ) - def _attach(self, transport: AsyncTransport) -> None: - self._transport = transport - self.account = AsyncAccountResource(self._transport) - self.companies = AsyncCompaniesResource(self._transport) - self.contacts = AsyncContactsResource(self._transport) - self.match = AsyncMatchResource(self._transport) - self.discogen = AsyncDiscogenResource(self._transport) - self.email = AsyncEmailResource(self._transport) - self.queries = AsyncQueriesResource(self._transport) - self.search_providers = AsyncSearchProvidersResource(self._transport) - self.llm_providers = AsyncLLMProvidersResource(self._transport) - self._discovery = AsyncDiscoveryResource(self._transport) - self._validate = AsyncValidateResource(self._transport) - self._enrich = AsyncEnrichResource(self._transport) - def with_options(self, *, timeout: float | httpx2.Timeout) -> AsyncDiscolike: """A client view with a different request timeout, sharing this client's connection pool.""" clone = object.__new__(AsyncDiscolike) diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 9f3b048..071a2ea 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -1460,7 +1460,7 @@ class DiscoGenProcessRequest(DiscolikeRequest): ), ] context_mode: Annotated[Literal["website", "profile", "domain"] | None, Field(title="Context Mode")] = "website" - previous_discogen_data: Annotated[dict[str, Any] | None, Field(title="Previous Discogen Data")] = None + previous_discogen_data: Annotated[dict[str, dict[str, Any]] | None, Field(title="Previous Discogen Data")] = None class DiscoGenPersonaProcessRequest(DiscolikeRequest): @@ -1506,7 +1506,7 @@ class DiscoGenPersonaProcessRequest(DiscolikeRequest): Literal["name_only", "profile", "profile_summary", "company", "full"] | None, Field(title="Context Mode"), ] = "profile" - previous_discogen_data: Annotated[dict[str, Any] | None, Field(title="Previous Discogen Data")] = None + previous_discogen_data: Annotated[dict[str, dict[str, Any]] | None, Field(title="Previous Discogen Data")] = None class ValidateIcpRequest(DiscolikeRequest): @@ -2906,6 +2906,26 @@ class MatchBulkParams(DiscolikeRequest): ] = 50 +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 + exclude_domains: Annotated[list[str] | None, Field(max_length=1000, title="Exclude Domains")] = None + target_companies: Annotated[int | None, Field(ge=1, le=100, title="Target Companies")] = 25 + contacts_per_company: Annotated[int | None, Field(ge=1, le=5, title="Contacts Per Company")] = 2 + max_candidates: Annotated[int | None, Field(ge=1, le=1000, title="Max Candidates")] = 200 + max_actions: Annotated[int | None, Field(ge=1, le=60, title="Max Actions")] = 24 + 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 + + +class ProspectingGetParams(DiscolikeRequest): + offset: Annotated[int | None, Field(ge=0, title="Offset")] = 0 + limit: Annotated[int | None, Field(ge=1, le=100, title="Limit")] = 100 + + class LLMProviderCreateRequest(DiscolikeRequest): integration_name: Annotated[ str, diff --git a/packages/discolike/src/discolike/_transport.py b/packages/discolike/src/discolike/_transport.py index 0b54f57..4e12af0 100644 --- a/packages/discolike/src/discolike/_transport.py +++ b/packages/discolike/src/discolike/_transport.py @@ -75,6 +75,7 @@ def request( path: str, *, params: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, json_body: Any = None, # noqa: ANN401 -- forwarded verbatim to httpx2.Client.request files: Any = None, # noqa: ANN401 -- forwarded verbatim to httpx2.Client.request data: Any = None, # noqa: ANN401 -- forwarded verbatim to httpx2.Client.request @@ -86,7 +87,14 @@ def request( for attempt in range(self._max_retries + 1): try: response = self._client.request( - method, path, params=clean_params, json=json_body, files=files, data=data, timeout=timeout + method, + path, + params=clean_params, + json=json_body, + files=files, + data=data, + timeout=timeout, + headers=headers, ) except retryable_exceptions as exc: if attempt == self._max_retries: @@ -140,6 +148,7 @@ async def request( path: str, *, params: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, json_body: Any = None, # noqa: ANN401 -- forwarded verbatim to httpx2.Client.request files: Any = None, # noqa: ANN401 -- forwarded verbatim to httpx2.Client.request data: Any = None, # noqa: ANN401 -- forwarded verbatim to httpx2.Client.request @@ -151,7 +160,14 @@ async def request( for attempt in range(self._max_retries + 1): try: response = await self._client.request( - method, path, params=clean_params, json=json_body, files=files, data=data, timeout=timeout + method, + path, + params=clean_params, + json=json_body, + files=files, + data=data, + timeout=timeout, + headers=headers, ) except retryable_exceptions as exc: if attempt == self._max_retries: diff --git a/packages/discolike/src/discolike/requests.py b/packages/discolike/src/discolike/requests.py index 9cdb359..9a06edb 100644 --- a/packages/discolike/src/discolike/requests.py +++ b/packages/discolike/src/discolike/requests.py @@ -28,6 +28,8 @@ from discolike._generated.requests import LLMProviderUpdateRequest from discolike._generated.requests import MatchBulkParams from discolike._generated.requests import MatchCompanyParams +from discolike._generated.requests import ProspectingBrief +from discolike._generated.requests import ProspectingGetParams from discolike._generated.requests import QueriesListParams from discolike._generated.requests import SaveResultsRequest from discolike._generated.requests import SearchProviderRequest @@ -65,6 +67,8 @@ "LLMProviderUpdateRequest", "MatchBulkParams", "MatchCompanyParams", + "ProspectingBrief", + "ProspectingGetParams", "QueriesListParams", "SaveResultsRequest", "SearchProviderRequest", diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py new file mode 100644 index 0000000..7843292 --- /dev/null +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import asyncio +import math +import time +from typing import Any +from typing import Literal +from uuid import UUID + +from pydantic import Field + +from discolike._exceptions import JobTimeoutError +from discolike._models import DiscolikeModel +from discolike.requests import ProspectingBrief +from discolike.requests import ProspectingGetParams +from discolike.resources._base import AsyncAPIResource +from discolike.resources._base import SyncAPIResource +from discolike.resources._base import api_route + +TERMINAL_STATUSES = frozenset({"completed", "needs_input", "failed", "cancelled"}) + + +class ProspectingPlan(DiscolikeModel): + company_queries: list[dict[str, Any]] = Field(default_factory=list) + contact_filters: dict[str, Any] = Field(default_factory=dict) + company_criteria: str + persona_criteria: str + issues: list[str] = Field(default_factory=list) + + +class ProspectingRun(DiscolikeModel): + run_id: UUID + status: Literal["queued", "running", "needs_input", "completed", "failed", "cancelled"] + stop_reason: str | None = None + stage: str | None = None + plan: ProspectingPlan | None = None + companies: list[dict[str, Any]] = Field(default_factory=list) + contacts: list[dict[str, Any]] = Field(default_factory=list) + total_companies: int = 0 + total_contacts: int = 0 + qualified_companies: int = 0 + accepted_contacts: int = 0 + offset: int = 0 + limit: int = 100 + actions_used: int = 0 + max_actions: int + error: str | None = None + + +def _key(value: str) -> str: + if not value.strip() or len(value) > 128: + raise ValueError("idempotency_key must contain 1-128 characters") + return value + + +def _path(run_id: str | UUID) -> str: + return f"/prospecting/runs/{UUID(str(run_id))}" + + +def _deadline(timeout: float, poll_interval: float) -> float: + if not math.isfinite(timeout) or timeout <= 0 or not math.isfinite(poll_interval) or poll_interval < 5: + raise ValueError("timeout must be finite and positive; poll_interval must be finite and at least 5 seconds") + return time.monotonic() + timeout + + +class ProspectingResource(SyncAPIResource): + @api_route("POST", "/prospecting/runs") + def start(self, request: ProspectingBrief, *, idempotency_key: str) -> ProspectingRun: + """Start a managed run; retain the key when retrying this submission.""" + response = self._transport.request( + "POST", "/prospecting/runs", json_body=request.to_wire(), headers={"Idempotency-Key": _key(idempotency_key)} + ) + return ProspectingRun.model_validate(response.json()) + + @api_route("GET", "/prospecting/runs/{run_id}") + def get(self, run_id: str | UUID, params: ProspectingGetParams | None = None) -> ProspectingRun: + response = self._transport.request("GET", _path(run_id), params=params.to_wire() if params else None) + return ProspectingRun.model_validate(response.json()) + + @api_route("DELETE", "/prospecting/runs/{run_id}") + def cancel(self, run_id: str | UUID) -> ProspectingRun: + response = self._transport.request("DELETE", _path(run_id)) + return ProspectingRun.model_validate(response.json()) + + def wait(self, run_id: str | UUID, *, timeout: float = 3600, poll_interval: float = 5) -> ProspectingRun: + """Return the first result page at any terminal status, preserving partial results. + + Inspect status and stop_reason; completed does not guarantee the target was met. + Timeout stops local polling only. Fetch subsequent pages with get(). + """ + deadline = _deadline(timeout, poll_interval) + while True: + run = self.get(run_id) + if run.status in TERMINAL_STATUSES: + return run + remaining = deadline - time.monotonic() + if remaining <= 0: + raise JobTimeoutError("Timed out waiting for prospecting; the run continues on the server") + time.sleep(min(poll_interval, remaining)) + + +class AsyncProspectingResource(AsyncAPIResource): + @api_route("POST", "/prospecting/runs") + async def start(self, request: ProspectingBrief, *, idempotency_key: str) -> ProspectingRun: + response = await self._transport.request( + "POST", "/prospecting/runs", json_body=request.to_wire(), headers={"Idempotency-Key": _key(idempotency_key)} + ) + return ProspectingRun.model_validate(response.json()) + + @api_route("GET", "/prospecting/runs/{run_id}") + async def get(self, run_id: str | UUID, params: ProspectingGetParams | None = None) -> ProspectingRun: + response = await self._transport.request("GET", _path(run_id), params=params.to_wire() if params else None) + return ProspectingRun.model_validate(response.json()) + + @api_route("DELETE", "/prospecting/runs/{run_id}") + async def cancel(self, run_id: str | UUID) -> ProspectingRun: + response = await self._transport.request("DELETE", _path(run_id)) + return ProspectingRun.model_validate(response.json()) + + async def wait(self, run_id: str | UUID, *, timeout: float = 3600, poll_interval: float = 5) -> ProspectingRun: + """Return the first page on completed/needs_input/failed/cancelled; inspect stop_reason.""" + deadline = _deadline(timeout, poll_interval) + while True: + run = await self.get(run_id) + if run.status in TERMINAL_STATUSES: + return run + remaining = deadline - time.monotonic() + if remaining <= 0: + raise JobTimeoutError("Timed out waiting for prospecting; the run continues on the server") + await asyncio.sleep(min(poll_interval, remaining)) diff --git a/packages/discolike/tests/test_contract_registry.py b/packages/discolike/tests/test_contract_registry.py index 00fca89..91388dc 100644 --- a/packages/discolike/tests/test_contract_registry.py +++ b/packages/discolike/tests/test_contract_registry.py @@ -5,7 +5,8 @@ from discolike.resources._base import get_discolike_route -ALLOW_UNSTAMPED = {"job", "batch"} +# wait orchestrates repeated get() calls and is not a separate route. +ALLOW_UNSTAMPED = {"job", "batch", "wait"} SCRIPT_PATH = pathlib.Path(__file__).parents[3] / "scripts" / "check_contract.py" diff --git a/packages/discolike/tests/test_gen_requests.py b/packages/discolike/tests/test_gen_requests.py index 93f4630..828039d 100644 --- a/packages/discolike/tests/test_gen_requests.py +++ b/packages/discolike/tests/test_gen_requests.py @@ -263,5 +263,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) == 48 + assert len(routes) == 51 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 new file mode 100644 index 0000000..fb25fa3 --- /dev/null +++ b/packages/discolike/tests/test_prospecting.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import json +from uuid import UUID + +import httpx2 +import pytest + +import discolike.resources.prospecting as module +from discolike import JobTimeoutError +from discolike.requests import ProspectingBrief +from discolike.requests import ProspectingGetParams +from discolike_testkit import AsyncClientFactory +from discolike_testkit import ClientFactory + +RUN_ID = "00000000-0000-0000-0000-000000000001" + + +def payload(status: str = "queued") -> dict: + return {"run_id": RUN_ID, "status": status, "max_actions": 24, "companies": [{"domain": "example.com"}]} + + +def test_start_key_does_not_leak_to_other_requests(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(202 if request.method == "POST" else 200, json=payload()) + + with make_client(handler) as client: + brief = ProspectingBrief(brief="US logistics companies and operations leaders") + first = client.prospecting.start(brief, idempotency_key="stable-key") + second = client.prospecting.start(brief, idempotency_key="stable-key") + assert first.run_id == second.run_id == UUID(RUN_ID) + client.prospecting.get(RUN_ID, ProspectingGetParams(offset=100, limit=25)) + assert [r.headers.get("Idempotency-Key") for r in seen] == ["stable-key", "stable-key", None] + assert json.loads(seen[0].content) == {"brief": brief.brief} + assert dict(seen[2].url.params) == {"offset": "100", "limit": "25"} + + +@pytest.mark.parametrize("status", ["completed", "needs_input", "failed", "cancelled"]) +def test_wait_preserves_terminal_partial_results(make_client: ClientFactory, status: str) -> None: + with make_client(lambda request: httpx2.Response(200, json=payload(status))) as client: + run = client.prospecting.wait(RUN_ID) + assert run.status == status + assert run.companies == [{"domain": "example.com"}] + + +def test_wait_timeout_never_cancels(make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch) -> None: + times = iter([0.0, 2.0]) + monkeypatch.setattr(module.time, "monotonic", lambda: next(times)) + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request.method) + return httpx2.Response(200, json=payload()) + + with make_client(handler) as client, pytest.raises(JobTimeoutError): + client.prospecting.wait(RUN_ID, timeout=1) + assert seen == ["GET"] + + +@pytest.mark.parametrize("key", ["", " ", "a" * 129]) +def test_invalid_key_is_rejected_locally(make_client: ClientFactory, key: str) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + pytest.fail("invalid key must not reach the network") + + with make_client(handler) as client, pytest.raises(ValueError, match="idempotency_key"): + client.prospecting.start(ProspectingBrief(brief="Logistics companies and buyers"), idempotency_key=key) + + +async def test_async_start_wait_and_cancel(make_async_client: AsyncClientFactory) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request.method) + if request.method == "POST": + assert request.headers["Idempotency-Key"] == "async-key" + return httpx2.Response(200, json=payload("cancelled" if request.method == "DELETE" else "completed")) + + async with make_async_client(handler) as client: + run = await client.prospecting.start( + ProspectingBrief(brief="Logistics companies and operations leaders"), idempotency_key="async-key" + ) + assert (await client.prospecting.wait(run.run_id)).status == "completed" + assert (await client.prospecting.cancel(run.run_id)).status == "cancelled" + assert seen == ["POST", "GET", "DELETE"] diff --git a/scripts/check_contract.py b/scripts/check_contract.py index 4cf0d93..d29d195 100644 --- a/scripts/check_contract.py +++ b/scripts/check_contract.py @@ -26,6 +26,8 @@ from discolike.resources.companies import Subsidiary from discolike.resources.companies import Vendor from discolike.resources.match import MatchResponse +from discolike.resources.prospecting import ProspectingPlan +from discolike.resources.prospecting import ProspectingRun from discolike.resources.queries import SavedQueries IGNORE_PARAMS = {"file"} @@ -36,6 +38,8 @@ # checked field-by-field against the spec, so a platform-side model change surfaces as a # contract failure instead of silently landing in `extra`. MIRRORED_SCHEMAS: dict[str, type[DiscolikeModel]] = { + "ProspectingRunResponse": ProspectingRun, + "ProspectingPlan": ProspectingPlan, "CompanyResult": CompanyProfile, "ExtractResponse": ExtractResult, "ScoreResponse": Score, @@ -74,8 +78,9 @@ def _resource_modules() -> list[ModuleType]: def _request_model(member: object) -> type[DiscolikeRequest] | None: for annotation in typing.get_type_hints(member).values(): - if inspect.isclass(annotation) and issubclass(annotation, DiscolikeRequest): - return annotation + for candidate in (annotation, *typing.get_args(annotation)): + if inspect.isclass(candidate) and issubclass(candidate, DiscolikeRequest): + return candidate return None From 2daa3d1e807555b2acd373ac1e4434489b01cd09 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 25 Sep 2026 17:45:04 -0700 Subject: [PATCH 03/13] Explain prospecting scope rejection in SDK guidance --- CHANGELOG.md | 2 +- README.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb361fc..d2f311f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased - CLI: add `prospecting start/status/cancel/wait`, with explicit submission keys, work limits, integration selection, and result pagination. -- SDK: add sync/async `client.prospecting.start/get/cancel/wait` for managed prospecting. Starts require an idempotency key; status responses preserve partial results and support pagination. `wait` stops on needs-input, failed, and cancelled runs as well as completion; inspect `stop_reason` before assuming the target was reached. +- SDK: add sync/async `client.prospecting.start/get/cancel/wait` for managed prospecting. Starts require an idempotency key; status responses preserve partial results and support pagination. `wait` stops on needs-input, failed, and cancelled runs as well as completion; inspect `stop_reason` before assuming the target was reached, including scope rejection and targeting-clarification reasons. - SDK/CLI: `discogen.process` / `discogen.process_personas` with `typed_columns=True` now raise a 400 `ValidationError` at submit when the account has no TypeSafe integration, instead of returning a job whose typed cells all read `Error: No TypeSafe integration is configured`. No SDK code change; the server rejects earlier. diff --git a/README.md b/README.md index a52b01e..0fc9242 100644 --- a/README.md +++ b/README.md @@ -441,3 +441,5 @@ page = client.prospecting.get(run.run_id, ProspectingGetParams(offset=100, limit ``` The async client has the same methods with `await`. Save the run ID and submission key. Reuse the key on retries; a different brief with the same key is rejected. `wait` returns the first page on `completed`, `needs_input`, `failed`, or `cancelled`, and a local timeout leaves server execution running. Partial results remain available. The pilot uses email finder outcomes; it does not expose raw email verification through the public API. + +Prospecting scope checks can stop with `needs_input`: inspect `stop_reason` for `out_of_scope`, `company_target`, `persona_target`, or `ambiguous_target`, and `error` for guidance. These runs count the interpretation action and do not execute downstream research. Correct the brief and use a new submission key. From 8008cfc7d84042fc9f2f86aa8a00572f326f97ee Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 25 Sep 2026 17:59:01 -0700 Subject: [PATCH 04/13] Clarify prospecting customer credential requirements --- CHANGELOG.md | 2 ++ README.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f311f..5e0ec3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Managed prospecting contact qualification now uses customer LLM credentials, including when contact extraction is native. Missing keys and provider errors do not fall back to platform credentials. Request and response schemas are unchanged. + - CLI: add `prospecting start/status/cancel/wait`, with explicit submission keys, work limits, integration selection, and result pagination. - SDK: add sync/async `client.prospecting.start/get/cancel/wait` for managed prospecting. Starts require an idempotency key; status responses preserve partial results and support pagination. `wait` stops on needs-input, failed, and cancelled runs as well as completion; inspect `stop_reason` before assuming the target was reached, including scope rejection and targeting-clarification reasons. diff --git a/README.md b/README.md index 0fc9242..06a8957 100644 --- a/README.md +++ b/README.md @@ -425,7 +425,7 @@ Committed request models track the dev spec (`--spec-url https://api.dev.discoli ### Managed prospecting -Existing processing charges and configured BYOK/BYOS integrations apply; the coordinator uses platform credentials. Limits bound work, not provider dollar spend. +Existing processing charges and configured BYOK/BYOS integrations apply. Interpretation, coordination, and prompt preparation use platform credentials. Independent contact qualification uses 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. ```python from discolike.requests import ProspectingBrief, ProspectingGetParams From 2f4a9cc271fd49b136415b8f52f667f560bd9321 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 25 Sep 2026 18:34:45 -0700 Subject: [PATCH 05/13] Clarify customer-funded agent coordination --- CHANGELOG.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e0ec3a..7ae2cc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Managed prospecting contact qualification now uses customer LLM credentials, including when contact extraction is native. Missing keys and provider errors do not fall back to platform credentials. Request and response schemas are unchanged. +- Managed prospecting agent coordination and contact qualification now use customer LLM credentials, including when contact extraction is native. Missing keys and provider errors do not fall back to platform credentials. Request and response schemas are unchanged. - CLI: add `prospecting start/status/cancel/wait`, with explicit submission keys, work limits, integration selection, and result pagination. - SDK: add sync/async `client.prospecting.start/get/cancel/wait` for managed prospecting. Starts require an idempotency key; status responses preserve partial results and support pagination. `wait` stops on needs-input, failed, and cancelled runs as well as completion; inspect `stop_reason` before assuming the target was reached, including scope rejection and targeting-clarification reasons. diff --git a/README.md b/README.md index 06a8957..219ba19 100644 --- a/README.md +++ b/README.md @@ -425,7 +425,7 @@ Committed request models track the dev spec (`--spec-url https://api.dev.discoli ### Managed prospecting -Existing processing charges and configured BYOK/BYOS integrations apply. Interpretation, coordination, and prompt preparation use platform credentials. Independent contact qualification uses 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. +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. ```python from discolike.requests import ProspectingBrief, ProspectingGetParams From 498a5927a95533d6a1afee6da7dd1a4cae873cb7 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Sat, 26 Sep 2026 18:31:24 -0700 Subject: [PATCH 06/13] Support prospecting chat approval and messages in SDK and CLI --- CHANGELOG.md | 6 +- README.md | 35 ++++- packages/discolike-cli/README.md | 19 ++- .../src/discolike_cli/prospecting.py | 84 ++++++++++- .../tests/test_prospecting_cli.py | 124 +++++++++++++++- .../src/discolike_testkit/prospecting.py | 30 ++++ packages/discolike/README.md | 8 + .../src/discolike/_generated/requests.py | 22 ++- packages/discolike/src/discolike/requests.py | 6 + .../src/discolike/resources/prospecting.py | 137 +++++++++++++++++- packages/discolike/tests/test_gen_requests.py | 2 +- packages/discolike/tests/test_prospecting.py | 137 +++++++++++++++++- scripts/check_contract.py | 8 + 13 files changed, 582 insertions(+), 36 deletions(-) create mode 100644 packages/discolike-testkit/src/discolike_testkit/prospecting.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ae2cc8..8167e3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,10 @@ ## Unreleased -- Managed prospecting agent coordination and contact qualification now use customer LLM credentials, including when contact extraction is native. Missing keys and provider errors do not fall back to platform credentials. Request and response schemas are unchanged. +- Managed prospecting agent coordination and contact qualification now use customer LLM credentials, including when contact extraction is native. Missing keys and provider errors do not fall back to platform credentials. -- CLI: add `prospecting start/status/cancel/wait`, with explicit submission keys, work limits, integration selection, and result pagination. -- SDK: add sync/async `client.prospecting.start/get/cancel/wait` for managed prospecting. Starts require an idempotency key; status responses preserve partial results and support pagination. `wait` stops on needs-input, failed, and cancelled runs as well as completion; inspect `stop_reason` before assuming the target was reached, including scope rejection and targeting-clarification reasons. +- CLI: add prospecting plan approval, recent-run lists, chat messages, and event/message cursors. Omitted quantity flags preserve brief inference; larger target and automatic work limits match the API. `wait` returns when a plan needs approval. +- SDK: add sync/async prospecting start/get/list/approve/message/cancel/wait, with typed chat, progress, saved-query, and recent-run responses. Starts and messages require idempotency keys; approval requires the reviewed plan version. `wait` returns on proposed plans, needs-input, and terminal outcomes. - SDK/CLI: `discogen.process` / `discogen.process_personas` with `typed_columns=True` now raise a 400 `ValidationError` at submit when the account has no TypeSafe integration, instead of returning a job whose typed cells all read `Error: No TypeSafe integration is configured`. No SDK code change; the server rejects earlier. diff --git a/README.md b/README.md index 219ba19..6d9af4a 100644 --- a/README.md +++ b/README.md @@ -425,21 +425,40 @@ Committed request models track the dev spec (`--spec-url https://api.dev.discoli ### Managed prospecting -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. +REST starts in `drafting`, then waits at `proposed` for approval. Review the plan and approve its exact version before research starts. ```python -from discolike.requests import ProspectingBrief, ProspectingGetParams +from discolike.requests import ( + ProspectingApproveRequest, ProspectingBrief, ProspectingGetParams, + ProspectingListParams, ProspectingMessageRequest, +) run = client.prospecting.start( - ProspectingBrief(brief="US logistics companies; operations directors", target_companies=10), - idempotency_key="logistics-pilot-2026-09-25", + ProspectingBrief(brief="Find 100 US logistics companies and 3 operations directors each"), + idempotency_key="logistics-search-2026-09-26", ) run = client.prospecting.wait(run.run_id) -print(run.status, run.stop_reason, run.accepted_contacts) -page = client.prospecting.get(run.run_id, ProspectingGetParams(offset=100, limit=100)) +print(run.status, run.plan, run.messages) # Review before approving. +# After reviewing a proposed plan: +# client.prospecting.approve(run.run_id, ProspectingApproveRequest(plan_version=run.plan_version)) +# run = client.prospecting.wait(run.run_id) + +recent = client.prospecting.list(ProspectingListParams(limit=20)) +message = client.prospecting.message( + run.run_id, ProspectingMessageRequest(text="Make it 250 companies"), + idempotency_key="logistics-target-edit-1", +) +page = client.prospecting.get( + run.run_id, + ProspectingGetParams(offset=0, limit=100, events_after=run.next_event_seq, messages_after=run.next_message_seq), +) # client.prospecting.cancel(run.run_id) ``` -The async client has the same methods with `await`. Save the run ID and submission key. Reuse the key on retries; a different brief with the same key is rejected. `wait` returns the first page on `completed`, `needs_input`, `failed`, or `cancelled`, and a local timeout leaves server execution running. Partial results remain available. The pilot uses email finder outcomes; it does not expose raw email verification through the public API. +The async client exposes the same methods with `await`. Starts and messages require separate idempotency keys; reuse each key when retrying that operation. Approving an already approved version is safe. A stale plan version is rejected: fetch the current plan and review it again. + +`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. A `needs_input` question can be answered with `message()`. + +Initial planning extracts company counts and contacts per company from the brief. Omitted settings keep that inference available, falling back to 25 companies and 2 contacts 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. -Prospecting scope checks can stop with `needs_input`: inspect `stop_reason` for `out_of_scope`, `company_target`, `persona_target`, or `ambiguous_target`, and `error` for guidance. These runs count the interpretation action and do not execute downstream research. Correct the brief and use a new submission key. +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 82f4262..09a0411 100644 --- a/packages/discolike-cli/README.md +++ b/packages/discolike-cli/README.md @@ -35,7 +35,7 @@ discolike company data stripe.com discolike extract https://stripe.com/enterprise ``` -Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment` — plus `auth`, `bulk`, `company`, `contacts`, `discogen`, `queries`, `account`, `search-providers`, and `llm-providers` command groups. +Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment` — plus `auth`, `bulk`, `company`, `contacts`, `discogen`, `prospecting`, `queries`, `account`, `search-providers`, and `llm-providers` command groups. ### Volume pulls @@ -49,6 +49,23 @@ discolike bulk contacts --domains-file companies.csv --per-company 10 --summary `companies` saves each page as an exclusion list (`-round-N`) and excludes it from the next page; rerunning with the same `--out` resumes from the CSV. `contacts` slices the domain list at `10000 / per-company` domains per call and records finished slices in `.checkpoint`. Both keep one call in flight under `--rate-limit` (default 10/min, the Pro rate on `/discover` and `/contacts`), retry on 429/5xx, and print a JSON summary at the end. Filters come from `--params-file`, `--param` and the common flags; the paging fields are managed for you. +### Managed prospecting + +```bash +discolike prospecting start --brief "Find 100 US logistics companies and 3 operations directors each" --idempotency-key logistics-1 +discolike prospecting wait RUN_ID +# Review the proposed plan, then approve the exact version you saw: +discolike prospecting approve RUN_ID --plan-version 1 +discolike prospecting list --limit 20 +discolike prospecting message RUN_ID --text "Make it 250 companies" --idempotency-key logistics-edit-1 +discolike prospecting status RUN_ID --events-after 12 --messages-after 8 --limit 100 +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`. + +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. + ### Conventions - Results print as JSON to stdout; errors print as JSON (`error`, `message`, `status_code`) to stderr. diff --git a/packages/discolike-cli/src/discolike_cli/prospecting.py b/packages/discolike-cli/src/discolike_cli/prospecting.py index 6578871..576f9bd 100644 --- a/packages/discolike-cli/src/discolike_cli/prospecting.py +++ b/packages/discolike-cli/src/discolike_cli/prospecting.py @@ -2,8 +2,11 @@ import typer +from discolike.requests import ProspectingApproveRequest from discolike.requests import ProspectingBrief from discolike.requests import ProspectingGetParams +from discolike.requests import ProspectingListParams +from discolike.requests import ProspectingMessageRequest from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import handle_errors @@ -21,16 +24,32 @@ def start_command( domain: list[str] | None = typer.Option(None, "--domain", help="Starting domain (repeatable)."), company_name: list[str] | None = typer.Option(None, "--company-name", help="Company to match (repeatable)."), exclude_domain: list[str] | None = typer.Option(None, "--exclude-domain", help="Suppressed domain (repeatable)."), - target_companies: int = typer.Option(25, "--target-companies", min=1, max=100), - contacts_per_company: int = typer.Option(2, "--contacts-per-company", min=1, max=5), - max_candidates: int = typer.Option(200, "--max-candidates", min=1, max=1000), - max_actions: int = typer.Option(24, "--max-actions", min=1, max=60), + target_companies: int | None = typer.Option( + None, + "--target-companies", + min=1, + max=10000, + help="Override the company count in the brief; otherwise inferred, default 25.", + ), + contacts_per_company: int | None = typer.Option( + None, + "--contacts-per-company", + min=1, + max=5, + help="Override contacts per company; otherwise inferred, default 2.", + ), + max_candidates: int | None = typer.Option( + None, "--max-candidates", min=0, max=100000, help="Candidate work cap; omitted or 0 means automatic." + ), + max_actions: int | None = typer.Option( + None, "--max-actions", min=0, max=10000, help="Action work cap; omitted or 0 means automatic." + ), validation_integration_id: str | None = typer.Option(None, "--validation-integration-id"), contact_integration_id: str | None = typer.Option(None, "--contact-integration-id"), search_provider_id: str | None = typer.Option(None, "--search-provider-id"), segment: bool = typer.Option(False, "--segment/--no-segment"), ) -> None: - """Return a run ID immediately. Poll status or wait; limits bound work, not provider dollars.""" + """Draft a plan. Wait for proposed, review it, then approve its plan version.""" from discolike_cli.main import get_client request = build_request( @@ -60,12 +79,17 @@ def status_command( ctx: typer.Context, run_id: str = typer.Argument(...), offset: int = typer.Option(0, "--offset", min=0), - limit: int = typer.Option(100, "--limit", min=1, max=100), + limit: int = typer.Option(100, "--limit", min=1, max=500), + events_after: int = typer.Option(0, "--events-after", min=0), + messages_after: int = typer.Option(0, "--messages-after", min=0), ) -> None: """Retrieve status and one page of partial results.""" from discolike_cli.main import get_client - params = build_request(ProspectingGetParams, {"offset": offset, "limit": limit}) + params = build_request( + ProspectingGetParams, + {"offset": offset, "limit": limit, "events_after": events_after, "messages_after": messages_after}, + ) emit(get_client(ctx).prospecting.get(run_id, params)) @@ -86,7 +110,51 @@ def wait_command( timeout: float = typer.Option(3600, "--timeout", min=0.01), poll_interval: float = typer.Option(5, "--poll-interval", min=5), ) -> None: - """Return the first page on any terminal status. Inspect stop_reason; timeout leaves the run active.""" + """Return the first page on proposed, needs_input, completed, failed, or cancelled. Approve proposed plans; timeout stops polling only.""" from discolike_cli.main import get_client emit(get_client(ctx).prospecting.wait(run_id, timeout=timeout, poll_interval=poll_interval)) + + +@app.command("list") +@handle_errors +def list_command(ctx: typer.Context, limit: int = typer.Option(20, "--limit", min=1, max=50)) -> None: + """List recent organization runs, newest first.""" + from discolike_cli.main import get_client + + emit(get_client(ctx).prospecting.list(build_request(ProspectingListParams, {"limit": limit}))) + + +@app.command("approve") +@handle_errors +def approve_command( + ctx: typer.Context, + run_id: str = typer.Argument(...), + plan_version: int = typer.Option(..., "--plan-version", min=1), +) -> None: + """Approve the reviewed plan version and start research.""" + from discolike_cli.main import get_client + + emit( + get_client(ctx).prospecting.approve( + run_id, build_request(ProspectingApproveRequest, {"plan_version": plan_version}) + ) + ) + + +@app.command("message") +@handle_errors +def message_command( + ctx: typer.Context, + run_id: str = typer.Argument(...), + text: str = typer.Option(..., "--text"), + idempotency_key: str = typer.Option(..., "--idempotency-key", help="Reuse when retrying this message."), +) -> None: + """Send steering or a clarification answer; poll status --messages-after for the reply.""" + from discolike_cli.main import get_client + + emit( + get_client(ctx).prospecting.message( + run_id, build_request(ProspectingMessageRequest, {"text": text}), 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 d238892..ef2e17d 100644 --- a/packages/discolike-cli/tests/test_prospecting_cli.py +++ b/packages/discolike-cli/tests/test_prospecting_cli.py @@ -4,10 +4,14 @@ from collections.abc import Callable import httpx2 +import pytest from typer.testing import CliRunner from discolike_cli.main import app from discolike_testkit import Handler +from discolike_testkit.prospecting import message_payload +from discolike_testkit.prospecting import run_payload +from discolike_testkit.prospecting import summary_payload runner = CliRunner() RUN_ID = "00000000-0000-0000-0000-000000000001" @@ -18,7 +22,7 @@ def test_start_forwards_key_and_brief(install_build_client: Callable[[Handler], def handler(request: httpx2.Request) -> httpx2.Response: seen.append(request) - return httpx2.Response(202, json={"run_id": RUN_ID, "status": "queued", "max_actions": 24}) + return httpx2.Response(202, json=run_payload("queued")) install_build_client(handler) result = runner.invoke( @@ -46,7 +50,7 @@ def test_status_paginates(install_build_client: Callable[[Handler], None]) -> No def handler(request: httpx2.Request) -> httpx2.Response: assert request.url.params["offset"] == "100" assert request.url.params["limit"] == "20" - return httpx2.Response(200, json={"run_id": RUN_ID, "status": "needs_input", "max_actions": 24}) + return httpx2.Response(200, json=run_payload("needs_input")) install_build_client(handler) result = runner.invoke(app, ["prospecting", "status", RUN_ID, "--offset", "100", "--limit", "20"]) @@ -59,7 +63,7 @@ def test_cancel_and_wait_preserve_terminal_outcomes(install_build_client: Callab def handler(request: httpx2.Request) -> httpx2.Response: methods.append(request.method) - return httpx2.Response(200, json={"run_id": RUN_ID, "status": "cancelled", "max_actions": 24}) + return httpx2.Response(200, json=run_payload("cancelled")) install_build_client(handler) for command in ("cancel", "wait"): @@ -67,3 +71,117 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert result.exit_code == 0, result.output assert json.loads(result.stdout)["status"] == "cancelled" assert methods == ["DELETE", "GET"] + + +def test_start_omits_unspecified_quantities_and_caps(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", + "Find 1000 software companies and three founders each", + "--idempotency-key", + "counts", + ], + ) + assert result.exit_code == 0, result.output + for key in ("target_companies", "contacts_per_company", "max_candidates", "max_actions"): + assert key not in seen[0] + + +def test_chat_commands_send_exact_payloads(install_build_client: Callable[[Handler], None]) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + if request.url.path.endswith("/messages"): + return httpx2.Response(202, json=message_payload()) + if request.url.path.endswith("/runs"): + return httpx2.Response(200, json=[summary_payload()]) + return httpx2.Response(200, json=run_payload("proposed")) + + install_build_client(handler) + commands = [ + ["list"], + ["approve", RUN_ID, "--plan-version", "2"], + ["message", RUN_ID, "--text", "Make it 100 companies", "--idempotency-key", "cli-message"], + ["status", RUN_ID, "--events-after", "12", "--messages-after", "8", "--limit", "500"], + ["wait", RUN_ID], + ] + for command in commands: + result = runner.invoke(app, ["prospecting", *command]) + assert result.exit_code == 0, result.output + assert dict(seen[0].url.params) == {"limit": "20"} + assert json.loads(seen[1].content) == {"plan_version": 2} + assert json.loads(seen[2].content) == {"text": "Make it 100 companies"} + assert seen[2].headers["Idempotency-Key"] == "cli-message" + assert dict(seen[3].url.params) == {"offset": "0", "limit": "500", "events_after": "12", "messages_after": "8"} + assert len(seen) == 5 + + +@pytest.mark.parametrize( + "arguments", + [ + ["approve", RUN_ID], + ["message", RUN_ID, "--text", "Continue"], + ["list", "--limit", "51"], + ["status", RUN_ID, "--messages-after", "-1"], + ], +) +def test_chat_commands_validate_before_network( + install_build_client: Callable[[Handler], None], arguments: list[str] +) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + pytest.fail("invalid command must not reach the network") + + install_build_client(handler) + assert runner.invoke(app, ["prospecting", *arguments]).exit_code == 2 + + +@pytest.mark.parametrize(("target", "contacts", "candidates", "actions"), [(25, 2, 0, 0), (10000, 5, 100000, 10000)]) +def test_explicit_defaults_and_larger_caps_are_preserved( + install_build_client: Callable[[Handler], None], target: int, contacts: int, candidates: int, actions: int +) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(json.loads(request.content)) + return httpx2.Response(202, json=run_payload()) + + install_build_client(handler) + result = runner.invoke( + app, + [ + "prospecting", + "start", + "--brief", + "Find software companies and founders", + "--idempotency-key", + "explicit", + "--target-companies", + str(target), + "--contacts-per-company", + str(contacts), + "--max-candidates", + str(candidates), + "--max-actions", + str(actions), + ], + ) + assert result.exit_code == 0, result.output + assert { + key: seen[0][key] for key in ("target_companies", "contacts_per_company", "max_candidates", "max_actions") + } == { + "target_companies": target, + "contacts_per_company": contacts, + "max_candidates": candidates, + "max_actions": actions, + } diff --git a/packages/discolike-testkit/src/discolike_testkit/prospecting.py b/packages/discolike-testkit/src/discolike_testkit/prospecting.py new file mode 100644 index 0000000..b795d2b --- /dev/null +++ b/packages/discolike-testkit/src/discolike_testkit/prospecting.py @@ -0,0 +1,30 @@ +"""Complete minimal REST prospecting responses shared by SDK and CLI tests.""" + +from typing import Any + +RUN_ID = "00000000-0000-0000-0000-000000000001" +CREATED_AT = "2026-09-26T12:00:00Z" + + +def run_payload(status: str = "drafting") -> dict[str, Any]: + return { + "run_id": RUN_ID, + "status": status, + "max_actions": 30, + "created_at": CREATED_AT, + "updated_at": CREATED_AT, + "brief": {"brief": "US logistics companies and operations leaders"}, + "target_companies": 25, + "contacts_per_company": 2, + "companies": [{"domain": "example.com"}], + } + + +def message_payload() -> dict[str, Any]: + return {"seq": 8, "created_at": CREATED_AT, "role": "user", "kind": "text", "content": "Make it 100 companies"} + + +def summary_payload() -> dict[str, Any]: + return {key: value for key, value in run_payload().items() if key not in {"max_actions", "companies", "brief"}} | { + "brief": "US logistics companies and operations leaders" + } diff --git a/packages/discolike/README.md b/packages/discolike/README.md index 152a58a..45b0954 100644 --- a/packages/discolike/README.md +++ b/packages/discolike/README.md @@ -122,6 +122,14 @@ The engine decides the result columns, so read `job.column_name` instead of hard Two errors are specific to the native engine: a 400 `ValidationError` when the ICP text does not yield a Mandatory / Reject if / Nice-to-have prompt, and a 503 `ServerError` when no ICP-fit engine is available. Task lifecycle, polling and statuses are the same either way. +## Managed prospecting + +`client.prospecting.start(ProspectingBrief(...), idempotency_key="...")` drafts a plan. Call `wait(run_id)`, review its `plan` and `messages`, then approve the returned `plan_version` with `approve(run_id, ProspectingApproveRequest(plan_version=...))`. + +`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. The async client has the same methods with `await`. Import these request models from `discolike.requests`. + +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. Customer integration charges apply; work caps do not cap provider dollar spend. + ## Links - **API documentation**: [docs.discolike.com](https://docs.discolike.com) diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 071a2ea..d1d3bfb 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -2906,15 +2906,27 @@ class MatchBulkParams(DiscolikeRequest): ] = 50 +class ProspectingListParams(DiscolikeRequest): + limit: Annotated[int | None, Field(ge=1, le=50, title="Limit")] = 20 + + +class ProspectingApproveRequest(DiscolikeRequest): + plan_version: Annotated[int, Field(ge=1, title="Plan Version")] + + +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 company_names: Annotated[list[str] | None, Field(max_length=100, title="Company Names")] = None exclude_domains: Annotated[list[str] | None, Field(max_length=1000, title="Exclude Domains")] = None - target_companies: Annotated[int | None, Field(ge=1, le=100, title="Target Companies")] = 25 + target_companies: Annotated[int | None, Field(ge=1, le=10000, title="Target Companies")] = 25 contacts_per_company: Annotated[int | None, Field(ge=1, le=5, title="Contacts Per Company")] = 2 - max_candidates: Annotated[int | None, Field(ge=1, le=1000, title="Max Candidates")] = 200 - max_actions: Annotated[int | None, Field(ge=1, le=60, title="Max Actions")] = 24 + 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 @@ -2923,7 +2935,9 @@ class ProspectingBrief(DiscolikeRequest): class ProspectingGetParams(DiscolikeRequest): offset: Annotated[int | None, Field(ge=0, title="Offset")] = 0 - limit: Annotated[int | None, Field(ge=1, le=100, title="Limit")] = 100 + limit: Annotated[int | None, Field(ge=1, le=500, title="Limit")] = 100 + events_after: Annotated[int | None, Field(ge=0, title="Events After")] = 0 + messages_after: Annotated[int | None, Field(ge=0, title="Messages After")] = 0 class LLMProviderCreateRequest(DiscolikeRequest): diff --git a/packages/discolike/src/discolike/requests.py b/packages/discolike/src/discolike/requests.py index 9a06edb..55b173d 100644 --- a/packages/discolike/src/discolike/requests.py +++ b/packages/discolike/src/discolike/requests.py @@ -28,8 +28,11 @@ from discolike._generated.requests import LLMProviderUpdateRequest from discolike._generated.requests import MatchBulkParams from discolike._generated.requests import MatchCompanyParams +from discolike._generated.requests import ProspectingApproveRequest from discolike._generated.requests import ProspectingBrief from discolike._generated.requests import ProspectingGetParams +from discolike._generated.requests import ProspectingListParams +from discolike._generated.requests import ProspectingMessageRequest from discolike._generated.requests import QueriesListParams from discolike._generated.requests import SaveResultsRequest from discolike._generated.requests import SearchProviderRequest @@ -67,8 +70,11 @@ "LLMProviderUpdateRequest", "MatchBulkParams", "MatchCompanyParams", + "ProspectingApproveRequest", "ProspectingBrief", "ProspectingGetParams", + "ProspectingListParams", + "ProspectingMessageRequest", "QueriesListParams", "SaveResultsRequest", "SearchProviderRequest", diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index 7843292..f11cfe6 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -1,8 +1,10 @@ from __future__ import annotations import asyncio +import builtins import math import time +from datetime import datetime from typing import Any from typing import Literal from uuid import UUID @@ -11,13 +13,20 @@ from discolike._exceptions import JobTimeoutError from discolike._models import DiscolikeModel +from discolike.requests import ProspectingApproveRequest from discolike.requests import ProspectingBrief from discolike.requests import ProspectingGetParams +from discolike.requests import ProspectingListParams +from discolike.requests import ProspectingMessageRequest from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route -TERMINAL_STATUSES = frozenset({"completed", "needs_input", "failed", "cancelled"}) +WAIT_STATUSES = frozenset({"proposed", "completed", "needs_input", "failed", "cancelled"}) +ProspectingStatus = Literal[ + "drafting", "proposed", "queued", "running", "needs_input", "completed", "failed", "cancelled" +] +ProspectingStage = Literal["plan", "discover", "validate", "contacts", "generate", "verify", "segment"] class ProspectingPlan(DiscolikeModel): @@ -28,12 +37,63 @@ class ProspectingPlan(DiscolikeModel): issues: list[str] = Field(default_factory=list) +class ProspectingEvent(DiscolikeModel): + seq: int + created_at: datetime + stage: str | None = None + kind: Literal["queued", "decision", "started", "progress", "result", "stopped", "warning"] + message: str + data: dict[str, Any] | None = None + + +class ProspectingMessage(DiscolikeModel): + seq: int + created_at: datetime + role: Literal["user", "agent"] + kind: Literal["text", "plan", "milestone", "question", "ack", "error"] + content: str + data: dict[str, Any] | None = None + + +class ProspectingInFlight(DiscolikeModel): + stage: ProspectingStage + items: int + plan_version: int + state: Literal["dispatching", "running"] + started_at: datetime + + +class ProspectingRunSummary(DiscolikeModel): + run_id: UUID + status: ProspectingStatus + title: str | None = None + stop_reason: str | None = None + stage: str | None = None + brief: str = Field(max_length=200) + target_companies: int + contacts_per_company: int + qualified_companies: int = 0 + accepted_contacts: int = 0 + created_at: datetime + updated_at: datetime + + class ProspectingRun(DiscolikeModel): run_id: UUID - status: Literal["queued", "running", "needs_input", "completed", "failed", "cancelled"] + status: ProspectingStatus stop_reason: str | None = None stage: str | None = None + phase: str | None = None + created_at: datetime + updated_at: datetime + brief: ProspectingBrief + target_companies: int + contacts_per_company: int plan: ProspectingPlan | None = None + last_decision: dict[str, Any] | None = None + events: list[ProspectingEvent] = Field(default_factory=list) + next_event_seq: int = 0 + waiting_for_worker: bool = False companies: list[dict[str, Any]] = Field(default_factory=list) contacts: list[dict[str, Any]] = Field(default_factory=list) total_companies: int = 0 @@ -45,6 +105,16 @@ class ProspectingRun(DiscolikeModel): actions_used: int = 0 max_actions: int error: str | None = None + title: str | None = None + plan_version: int = 1 + approved_plan_version: int | None = None + saved_query_id: UUID | None = None + 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 + reply_pending: bool = False def _key(value: str) -> str: @@ -64,9 +134,34 @@ def _deadline(timeout: float, poll_interval: float) -> float: class ProspectingResource(SyncAPIResource): + @api_route("GET", "/prospecting/runs") + def list(self, params: ProspectingListParams | None = None) -> builtins.list[ProspectingRunSummary]: + """List recent organization runs, newest first; default 20, maximum 50.""" + response = self._transport.request("GET", "/prospecting/runs", params=params.to_wire() if params else None) + return [ProspectingRunSummary.model_validate(row) for row in response.json()] + + @api_route("POST", "/prospecting/runs/{run_id}/approve") + def approve(self, run_id: str | UUID, request: ProspectingApproveRequest) -> ProspectingRun: + """Approve the reviewed plan version; repeating the same approval is safe.""" + response = self._transport.request("POST", _path(run_id) + "/approve", json_body=request.to_wire()) + return ProspectingRun.model_validate(response.json()) + + @api_route("POST", "/prospecting/runs/{run_id}/messages") + def message( + self, run_id: str | UUID, request: ProspectingMessageRequest, *, idempotency_key: str + ) -> ProspectingMessage: + """Send a chat message; poll get() with ProspectingGetParams(messages_after=...) for the agent's reply.""" + response = self._transport.request( + "POST", + _path(run_id) + "/messages", + json_body=request.to_wire(), + headers={"Idempotency-Key": _key(idempotency_key)}, + ) + return ProspectingMessage.model_validate(response.json()) + @api_route("POST", "/prospecting/runs") def start(self, request: ProspectingBrief, *, idempotency_key: str) -> ProspectingRun: - """Start a managed run; retain the key when retrying this submission.""" + """Draft a plan for approval; retain the key when retrying this submission.""" response = self._transport.request( "POST", "/prospecting/runs", json_body=request.to_wire(), headers={"Idempotency-Key": _key(idempotency_key)} ) @@ -83,15 +178,16 @@ def cancel(self, run_id: str | UUID) -> ProspectingRun: return ProspectingRun.model_validate(response.json()) def wait(self, run_id: str | UUID, *, timeout: float = 3600, poll_interval: float = 5) -> ProspectingRun: - """Return the first result page at any terminal status, preserving partial results. + """Return the first page when approval, input, or a terminal outcome is ready. + A proposed run needs approve() with its plan_version before research starts. Inspect status and stop_reason; completed does not guarantee the target was met. Timeout stops local polling only. Fetch subsequent pages with get(). """ deadline = _deadline(timeout, poll_interval) while True: run = self.get(run_id) - if run.status in TERMINAL_STATUSES: + if run.status in WAIT_STATUSES: return run remaining = deadline - time.monotonic() if remaining <= 0: @@ -100,6 +196,33 @@ def wait(self, run_id: str | UUID, *, timeout: float = 3600, poll_interval: floa class AsyncProspectingResource(AsyncAPIResource): + @api_route("GET", "/prospecting/runs") + async def list(self, params: ProspectingListParams | None = None) -> builtins.list[ProspectingRunSummary]: + """List recent organization runs, newest first; default 20, maximum 50.""" + response = await self._transport.request( + "GET", "/prospecting/runs", params=params.to_wire() if params else None + ) + return [ProspectingRunSummary.model_validate(row) for row in response.json()] + + @api_route("POST", "/prospecting/runs/{run_id}/approve") + async def approve(self, run_id: str | UUID, request: ProspectingApproveRequest) -> ProspectingRun: + """Approve the reviewed plan version; repeating the same approval is safe.""" + response = await self._transport.request("POST", _path(run_id) + "/approve", json_body=request.to_wire()) + return ProspectingRun.model_validate(response.json()) + + @api_route("POST", "/prospecting/runs/{run_id}/messages") + async def message( + self, run_id: str | UUID, request: ProspectingMessageRequest, *, idempotency_key: str + ) -> ProspectingMessage: + """Send a chat message; poll get() with ProspectingGetParams(messages_after=...) for the agent's reply.""" + response = await self._transport.request( + "POST", + _path(run_id) + "/messages", + json_body=request.to_wire(), + headers={"Idempotency-Key": _key(idempotency_key)}, + ) + return ProspectingMessage.model_validate(response.json()) + @api_route("POST", "/prospecting/runs") async def start(self, request: ProspectingBrief, *, idempotency_key: str) -> ProspectingRun: response = await self._transport.request( @@ -118,11 +241,11 @@ async def cancel(self, run_id: str | UUID) -> ProspectingRun: return ProspectingRun.model_validate(response.json()) async def wait(self, run_id: str | UUID, *, timeout: float = 3600, poll_interval: float = 5) -> ProspectingRun: - """Return the first page on completed/needs_input/failed/cancelled; inspect stop_reason.""" + """Return the first page on proposed/needs_input/completed/failed/cancelled; inspect status.""" deadline = _deadline(timeout, poll_interval) while True: run = await self.get(run_id) - if run.status in TERMINAL_STATUSES: + if run.status in WAIT_STATUSES: return run remaining = deadline - time.monotonic() if remaining <= 0: diff --git a/packages/discolike/tests/test_gen_requests.py b/packages/discolike/tests/test_gen_requests.py index 828039d..ce79bb6 100644 --- a/packages/discolike/tests/test_gen_requests.py +++ b/packages/discolike/tests/test_gen_requests.py @@ -263,5 +263,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) == 51 + assert len(routes) == 54 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 fb25fa3..f37b6a1 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -5,19 +5,26 @@ import httpx2 import pytest +from pydantic import ValidationError import discolike.resources.prospecting as module from discolike import JobTimeoutError +from discolike.requests import ProspectingApproveRequest from discolike.requests import ProspectingBrief from discolike.requests import ProspectingGetParams +from discolike.requests import ProspectingListParams +from discolike.requests import ProspectingMessageRequest from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory +from discolike_testkit.prospecting import message_payload +from discolike_testkit.prospecting import run_payload +from discolike_testkit.prospecting import summary_payload RUN_ID = "00000000-0000-0000-0000-000000000001" def payload(status: str = "queued") -> dict: - return {"run_id": RUN_ID, "status": status, "max_actions": 24, "companies": [{"domain": "example.com"}]} + return run_payload(status) def test_start_key_does_not_leak_to_other_requests(make_client: ClientFactory) -> None: @@ -85,3 +92,131 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert (await client.prospecting.wait(run.run_id)).status == "completed" assert (await client.prospecting.cancel(run.run_id)).status == "cancelled" assert seen == ["POST", "GET", "DELETE"] + + +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" + + +def test_list_approve_message_and_cursors(make_client: ClientFactory) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + if request.url.path.endswith("/messages"): + return httpx2.Response(202, json=message_payload()) + if request.url.path.endswith("/runs"): + return httpx2.Response(200, json=[summary_payload()]) + result = payload("queued" if request.url.path.endswith("/approve") else "running") + result.update( + messages=[message_payload()], + next_message_seq=8, + next_event_seq=12, + in_flight=[ + { + "stage": "generate", + "items": 50, + "plan_version": 2, + "state": "running", + "started_at": result["created_at"], + } + ], + saved_query_id=RUN_ID, + plan_version=2, + approved_plan_version=2, + reply_pending=True, + fit_companies=40, + emails_found=15, + ) + return httpx2.Response(200, json=result) + + with make_client(handler) as client: + assert client.prospecting.list(ProspectingListParams(limit=50))[0].target_companies == 25 + assert client.prospecting.approve(RUN_ID, ProspectingApproveRequest(plan_version=2)).approved_plan_version == 2 + assert ( + client.prospecting.message( + RUN_ID, ProspectingMessageRequest(text="Make it 100 companies"), idempotency_key="message-1" + ).seq + == 8 + ) + run = client.prospecting.get(RUN_ID, ProspectingGetParams(events_after=12, messages_after=8, limit=500)) + assert dict(seen[0].url.params) == {"limit": "50"} + assert json.loads(seen[1].content) == {"plan_version": 2} + assert json.loads(seen[2].content) == {"text": "Make it 100 companies"} + assert [r.headers.get("Idempotency-Key") for r in seen] == [None, None, "message-1", None] + assert dict(seen[3].url.params) == {"events_after": "12", "messages_after": "8", "limit": "500"} + assert run.messages[0].created_at.year == 2026 + assert run.in_flight[0].stage == "generate" + assert run.saved_query_id == UUID(RUN_ID) + assert (run.fit_companies, run.emails_found, run.reply_pending) == (40, 15, True) + + +async def test_async_chat_lifecycle_and_proposed_wait(make_async_client: AsyncClientFactory) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + if request.url.path.endswith("/messages"): + return httpx2.Response(202, json=message_payload()) + if request.url.path.endswith("/runs"): + return httpx2.Response(200, json=[summary_payload()]) + return httpx2.Response(200, json=payload("queued" if request.method == "POST" else "proposed")) + + async with make_async_client(handler) as client: + assert len(await client.prospecting.list()) == 1 + assert (await client.prospecting.wait(RUN_ID)).status == "proposed" + assert (await client.prospecting.approve(RUN_ID, ProspectingApproveRequest(plan_version=1))).status == "queued" + message = await client.prospecting.message( + RUN_ID, ProspectingMessageRequest(text="Make it 100 companies"), idempotency_key="async-message" + ) + await client.prospecting.get(RUN_ID, ProspectingGetParams(events_after=7, messages_after=message.seq)) + assert seen[0].url.params == httpx2.QueryParams() + assert json.loads(seen[2].content) == {"plan_version": 1} + assert seen[3].headers["Idempotency-Key"] == "async-message" + assert json.loads(seen[3].content) == {"text": "Make it 100 companies"} + assert dict(seen[4].url.params) == {"events_after": "7", "messages_after": "8"} + assert seen[4].headers.get("Idempotency-Key") is None + + +@pytest.mark.parametrize("key", ["", " ", "a" * 129]) +def test_invalid_message_key_never_reaches_network(make_client: ClientFactory, key: str) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + pytest.fail("invalid key must fail locally") + + with make_client(handler) as client, pytest.raises(ValueError, match="idempotency_key"): + client.prospecting.message(RUN_ID, ProspectingMessageRequest(text="Continue"), idempotency_key=key) + + +@pytest.mark.parametrize( + ("model", "values"), + [ + (ProspectingApproveRequest, {}), + (ProspectingApproveRequest, {"plan_version": 0}), + (ProspectingListParams, {"limit": 51}), + (ProspectingListParams, {"limit": 0}), + (ProspectingMessageRequest, {"text": ""}), + (ProspectingMessageRequest, {"text": "x" * 4001}), + (ProspectingGetParams, {"events_after": -1}), + (ProspectingGetParams, {"messages_after": -1}), + ], +) +def test_chat_request_constraints(model, values) -> None: + with pytest.raises(ValidationError): + model.model_validate(values) + + +def test_request_defaults_preserve_explicit_quantity_intent() -> None: + implicit = ProspectingBrief(brief="Find 1000 companies and three founders each") + explicit = ProspectingBrief( + brief=implicit.brief, target_companies=25, contacts_per_company=2, max_actions=0, max_candidates=0 + ) + assert implicit.to_wire() == {"brief": implicit.brief} + assert explicit.to_wire() == { + "brief": implicit.brief, + "target_companies": 25, + "contacts_per_company": 2, + "max_actions": 0, + "max_candidates": 0, + } + assert ProspectingListParams().limit == 20 diff --git a/scripts/check_contract.py b/scripts/check_contract.py index d29d195..e6856b9 100644 --- a/scripts/check_contract.py +++ b/scripts/check_contract.py @@ -26,8 +26,12 @@ from discolike.resources.companies import Subsidiary from discolike.resources.companies import Vendor from discolike.resources.match import MatchResponse +from discolike.resources.prospecting import ProspectingEvent +from discolike.resources.prospecting import ProspectingInFlight +from discolike.resources.prospecting import ProspectingMessage from discolike.resources.prospecting import ProspectingPlan from discolike.resources.prospecting import ProspectingRun +from discolike.resources.prospecting import ProspectingRunSummary from discolike.resources.queries import SavedQueries IGNORE_PARAMS = {"file"} @@ -40,6 +44,10 @@ MIRRORED_SCHEMAS: dict[str, type[DiscolikeModel]] = { "ProspectingRunResponse": ProspectingRun, "ProspectingPlan": ProspectingPlan, + "ProspectingEvent": ProspectingEvent, + "ProspectingMessage": ProspectingMessage, + "ProspectingInFlight": ProspectingInFlight, + "ProspectingRunSummary": ProspectingRunSummary, "CompanyResult": CompanyProfile, "ExtractResponse": ExtractResult, "ScoreResponse": Score, From f71e90f66759d910e1cf774e511798562feabe1f Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Sun, 27 Sep 2026 13:01:22 -0700 Subject: [PATCH 07/13] Surface every saved contact list on a prospecting run Large runs now split their saved contact list into parts instead of truncating at 50 MiB (platform commit 2fbd2d6e5). saved_query_ids carries all of them in order, with saved_query_id staying the first entry for backward compatibility. --- CHANGELOG.md | 1 + .../discolike-cli/tests/test_prospecting_cli.py | 14 ++++++++++++++ packages/discolike/README.md | 2 +- .../src/discolike/resources/prospecting.py | 5 +++++ packages/discolike/tests/test_prospecting.py | 3 +++ 5 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8167e3d..985864f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - CLI: add prospecting plan approval, recent-run lists, chat messages, and event/message cursors. Omitted quantity flags preserve brief inference; larger target and automatic work limits match the API. `wait` returns when a plan needs approval. - SDK: add sync/async prospecting start/get/list/approve/message/cancel/wait, with typed chat, progress, saved-query, and recent-run responses. Starts and messages require idempotency keys; approval requires the reviewed plan version. `wait` returns on proposed plans, needs-input, and terminal outcomes. +- SDK: prospecting runs gain `saved_query_ids`, every saved contact list for the run in order (first entry is `saved_query_id`). Large results are now split across several lists instead of being cut off at 50 MiB; parts are final once the run reaches a terminal status. - SDK/CLI: `discogen.process` / `discogen.process_personas` with `typed_columns=True` now raise a 400 `ValidationError` at submit when the account has no TypeSafe integration, instead of returning a job whose typed cells all read `Error: No TypeSafe integration is configured`. No SDK code change; the server rejects earlier. diff --git a/packages/discolike-cli/tests/test_prospecting_cli.py b/packages/discolike-cli/tests/test_prospecting_cli.py index ef2e17d..8295724 100644 --- a/packages/discolike-cli/tests/test_prospecting_cli.py +++ b/packages/discolike-cli/tests/test_prospecting_cli.py @@ -58,6 +58,20 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert json.loads(result.stdout)["status"] == "needs_input" +def test_status_lists_every_saved_query_part(install_build_client: Callable[[Handler], None]) -> None: + other_query_id = "00000000-0000-0000-0000-000000000002" + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response( + 200, json=run_payload("completed") | {"saved_query_id": RUN_ID, "saved_query_ids": [RUN_ID, other_query_id]} + ) + + install_build_client(handler) + result = runner.invoke(app, ["prospecting", "status", RUN_ID]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["saved_query_ids"] == [RUN_ID, other_query_id] + + def test_cancel_and_wait_preserve_terminal_outcomes(install_build_client: Callable[[Handler], None]) -> None: methods = [] diff --git a/packages/discolike/README.md b/packages/discolike/README.md index 45b0954..ab875f0 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. The async client has the same methods with `await`. Import these request models from `discolike.requests`. -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. 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. Customer integration charges apply; work caps do not cap provider dollar spend. ## Links diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index f11cfe6..0e06841 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -109,6 +109,11 @@ class ProspectingRun(DiscolikeModel): plan_version: int = 1 approved_plan_version: int | None = None saved_query_id: UUID | None = None + 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.", + ) messages: list[ProspectingMessage] = Field(default_factory=list) next_message_seq: int = 0 in_flight: list[ProspectingInFlight] = Field(default_factory=list) diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index f37b6a1..32e70aa 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -21,6 +21,7 @@ from discolike_testkit.prospecting import summary_payload RUN_ID = "00000000-0000-0000-0000-000000000001" +OTHER_QUERY_ID = "00000000-0000-0000-0000-000000000002" def payload(status: str = "queued") -> dict: @@ -123,6 +124,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: } ], saved_query_id=RUN_ID, + saved_query_ids=[RUN_ID, OTHER_QUERY_ID], plan_version=2, approved_plan_version=2, reply_pending=True, @@ -149,6 +151,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert run.messages[0].created_at.year == 2026 assert run.in_flight[0].stage == "generate" assert run.saved_query_id == UUID(RUN_ID) + assert run.saved_query_ids == [UUID(RUN_ID), UUID(OTHER_QUERY_ID)] assert (run.fit_companies, run.emails_found, run.reply_pending) == (40, 15, True) From 99000c60f1bddf7f55aa0b7432aa1b6cd0e30a88 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Sun, 27 Sep 2026 19:23:13 -0700 Subject: [PATCH 08/13] Track chat_closed and the misuse stop reason on prospecting runs A chat closed for off-topic use cancels an unapproved or paused run with stop_reason "misuse" while an approved queued or running one keeps working. Callers need chat_closed on the run model to know when to stop chatting and start a new conversation. The API's 403 on a start past the daily new-chat limit already surfaces cleanly through the existing detail-message extraction, so no client change was needed there. --- CHANGELOG.md | 1 + packages/discolike/src/discolike/resources/prospecting.py | 5 +++++ packages/discolike/tests/test_prospecting.py | 3 +++ 3 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 985864f..dd73dd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Managed prospecting agent coordination and contact qualification now use customer LLM credentials, including when contact extraction is native. Missing keys and provider errors do not fall back to platform credentials. +- SDK: prospecting runs gain `chat_closed`, set once a chat is closed for repeated off-topic use; every later message then gets a fixed reply instead of a model call. A run with no approved plan, or an approved run paused waiting on a question, is cancelled with the new `stop_reason` value `"misuse"` when its chat closes; an approved queued or running run keeps working and its saved lists still fill. Starting a run past an organization's daily new-conversation limit now returns a 403 (`PlanAccessError`) with a message naming the limit. - CLI: add prospecting plan approval, recent-run lists, chat messages, and event/message cursors. Omitted quantity flags preserve brief inference; larger target and automatic work limits match the API. `wait` returns when a plan needs approval. - SDK: add sync/async prospecting start/get/list/approve/message/cancel/wait, with typed chat, progress, saved-query, and recent-run responses. Starts and messages require idempotency keys; approval requires the reviewed plan version. `wait` returns on proposed plans, needs-input, and terminal outcomes. diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index 0e06841..9b6e185 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -120,6 +120,11 @@ class ProspectingRun(DiscolikeModel): fit_companies: int = 0 emails_found: int = 0 reply_pending: bool = False + chat_closed: bool = Field( + default=False, + description="The chat was closed for off-topic use: every new message gets the same fixed reply. " + "An approved run keeps working and its saved lists still fill.", + ) def _key(value: str) -> str: diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index 32e70aa..9cc44f3 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -130,6 +130,8 @@ def handler(request: httpx2.Request) -> httpx2.Response: reply_pending=True, fit_companies=40, emails_found=15, + chat_closed=True, + stop_reason="misuse", ) return httpx2.Response(200, json=result) @@ -153,6 +155,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert run.saved_query_id == UUID(RUN_ID) assert run.saved_query_ids == [UUID(RUN_ID), UUID(OTHER_QUERY_ID)] assert (run.fit_companies, run.emails_found, run.reply_pending) == (40, 15, True) + assert (run.chat_closed, run.stop_reason) == (True, "misuse") async def test_async_chat_lifecycle_and_proposed_wait(make_async_client: AsyncClientFactory) -> None: From c80beccf99881f730197d09b86d1cc6c8de895f0 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Sun, 27 Sep 2026 20:04:03 -0700 Subject: [PATCH 09/13] Reject control-character idempotency keys and check response field types in the contract script A key with a newline passed local validation and only failed later when the transport built the Idempotency-Key header. check_models() also compared schema property names only, so a type or requiredness change on a mirrored model (e.g. saved_query_ids, chat_closed) would pass silently. --- .../src/discolike/resources/prospecting.py | 2 + .../discolike/tests/test_contract_registry.py | 108 ++++++++++++++++++ packages/discolike/tests/test_prospecting.py | 4 +- scripts/check_contract.py | 67 ++++++++++- 4 files changed, 178 insertions(+), 3 deletions(-) diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index 9b6e185..22ed3a5 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -130,6 +130,8 @@ class ProspectingRun(DiscolikeModel): def _key(value: str) -> str: if not value.strip() or len(value) > 128: raise ValueError("idempotency_key must contain 1-128 characters") + if not value.isascii() or not value.isprintable(): + raise ValueError("idempotency_key must be printable ASCII with no control characters") return value diff --git a/packages/discolike/tests/test_contract_registry.py b/packages/discolike/tests/test_contract_registry.py index 91388dc..356237e 100644 --- a/packages/discolike/tests/test_contract_registry.py +++ b/packages/discolike/tests/test_contract_registry.py @@ -3,6 +3,7 @@ import pathlib import sys +from discolike._models import DiscolikeModel from discolike.resources._base import get_discolike_route # wait orchestrates repeated get() calls and is not a separate route. @@ -86,6 +87,113 @@ def test_check_models_reports_field_the_sdk_does_not_declare(): assert mismatches == ["ExtractResult: spec schema 'ExtractResponse' has field 'summary' the SDK does not declare"] +def test_check_models_ignores_type_and_requiredness_when_spec_gives_no_type_info(): + check_contract = _load_check_contract() + from discolike.resources.companies import ExtractResult + + spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}, "language": {}}}}}} + assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] + + +def test_check_models_reports_a_type_change(): + check_contract = _load_check_contract() + from discolike.resources.prospecting import ProspectingRun + + schema = _spec_schema_for(ProspectingRun) + schema["properties"]["chat_closed"] = {"type": "string"} + spec = {"components": {"schemas": {"ProspectingRunResponse": schema}}} + mismatches = check_contract.check_models(spec, {"ProspectingRunResponse": ProspectingRun}) + assert mismatches == [ + "ProspectingRun: field 'chat_closed' has type (frozenset({'boolean'}), None) but spec schema " + "'ProspectingRunResponse' declares (frozenset({'string'}), None)" + ] + + +def test_check_models_reports_an_array_item_type_change(): + check_contract = _load_check_contract() + from discolike.resources.prospecting import ProspectingRun + + schema = _spec_schema_for(ProspectingRun) + schema["properties"]["saved_query_ids"] = {"type": "array", "items": {"type": "integer"}} + spec = {"components": {"schemas": {"ProspectingRunResponse": schema}}} + mismatches = check_contract.check_models(spec, {"ProspectingRunResponse": ProspectingRun}) + assert mismatches == [ + "ProspectingRun: field 'saved_query_ids' has type (frozenset({'array'}), 'string') but spec schema " + "'ProspectingRunResponse' declares (frozenset({'array'}), 'integer')" + ] + + +def test_check_models_passes_a_nullable_field_expressed_via_anyof(): + check_contract = _load_check_contract() + from discolike.resources.companies import ExtractResult + + spec = { + "components": { + "schemas": { + "ExtractResponse": { + "properties": { + "text": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "language": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + }, + } + } + } + } + assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] + + +def test_check_models_passes_a_nullable_field_expressed_via_openapi_nullable_flag(): + check_contract = _load_check_contract() + from discolike.resources.companies import ExtractResult + + spec = { + "components": { + "schemas": { + "ExtractResponse": { + "properties": { + "text": {"type": "string", "nullable": True}, + "language": {"type": "string", "nullable": True}, + }, + } + } + } + } + assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] + + +def test_check_models_reports_a_field_the_spec_marks_required_but_the_sdk_does_not(): + check_contract = _load_check_contract() + from discolike.resources.prospecting import ProspectingRun + + schema = _spec_schema_for(ProspectingRun) + schema["required"] = [*schema.get("required", []), "chat_closed"] + spec = {"components": {"schemas": {"ProspectingRunResponse": schema}}} + mismatches = check_contract.check_models(spec, {"ProspectingRunResponse": ProspectingRun}) + assert mismatches == [ + "ProspectingRun: field 'chat_closed' is required in spec schema 'ProspectingRunResponse' but optional on " + "the SDK model" + ] + + +def test_check_models_reports_a_field_the_sdk_requires_but_the_spec_does_not(): + check_contract = _load_check_contract() + from discolike.resources.prospecting import ProspectingRun + + schema = _spec_schema_for(ProspectingRun) + schema["required"] = [field for field in schema["required"] if field != "run_id"] + spec = {"components": {"schemas": {"ProspectingRunResponse": schema}}} + mismatches = check_contract.check_models(spec, {"ProspectingRunResponse": ProspectingRun}) + assert mismatches == [ + "ProspectingRun: field 'run_id' is optional in spec schema 'ProspectingRunResponse' but required on the " + "SDK model" + ] + + +def _spec_schema_for(model: type[DiscolikeModel]) -> dict: + schema = model.model_json_schema() + return {"properties": schema.get("properties", {}), "required": list(schema.get("required", []))} + + def test_check_models_reports_missing_schema(): check_contract = _load_check_contract() from discolike.resources.companies import ExtractResult diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index 9cc44f3..27154f3 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -68,7 +68,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert seen == ["GET"] -@pytest.mark.parametrize("key", ["", " ", "a" * 129]) +@pytest.mark.parametrize("key", ["", " ", "a" * 129, "a\nb", "a\rb", "a\tb", "a\x00b", "a🚀b"]) def test_invalid_key_is_rejected_locally(make_client: ClientFactory, key: str) -> None: def handler(request: httpx2.Request) -> httpx2.Response: pytest.fail("invalid key must not reach the network") @@ -185,7 +185,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert seen[4].headers.get("Idempotency-Key") is None -@pytest.mark.parametrize("key", ["", " ", "a" * 129]) +@pytest.mark.parametrize("key", ["", " ", "a" * 129, "a\nb", "a\rb", "a\tb", "a\x00b", "a🚀b"]) def test_invalid_message_key_never_reaches_network(make_client: ClientFactory, key: str) -> None: def handler(request: httpx2.Request) -> httpx2.Response: pytest.fail("invalid key must fail locally") diff --git a/scripts/check_contract.py b/scripts/check_contract.py index e6856b9..868a10c 100644 --- a/scripts/check_contract.py +++ b/scripts/check_contract.py @@ -176,6 +176,40 @@ def check(spec: dict, routes: list[RouteEntry]) -> list[str]: return mismatches +TYPE_INFO_KEYS = {"type", "anyOf", "oneOf", "$ref", "nullable"} + + +def _resolved_type(node: dict) -> str | None: + return "object" if "$ref" in node else node.get("type") + + +def _type_variants(prop: dict) -> list[dict]: + return prop.get("anyOf") or prop.get("oneOf") or [prop] + + +def _field_types(prop: dict) -> frozenset[str]: + types = {resolved for variant in _type_variants(prop) if (resolved := _resolved_type(variant)) is not None} + if prop.get("nullable"): + types.add("null") + return frozenset(types) + + +def _item_type(prop: dict) -> str | None: + for variant in _type_variants(prop): + items = variant.get("items") + if items is not None: + return _resolved_type(items) + return None + + +def _has_type_info(prop: dict) -> bool: + return bool(prop.keys() & TYPE_INFO_KEYS) + + +def _field_shape(prop: dict) -> tuple[frozenset[str], str | None]: + return (_field_types(prop), _item_type(prop)) + + def check_models(spec: dict, mirrored: dict[str, type[DiscolikeModel]] | None = None) -> list[str]: mismatches: list[str] = [] schemas = spec.get("components", {}).get("schemas", {}) @@ -184,7 +218,10 @@ def check_models(spec: dict, mirrored: dict[str, type[DiscolikeModel]] | None = if schema is None: mismatches.append(f"{model.__name__}: schema '{schema_name}' not found in spec") continue - spec_fields = set(schema.get("properties", {}).keys()) + spec_properties = schema.get("properties", {}) + spec_fields = set(spec_properties) + model_schema = model.model_json_schema() + model_properties = model_schema.get("properties", {}) model_fields = set(model.model_fields) mismatches.extend( f"{model.__name__}: field '{field}' not in spec schema '{schema_name}'" @@ -194,6 +231,34 @@ def check_models(spec: dict, mirrored: dict[str, type[DiscolikeModel]] | None = f"{model.__name__}: spec schema '{schema_name}' has field '{field}' the SDK does not declare" for field in sorted(spec_fields - model_fields) ) + + # A fixture that doesn't spell out "type"/"required" info is asserting nothing about it, not + # that nothing is required or typed, so leave those fields alone rather than flag every one. + if "required" in schema: + spec_required = set(schema["required"]) + model_required = set(model_schema.get("required", [])) + mismatches.extend( + f"{model.__name__}: field '{field}' is required in spec schema '{schema_name}' but optional on " + f"the SDK model" + for field in sorted((spec_required - model_required) & model_fields) + ) + mismatches.extend( + f"{model.__name__}: field '{field}' is optional in spec schema '{schema_name}' but required on " + f"the SDK model" + for field in sorted((model_required - spec_required) & spec_fields) + ) + + for field in sorted(model_fields & spec_fields): + spec_prop = spec_properties[field] + if not _has_type_info(spec_prop): + continue + model_shape = _field_shape(model_properties.get(field, {})) + spec_shape = _field_shape(spec_prop) + if model_shape != spec_shape: + mismatches.append( + f"{model.__name__}: field '{field}' has type {model_shape} but spec schema " + f"'{schema_name}' declares {spec_shape}" + ) return mismatches From 33150a9da1c488670ca6713aa64860eb9967f4ec Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Sun, 27 Sep 2026 20:27:20 -0700 Subject: [PATCH 10/13] Add keyset paging to prospecting.list via before The platform's GET /prospecting/runs now accepts before (a run ID) to page past a full page of results, ordered by created_at then run_id descending. Forward it through the SDK and CLI so callers can walk past the 50-row max instead of only ever seeing the newest page. --- CHANGELOG.md | 1 + .../discolike-cli/src/discolike_cli/prospecting.py | 12 ++++++++++-- packages/discolike-cli/tests/test_prospecting_cli.py | 5 +++-- .../discolike/src/discolike/_generated/requests.py | 5 +++++ .../discolike/src/discolike/resources/prospecting.py | 10 ++++++++-- packages/discolike/tests/test_prospecting.py | 7 ++++--- 6 files changed, 31 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd73dd6..47bf3a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - CLI: add prospecting plan approval, recent-run lists, chat messages, and event/message cursors. Omitted quantity flags preserve brief inference; larger target and automatic work limits match the API. `wait` returns when a plan needs approval. - SDK: add sync/async prospecting start/get/list/approve/message/cancel/wait, with typed chat, progress, saved-query, and recent-run responses. Starts and messages require idempotency keys; approval requires the reviewed plan version. `wait` returns on proposed plans, needs-input, and terminal outcomes. - SDK: prospecting runs gain `saved_query_ids`, every saved contact list for the run in order (first entry is `saved_query_id`). Large results are now split across several lists instead of being cut off at 50 MiB; parts are final once the run reaches a terminal status. +- SDK/CLI: `prospecting.list` / `ProspectingListParams` / `prospecting list --before` gain `before` (a run ID) for keyset paging past a full page of runs, ordered by `created_at` then `run_id` descending. An unknown or other-organization run ID returns an empty page. - SDK/CLI: `discogen.process` / `discogen.process_personas` with `typed_columns=True` now raise a 400 `ValidationError` at submit when the account has no TypeSafe integration, instead of returning a job whose typed cells all read `Error: No TypeSafe integration is configured`. No SDK code change; the server rejects earlier. diff --git a/packages/discolike-cli/src/discolike_cli/prospecting.py b/packages/discolike-cli/src/discolike_cli/prospecting.py index 576f9bd..be93abc 100644 --- a/packages/discolike-cli/src/discolike_cli/prospecting.py +++ b/packages/discolike-cli/src/discolike_cli/prospecting.py @@ -118,11 +118,19 @@ def wait_command( @app.command("list") @handle_errors -def list_command(ctx: typer.Context, limit: int = typer.Option(20, "--limit", min=1, max=50)) -> None: +def list_command( + ctx: typer.Context, + limit: int = typer.Option(20, "--limit", min=1, max=50), + before: str | None = typer.Option(None, "--before", help="Page past this run ID (last run ID from a prior page)."), +) -> None: """List recent organization runs, newest first.""" from discolike_cli.main import get_client - emit(get_client(ctx).prospecting.list(build_request(ProspectingListParams, {"limit": limit}))) + emit( + get_client(ctx).prospecting.list( + build_request(ProspectingListParams, _merge_params(None, limit=limit, before=before)) + ) + ) @app.command("approve") diff --git a/packages/discolike-cli/tests/test_prospecting_cli.py b/packages/discolike-cli/tests/test_prospecting_cli.py index 8295724..cfc27c8 100644 --- a/packages/discolike-cli/tests/test_prospecting_cli.py +++ b/packages/discolike-cli/tests/test_prospecting_cli.py @@ -124,7 +124,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: install_build_client(handler) commands = [ - ["list"], + ["list", "--before", RUN_ID], ["approve", RUN_ID, "--plan-version", "2"], ["message", RUN_ID, "--text", "Make it 100 companies", "--idempotency-key", "cli-message"], ["status", RUN_ID, "--events-after", "12", "--messages-after", "8", "--limit", "500"], @@ -133,7 +133,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: for command in commands: result = runner.invoke(app, ["prospecting", *command]) assert result.exit_code == 0, result.output - assert dict(seen[0].url.params) == {"limit": "20"} + assert dict(seen[0].url.params) == {"limit": "20", "before": RUN_ID} assert json.loads(seen[1].content) == {"plan_version": 2} assert json.loads(seen[2].content) == {"text": "Make it 100 companies"} assert seen[2].headers["Idempotency-Key"] == "cli-message" @@ -147,6 +147,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: ["approve", RUN_ID], ["message", RUN_ID, "--text", "Continue"], ["list", "--limit", "51"], + ["list", "--before", "not-a-uuid"], ["status", RUN_ID, "--messages-after", "-1"], ], ) diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index d1d3bfb..5a6b28f 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -5,6 +5,7 @@ from typing import Annotated from typing import Any from typing import Literal +from uuid import UUID from pydantic import Field @@ -2908,6 +2909,10 @@ class MatchBulkParams(DiscolikeRequest): class ProspectingListParams(DiscolikeRequest): limit: Annotated[int | None, Field(ge=1, le=50, title="Limit")] = 20 + before: Annotated[ + UUID | None, + Field(description="Return the runs created before this run.", title="Before"), + ] = None class ProspectingApproveRequest(DiscolikeRequest): diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index 22ed3a5..0b34417 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -148,7 +148,10 @@ def _deadline(timeout: float, poll_interval: float) -> float: class ProspectingResource(SyncAPIResource): @api_route("GET", "/prospecting/runs") def list(self, params: ProspectingListParams | None = None) -> builtins.list[ProspectingRunSummary]: - """List recent organization runs, newest first; default 20, maximum 50.""" + """List recent organization runs, newest first; default 20, maximum 50. + + Pass `before` (a run ID) to page past a full page of results. + """ response = self._transport.request("GET", "/prospecting/runs", params=params.to_wire() if params else None) return [ProspectingRunSummary.model_validate(row) for row in response.json()] @@ -210,7 +213,10 @@ def wait(self, run_id: str | UUID, *, timeout: float = 3600, poll_interval: floa class AsyncProspectingResource(AsyncAPIResource): @api_route("GET", "/prospecting/runs") async def list(self, params: ProspectingListParams | None = None) -> builtins.list[ProspectingRunSummary]: - """List recent organization runs, newest first; default 20, maximum 50.""" + """List recent organization runs, newest first; default 20, maximum 50. + + Pass `before` (a run ID) to page past a full page of results. + """ response = await self._transport.request( "GET", "/prospecting/runs", params=params.to_wire() if params else None ) diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index 27154f3..bf20b0d 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -136,7 +136,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=result) with make_client(handler) as client: - assert client.prospecting.list(ProspectingListParams(limit=50))[0].target_companies == 25 + assert client.prospecting.list(ProspectingListParams(limit=50, before=RUN_ID))[0].target_companies == 25 assert client.prospecting.approve(RUN_ID, ProspectingApproveRequest(plan_version=2)).approved_plan_version == 2 assert ( client.prospecting.message( @@ -145,7 +145,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: == 8 ) run = client.prospecting.get(RUN_ID, ProspectingGetParams(events_after=12, messages_after=8, limit=500)) - assert dict(seen[0].url.params) == {"limit": "50"} + assert dict(seen[0].url.params) == {"limit": "50", "before": RUN_ID} assert json.loads(seen[1].content) == {"plan_version": 2} assert json.loads(seen[2].content) == {"text": "Make it 100 companies"} assert [r.headers.get("Idempotency-Key") for r in seen] == [None, None, "message-1", None] @@ -201,6 +201,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: (ProspectingApproveRequest, {"plan_version": 0}), (ProspectingListParams, {"limit": 51}), (ProspectingListParams, {"limit": 0}), + (ProspectingListParams, {"before": "not-a-uuid"}), (ProspectingMessageRequest, {"text": ""}), (ProspectingMessageRequest, {"text": "x" * 4001}), (ProspectingGetParams, {"events_after": -1}), @@ -225,4 +226,4 @@ def test_request_defaults_preserve_explicit_quantity_intent() -> None: "max_actions": 0, "max_candidates": 0, } - assert ProspectingListParams().limit == 20 + assert (ProspectingListParams().limit, ProspectingListParams().before) == (20, None) From 37ae830e01f532e8e46ddadb58c0e1ff977b8156 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Sun, 27 Sep 2026 23:26:49 -0700 Subject: [PATCH 11/13] Support prospecting checkpoints in the SDK and CLI Prospecting runs can now pause at decision points (a pilot check on large lists, a search drifting off target, running short, reaching the target) and ask the user, or decide on their own in auto mode. The API defaults to auto so unattended callers never stall; the web chat defaults to asking, and the CLI is a person at a terminal, so it follows the chat rather than the API. The brief's checkpoints field is hidden from the platform's OpenAPI schema to keep the MCP tool listing small, so the request generator pins it by hand and the contract check allows it instead of flagging drift. Approval's field is pending until the platform deploys it. At a checkpoint, `prospecting wait` asks on a terminal and keeps waiting. Scripts and agents get a distinct exit code 7 with the question and suggested replies on stderr, so they can answer through `prospecting message` without parsing prose or mistaking a pause for success. --- CHANGELOG.md | 3 + README.md | 3 + packages/discolike-cli/README.md | 3 + .../discolike-cli/src/discolike_cli/_help.py | 3 + .../src/discolike_cli/_output.py | 1 + .../src/discolike_cli/prospecting.py | 151 +++++++++++++++++- .../tests/test_prospecting_cli.py | 147 ++++++++++++++++- packages/discolike/README.md | 2 + packages/discolike/src/discolike/__init__.py | 2 + .../src/discolike/_generated/requests.py | 8 + .../src/discolike/resources/prospecting.py | 23 ++- .../discolike/tests/test_contract_registry.py | 28 ++++ packages/discolike/tests/test_gen_requests.py | 14 ++ packages/discolike/tests/test_prospecting.py | 44 +++++ scripts/check_contract.py | 4 +- scripts/gen_requests.py | 18 +++ 16 files changed, 445 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47bf3a6..89e509e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ - CLI: add prospecting plan approval, recent-run lists, chat messages, and event/message cursors. Omitted quantity flags preserve brief inference; larger target and automatic work limits match the API. `wait` returns when a plan needs approval. - SDK: add sync/async prospecting start/get/list/approve/message/cancel/wait, with typed chat, progress, saved-query, and recent-run responses. Starts and messages require idempotency keys; approval requires the reviewed plan version. `wait` returns on proposed plans, needs-input, and terminal outcomes. - SDK: prospecting runs gain `saved_query_ids`, every saved contact list for the run in order (first entry is `saved_query_id`). Large results are now split across several lists instead of being cut off at 50 MiB; parts are final once the run reaches a terminal status. +- SDK: prospecting checkpoints. `ProspectingBrief.checkpoints` (`"ask"` or `"auto"`, default `"auto"`, the API's default) and `ProspectingApproveRequest.checkpoints` (`None` keeps the brief's mode). In `"ask"` mode a run pauses with `status="needs_input"` and a `stop_reason` in the new `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`, exported from `discolike`); the latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`. Answer with `message()` using a reply's exact text and `wait()` again; `wait()` already returns on `needs_input`. `"auto"` never pauses: a poor pilot is sharpened once, then the run stops with the new `stop_reason` `"pilot_failed"` and the new `ProspectingRun.pilot_sample` lists the checked companies. Finishing at a checkpoint stops with `"user_finished"`. +- CLI: `prospecting start` and `prospecting approve` send `checkpoints="ask"` by default, matching the web chat; `--auto` sends `"auto"`. `prospecting wait` asks at a checkpoint on a terminal (question, sample companies, numbered suggested replies or free text), posts the answer and keeps waiting. Without a terminal, or with the new `--no-input`, it prints the run on stdout, a `needs_input` envelope with the question and `suggested_replies` on stderr, and exits with the new exit code 7. Other `needs_input` pauses still exit 0. +- SDK (note for maintainers): `ProspectingBrief.checkpoints` is hidden from the platform's OpenAPI schema, so `scripts/gen_requests.py` pins it through `PROPERTY_OVERRIDES` and `scripts/check_contract.py` skips it via `HIDDEN_REQUEST_FIELDS`. `ProspectingApproveRequest.checkpoints` is in `PENDING_PROPERTIES` until the platform deploys it; after that, regenerating moves the class within `_generated/requests.py`, which `--check` reports as a diff until you regenerate. - SDK/CLI: `prospecting.list` / `ProspectingListParams` / `prospecting list --before` gain `before` (a run ID) for keyset paging past a full page of runs, ordered by `created_at` then `run_id` descending. An unknown or other-organization run ID returns an empty page. - SDK/CLI: `discogen.process` / `discogen.process_personas` with `typed_columns=True` now raise a 400 `ValidationError` at submit when the account has no TypeSafe integration, instead of returning a job whose typed cells all read `Error: No TypeSafe integration is configured`. No SDK code change; the server rejects earlier. diff --git a/README.md b/README.md index 6d9af4a..794e0e4 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,7 @@ Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `ap | 4 | Rate limited | | 5 | Network error | | 6 | Not found | +| 7 | Needs input: `prospecting wait` reached a checkpoint with no terminal to ask (or `--no-input`) | ## What's in the box @@ -459,6 +460,8 @@ 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. 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. + Initial planning extracts company counts and contacts per company from the brief. Omitted settings keep that inference available, falling back to 25 companies and 2 contacts 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. 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 09a0411..bcdca0a 100644 --- a/packages/discolike-cli/README.md +++ b/packages/discolike-cli/README.md @@ -64,6 +64,8 @@ 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`. +`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 stops with `pilot_failed` if it still fits poorly. 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. ### Conventions @@ -84,6 +86,7 @@ Omit `--target-companies` and `--contacts-per-company` to infer counts from the | 4 | Rate limited | | 5 | Network error | | 6 | Not found | +| 7 | Needs input: `prospecting wait` reached a checkpoint with no terminal to ask (or `--no-input`) | ## Links diff --git a/packages/discolike-cli/src/discolike_cli/_help.py b/packages/discolike-cli/src/discolike_cli/_help.py index 7a9913e..ab894b7 100644 --- a/packages/discolike-cli/src/discolike_cli/_help.py +++ b/packages/discolike-cli/src/discolike_cli/_help.py @@ -74,6 +74,9 @@ def format_help(self, ctx: Context, formatter: HelpFormatter) -> None: 4 rate_limited HTTP 429; wait "retry_after" seconds, then retry 5 network_error could not reach the API 6 not_found HTTP 404 + 7 needs_input `prospecting wait` stopped at a checkpoint with no terminal to ask + (or --no-input): the run is on stdout, the question and + "suggested_replies" in the stderr envelope Environment: {ENV_API_KEY} API key; overrides the config file written by `discolike auth login`. diff --git a/packages/discolike-cli/src/discolike_cli/_output.py b/packages/discolike-cli/src/discolike_cli/_output.py index 0b3a7be..fd7aa66 100644 --- a/packages/discolike-cli/src/discolike_cli/_output.py +++ b/packages/discolike-cli/src/discolike_cli/_output.py @@ -43,6 +43,7 @@ NotFoundError: 6, } DEFAULT_EXIT_CODE = 1 +NEEDS_INPUT_EXIT_CODE = 7 # Stable, snake_case error codes for agents and scripts to branch on. The class # name in `error` is kept for backwards compatibility; `code` is the contract. diff --git a/packages/discolike-cli/src/discolike_cli/prospecting.py b/packages/discolike-cli/src/discolike_cli/prospecting.py index be93abc..2526cf1 100644 --- a/packages/discolike-cli/src/discolike_cli/prospecting.py +++ b/packages/discolike-cli/src/discolike_cli/prospecting.py @@ -1,12 +1,26 @@ from __future__ import annotations +import json +import sys +import time +from typing import Any +from typing import Literal +from typing import NamedTuple +from uuid import uuid4 + import typer +from discolike import CHECKPOINT_STOP_REASONS +from discolike import Discolike +from discolike import JobTimeoutError from discolike.requests import ProspectingApproveRequest from discolike.requests import ProspectingBrief from discolike.requests import ProspectingGetParams from discolike.requests import ProspectingListParams from discolike.requests import ProspectingMessageRequest +from discolike.resources.prospecting import ProspectingMessage +from discolike.resources.prospecting import ProspectingRun +from discolike_cli._output import NEEDS_INPUT_EXIT_CODE from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import handle_errors @@ -14,6 +28,127 @@ app = typer.Typer(help="Run managed prospecting; processing and provider charges apply.") +AUTO_HELP = "Never pause to ask: a poor pilot is sharpened once, then the run stops. Default: pause at checkpoints." +NO_INPUT_HELP = f"Never prompt: at a checkpoint, print the question and exit {NEEDS_INPUT_EXIT_CODE}." +NEEDS_INPUT_CODE = "needs_input" +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, " + "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 " + "` and wait again." +) +TIMEOUT_MESSAGE = "Timed out waiting for prospecting; the run continues on the server" + + +class Pause(NamedTuple): + question: str | None + suggested_replies: list[str] + sample: list[dict[str, Any]] + + +def _checkpoints(*, auto: bool) -> Literal["ask", "auto"]: + return "auto" if auto else "ask" + + +def _is_interactive() -> bool: + return sys.stdin.isatty() + + +def _at_checkpoint(run: ProspectingRun) -> bool: + return run.status == "needs_input" and run.stop_reason in CHECKPOINT_STOP_REASONS + + +def _latest_question(client: Discolike, run: ProspectingRun) -> ProspectingMessage | None: + """The run's newest question, paging past the first page of messages when there are more.""" + messages = list(run.messages) + cursor = run.next_message_seq + while page := client.prospecting.get(run.run_id, ProspectingGetParams(limit=1, messages_after=cursor)).messages: + messages.extend(page) + cursor = page[-1].seq + return next((message for message in reversed(messages) if message.kind == "question"), None) + + +def _pause(run: ProspectingRun, question: ProspectingMessage | None) -> Pause: + data = (question.data if question else None) or {} + return Pause( + question=question.content if question else run.error, + suggested_replies=list(data.get("suggested_replies", [])), + sample=list(data.get("sample", [])), + ) + + +def _report_pause(run: ProspectingRun, pause: Pause) -> typer.Exit: + emit(run) + envelope = { + "error": "NeedsInput", + "code": NEEDS_INPUT_CODE, + "message": pause.question, + "status_code": None, + "exit_code": NEEDS_INPUT_EXIT_CODE, + "run_id": str(run.run_id), + "stop_reason": run.stop_reason, + "suggested_replies": pause.suggested_replies, + "sample": pause.sample, + } + print(json.dumps(envelope, default=str), file=sys.stderr) + return typer.Exit(code=NEEDS_INPUT_EXIT_CODE) + + +def _ask(pause: Pause) -> str: + if pause.question: + typer.echo(pause.question, err=True) + for company in pause.sample: + reason = company.get("reason") + typer.echo(f" {company.get('domain')}: {reason}" if reason else f" {company.get('domain')}", err=True) + replies = pause.suggested_replies + for number, reply in enumerate(replies, start=1): + typer.echo(f" {number}. {reply}", err=True) + while True: + answer = typer.prompt("Pick a number or type your answer", err=True).strip() + if answer.isdigit() and 1 <= int(answer) <= len(replies): + return replies[int(answer) - 1] + if answer and not answer.isdigit(): + return answer + + +def _remaining(deadline: float) -> float: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise JobTimeoutError(TIMEOUT_MESSAGE) + return remaining + + +def _await_reply(client: Discolike, *, run_id: str, after: int, deadline: float, poll_interval: float) -> None: + """Poll until the agent has answered the message at seq `after`, then print its reply.""" + while True: + run = client.prospecting.get(run_id, ProspectingGetParams(limit=1, messages_after=after)) + if not run.reply_pending: + for message in run.messages: + if message.role == "agent": + typer.echo(message.content, err=True) + return + time.sleep(min(poll_interval, _remaining(deadline))) + + +def _wait_answering( + client: Discolike, *, run_id: str, timeout: float, poll_interval: float, no_input: bool +) -> ProspectingRun: + deadline = time.monotonic() + timeout + while True: + run = client.prospecting.wait(run_id, timeout=_remaining(deadline), poll_interval=poll_interval) + if not _at_checkpoint(run): + return run + pause = _pause(run, _latest_question(client, run)) + if no_input or not _is_interactive(): + raise _report_pause(run, pause) + sent = client.prospecting.message( + run_id, ProspectingMessageRequest(text=_ask(pause)), idempotency_key=f"cli-checkpoint-{uuid4()}" + ) + _await_reply(client, run_id=run_id, after=sent.seq, deadline=deadline, poll_interval=poll_interval) + @app.command("start") @handle_errors @@ -48,6 +183,7 @@ def start_command( contact_integration_id: str | None = typer.Option(None, "--contact-integration-id"), 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), ) -> None: """Draft a plan. Wait for proposed, review it, then approve its plan version.""" from discolike_cli.main import get_client @@ -68,6 +204,7 @@ def start_command( contact_integration_id=contact_integration_id, search_provider_id=search_provider_id, segment=segment, + checkpoints=_checkpoints(auto=auto), ), ) emit(get_client(ctx).prospecting.start(request, idempotency_key=idempotency_key)) @@ -102,18 +239,20 @@ def cancel_command(ctx: typer.Context, run_id: str = typer.Argument(...)) -> Non emit(get_client(ctx).prospecting.cancel(run_id)) -@app.command("wait") +@app.command("wait", help=WAIT_HELP) @handle_errors def wait_command( ctx: typer.Context, run_id: str = typer.Argument(...), timeout: float = typer.Option(3600, "--timeout", min=0.01), poll_interval: float = typer.Option(5, "--poll-interval", min=5), + no_input: bool = typer.Option(False, "--no-input", help=NO_INPUT_HELP), ) -> None: - """Return the first page on proposed, needs_input, completed, failed, or cancelled. Approve proposed plans; timeout stops polling only.""" from discolike_cli.main import get_client - emit(get_client(ctx).prospecting.wait(run_id, timeout=timeout, poll_interval=poll_interval)) + emit( + _wait_answering(get_client(ctx), run_id=run_id, timeout=timeout, poll_interval=poll_interval, no_input=no_input) + ) @app.command("list") @@ -139,13 +278,17 @@ def approve_command( ctx: typer.Context, run_id: str = typer.Argument(...), plan_version: int = typer.Option(..., "--plan-version", min=1), + auto: bool = typer.Option(False, "--auto", help=AUTO_HELP), ) -> None: """Approve the reviewed plan version and start research.""" from discolike_cli.main import get_client emit( get_client(ctx).prospecting.approve( - run_id, build_request(ProspectingApproveRequest, {"plan_version": plan_version}) + run_id, + build_request( + ProspectingApproveRequest, {"plan_version": plan_version, "checkpoints": _checkpoints(auto=auto)} + ), ) ) diff --git a/packages/discolike-cli/tests/test_prospecting_cli.py b/packages/discolike-cli/tests/test_prospecting_cli.py index cfc27c8..50da943 100644 --- a/packages/discolike-cli/tests/test_prospecting_cli.py +++ b/packages/discolike-cli/tests/test_prospecting_cli.py @@ -7,6 +7,8 @@ import pytest from typer.testing import CliRunner +import discolike_cli.prospecting as prospecting_cli +from discolike_cli._output import NEEDS_INPUT_EXIT_CODE from discolike_cli.main import app from discolike_testkit import Handler from discolike_testkit.prospecting import message_payload @@ -134,7 +136,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: result = runner.invoke(app, ["prospecting", *command]) assert result.exit_code == 0, result.output assert dict(seen[0].url.params) == {"limit": "20", "before": RUN_ID} - assert json.loads(seen[1].content) == {"plan_version": 2} + assert json.loads(seen[1].content) == {"plan_version": 2, "checkpoints": "ask"} assert json.loads(seen[2].content) == {"text": "Make it 100 companies"} assert seen[2].headers["Idempotency-Key"] == "cli-message" assert dict(seen[3].url.params) == {"offset": "0", "limit": "500", "events_after": "12", "messages_after": "8"} @@ -200,3 +202,146 @@ def handler(request: httpx2.Request) -> httpx2.Response: "max_candidates": candidates, "max_actions": actions, } + + +@pytest.mark.parametrize(("flags", "mode"), [([], "ask"), (["--auto"], "auto")]) +def test_start_and_approve_ask_at_checkpoints_unless_auto( + install_build_client: Callable[[Handler], None], flags: list[str], mode: str +) -> None: + bodies = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + bodies.append(json.loads(request.content)) + return httpx2.Response(202, json=run_payload("queued")) + + install_build_client(handler) + start = ["prospecting", "start", "--brief", "US logistics companies", "--idempotency-key", "mode", *flags] + for command in (start, ["prospecting", "approve", RUN_ID, "--plan-version", "1", *flags]): + result = runner.invoke(app, command) + assert result.exit_code == 0, result.output + assert [body["checkpoints"] for body in bodies] == [mode, mode] + + +PILOT_REPLIES = ["Run the full list", "Stop here"] +PILOT_SAMPLE = [{"domain": "fits.com", "name": "Fits", "company_fit": "Yes", "reason": "Runs a trucking fleet"}] +PILOT_QUESTION = "I checked the first 20 companies: 18 fit your criteria (90%). Here are some of them." + + +def _question_message(seq: int) -> dict: + return message_payload() | { + "seq": seq, + "role": "agent", + "kind": "question", + "content": PILOT_QUESTION, + "data": {"reason": "pilot", "suggested_replies": PILOT_REPLIES, "sample": PILOT_SAMPLE}, + } + + +def _emitted(stdout: str) -> dict: + """CliRunner echoes typed input to stdout, which a real terminal does not; the JSON follows it.""" + return json.loads(stdout[stdout.index("{") :]) + + +def _checkpoint_handler(seen: list[httpx2.Request], *, answer_seq: int = 20) -> Handler: + """A run paused at the pilot whose question is past the first message page; any answer resumes it.""" + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + answered = any(sent.method == "POST" for sent in seen) + after = request.url.params.get("messages_after") + if request.method == "POST": + return httpx2.Response(202, json=message_payload() | {"seq": answer_seq}) + if after == str(answer_seq): + ack = message_payload() | {"seq": answer_seq + 1, "role": "agent", "kind": "ack", "content": "On it."} + return httpx2.Response(200, json=run_payload("running") | {"messages": [ack]}) + if after == "7": + return httpx2.Response(200, json=run_payload("needs_input") | {"messages": [_question_message(8)]}) + if after is not None: + return httpx2.Response(200, json=run_payload("needs_input")) + if answered: + return httpx2.Response(200, json=run_payload("completed")) + paused = {"stop_reason": "pilot", "error": PILOT_QUESTION, "next_message_seq": 7} + return httpx2.Response(200, json=run_payload("needs_input") | paused) + + return handler + + +@pytest.mark.parametrize(("typed", "posted"), [("1", "Run the full list"), ("Only fleets over 50 trucks", None)]) +def test_wait_asks_at_a_checkpoint_and_keeps_waiting( + install_build_client: Callable[[Handler], None], + monkeypatch: pytest.MonkeyPatch, + typed: str, + posted: str | None, +) -> None: + seen: list[httpx2.Request] = [] + install_build_client(_checkpoint_handler(seen)) + monkeypatch.setattr(prospecting_cli, "_is_interactive", lambda: True) + + result = runner.invoke(app, ["prospecting", "wait", RUN_ID], input=f"{typed}\n") + + assert result.exit_code == 0, result.output + assert _emitted(result.stdout)["status"] == "completed" + (message,) = [request for request in seen if request.method == "POST"] + assert json.loads(message.content) == {"text": posted or typed} + assert message.headers["Idempotency-Key"].startswith("cli-checkpoint-") + for shown in (PILOT_QUESTION, "fits.com: Runs a trucking fleet", "1. Run the full list", "2. Stop here", "On it."): + assert shown in result.stderr + + +def test_wait_reprompts_for_a_number_out_of_range( + install_build_client: Callable[[Handler], None], monkeypatch: pytest.MonkeyPatch +) -> None: + seen: list[httpx2.Request] = [] + install_build_client(_checkpoint_handler(seen)) + monkeypatch.setattr(prospecting_cli, "_is_interactive", lambda: True) + + result = runner.invoke(app, ["prospecting", "wait", RUN_ID], input="3\n2\n") + + assert result.exit_code == 0, result.output + (message,) = [request for request in seen if request.method == "POST"] + assert json.loads(message.content) == {"text": "Stop here"} + + +@pytest.mark.parametrize(("interactive", "flags"), [(False, []), (True, ["--no-input"])]) +def test_wait_without_a_terminal_reports_the_checkpoint_and_exits_needs_input( + install_build_client: Callable[[Handler], None], + monkeypatch: pytest.MonkeyPatch, + interactive: bool, + flags: list[str], +) -> None: + seen: list[httpx2.Request] = [] + install_build_client(_checkpoint_handler(seen)) + monkeypatch.setattr(prospecting_cli, "_is_interactive", lambda: interactive) + + result = runner.invoke(app, ["prospecting", "wait", RUN_ID, *flags]) + + assert result.exit_code == NEEDS_INPUT_EXIT_CODE + assert json.loads(result.stdout)["stop_reason"] == "pilot" + envelope = json.loads(result.stderr.splitlines()[-1]) + assert envelope == { + "error": "NeedsInput", + "code": "needs_input", + "message": PILOT_QUESTION, + "status_code": None, + "exit_code": NEEDS_INPUT_EXIT_CODE, + "run_id": RUN_ID, + "stop_reason": "pilot", + "suggested_replies": PILOT_REPLIES, + "sample": PILOT_SAMPLE, + } + assert all(request.method == "GET" for request in seen) + + +def test_wait_returns_other_needs_input_pauses_unchanged( + install_build_client: Callable[[Handler], None], monkeypatch: pytest.MonkeyPatch +) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json=run_payload("needs_input") | {"stop_reason": "question"}) + + install_build_client(handler) + monkeypatch.setattr(prospecting_cli, "_is_interactive", lambda: False) + + result = runner.invoke(app, ["prospecting", "wait", RUN_ID]) + + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["stop_reason"] == "question" diff --git a/packages/discolike/README.md b/packages/discolike/README.md index ab875f0..796aa37 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. The async client has the same methods with `await`. Import these request models from `discolike.requests`. +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. + 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. ## Links diff --git a/packages/discolike/src/discolike/__init__.py b/packages/discolike/src/discolike/__init__.py index f992306..dfe1eaa 100644 --- a/packages/discolike/src/discolike/__init__.py +++ b/packages/discolike/src/discolike/__init__.py @@ -27,11 +27,13 @@ from discolike.resources.email import EnumerationMatch from discolike.resources.email import EnumerationOutput from discolike.resources.email import ValidationOutput +from discolike.resources.prospecting import CHECKPOINT_STOP_REASONS from discolike.signup import SignupResult from discolike.signup import async_signup from discolike.signup import signup __all__ = [ + "CHECKPOINT_STOP_REASONS", "NATIVE_ENGINE", "NATIVE_ICP_ENGINE", "APIConnectionError", diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 5a6b28f..96244b0 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -2917,6 +2917,7 @@ class ProspectingListParams(DiscolikeRequest): class ProspectingApproveRequest(DiscolikeRequest): plan_version: Annotated[int, Field(ge=1, title="Plan Version")] + checkpoints: Literal["ask", "auto"] | None = None class ProspectingMessageRequest(DiscolikeRequest): @@ -2936,6 +2937,13 @@ class ProspectingBrief(DiscolikeRequest): 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, then the run stops with stop_reason pilot_failed.", + title="Checkpoints", + ), + ] = "auto" class ProspectingGetParams(DiscolikeRequest): diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index 0b34417..13e3c88 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -23,6 +23,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"}) ProspectingStatus = Literal[ "drafting", "proposed", "queued", "running", "needs_input", "completed", "failed", "cancelled" ] @@ -125,6 +126,10 @@ class ProspectingRun(DiscolikeModel): description="The chat was closed for off-topic use: every new message gets the same fixed reply. " "An approved run keeps working and its saved lists still fill.", ) + pilot_sample: list[dict[str, Any]] = Field( + default_factory=list, + description="Checked companies (domain, name, company_fit, reason) when stop_reason is pilot_failed.", + ) def _key(value: str) -> str: @@ -157,7 +162,10 @@ def list(self, params: ProspectingListParams | None = None) -> builtins.list[Pro @api_route("POST", "/prospecting/runs/{run_id}/approve") def approve(self, run_id: str | UUID, request: ProspectingApproveRequest) -> ProspectingRun: - """Approve the reviewed plan version; repeating the same approval is safe.""" + """Approve the reviewed plan version; repeating the same approval is safe. + + `checkpoints` on the request overrides the brief's mode; None keeps it. + """ response = self._transport.request("POST", _path(run_id) + "/approve", json_body=request.to_wire()) return ProspectingRun.model_validate(response.json()) @@ -197,6 +205,9 @@ def wait(self, run_id: str | UUID, *, timeout: float = 3600, poll_interval: floa A proposed run needs approve() with its plan_version before research starts. 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. Timeout stops local polling only. Fetch subsequent pages with get(). """ deadline = _deadline(timeout, poll_interval) @@ -224,7 +235,10 @@ async def list(self, params: ProspectingListParams | None = None) -> builtins.li @api_route("POST", "/prospecting/runs/{run_id}/approve") async def approve(self, run_id: str | UUID, request: ProspectingApproveRequest) -> ProspectingRun: - """Approve the reviewed plan version; repeating the same approval is safe.""" + """Approve the reviewed plan version; repeating the same approval is safe. + + `checkpoints` on the request overrides the brief's mode; None keeps it. + """ response = await self._transport.request("POST", _path(run_id) + "/approve", json_body=request.to_wire()) return ProspectingRun.model_validate(response.json()) @@ -259,7 +273,10 @@ async def cancel(self, run_id: str | UUID) -> ProspectingRun: return ProspectingRun.model_validate(response.json()) async def wait(self, run_id: str | UUID, *, timeout: float = 3600, poll_interval: float = 5) -> ProspectingRun: - """Return the first page on proposed/needs_input/completed/failed/cancelled; inspect status.""" + """Return the first page on proposed/needs_input/completed/failed/cancelled; inspect status. + + A needs_input run with a stop_reason in CHECKPOINT_STOP_REASONS waits for an answer via message(). + """ deadline = _deadline(timeout, poll_interval) while True: run = await self.get(run_id) diff --git a/packages/discolike/tests/test_contract_registry.py b/packages/discolike/tests/test_contract_registry.py index 356237e..da3f1e0 100644 --- a/packages/discolike/tests/test_contract_registry.py +++ b/packages/discolike/tests/test_contract_registry.py @@ -320,3 +320,31 @@ def test_check_compares_json_body_properties_bidirectionally(): "components": {"schemas": {"FindEmailRequest": {"properties": properties}}}, } assert check_contract.check(spec, routes) == [] + + +def _prospecting_start_spec(names: list[str]) -> dict: + body = {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProspectingBrief"}}}} + return { + "paths": {"/prospecting/runs": {"post": {"requestBody": body}}}, + "components": {"schemas": {"ProspectingBrief": {"properties": {name: {} for name in names}}}}, + } + + +def test_check_allows_the_brief_checkpoints_field_the_spec_hides(): + check_contract = _load_check_contract() + from discolike.requests import ProspectingBrief + + routes = _route(check_contract, "ProspectingResource", "start") + names = [name for name in ProspectingBrief.model_fields if name != "checkpoints"] + assert check_contract.check(_prospecting_start_spec(names), routes) == [] + + +def test_check_still_reports_other_brief_fields_the_spec_lacks(): + check_contract = _load_check_contract() + from discolike.requests import ProspectingBrief + + routes = _route(check_contract, "ProspectingResource", "start") + names = [name for name in ProspectingBrief.model_fields if name not in {"checkpoints", "segment"}] + assert check_contract.check(_prospecting_start_spec(names), routes) == [ + "ProspectingResource.start (POST /prospecting/runs): field 'segment' of ProspectingBrief not found in spec" + ] diff --git a/packages/discolike/tests/test_gen_requests.py b/packages/discolike/tests/test_gen_requests.py index ce79bb6..0d97ca4 100644 --- a/packages/discolike/tests/test_gen_requests.py +++ b/packages/discolike/tests/test_gen_requests.py @@ -233,6 +233,20 @@ def test_apply_overlays_pins_sub_industry_over_a_spec_enum(gen) -> None: assert sub_industry["items"] == {"type": "string"} +def test_apply_overlays_adds_the_checkpoint_modes_the_spec_hides_or_lacks(gen) -> None: + kept = { + "ProspectingBrief": {"type": "object", "properties": {"brief": {"type": "string"}}}, + "ProspectingApproveRequest": {"type": "object", "properties": {"plan_version": {"type": "integer"}}}, + } + + overlaid = gen.apply_overlays(kept=kept) + + brief = overlaid["ProspectingBrief"]["properties"]["checkpoints"] + assert (brief["enum"], brief["default"]) == (["ask", "auto"], "auto") + approve = overlaid["ProspectingApproveRequest"]["properties"]["checkpoints"] + assert (approve["enum"], approve["nullable"]) == (["ask", "auto"], True) + + def test_apply_overlays_skips_schemas_this_run_does_not_generate(gen) -> None: assert gen.apply_overlays(kept={}) == {} diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index bf20b0d..4ba7bdd 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -8,6 +8,7 @@ from pydantic import ValidationError import discolike.resources.prospecting as module +from discolike import CHECKPOINT_STOP_REASONS from discolike import JobTimeoutError from discolike.requests import ProspectingApproveRequest from discolike.requests import ProspectingBrief @@ -199,6 +200,8 @@ def handler(request: httpx2.Request) -> httpx2.Response: [ (ProspectingApproveRequest, {}), (ProspectingApproveRequest, {"plan_version": 0}), + (ProspectingApproveRequest, {"plan_version": 1, "checkpoints": "sometimes"}), + (ProspectingBrief, {"brief": "US logistics companies", "checkpoints": "never"}), (ProspectingListParams, {"limit": 51}), (ProspectingListParams, {"limit": 0}), (ProspectingListParams, {"before": "not-a-uuid"}), @@ -227,3 +230,44 @@ def test_request_defaults_preserve_explicit_quantity_intent() -> None: "max_candidates": 0, } assert (ProspectingListParams().limit, ProspectingListParams().before) == (20, None) + + +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" + assert ProspectingBrief(brief=brief).to_wire() == {"brief": brief} + assert ProspectingBrief(brief=brief, checkpoints="ask").to_wire() == {"brief": brief, "checkpoints": "ask"} + assert ProspectingApproveRequest(plan_version=2).to_wire() == {"plan_version": 2} + assert ProspectingApproveRequest(plan_version=2, checkpoints="auto").to_wire() == { + "plan_version": 2, + "checkpoints": "auto", + } + + +def test_wait_returns_at_a_checkpoint_with_its_question(make_client: ClientFactory) -> None: + question = message_payload() | { + "role": "agent", + "kind": "question", + "content": "Found 25 companies and 50 emails. Want more?", + "data": {"reason": "target_reached", "suggested_replies": ["That's enough", "Find 25 more"], "sample": []}, + } + paused = payload("needs_input") | { + "stop_reason": "target_reached", + "brief": {"brief": "US logistics companies and operations leaders", "checkpoints": "ask"}, + "messages": [question], + } + with make_client(lambda request: httpx2.Response(200, json=paused)) as client: + run = client.prospecting.wait(RUN_ID) + assert (run.status, run.stop_reason) == ("needs_input", "target_reached") + assert run.stop_reason in CHECKPOINT_STOP_REASONS + assert run.brief.checkpoints == "ask" + assert run.messages[-1].data == question["data"] + + +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} + with make_client(lambda request: httpx2.Response(200, json=stopped)) as client: + run = client.prospecting.wait(RUN_ID) + assert (run.stop_reason, run.pilot_sample) == ("pilot_failed", sample) + assert run.stop_reason not in CHECKPOINT_STOP_REASONS diff --git a/scripts/check_contract.py b/scripts/check_contract.py index 868a10c..c97e530 100644 --- a/scripts/check_contract.py +++ b/scripts/check_contract.py @@ -59,6 +59,8 @@ "MatchResponse": MatchResponse, "SavedQueriesListResponse": SavedQueries, } +# Request fields the platform accepts but hides from its OpenAPI schema (SkipJsonSchema), so the spec never lists them. +HIDDEN_REQUEST_FIELDS: dict[str, frozenset[str]] = {"ProspectingBrief": frozenset({"checkpoints"})} SPEC_URL = "https://api.discolike.com/v1/openapi.json" REQUEST_TIMEOUT_SECONDS = 30.0 @@ -164,7 +166,7 @@ def check(spec: dict, routes: list[RouteEntry]) -> list[str]: ) continue model = route.request_model - model_fields = set(model.model_fields) + model_fields = set(model.model_fields) - HIDDEN_REQUEST_FIELDS.get(model.__name__, frozenset()) mismatches.extend( f"{label}: field '{field}' of {model.__name__} not found in spec" for field in sorted(model_fields - spec_fields) diff --git a/scripts/gen_requests.py b/scripts/gen_requests.py index 43e5f14..3c0f00d 100644 --- a/scripts/gen_requests.py +++ b/scripts/gen_requests.py @@ -137,21 +137,39 @@ }, } +_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, then the run " + "stops with stop_reason pilot_failed." +) + # Properties the SDK ships before the deployed spec has them. Merged in only while the spec # lacks them, so each entry clears itself once the platform release lands -- generation prints # the ones that have, to be deleted here. PENDING_PROPERTIES: dict[str, dict[str, dict[str, Any]]] = { "DiscoverParams": _GEO_PROPERTIES, "CountParams": _GEO_PROPERTIES, + "ProspectingApproveRequest": {"checkpoints": {"type": "string", "enum": _CHECKPOINT_MODES, "nullable": True}}, } # Properties generated from this schema rather than the spec's, whatever the spec says. The # platform's sub-industry enum lists parent-qualified keys only; a bare label reaches it through # a server-side normalizer with no client-side counterpart, so generating that enum would reject # values the API accepts. +# ProspectingBrief.checkpoints is SkipJsonSchema on the platform, so the spec never carries it. PROPERTY_OVERRIDES: dict[str, dict[str, dict[str, Any]]] = { "DiscoverParams": _SUB_INDUSTRY_PROPERTIES, "CountParams": _SUB_INDUSTRY_PROPERTIES, + "ProspectingBrief": { + "checkpoints": { + "type": "string", + "enum": _CHECKPOINT_MODES, + "default": "auto", + "description": _BRIEF_CHECKPOINTS_DESCRIPTION, + "title": "Checkpoints", + } + }, } From 395b5e33a0707d333d09dfd2e8510643a1705765 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Sun, 27 Sep 2026 23:28:57 -0700 Subject: [PATCH 12/13] Resolve named enum refs in the contract check and parse the companies list id The platform declares prospecting status, kind, role and stage as named type aliases, which its OpenAPI spec emits as $ref schemas. The contract check read every $ref as "object", so all four fields reported drift on models that actually matched. It now follows the ref to the target's type, so a genuine type change behind a ref is still caught. Runs gain companies_saved_query_id. The spec marks it required, but the SDK defaults it to None so it still parses servers from before the field existed; the contract check allows that one difference instead of failing on it. --- .../src/discolike/resources/prospecting.py | 3 ++ .../discolike/tests/test_contract_registry.py | 45 +++++++++++++++++++ packages/discolike/tests/test_prospecting.py | 7 +++ scripts/check_contract.py | 36 ++++++++++----- 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index 13e3c88..f87896d 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -130,6 +130,9 @@ class ProspectingRun(DiscolikeModel): default_factory=list, description="Checked companies (domain, name, company_fit, reason) when stop_reason is pilot_failed.", ) + companies_saved_query_id: UUID | None = Field( + default=None, description="The saved list of the run's companies; None until the run has a saved list." + ) def _key(value: str) -> str: diff --git a/packages/discolike/tests/test_contract_registry.py b/packages/discolike/tests/test_contract_registry.py index da3f1e0..106dfcf 100644 --- a/packages/discolike/tests/test_contract_registry.py +++ b/packages/discolike/tests/test_contract_registry.py @@ -348,3 +348,48 @@ def test_check_still_reports_other_brief_fields_the_spec_lacks(): assert check_contract.check(_prospecting_start_spec(names), routes) == [ "ProspectingResource.start (POST /prospecting/runs): field 'segment' of ProspectingBrief not found in spec" ] + + +def _prospecting_run_spec(*, status: dict, status_schema: dict | None = None) -> dict: + from discolike.resources.prospecting import ProspectingRun + + schema = _spec_schema_for(ProspectingRun) + schema["properties"]["status"] = status + schemas: dict[str, dict] = {"ProspectingRunResponse": schema} + if status_schema is not None: + schemas["ProspectingStatus"] = status_schema + return {"components": {"schemas": schemas}} + + +def test_check_models_resolves_a_named_enum_ref_to_its_type(): + check_contract = _load_check_contract() + from discolike.resources.prospecting import ProspectingRun + + spec = _prospecting_run_spec( + status={"$ref": "#/components/schemas/ProspectingStatus"}, + status_schema={"type": "string", "enum": ["running", "completed"]}, + ) + assert check_contract.check_models(spec, {"ProspectingRunResponse": ProspectingRun}) == [] + + +def test_check_models_still_reports_a_ref_to_a_different_type(): + check_contract = _load_check_contract() + from discolike.resources.prospecting import ProspectingRun + + spec = _prospecting_run_spec( + status={"$ref": "#/components/schemas/ProspectingStatus"}, status_schema={"type": "integer", "enum": [1, 2]} + ) + assert check_contract.check_models(spec, {"ProspectingRunResponse": ProspectingRun}) == [ + "ProspectingRun: field 'status' has type (frozenset({'string'}), None) but spec schema " + "'ProspectingRunResponse' declares (frozenset({'integer'}), None)" + ] + + +def test_check_models_accepts_the_saved_companies_id_the_sdk_keeps_optional(): + check_contract = _load_check_contract() + from discolike.resources.prospecting import ProspectingRun + + schema = _spec_schema_for(ProspectingRun) + schema["required"] = [*schema["required"], "companies_saved_query_id"] + spec = {"components": {"schemas": {"ProspectingRunResponse": schema}}} + assert check_contract.check_models(spec, {"ProspectingRunResponse": ProspectingRun}) == [] diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index 4ba7bdd..46fc7c7 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -270,4 +270,11 @@ def test_a_failed_pilot_carries_its_sample(make_client: ClientFactory) -> None: with make_client(lambda request: httpx2.Response(200, json=stopped)) as client: run = client.prospecting.wait(RUN_ID) assert (run.stop_reason, run.pilot_sample) == ("pilot_failed", sample) + assert run.companies_saved_query_id is None assert run.stop_reason not in CHECKPOINT_STOP_REASONS + + +def test_a_run_carries_its_saved_companies_list(make_client: ClientFactory) -> None: + completed = payload("completed") | {"saved_query_id": RUN_ID, "companies_saved_query_id": OTHER_QUERY_ID} + with make_client(lambda request: httpx2.Response(200, json=completed)) as client: + assert client.prospecting.get(RUN_ID).companies_saved_query_id == UUID(OTHER_QUERY_ID) diff --git a/scripts/check_contract.py b/scripts/check_contract.py index c97e530..2449b88 100644 --- a/scripts/check_contract.py +++ b/scripts/check_contract.py @@ -61,6 +61,8 @@ } # Request fields the platform accepts but hides from its OpenAPI schema (SkipJsonSchema), so the spec never lists them. HIDDEN_REQUEST_FIELDS: dict[str, frozenset[str]] = {"ProspectingBrief": frozenset({"checkpoints"})} +# Response fields the spec requires but the SDK defaults, so it still parses servers from before they were added. +OPTIONAL_RESPONSE_FIELDS: dict[str, frozenset[str]] = {"ProspectingRun": frozenset({"companies_saved_query_id"})} SPEC_URL = "https://api.discolike.com/v1/openapi.json" REQUEST_TIMEOUT_SECONDS = 30.0 @@ -181,26 +183,35 @@ def check(spec: dict, routes: list[RouteEntry]) -> list[str]: TYPE_INFO_KEYS = {"type", "anyOf", "oneOf", "$ref", "nullable"} -def _resolved_type(node: dict) -> str | None: - return "object" if "$ref" in node else node.get("type") +def _resolved_type(node: dict, *, root: dict) -> str | None: + """A $ref resolves to its target's type, so a named enum alias reads as "string" rather than "object".""" + ref = node.get("$ref") + if ref is None: + return node.get("type") + target: object = root + for part in ref.lstrip("#/").split("/"): + target = target.get(part) if isinstance(target, dict) else None + return target.get("type", "object") if isinstance(target, dict) else "object" def _type_variants(prop: dict) -> list[dict]: return prop.get("anyOf") or prop.get("oneOf") or [prop] -def _field_types(prop: dict) -> frozenset[str]: - types = {resolved for variant in _type_variants(prop) if (resolved := _resolved_type(variant)) is not None} +def _field_types(prop: dict, *, root: dict) -> frozenset[str]: + types = { + resolved for variant in _type_variants(prop) if (resolved := _resolved_type(variant, root=root)) is not None + } if prop.get("nullable"): types.add("null") return frozenset(types) -def _item_type(prop: dict) -> str | None: +def _item_type(prop: dict, *, root: dict) -> str | None: for variant in _type_variants(prop): items = variant.get("items") if items is not None: - return _resolved_type(items) + return _resolved_type(items, root=root) return None @@ -208,8 +219,8 @@ def _has_type_info(prop: dict) -> bool: return bool(prop.keys() & TYPE_INFO_KEYS) -def _field_shape(prop: dict) -> tuple[frozenset[str], str | None]: - return (_field_types(prop), _item_type(prop)) +def _field_shape(prop: dict, *, root: dict) -> tuple[frozenset[str], str | None]: + return (_field_types(prop, root=root), _item_type(prop, root=root)) def check_models(spec: dict, mirrored: dict[str, type[DiscolikeModel]] | None = None) -> list[str]: @@ -242,7 +253,10 @@ def check_models(spec: dict, mirrored: dict[str, type[DiscolikeModel]] | None = mismatches.extend( f"{model.__name__}: field '{field}' is required in spec schema '{schema_name}' but optional on " f"the SDK model" - for field in sorted((spec_required - model_required) & model_fields) + for field in sorted( + (spec_required - model_required) + & model_fields - OPTIONAL_RESPONSE_FIELDS.get(model.__name__, frozenset()) + ) ) mismatches.extend( f"{model.__name__}: field '{field}' is optional in spec schema '{schema_name}' but required on " @@ -254,8 +268,8 @@ def check_models(spec: dict, mirrored: dict[str, type[DiscolikeModel]] | None = spec_prop = spec_properties[field] if not _has_type_info(spec_prop): continue - model_shape = _field_shape(model_properties.get(field, {})) - spec_shape = _field_shape(spec_prop) + model_shape = _field_shape(model_properties.get(field, {}), root=model_schema) + spec_shape = _field_shape(spec_prop, root=spec) if model_shape != spec_shape: mismatches.append( f"{model.__name__}: field '{field}' has type {model_shape} but spec schema " From 6c6d5935c86f8e672c325cd38bb04902638315bd Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Sun, 27 Sep 2026 23:37:18 -0700 Subject: [PATCH 13/13] Correct auto-mode pilot behavior: sharpen and continue, not stop The owner changed the rule: a poor pilot in auto mode now sharpens the criteria once and keeps the run going with a notice, instead of stopping outright. pilot_failed is now reserved for a re-pilot fit still under 20% (the searches are broken); a failed sharpening attempt falls back to the original criteria and continues. --- CHANGELOG.md | 2 +- packages/discolike-cli/README.md | 2 +- packages/discolike-cli/src/discolike_cli/prospecting.py | 2 +- packages/discolike/README.md | 2 +- packages/discolike/src/discolike/_generated/requests.py | 2 +- scripts/gen_requests.py | 5 +++-- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89e509e..0ed3503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - CLI: add prospecting plan approval, recent-run lists, chat messages, and event/message cursors. Omitted quantity flags preserve brief inference; larger target and automatic work limits match the API. `wait` returns when a plan needs approval. - SDK: add sync/async prospecting start/get/list/approve/message/cancel/wait, with typed chat, progress, saved-query, and recent-run responses. Starts and messages require idempotency keys; approval requires the reviewed plan version. `wait` returns on proposed plans, needs-input, and terminal outcomes. - SDK: prospecting runs gain `saved_query_ids`, every saved contact list for the run in order (first entry is `saved_query_id`). Large results are now split across several lists instead of being cut off at 50 MiB; parts are final once the run reaches a terminal status. -- SDK: prospecting checkpoints. `ProspectingBrief.checkpoints` (`"ask"` or `"auto"`, default `"auto"`, the API's default) and `ProspectingApproveRequest.checkpoints` (`None` keeps the brief's mode). In `"ask"` mode a run pauses with `status="needs_input"` and a `stop_reason` in the new `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`, exported from `discolike`); the latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`. Answer with `message()` using a reply's exact text and `wait()` again; `wait()` already returns on `needs_input`. `"auto"` never pauses: a poor pilot is sharpened once, then the run stops with the new `stop_reason` `"pilot_failed"` and the new `ProspectingRun.pilot_sample` lists the checked companies. Finishing at a checkpoint stops with `"user_finished"`. +- SDK: prospecting checkpoints. `ProspectingBrief.checkpoints` (`"ask"` or `"auto"`, default `"auto"`, the API's default) and `ProspectingApproveRequest.checkpoints` (`None` keeps the brief's mode). In `"ask"` mode a run pauses with `status="needs_input"` and a `stop_reason` in the new `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`, exported from `discolike`); the latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`. Answer with `message()` using a reply's exact text and `wait()` again; `wait()` already returns on `needs_input`. `"auto"` never pauses: a poor pilot is sharpened once and the run continues with a notice. It only stops with the new `stop_reason` `"pilot_failed"` (and the new `ProspectingRun.pilot_sample` lists the checked companies) if the re-pilot fit is still under 20%; if sharpening itself fails, the run continues on the original criteria. Finishing at a checkpoint stops with `"user_finished"`. - CLI: `prospecting start` and `prospecting approve` send `checkpoints="ask"` by default, matching the web chat; `--auto` sends `"auto"`. `prospecting wait` asks at a checkpoint on a terminal (question, sample companies, numbered suggested replies or free text), posts the answer and keeps waiting. Without a terminal, or with the new `--no-input`, it prints the run on stdout, a `needs_input` envelope with the question and `suggested_replies` on stderr, and exits with the new exit code 7. Other `needs_input` pauses still exit 0. - SDK (note for maintainers): `ProspectingBrief.checkpoints` is hidden from the platform's OpenAPI schema, so `scripts/gen_requests.py` pins it through `PROPERTY_OVERRIDES` and `scripts/check_contract.py` skips it via `HIDDEN_REQUEST_FIELDS`. `ProspectingApproveRequest.checkpoints` is in `PENDING_PROPERTIES` until the platform deploys it; after that, regenerating moves the class within `_generated/requests.py`, which `--check` reports as a diff until you regenerate. - SDK/CLI: `prospecting.list` / `ProspectingListParams` / `prospecting list --before` gain `before` (a run ID) for keyset paging past a full page of runs, ordered by `created_at` then `run_id` descending. An unknown or other-organization run ID returns an empty page. diff --git a/packages/discolike-cli/README.md b/packages/discolike-cli/README.md index bcdca0a..aed8842 100644 --- a/packages/discolike-cli/README.md +++ b/packages/discolike-cli/README.md @@ -64,7 +64,7 @@ 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`. -`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 stops with `pilot_failed` if it still fits poorly. 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`), 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. 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. diff --git a/packages/discolike-cli/src/discolike_cli/prospecting.py b/packages/discolike-cli/src/discolike_cli/prospecting.py index 2526cf1..4f34aa4 100644 --- a/packages/discolike-cli/src/discolike_cli/prospecting.py +++ b/packages/discolike-cli/src/discolike_cli/prospecting.py @@ -28,7 +28,7 @@ app = typer.Typer(help="Run managed prospecting; processing and provider charges apply.") -AUTO_HELP = "Never pause to ask: a poor pilot is sharpened once, then the run stops. 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%. Default: pause at checkpoints." NO_INPUT_HELP = f"Never prompt: at a checkpoint, print the question and exit {NEEDS_INPUT_EXIT_CODE}." NEEDS_INPUT_CODE = "needs_input" WAIT_HELP = ( diff --git a/packages/discolike/README.md b/packages/discolike/README.md index 796aa37..6c4ec3f 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. The async client has the same methods with `await`. Import these request models from `discolike.requests`. -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 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 96244b0..ae6683b 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -2940,7 +2940,7 @@ class ProspectingBrief(DiscolikeRequest): 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, then the run stops with stop_reason pilot_failed.", + 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" diff --git a/scripts/gen_requests.py b/scripts/gen_requests.py index 3c0f00d..0c4efb0 100644 --- a/scripts/gen_requests.py +++ b/scripts/gen_requests.py @@ -140,8 +140,9 @@ _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, then the run " - "stops with stop_reason pilot_failed." + "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%." ) # Properties the SDK ships before the deployed spec has them. Merged in only while the spec