From 41ee19b6b682d1cb01231fbe42f1a0307e3531fb Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 25 Sep 2026 10:46:46 -0700 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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 From 9f438d96861e27aab252c9a3937556636912da1d Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Mon, 28 Sep 2026 14:20:34 -0700 Subject: [PATCH 14/21] Describe the CLI summary filters as the word match they are --summary and --negate-summary help said semantic search, which the API never did. It matches every word of a term in any order; quoting keeps words together as one term but no longer means an exact phrase, since the persona summary index dropped phrase positions. --- packages/discolike-cli/src/discolike_cli/bulk.py | 4 ++-- packages/discolike-cli/src/discolike_cli/contacts.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/discolike-cli/src/discolike_cli/bulk.py b/packages/discolike-cli/src/discolike_cli/bulk.py index 7e622ee..43b136d 100644 --- a/packages/discolike-cli/src/discolike_cli/bulk.py +++ b/packages/discolike-cli/src/discolike_cli/bulk.py @@ -382,8 +382,8 @@ def _persona_ids_in(path: pathlib.Path) -> set[str]: ICP_PROMPT_HELP = "Natural-language ICP prompt used to derive contact filters." -SUMMARY_HELP = "Filter by profile summary text (semantic search); ranks who comes back per company." -NEGATE_SUMMARY_HELP = "Exclude contacts matching this summary description." +SUMMARY_HELP = "Match profile summary text: every word of any one term, in any order. Quote a multi-word term to keep it together, prefix + to require it. Ranks who comes back per company." +NEGATE_SUMMARY_HELP = "Exclude contacts whose profile summary contains every word of any one term, in any order." HAS_EMAIL_HELP = "Only contacts with an email address (on by default)." diff --git a/packages/discolike-cli/src/discolike_cli/contacts.py b/packages/discolike-cli/src/discolike_cli/contacts.py index be81c1e..c4281c0 100644 --- a/packages/discolike-cli/src/discolike_cli/contacts.py +++ b/packages/discolike-cli/src/discolike_cli/contacts.py @@ -30,8 +30,8 @@ TIMEOUT_HELP = "Max seconds to wait with --wait." PARAM_HELP = "Extra key=value query parameter forwarded to the SDK (repeatable)." JOBSTART_DATE_HELP = "Job start date filter: min date or 'min,max' range, e.g. 2025-01-01 or 2025-01-01,2025-06-30." -SUMMARY_HELP = "Filter by profile summary text (semantic search)." -NEGATE_SUMMARY_HELP = "Exclude contacts matching this summary description." +SUMMARY_HELP = "Match profile summary text: every word of any one term, in any order. Quote a multi-word term to keep it together, prefix + to require it." +NEGATE_SUMMARY_HELP = "Exclude contacts whose profile summary contains every word of any one term, in any order." NAME_HELP = "Filter by contact name (partial match supported)." SKILLS_HELP = "Filter by skill (repeatable)." FILTER_STATE_HELP = "Filter by company state/region (repeatable)." From 3785878bf3e3b992047daedfeb89f3a9f441d66a Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Mon, 28 Sep 2026 16:00:46 -0700 Subject: [PATCH 15/21] Keep released SDKs parsing newer API responses A released SDK should keep working when the API adds a status, stage or message kind, or raises a server-side cap. Response enums are now open (Literal | str), and a run's brief parses into a response model without the request-side limits. The contract check flags only drift that would break parsing, and CONTRIBUTING spells out the rules for new fields. The polling deadline is renamed timeout -> max_wait (signup takes request_timeout) so it no longer reads like the HTTP timeout, and the floor moves to Python 3.11 ahead of 3.10's end of life. --- .github/workflows/ci.yml | 4 +- .pre-commit-config.yaml | 2 +- CHANGELOG.md | 3 + CONTRIBUTING.md | 26 +++- README.md | 2 +- packages/discolike-cli/README.md | 2 +- packages/discolike-cli/pyproject.toml | 3 +- .../src/discolike_cli/_output.py | 4 +- .../discolike-cli/src/discolike_cli/auth.py | 4 +- .../src/discolike_cli/discogen.py | 2 +- .../discolike-cli/src/discolike_cli/email.py | 10 +- .../src/discolike_cli/prospecting.py | 2 +- .../src/discolike_cli/queries.py | 4 +- packages/discolike-cli/tests/test_output.py | 6 +- packages/discolike-testkit/pyproject.toml | 2 +- packages/discolike/README.md | 2 +- packages/discolike/pyproject.toml | 3 +- packages/discolike/src/discolike/_email.py | 26 ++-- packages/discolike/src/discolike/_jobs.py | 14 +- .../src/discolike/resources/prospecting.py | 53 ++++--- packages/discolike/src/discolike/signup.py | 8 +- .../discolike/tests/test_contract_registry.py | 41 +++-- packages/discolike/tests/test_email.py | 20 +-- packages/discolike/tests/test_jobs.py | 8 +- packages/discolike/tests/test_prospecting.py | 17 ++- pyproject.toml | 2 +- scripts/check_contract.py | 33 ++-- scripts/gen_requests.py | 2 +- uv.lock | 143 +----------------- 29 files changed, 192 insertions(+), 256 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9b1697..6cf64f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + python-version: ['3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 @@ -36,7 +36,7 @@ jobs: - resolution: highest python-version: '3.14' - resolution: lowest-direct - python-version: '3.10' + python-version: '3.11' steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c29f46e..3b5dddb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: rev: v3.21.2 hooks: - id: pyupgrade - args: [--py310-plus, --keep-mock] + args: [--py311-plus, --keep-mock] - repo: https://github.com/bwhmather/ssort rev: 0.16.0 hooks: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ed3503..4acce1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- **Breaking:** Python 3.10 is no longer supported (end of life October 2026); the SDK and CLI require Python 3.11+. +- **Breaking:** the polling deadline on `Job.wait()`, `EmailJob.wait()`, `EmailBatch.results()` and `prospecting.wait()` (sync and async) is renamed from `timeout=` to `max_wait=`, and `signup()` / `async_signup()` take `request_timeout=` instead of `timeout=`. Behavior is unchanged: `max_wait` still raises `JobTimeoutError` while the work keeps running server-side. The client constructor and `with_options(timeout=...)` keep their name. The CLI's `--timeout` flag is unchanged. +- SDK: response enums are open. `JobStatus.title_validation` and the prospecting `status`, `stage`, message `role`/`kind`, event `kind` and in-flight `state` accept values a newer API adds instead of failing to parse, and `ProspectingRun.brief` is a `ProspectingRunBrief` without the request-side limits, so a run started with a higher server-side cap still parses. Compare against the documented values and treat anything else as unknown. - 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fba3e74..eae594e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ uv run pytest packages/discolike/tests -q uv run pytest packages/discolike-cli/tests -q ``` -CI runs the same checks across Python 3.10–3.14. +CI runs the same checks across Python 3.11–3.14. ## Branches @@ -42,6 +42,30 @@ models in `discolike.requests` track dev, not prod. The prod spec lags, so both `check_contract.py` and `gen_requests.py --check` against prod stay red until the platform deploys; don't regenerate against prod to "fix" it. +## Compatibility + +Released SDK versions stay installed long after a new one ships, and they +all talk to the same live API. Keep them working: + +- **Response models parse anything a newer server can send.** Models allow + extra fields (`extra="allow"`). Response enums are open + (`Literal[...] | str`), never a closed `Literal`. Response fields carry no + request-side limits (`max_length`, `ge`/`le`). Don't reuse a request model + as a response field (see `ProspectingRunBrief`). A new response field gets + a default, so the SDK still parses servers from before it existed. +- **The SDK may be looser than the spec, never stricter.** The contract check + flags only drift that breaks parsing: the SDK requiring a field the spec + makes optional, or the spec allowing a type the SDK rejects. +- **Public signatures don't change silently.** Never rename or remove a + public method, kwarg, exception, or exported name in a patch release. When + one has to go, keep the old spelling working with a `DeprecationWarning` + that names the replacement for at least one minor release, and record the + removal under **Breaking** in `CHANGELOG.md`. +- **New request fields are optional** and omitted from the wire when unset, so + a newer SDK still works against an API that hasn't deployed them yet. +- **Python support follows upstream EOL.** Drop a version once it reaches + end of life, as a **Breaking** CHANGELOG line. + ## Reporting bugs Open a GitHub issue with the package name, version, and a minimal diff --git a/README.md b/README.md index 794e0e4..e480030 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Or run the CLI without installing: uvx --from discolike-cli discolike --help ``` -Requires Python 3.10+. +Requires Python 3.11+. ## Authentication diff --git a/packages/discolike-cli/README.md b/packages/discolike-cli/README.md index aed8842..29f4729 100644 --- a/packages/discolike-cli/README.md +++ b/packages/discolike-cli/README.md @@ -14,7 +14,7 @@ Or run it without installing: uvx --from discolike-cli discolike --help ``` -Requires Python 3.10+. Installing this package gives you the `discolike` command. +Requires Python 3.11+. Installing this package gives you the `discolike` command. ## Authentication diff --git a/packages/discolike-cli/pyproject.toml b/packages/discolike-cli/pyproject.toml index 5202784..bf65301 100644 --- a/packages/discolike-cli/pyproject.toml +++ b/packages/discolike-cli/pyproject.toml @@ -9,7 +9,7 @@ description = "Official CLI for the DiscoLike API" readme = "README.md" license = "MIT" license-files = ["LICENSE"] -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [{ name = "DiscoLike", email = "support@discolike.com" }] dependencies = [ "discolike==0.4.1", @@ -21,7 +21,6 @@ classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", diff --git a/packages/discolike-cli/src/discolike_cli/_output.py b/packages/discolike-cli/src/discolike_cli/_output.py index fd7aa66..0bf6a94 100644 --- a/packages/discolike-cli/src/discolike_cli/_output.py +++ b/packages/discolike-cli/src/discolike_cli/_output.py @@ -83,7 +83,7 @@ class SupportsWait(Protocol): task_id: str task_family: str - def wait(self, *, timeout: float, on_poll: Callable[[_JobStatusLike], None] | None = None) -> _JobStatusLike: ... + def wait(self, *, max_wait: float, on_poll: Callable[[_JobStatusLike], None] | None = None) -> _JobStatusLike: ... def _normalize(data: Any) -> Any: # noqa: ANN401 -- accepts arbitrary JSON-serializable CLI output data @@ -204,5 +204,5 @@ def run_job(job: SupportsWait, *, wait: bool, timeout: float, fmt: str | None = def _on_poll(status: _JobStatusLike) -> None: sys.stderr.write(f"progress: {status.progress}%\n") - final = job.wait(timeout=timeout, on_poll=_on_poll) + final = job.wait(max_wait=timeout, on_poll=_on_poll) emit(final.results if final.results is not None else final.to_dict(), fmt=fmt) diff --git a/packages/discolike-cli/src/discolike_cli/auth.py b/packages/discolike-cli/src/discolike_cli/auth.py index ccd86ba..d9b353e 100644 --- a/packages/discolike-cli/src/discolike_cli/auth.py +++ b/packages/discolike-cli/src/discolike_cli/auth.py @@ -4,8 +4,8 @@ import secrets import sys import webbrowser +from datetime import UTC from datetime import datetime -from datetime import timezone from typing import Any from typing import NoReturn from urllib.parse import urlparse @@ -88,7 +88,7 @@ def _offer_signup(ctx: typer.Context) -> None: def _iso(epoch_seconds: float) -> str: - return datetime.fromtimestamp(epoch_seconds, tz=timezone.utc).isoformat() + return datetime.fromtimestamp(epoch_seconds, tz=UTC).isoformat() def _global_key_source(ctx: typer.Context) -> str: diff --git a/packages/discolike-cli/src/discolike_cli/discogen.py b/packages/discolike-cli/src/discolike_cli/discogen.py index b1ff0f6..9632cc6 100644 --- a/packages/discolike-cli/src/discolike_cli/discogen.py +++ b/packages/discolike-cli/src/discolike_cli/discogen.py @@ -44,7 +44,7 @@ app = typer.Typer(help="Run DiscoGen research jobs and check status of or cancel any async task (see --family)") -class TaskFamily(str, enum.Enum): +class TaskFamily(enum.StrEnum): discogen = "discogen" bulkmatch = "bulkmatch" contactmatch = "contactmatch" diff --git a/packages/discolike-cli/src/discolike_cli/email.py b/packages/discolike-cli/src/discolike_cli/email.py index c591f7f..651d813 100644 --- a/packages/discolike-cli/src/discolike_cli/email.py +++ b/packages/discolike-cli/src/discolike_cli/email.py @@ -71,7 +71,7 @@ def _fetch_batch_snapshot(batch: EmailBatch) -> EmailBatchResults: """Fetch the batch results once, whether or not the batch has finished.""" snapshot: dict[str, EmailBatchResults] = {} try: - return batch.results(timeout=0, on_poll=lambda results: snapshot.update(latest=results)) + return batch.results(max_wait=0, on_poll=lambda results: snapshot.update(latest=results)) except JobTimeoutError: return snapshot["latest"] @@ -101,7 +101,7 @@ def find_command( if not wait: emit({"job_id": job.job_id, "hint": f"poll with: discolike email job {job.job_id}"}) return - emit(job.wait(timeout=timeout, on_poll=_job_status_to_stderr), fmt=fmt) + emit(job.wait(max_wait=timeout, on_poll=_job_status_to_stderr), fmt=fmt) @app.command("find-batch") @@ -136,7 +136,7 @@ def find_batch_command( if not wait: emit({"batch_id": batch.batch_id, "hint": f"fetch with: discolike email results {batch.batch_id}"}) return - emit(batch.results(timeout=timeout, on_poll=_batch_progress_to_stderr), fmt=fmt) + emit(batch.results(max_wait=timeout, on_poll=_batch_progress_to_stderr), fmt=fmt) @app.command("results") @@ -156,7 +156,7 @@ def results_command( raise typer.BadParameter(f"--kind must be one of: {', '.join(EMAIL_KINDS)}") batch = get_client(ctx).email.batch(batch_id, kind=kind) # type: ignore[arg-type] if wait: - emit(batch.results(timeout=timeout, on_poll=_batch_progress_to_stderr), fmt=fmt) + emit(batch.results(max_wait=timeout, on_poll=_batch_progress_to_stderr), fmt=fmt) return emit(_fetch_batch_snapshot(batch), fmt=fmt) @@ -175,6 +175,6 @@ def job_command( job = get_client(ctx).email.job(job_id) if wait: - emit(job.wait(timeout=timeout, on_poll=_job_status_to_stderr), fmt=fmt) + emit(job.wait(max_wait=timeout, on_poll=_job_status_to_stderr), fmt=fmt) return emit(job.status(), fmt=fmt) diff --git a/packages/discolike-cli/src/discolike_cli/prospecting.py b/packages/discolike-cli/src/discolike_cli/prospecting.py index 4f34aa4..5f7cd07 100644 --- a/packages/discolike-cli/src/discolike_cli/prospecting.py +++ b/packages/discolike-cli/src/discolike_cli/prospecting.py @@ -138,7 +138,7 @@ def _wait_answering( ) -> ProspectingRun: deadline = time.monotonic() + timeout while True: - run = client.prospecting.wait(run_id, timeout=_remaining(deadline), poll_interval=poll_interval) + run = client.prospecting.wait(run_id, max_wait=_remaining(deadline), poll_interval=poll_interval) if not _at_checkpoint(run): return run pause = _pause(run, _latest_question(client, run)) diff --git a/packages/discolike-cli/src/discolike_cli/queries.py b/packages/discolike-cli/src/discolike_cli/queries.py index bd35625..8c569fe 100644 --- a/packages/discolike-cli/src/discolike_cli/queries.py +++ b/packages/discolike-cli/src/discolike_cli/queries.py @@ -2,7 +2,7 @@ import csv import json -from enum import Enum +from enum import StrEnum from pathlib import Path import typer @@ -25,7 +25,7 @@ app = typer.Typer(help="Manage saved queries and exclusion lists for reusable targeting.") -class SaveResultsAction(str, Enum): +class SaveResultsAction(StrEnum): discover = "discover" segment = "segment" contacts = "contacts" diff --git a/packages/discolike-cli/tests/test_output.py b/packages/discolike-cli/tests/test_output.py index fe6950f..0cbd44a 100644 --- a/packages/discolike-cli/tests/test_output.py +++ b/packages/discolike-cli/tests/test_output.py @@ -235,8 +235,8 @@ def __init__(self, *, task_id: str, task_family: str, final: _FakeJobStatus) -> self._final = final self.wait_calls: list[dict[str, object]] = [] - def wait(self, *, timeout: float, on_poll: Callable[[_FakeJobStatus], None] | None = None) -> _FakeJobStatus: - self.wait_calls.append({"timeout": timeout}) + def wait(self, *, max_wait: float, on_poll: Callable[[_FakeJobStatus], None] | None = None) -> _FakeJobStatus: + self.wait_calls.append({"max_wait": max_wait}) if on_poll is not None: on_poll(_FakeJobStatus(progress=50, results=None)) return self._final @@ -260,7 +260,7 @@ def test_run_job_with_wait_emits_final_results_and_progress(capsys: pytest.Captu captured = capsys.readouterr() assert json.loads(captured.out) == [{"domain": "acme.com"}] assert "progress: 50%" in captured.err - assert job.wait_calls == [{"timeout": 30.0}] + assert job.wait_calls == [{"max_wait": 30.0}] def test_run_job_with_wait_emits_full_status_when_no_results(capsys: pytest.CaptureFixture[str]) -> None: diff --git a/packages/discolike-testkit/pyproject.toml b/packages/discolike-testkit/pyproject.toml index 4534e5f..cb21394 100644 --- a/packages/discolike-testkit/pyproject.toml +++ b/packages/discolike-testkit/pyproject.toml @@ -7,7 +7,7 @@ name = "discolike-testkit" version = "0.0.0" description = "Shared test helpers for the discolike workspace. Not published." license = "MIT" -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ "discolike", "httpx2>=2.9", diff --git a/packages/discolike/README.md b/packages/discolike/README.md index 6c4ec3f..d25de6a 100644 --- a/packages/discolike/README.md +++ b/packages/discolike/README.md @@ -10,7 +10,7 @@ For the terminal, see [`discolike-cli`](https://pypi.org/project/discolike-cli/) pip install discolike ``` -Requires Python 3.10+. +Requires Python 3.11+. ## Authentication diff --git a/packages/discolike/pyproject.toml b/packages/discolike/pyproject.toml index 17b53d8..9737da0 100644 --- a/packages/discolike/pyproject.toml +++ b/packages/discolike/pyproject.toml @@ -9,7 +9,7 @@ description = "Official Python SDK for the DiscoLike API" readme = "README.md" license = "MIT" license-files = ["LICENSE"] -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [{ name = "DiscoLike", email = "support@discolike.com" }] dependencies = [ "authlib>=1.8.0", @@ -22,7 +22,6 @@ classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", diff --git a/packages/discolike/src/discolike/_email.py b/packages/discolike/src/discolike/_email.py index 27bc013..242373d 100644 --- a/packages/discolike/src/discolike/_email.py +++ b/packages/discolike/src/discolike/_email.py @@ -5,9 +5,9 @@ from collections.abc import Callable from typing import Any from typing import Literal +from typing import assert_never import pydantic -from typing_extensions import assert_never from discolike._exceptions import JobFailedError from discolike._exceptions import JobTimeoutError @@ -137,11 +137,11 @@ def status(self) -> EmailJobResult: def wait( self, *, - timeout: float = DEFAULT_WAIT_TIMEOUT_SECONDS, + max_wait: float = DEFAULT_WAIT_TIMEOUT_SECONDS, poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, on_poll: Callable[[EmailJobResult], None] | None = None, ) -> EnumerationOutput | ValidationOutput: - deadline = time.monotonic() + timeout + deadline = time.monotonic() + max_wait while True: current = self.status() if on_poll is not None: @@ -153,7 +153,7 @@ def wait( raise JobFailedError(f"email {self.kind} job completed without a result", payload=current.to_dict()) return current.result if time.monotonic() >= deadline: - raise _timeout_error("Email job", self.job_id, timeout) + raise _timeout_error("Email job", self.job_id, max_wait) time.sleep(poll_interval) @@ -172,11 +172,11 @@ async def status(self) -> EmailJobResult: async def wait( self, *, - timeout: float = DEFAULT_WAIT_TIMEOUT_SECONDS, + max_wait: float = DEFAULT_WAIT_TIMEOUT_SECONDS, poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, on_poll: Callable[[EmailJobResult], None] | None = None, ) -> EnumerationOutput | ValidationOutput: - deadline = time.monotonic() + timeout + deadline = time.monotonic() + max_wait while True: current = await self.status() if on_poll is not None: @@ -188,7 +188,7 @@ async def wait( raise JobFailedError(f"email {self.kind} job completed without a result", payload=current.to_dict()) return current.result if time.monotonic() >= deadline: - raise _timeout_error("Email job", self.job_id, timeout) + raise _timeout_error("Email job", self.job_id, max_wait) await asyncio.sleep(poll_interval) @@ -203,11 +203,11 @@ def __init__(self, transport: Transport, *, batch_id: str, kind: EmailKind) -> N def results( self, *, - timeout: float = DEFAULT_WAIT_TIMEOUT_SECONDS, + max_wait: float = DEFAULT_WAIT_TIMEOUT_SECONDS, poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, on_poll: Callable[[EmailBatchResults], None] | None = None, ) -> EmailBatchResults: - deadline = time.monotonic() + timeout + deadline = time.monotonic() + max_wait while True: raw = self._transport.request("GET", f"/email/batch/{self.batch_id}/results").json() parsed = _decode_batch_results(self.kind, raw) @@ -216,7 +216,7 @@ def results( if _batch_is_done(raw): return parsed if time.monotonic() >= deadline: - raise _timeout_error("Email batch", self.batch_id, timeout) + raise _timeout_error("Email batch", self.batch_id, max_wait) time.sleep(poll_interval) @@ -231,11 +231,11 @@ def __init__(self, transport: AsyncTransport, *, batch_id: str, kind: EmailKind) async def results( self, *, - timeout: float = DEFAULT_WAIT_TIMEOUT_SECONDS, + max_wait: float = DEFAULT_WAIT_TIMEOUT_SECONDS, poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, on_poll: Callable[[EmailBatchResults], None] | None = None, ) -> EmailBatchResults: - deadline = time.monotonic() + timeout + deadline = time.monotonic() + max_wait while True: response = await self._transport.request("GET", f"/email/batch/{self.batch_id}/results") raw = response.json() @@ -245,5 +245,5 @@ async def results( if _batch_is_done(raw): return parsed if time.monotonic() >= deadline: - raise _timeout_error("Email batch", self.batch_id, timeout) + raise _timeout_error("Email batch", self.batch_id, max_wait) await asyncio.sleep(poll_interval) diff --git a/packages/discolike/src/discolike/_jobs.py b/packages/discolike/src/discolike/_jobs.py index b3f9355..71371ed 100644 --- a/packages/discolike/src/discolike/_jobs.py +++ b/packages/discolike/src/discolike/_jobs.py @@ -42,7 +42,7 @@ class JobStatus(DiscolikeModel): # model's built-in search only; on a BYOS run read search_provider instead. estimated_cost: float | None = None cost_metadata: dict[str, dict[str, Any]] | None = None - title_validation: Literal["llm", "none"] | None = None + title_validation: Literal["llm", "none"] | str | None = None class Job: @@ -62,11 +62,11 @@ def cancel(self) -> None: def wait( self, *, - timeout: float = DEFAULT_WAIT_TIMEOUT_SECONDS, + max_wait: float = DEFAULT_WAIT_TIMEOUT_SECONDS, poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, on_poll: Callable[[JobStatus], None] | None = None, ) -> JobStatus: - deadline = time.monotonic() + timeout + deadline = time.monotonic() + max_wait while True: current = self.status() if on_poll is not None: @@ -77,7 +77,7 @@ def wait( return current if time.monotonic() >= deadline: raise JobTimeoutError( - f"Task {self.task_id} did not finish within {timeout:.0f}s — it is still running " + f"Task {self.task_id} did not finish within {max_wait:.0f}s — it is still running " f"server-side; call wait() again to resume or check status() later" ) time.sleep(poll_interval) @@ -102,11 +102,11 @@ async def cancel(self) -> None: async def wait( self, *, - timeout: float = DEFAULT_WAIT_TIMEOUT_SECONDS, + max_wait: float = DEFAULT_WAIT_TIMEOUT_SECONDS, poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, on_poll: Callable[[JobStatus], None] | None = None, ) -> JobStatus: - deadline = time.monotonic() + timeout + deadline = time.monotonic() + max_wait while True: current = await self.status() if on_poll is not None: @@ -117,7 +117,7 @@ async def wait( return current if time.monotonic() >= deadline: raise JobTimeoutError( - f"Task {self.task_id} did not finish within {timeout:.0f}s — it is still running " + f"Task {self.task_id} did not finish within {max_wait:.0f}s — it is still running " f"server-side; call wait() again to resume or check status() later" ) await asyncio.sleep(poll_interval) diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index f87896d..9938dc1 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -24,10 +24,11 @@ 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" -] -ProspectingStage = Literal["plan", "discover", "validate", "contacts", "generate", "verify", "segment"] +# Response enums stay open (`| str`) so a value the platform adds later never fails parsing in released SDKs. +ProspectingStatus = ( + Literal["drafting", "proposed", "queued", "running", "needs_input", "completed", "failed", "cancelled"] | str +) +ProspectingStage = Literal["plan", "discover", "validate", "contacts", "generate", "verify", "segment"] | str class ProspectingPlan(DiscolikeModel): @@ -38,11 +39,27 @@ class ProspectingPlan(DiscolikeModel): issues: list[str] = Field(default_factory=list) +class ProspectingRunBrief(DiscolikeModel): + brief: str + domains: list[str] | None = None + company_names: list[str] | None = None + exclude_domains: list[str] | None = None + target_companies: int | None = None + contacts_per_company: int | None = None + max_candidates: int | None = None + max_actions: int | None = None + validation_integration_id: str | None = None + contact_integration_id: str | None = None + search_provider_id: str | None = None + segment: bool | None = None + checkpoints: Literal["ask", "auto"] | str | None = None + + class ProspectingEvent(DiscolikeModel): seq: int created_at: datetime stage: str | None = None - kind: Literal["queued", "decision", "started", "progress", "result", "stopped", "warning"] + kind: Literal["queued", "decision", "started", "progress", "result", "stopped", "warning"] | str message: str data: dict[str, Any] | None = None @@ -50,8 +67,8 @@ class ProspectingEvent(DiscolikeModel): class ProspectingMessage(DiscolikeModel): seq: int created_at: datetime - role: Literal["user", "agent"] - kind: Literal["text", "plan", "milestone", "question", "ack", "error"] + role: Literal["user", "agent"] | str + kind: Literal["text", "plan", "milestone", "question", "ack", "error"] | str content: str data: dict[str, Any] | None = None @@ -60,7 +77,7 @@ class ProspectingInFlight(DiscolikeModel): stage: ProspectingStage items: int plan_version: int - state: Literal["dispatching", "running"] + state: Literal["dispatching", "running"] | str started_at: datetime @@ -70,7 +87,7 @@ class ProspectingRunSummary(DiscolikeModel): title: str | None = None stop_reason: str | None = None stage: str | None = None - brief: str = Field(max_length=200) + brief: str target_companies: int contacts_per_company: int qualified_companies: int = 0 @@ -87,7 +104,7 @@ class ProspectingRun(DiscolikeModel): phase: str | None = None created_at: datetime updated_at: datetime - brief: ProspectingBrief + brief: ProspectingRunBrief target_companies: int contacts_per_company: int plan: ProspectingPlan | None = None @@ -147,10 +164,10 @@ 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 +def _deadline(max_wait: float, poll_interval: float) -> float: + if not math.isfinite(max_wait) or max_wait <= 0 or not math.isfinite(poll_interval) or poll_interval < 5: + raise ValueError("max_wait must be finite and positive; poll_interval must be finite and at least 5 seconds") + return time.monotonic() + max_wait class ProspectingResource(SyncAPIResource): @@ -203,7 +220,7 @@ 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: + def wait(self, run_id: str | UUID, *, max_wait: float = 3600, poll_interval: float = 5) -> ProspectingRun: """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. @@ -213,7 +230,7 @@ def wait(self, run_id: str | UUID, *, timeout: float = 3600, poll_interval: floa then wait again. Timeout stops local polling only. Fetch subsequent pages with get(). """ - deadline = _deadline(timeout, poll_interval) + deadline = _deadline(max_wait, poll_interval) while True: run = self.get(run_id) if run.status in WAIT_STATUSES: @@ -275,12 +292,12 @@ 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: + async def wait(self, run_id: str | UUID, *, max_wait: float = 3600, poll_interval: float = 5) -> ProspectingRun: """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) + deadline = _deadline(max_wait, poll_interval) while True: run = await self.get(run_id) if run.status in WAIT_STATUSES: diff --git a/packages/discolike/src/discolike/signup.py b/packages/discolike/src/discolike/signup.py index d15b15d..fc73943 100644 --- a/packages/discolike/src/discolike/signup.py +++ b/packages/discolike/src/discolike/signup.py @@ -78,7 +78,7 @@ def signup( last_name: str, agent: str | None = None, base_url: str = DEFAULT_BASE_URL, - timeout: float = DEFAULT_TIMEOUT_SECONDS, + request_timeout: float = DEFAULT_TIMEOUT_SECONDS, http_client: httpx2.Client | None = None, allow_new_email: bool = False, ) -> SignupResult: @@ -87,7 +87,7 @@ def signup( first_name = validate_name(first_name, field="first_name") last_name = validate_name(last_name, field="last_name") _check_email_change(email, allow_new_email) - client = http_client or httpx2.Client(timeout=timeout) + client = http_client or httpx2.Client(timeout=request_timeout) try: response = client.post( _signup_url(base_url), @@ -111,14 +111,14 @@ async def async_signup( last_name: str, agent: str | None = None, base_url: str = DEFAULT_BASE_URL, - timeout: float = DEFAULT_TIMEOUT_SECONDS, + request_timeout: float = DEFAULT_TIMEOUT_SECONDS, http_client: httpx2.AsyncClient | None = None, allow_new_email: bool = False, ) -> SignupResult: first_name = validate_name(first_name, field="first_name") last_name = validate_name(last_name, field="last_name") _check_email_change(email, allow_new_email) - client = http_client or httpx2.AsyncClient(timeout=timeout) + client = http_client or httpx2.AsyncClient(timeout=request_timeout) try: response = await client.post( _signup_url(base_url), diff --git a/packages/discolike/tests/test_contract_registry.py b/packages/discolike/tests/test_contract_registry.py index 106dfcf..354288b 100644 --- a/packages/discolike/tests/test_contract_registry.py +++ b/packages/discolike/tests/test_contract_registry.py @@ -104,8 +104,7 @@ def test_check_models_reports_a_type_change(): 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)" + "ProspectingRun: field 'chat_closed' has type boolean but spec schema 'ProspectingRunResponse' declares string" ] @@ -118,8 +117,8 @@ def test_check_models_reports_an_array_item_type_change(): 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')" + "ProspectingRun: field 'saved_query_ids' has type array of string but spec schema " + "'ProspectingRunResponse' declares array of integer" ] @@ -161,17 +160,38 @@ def test_check_models_passes_a_nullable_field_expressed_via_openapi_nullable_fla assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] -def test_check_models_reports_a_field_the_spec_marks_required_but_the_sdk_does_not(): +def test_check_models_accepts_a_field_the_spec_marks_required_but_the_sdk_keeps_optional(): 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" + assert check_contract.check_models(spec, {"ProspectingRunResponse": ProspectingRun}) == [] + + +def test_check_models_accepts_an_sdk_field_nullable_where_the_spec_is_not(): + check_contract = _load_check_contract() + from discolike.resources.companies import ExtractResult + + spec = { + "components": { + "schemas": {"ExtractResponse": {"properties": {"text": {"type": "string"}, "language": {"type": "string"}}}} + } + } + assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] + + +def test_check_models_reports_a_spec_field_turning_nullable_under_a_non_null_sdk_field(): + check_contract = _load_check_contract() + from discolike.resources.prospecting import ProspectingRun + + schema = _spec_schema_for(ProspectingRun) + schema["properties"]["chat_closed"] = {"anyOf": [{"type": "boolean"}, {"type": "null"}]} + spec = {"components": {"schemas": {"ProspectingRunResponse": schema}}} + assert check_contract.check_models(spec, {"ProspectingRunResponse": ProspectingRun}) == [ + "ProspectingRun: field 'chat_closed' has type boolean but spec schema " + "'ProspectingRunResponse' declares boolean | null" ] @@ -380,8 +400,7 @@ def test_check_models_still_reports_a_ref_to_a_different_type(): 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)" + "ProspectingRun: field 'status' has type string but spec schema 'ProspectingRunResponse' declares integer" ] diff --git a/packages/discolike/tests/test_email.py b/packages/discolike/tests/test_email.py index 682b7f2..0b4a5a5 100644 --- a/packages/discolike/tests/test_email.py +++ b/packages/discolike/tests/test_email.py @@ -122,7 +122,7 @@ def test_verify_batch_results_polls_and_decodes_validation_output(make_client: C with make_client(handler) as client: batch = client.email.batch("b-2", kind="verify") - results = batch.results(timeout=60.0, poll_interval=1.0) + results = batch.results(max_wait=60.0, poll_interval=1.0) assert results.batch_id == "b-2" assert results.total == 1 @@ -179,7 +179,7 @@ def test_find_batch_results_decodes_enumeration_output_and_failed_item(make_clie ) with make_client(handler) as client: - results = client.email.batch("b-1", kind="find").results(timeout=60.0, poll_interval=1.0) + results = client.email.batch("b-1", kind="find").results(max_wait=60.0, poll_interval=1.0) assert results.total == 2 assert results.failed == 1 @@ -238,7 +238,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: job = client.email.find(FindEmailRequest(first_name="Grace", last_name="Hopper", domain="navy.mil")) assert isinstance(job, EmailJob) assert job.job_id == "j-9" - output = job.wait(timeout=60.0, poll_interval=1.0) + output = job.wait(max_wait=60.0, poll_interval=1.0) assert seen["path"] == "/v1/email/find" assert seen["body"] == {"first_name": "Grace", "last_name": "Hopper", "domain": "navy.mil"} @@ -279,7 +279,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: job = client.email.find(FindEmailRequest(first_name="No", last_name="One", domain="void.dev")) with pytest.raises(JobFailedError, match="boom"): - job.wait(timeout=60.0) + job.wait(max_wait=60.0) def test_job_reattaches_without_http_call(make_client: ClientFactory) -> None: @@ -352,7 +352,7 @@ async def test_verify_batch_async_results_decodes(make_async_client: AsyncClient async with make_async_client(handler) as client: batch = client.email.batch("b-async2", kind="verify") - results = await batch.results(timeout=60.0, poll_interval=1.0) + results = await batch.results(max_wait=60.0, poll_interval=1.0) item = results.results[0] assert isinstance(item.result, ValidationOutput) @@ -392,7 +392,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: async with make_async_client(handler) as client: job = await client.email.find(FindEmailRequest(first_name="Ada", last_name="Lovelace", domain="acme.com")) assert isinstance(job, AsyncEmailJob) - output = await job.wait(timeout=60.0, poll_interval=1.0) + output = await job.wait(max_wait=60.0, poll_interval=1.0) assert isinstance(output, EnumerationOutput) assert output.result is not None @@ -454,7 +454,7 @@ def test_batch_can_still_re_attach_to_a_verify_batch(make_client: ClientFactory) with make_client(handler) as client: batch = client.email.batch("b-9", kind="verify") - results = batch.results(timeout=60.0, poll_interval=1.0) + results = batch.results(max_wait=60.0, poll_interval=1.0) assert isinstance(batch, EmailBatch) assert batch.batch_id == "b-9" @@ -495,7 +495,7 @@ def test_server_reported_kind_overrides_handle_kind(make_client: ClientFactory) ) with make_client(handler) as client: - results = client.email.batch("b-10", kind="find").results(timeout=60.0, poll_interval=1.0) + results = client.email.batch("b-10", kind="find").results(max_wait=60.0, poll_interval=1.0) assert isinstance(results.results[0].result, ValidationOutput) @@ -524,7 +524,7 @@ def test_job_wait_honors_server_reported_kind(make_client: ClientFactory) -> Non ValidationOutput. The server-reported kind picks the model, so wait() must not gate the result on the handle's kind.""" with make_client(_verify_job_handler) as client: - output = client.email.job("j-11").wait(timeout=60.0, poll_interval=1.0) + output = client.email.job("j-11").wait(max_wait=60.0, poll_interval=1.0) assert isinstance(output, ValidationOutput) assert output.reason == "no_mailbox" @@ -532,7 +532,7 @@ def test_job_wait_honors_server_reported_kind(make_client: ClientFactory) -> Non async def test_job_wait_honors_server_reported_kind_async(make_async_client: AsyncClientFactory) -> None: async with make_async_client(_verify_job_handler) as client: - output = await client.email.job("j-11").wait(timeout=60.0, poll_interval=1.0) + output = await client.email.job("j-11").wait(max_wait=60.0, poll_interval=1.0) assert isinstance(output, ValidationOutput) assert output.reason == "no_mailbox" diff --git a/packages/discolike/tests/test_jobs.py b/packages/discolike/tests/test_jobs.py index cba2095..5d38905 100644 --- a/packages/discolike/tests/test_jobs.py +++ b/packages/discolike/tests/test_jobs.py @@ -50,7 +50,7 @@ def test_wait_polls_to_completion() -> None: {"status": "completed", "progress": 100, "results": [{"domain": "a.com"}]}, ] ) - final = make_job(handler).wait(timeout=60.0, poll_interval=1.0) + final = make_job(handler).wait(max_wait=60.0, poll_interval=1.0) assert final.status == "completed" assert final.results == [{"domain": "a.com"}] @@ -104,7 +104,7 @@ def test_status_without_title_validation_defaults_to_none() -> None: def test_wait_failed_raises() -> None: handler = _status_sequence([{"status": "failed", "progress": 100, "result": "LLM exploded"}]) with pytest.raises(JobFailedError, match="LLM exploded"): - make_job(handler).wait(timeout=60.0) + make_job(handler).wait(max_wait=60.0) def test_wait_timeout(monkeypatch) -> None: @@ -117,7 +117,7 @@ def mock_monotonic(): monkeypatch.setattr(jobs_module.time, "monotonic", mock_monotonic) handler = _status_sequence([{"status": "in_progress", "progress": 1}]) with pytest.raises(JobTimeoutError, match="t-1"): - make_job(handler).wait(timeout=50.0) + make_job(handler).wait(max_wait=50.0) def test_cancel_issues_delete() -> None: @@ -138,5 +138,5 @@ async def test_async_job_wait() -> None: ) http = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url=BASE) transport = AsyncTransport(api_key_auth("k"), base_url=BASE, timeout=5.0, max_retries=0, http_client=http) - final = await AsyncJob(transport, task_family=FAMILY_DISCOGEN, task_id="t-1").wait(timeout=60.0) + final = await AsyncJob(transport, task_family=FAMILY_DISCOGEN, task_id="t-1").wait(max_wait=60.0) assert final.status == "completed" diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index 46fc7c7..4a5674f 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -65,7 +65,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=payload()) with make_client(handler) as client, pytest.raises(JobTimeoutError): - client.prospecting.wait(RUN_ID, timeout=1) + client.prospecting.wait(RUN_ID, max_wait=1) assert seen == ["GET"] @@ -278,3 +278,18 @@ def test_a_run_carries_its_saved_companies_list(make_client: ClientFactory) -> N 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) + + +def test_a_run_from_a_newer_server_still_parses(make_client: ClientFactory) -> None: + newer = payload("archived") | { + "brief": {"brief": "Dentists", "contacts_per_company": 50, "target_companies": 50_000, "checkpoints": "review"}, + "messages": [message_payload() | {"kind": "chart", "role": "system"}], + } + with make_client(lambda request: httpx2.Response(200, json=newer)) as client: + run = client.prospecting.get(RUN_ID) + assert (run.status, run.brief.contacts_per_company, run.messages[0].kind) == ("archived", 50, "chart") + + +def test_a_summary_keeps_a_brief_longer_than_the_list_preview() -> None: + summary = module.ProspectingRunSummary.model_validate(summary_payload() | {"brief": "x" * 500}) + assert len(summary.brief) == 500 diff --git a/pyproject.toml b/pyproject.toml index f5aa784..71335cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ exclude = [".venv"] line-length = 120 respect-gitignore = true indent-width = 4 -target-version = "py310" +target-version = "py311" src = ["packages/discolike/src", "packages/discolike/tests", "packages/discolike-cli/src", "packages/discolike-cli/tests", "packages/discolike-testkit/src"] [tool.ruff.lint] diff --git a/scripts/check_contract.py b/scripts/check_contract.py index 2449b88..ec1abd4 100644 --- a/scripts/check_contract.py +++ b/scripts/check_contract.py @@ -61,8 +61,6 @@ } # 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,6 +179,7 @@ def check(spec: dict, routes: list[RouteEntry]) -> list[str]: TYPE_INFO_KEYS = {"type", "anyOf", "oneOf", "$ref", "nullable"} +FieldShape = tuple[frozenset[str], str | None] def _resolved_type(node: dict, *, root: dict) -> str | None: @@ -219,10 +218,19 @@ def _has_type_info(prop: dict) -> bool: return bool(prop.keys() & TYPE_INFO_KEYS) -def _field_shape(prop: dict, *, root: dict) -> tuple[frozenset[str], str | None]: +def _field_shape(prop: dict, *, root: dict) -> FieldShape: return (_field_types(prop, root=root), _item_type(prop, root=root)) +def _describe(shape: FieldShape) -> str: + types = " | ".join(sorted(shape[0])) + return types if shape[1] is None else f"{types} of {shape[1]}" + + +def _accepts(*, model_shape: FieldShape, spec_shape: FieldShape) -> bool: + return spec_shape[0] <= model_shape[0] and spec_shape[1] == model_shape[1] + + def check_models(spec: dict, mirrored: dict[str, type[DiscolikeModel]] | None = None) -> list[str]: mismatches: list[str] = [] schemas = spec.get("components", {}).get("schemas", {}) @@ -247,21 +255,12 @@ def check_models(spec: dict, mirrored: dict[str, type[DiscolikeModel]] | None = # 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. + # Only drift that breaks parsing is flagged: an SDK looser than the spec still reads every response. 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 - OPTIONAL_RESPONSE_FIELDS.get(model.__name__, frozenset()) - ) - ) 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((set(model_schema.get("required", [])) - set(schema["required"])) & spec_fields) ) for field in sorted(model_fields & spec_fields): @@ -270,10 +269,10 @@ def check_models(spec: dict, mirrored: dict[str, type[DiscolikeModel]] | None = continue model_shape = _field_shape(model_properties.get(field, {}), root=model_schema) spec_shape = _field_shape(spec_prop, root=spec) - if model_shape != spec_shape: + if not _accepts(model_shape=model_shape, spec_shape=spec_shape): mismatches.append( - f"{model.__name__}: field '{field}' has type {model_shape} but spec schema " - f"'{schema_name}' declares {spec_shape}" + f"{model.__name__}: field '{field}' has type {_describe(model_shape)} but spec schema " + f"'{schema_name}' declares {_describe(spec_shape)}" ) return mismatches diff --git a/scripts/gen_requests.py b/scripts/gen_requests.py index 0c4efb0..de2be57 100644 --- a/scripts/gen_requests.py +++ b/scripts/gen_requests.py @@ -50,7 +50,7 @@ "--enum-field-as-literal", "all", "--target-python-version", - "3.10", + "3.11", "--use-double-quotes", "--disable-timestamp", "--formatters", diff --git a/uv.lock b/uv.lock index c07afc0..20e8092 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,10 @@ version = 1 revision = 3 -requires-python = ">=3.10" +requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten'", "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')", ] @@ -38,7 +38,6 @@ name = "anyio" version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -69,15 +68,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/c6/6f124bcfbbfb20fba22c939b4e43a06dccfc0e1ca20e5634ca573cb1e271/authlib-1.8.0-py2.py3-none-any.whl", hash = "sha256:88aebbd9af6757e14e912d5dc007ae1dc1f3e27e3b2152ce7c552ee2c3b3c121", size = 260804, upload-time = "2026-08-30T12:12:33.162Z" }, ] -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, -] - [[package]] name = "black" version = "26.5.1" @@ -89,16 +79,9 @@ dependencies = [ { name = "pathspec" }, { name = "platformdirs" }, { name = "pytokens" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" }, - { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" }, - { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" }, - { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" }, { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, @@ -131,18 +114,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" }, - { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" }, - { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, - { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, - { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" }, - { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" }, - { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, - { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, - { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, - { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, - { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, @@ -256,7 +227,6 @@ version = "50.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } wheels = [ @@ -320,7 +290,6 @@ dependencies = [ { name = "jinja2" }, { name = "pydantic" }, { name = "pyyaml" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/d2/187e101ed143f5280079a57c7c7e3d702609826aadd85c095fd5a8267af9/datamodel_code_generator-0.75.1.tar.gz", hash = "sha256:d2053a889161af7e495a3eedb92b68c81848e6ea7988d467c602e5b2da30f018", size = 2116531, upload-time = "2026-08-24T19:16:37.119Z" } wheels = [ @@ -424,18 +393,6 @@ requires-dist = [ { name = "pytest", specifier = ">=8" }, ] -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - [[package]] name = "genson" version = "1.4.0" @@ -575,17 +532,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, @@ -750,20 +696,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, - { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, - { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, - { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, - { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, - { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, - { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, - { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, @@ -872,12 +804,10 @@ version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -889,7 +819,6 @@ name = "pytest-asyncio" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -904,11 +833,6 @@ version = "0.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, - { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, - { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, @@ -943,15 +867,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, @@ -1048,60 +963,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] -[[package]] -name = "tomli" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, -] - [[package]] name = "truststore" version = "0.10.4" From 3db4c24ac2a3704b258fdf6ed66c9f5fa3291b32 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Mon, 28 Sep 2026 16:00:46 -0700 Subject: [PATCH 16/21] Add seeded prospecting and segment selection to the SDK and CLI Customers go in as their own field so the platform can group them into segments and find lookalikes of each; approval can narrow which segments are expanded and turn results grouping on or off. The CLI reads the customer list from a file, the same format as --domains-file. The spec now carries every property that was pending in the generator (geo filters, approve checkpoints), so those overlays are gone and the merge path is tested with a stand-in entry instead. --- CHANGELOG.md | 3 +- packages/discolike-cli/README.md | 1 + .../src/discolike_cli/prospecting.py | 23 ++++++- .../tests/test_prospecting_cli.py | 61 +++++++++++++++++++ .../src/discolike/_generated/requests.py | 32 ++++++---- .../src/discolike/resources/prospecting.py | 10 ++- packages/discolike/tests/test_gen_requests.py | 19 +++--- packages/discolike/tests/test_prospecting.py | 40 ++++++++++++ scripts/gen_requests.py | 56 +---------------- 9 files changed, 165 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4acce1c..52d6fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,9 @@ - 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 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 (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`. - 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: seeded prospecting. `ProspectingBrief.customer_domains` (up to 1,000, not with `domains` or `company_names`) groups your customers into segments and finds lookalikes of each; `ProspectingApproveRequest.seed_segments` picks which of the plan card's segments to expand, by the ids listed in the latest `kind="plan"` message's `data.seed_segments` (`None` keeps the default, every segment but Other) and `segment` turns results grouping on or off. `ProspectingRun.brief.customer_domains` reads them back and `brief.selected_seed_segments` the chosen segment ids; the new stage value `seed_segment` appears while customers are grouped. CLI: `prospecting start --customers-file`, `prospecting approve --seed-segment N` (repeatable) and `--segment/--no-segment`. - 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/README.md b/packages/discolike-cli/README.md index 29f4729..296675f 100644 --- a/packages/discolike-cli/README.md +++ b/packages/discolike-cli/README.md @@ -53,6 +53,7 @@ discolike bulk contacts --domains-file companies.csv --per-company 10 --summary ```bash discolike prospecting start --brief "Find 100 US logistics companies and 3 operations directors each" --idempotency-key logistics-1 +discolike prospecting start --brief "Find lookalikes of our customers and their CTOs" --customers-file customers.csv --idempotency-key seeded-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 diff --git a/packages/discolike-cli/src/discolike_cli/prospecting.py b/packages/discolike-cli/src/discolike_cli/prospecting.py index 5f7cd07..64624b3 100644 --- a/packages/discolike-cli/src/discolike_cli/prospecting.py +++ b/packages/discolike-cli/src/discolike_cli/prospecting.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import pathlib import sys import time from typing import Any @@ -20,6 +21,7 @@ from discolike.requests import ProspectingMessageRequest from discolike.resources.prospecting import ProspectingMessage from discolike.resources.prospecting import ProspectingRun +from discolike_cli._inputs import read_domains_file from discolike_cli._output import NEEDS_INPUT_EXIT_CODE from discolike_cli._output import build_request from discolike_cli._output import emit @@ -29,6 +31,14 @@ app = typer.Typer(help="Run managed prospecting; processing and provider charges apply.") AUTO_HELP = "Never pause to ask: a poor pilot is sharpened once and the run continues; it only stops if the re-pilot fit is still under 20%. Default: pause at checkpoints." +CUSTOMERS_FILE_HELP = ( + "Your customers (CSV with a 'domain' column, or one per line): grouped into segments, lookalikes of each are " + "found. Not with --domain or --company-name." +) +SEED_SEGMENT_HELP = ( + "A customer segment id from the plan card to expand (repeatable); omitted keeps the card's selection." +) +RESULTS_SEGMENT_HELP = "Group the finished results into segments; omitted keeps the brief's setting." 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 = ( @@ -158,6 +168,7 @@ def start_command( 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)."), + customers_file: pathlib.Path | None = typer.Option(None, "--customers-file", help=CUSTOMERS_FILE_HELP), exclude_domain: list[str] | None = typer.Option(None, "--exclude-domain", help="Suppressed domain (repeatable)."), target_companies: int | None = typer.Option( None, @@ -195,6 +206,7 @@ def start_command( brief=brief, domains=domain, company_names=company_name, + customer_domains=read_domains_file(customers_file) if customers_file is not None else None, exclude_domains=exclude_domain, target_companies=target_companies, contacts_per_company=contacts_per_company, @@ -279,6 +291,8 @@ def approve_command( run_id: str = typer.Argument(...), plan_version: int = typer.Option(..., "--plan-version", min=1), auto: bool = typer.Option(False, "--auto", help=AUTO_HELP), + seed_segment: list[int] | None = typer.Option(None, "--seed-segment", help=SEED_SEGMENT_HELP), + segment: bool | None = typer.Option(None, "--segment/--no-segment", help=RESULTS_SEGMENT_HELP), ) -> None: """Approve the reviewed plan version and start research.""" from discolike_cli.main import get_client @@ -287,7 +301,14 @@ def approve_command( get_client(ctx).prospecting.approve( run_id, build_request( - ProspectingApproveRequest, {"plan_version": plan_version, "checkpoints": _checkpoints(auto=auto)} + ProspectingApproveRequest, + _merge_params( + None, + plan_version=plan_version, + checkpoints=_checkpoints(auto=auto), + seed_segments=seed_segment, + segment=segment, + ), ), ) ) diff --git a/packages/discolike-cli/tests/test_prospecting_cli.py b/packages/discolike-cli/tests/test_prospecting_cli.py index 50da943..4a4a778 100644 --- a/packages/discolike-cli/tests/test_prospecting_cli.py +++ b/packages/discolike-cli/tests/test_prospecting_cli.py @@ -2,6 +2,7 @@ import json from collections.abc import Callable +from pathlib import Path import httpx2 import pytest @@ -345,3 +346,63 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert result.exit_code == 0, result.output assert json.loads(result.stdout)["stop_reason"] == "question" + + +def test_start_reads_customers_from_a_file(install_build_client: Callable[[Handler], None], tmp_path: Path) -> None: + customers = tmp_path / "customers.csv" + customers.write_text("domain\nAcme.com\nwww.example.com\nacme.com\n") + bodies = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + bodies.append(json.loads(request.content)) + return httpx2.Response(202, json=run_payload("drafting")) + + install_build_client(handler) + result = runner.invoke( + app, + [ + "prospecting", + "start", + "--brief", + "Lookalikes of our customers and their CTOs", + "--idempotency-key", + "seeded", + "--customers-file", + str(customers), + ], + ) + assert result.exit_code == 0, result.output + assert bodies[0]["customer_domains"] == ["acme.com", "example.com"] + + +def test_approve_sends_the_chosen_segments_and_results_grouping( + install_build_client: Callable[[Handler], None], +) -> 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) + chosen = runner.invoke( + app, + [ + "prospecting", + "approve", + RUN_ID, + "--plan-version", + "1", + "--seed-segment", + "1", + "--seed-segment", + "2", + "--segment", + ], + ) + default = runner.invoke(app, ["prospecting", "approve", RUN_ID, "--plan-version", "1"]) + assert chosen.exit_code == default.exit_code == 0, chosen.output + default.output + assert bodies == [ + {"plan_version": 1, "checkpoints": "ask", "seed_segments": [1, 2], "segment": True}, + {"plan_version": 1, "checkpoints": "ask"}, + ] diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index ae6683b..16780ec 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -322,14 +322,14 @@ class ContactsSearchParams(DiscolikeRequest): summary: Annotated[ str | None, Field( - description="Filter by profile summary text (semantic search).", + description="Match the profile summary text: a contact matches when its summary contains every word of any one term, in any order. Words are separate terms; quote a multi-word term to keep it together, prefix + to require a term.", title="Summary", ), ] = None negate_summary: Annotated[ str | None, Field( - description="Exclude contacts matching this summary description.", + description="Exclude contacts whose profile summary contains every word of any one term, in any order. Quote a multi-word term to keep it together.", title="Negate Summary", ), ] = None @@ -751,14 +751,14 @@ class ContactsCountParams(DiscolikeRequest): summary: Annotated[ str | None, Field( - description="Filter by profile summary text (semantic search).", + description="Match the profile summary text: a contact matches when its summary contains every word of any one term, in any order. Words are separate terms; quote a multi-word term to keep it together, prefix + to require a term.", title="Summary", ), ] = None negate_summary: Annotated[ str | None, Field( - description="Exclude contacts matching this summary description.", + description="Exclude contacts whose profile summary contains every word of any one term, in any order. Quote a multi-word term to keep it together.", title="Negate Summary", ), ] = None @@ -1212,14 +1212,14 @@ class ContactFilters(DiscolikeRequest): summary: Annotated[ str | None, Field( - description="Filter by profile summary text (semantic search).", + description="Match the profile summary text: a contact matches when its summary contains every word of any one term, in any order. Words are separate terms; quote a multi-word term to keep it together, prefix + to require a term.", title="Summary", ), ] = None negate_summary: Annotated[ str | None, Field( - description="Exclude contacts matching this summary description.", + description="Exclude contacts whose profile summary contains every word of any one term, in any order. Quote a multi-word term to keep it together.", title="Negate Summary", ), ] = None @@ -2915,11 +2915,6 @@ class ProspectingListParams(DiscolikeRequest): ] = None -class ProspectingApproveRequest(DiscolikeRequest): - plan_version: Annotated[int, Field(ge=1, title="Plan Version")] - checkpoints: Literal["ask", "auto"] | None = None - - class ProspectingMessageRequest(DiscolikeRequest): text: Annotated[str, Field(max_length=4000, min_length=1, title="Text")] @@ -2928,6 +2923,14 @@ class ProspectingBrief(DiscolikeRequest): brief: Annotated[str, Field(max_length=4000, min_length=10, title="Brief")] domains: Annotated[list[str] | None, Field(max_length=1000, title="Domains")] = None company_names: Annotated[list[str] | None, Field(max_length=100, title="Company Names")] = None + customer_domains: Annotated[ + list[str] | None, + Field( + description="Your customers' domains, grouped into segments; lookalikes of each are found. Not with domains.", + max_length=1000, + title="Customer Domains", + ), + ] = None exclude_domains: Annotated[list[str] | None, Field(max_length=1000, title="Exclude Domains")] = None target_companies: Annotated[int | None, Field(ge=1, le=10000, title="Target Companies")] = 25 contacts_per_company: Annotated[int | None, Field(ge=1, le=5, title="Contacts Per Company")] = 2 @@ -3193,3 +3196,10 @@ class BulkContactMatchRequest(DiscolikeRequest): int | None, Field(description="Maximum matches per query (1-20).", ge=1, le=20, title="Limit"), ] = 10 + + +class ProspectingApproveRequest(DiscolikeRequest): + plan_version: Annotated[int, Field(ge=1, title="Plan Version")] + checkpoints: Literal["ask", "auto"] | None = None + seed_segments: Annotated[list[int] | None, Field(min_length=1, title="Seed Segments")] = None + segment: Annotated[bool | None, Field(title="Segment")] = None diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index 9938dc1..5a636d0 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -28,7 +28,9 @@ ProspectingStatus = ( Literal["drafting", "proposed", "queued", "running", "needs_input", "completed", "failed", "cancelled"] | str ) -ProspectingStage = Literal["plan", "discover", "validate", "contacts", "generate", "verify", "segment"] | str +ProspectingStage = ( + Literal["plan", "discover", "validate", "contacts", "generate", "verify", "segment", "seed_segment"] | str +) class ProspectingPlan(DiscolikeModel): @@ -43,6 +45,7 @@ class ProspectingRunBrief(DiscolikeModel): brief: str domains: list[str] | None = None company_names: list[str] | None = None + customer_domains: list[str] | None = None exclude_domains: list[str] | None = None target_companies: int | None = None contacts_per_company: int | None = None @@ -53,6 +56,7 @@ class ProspectingRunBrief(DiscolikeModel): search_provider_id: str | None = None segment: bool | None = None checkpoints: Literal["ask", "auto"] | str | None = None + selected_seed_segments: list[int] | None = None class ProspectingEvent(DiscolikeModel): @@ -185,6 +189,8 @@ def approve(self, run_id: str | UUID, request: ProspectingApproveRequest) -> Pro """Approve the reviewed plan version; repeating the same approval is safe. `checkpoints` on the request overrides the brief's mode; None keeps it. + `seed_segments` picks which customer segments a seeded plan expands (None keeps the card's default); + `segment` turns results grouping on or off. """ response = self._transport.request("POST", _path(run_id) + "/approve", json_body=request.to_wire()) return ProspectingRun.model_validate(response.json()) @@ -258,6 +264,8 @@ async def approve(self, run_id: str | UUID, request: ProspectingApproveRequest) """Approve the reviewed plan version; repeating the same approval is safe. `checkpoints` on the request overrides the brief's mode; None keeps it. + `seed_segments` picks which customer segments a seeded plan expands (None keeps the card's default); + `segment` turns results grouping on or off. """ response = await self._transport.request("POST", _path(run_id) + "/approve", json_body=request.to_wire()) return ProspectingRun.model_validate(response.json()) diff --git a/packages/discolike/tests/test_gen_requests.py b/packages/discolike/tests/test_gen_requests.py index 0d97ca4..b2c4327 100644 --- a/packages/discolike/tests/test_gen_requests.py +++ b/packages/discolike/tests/test_gen_requests.py @@ -214,14 +214,17 @@ def test_normalize_schema_strips_scalar_item_constraints(gen) -> None: } -def test_apply_overlays_fills_only_what_the_spec_is_missing(gen) -> None: +def test_apply_overlays_fills_only_what_the_spec_is_missing(gen, monkeypatch, capsys) -> None: + pending = {"lat": {"type": "number", "description": "pending"}, "radius": {"type": "string"}} + monkeypatch.setattr(gen, "PENDING_PROPERTIES", {"DiscoverParams": pending}) kept = {"DiscoverParams": {"type": "object", "properties": {"lat": {"type": "number", "description": "deployed"}}}} properties = gen.apply_overlays(kept=kept)["DiscoverParams"]["properties"] assert properties["lat"] == {"type": "number", "description": "deployed"} - assert properties["radius"]["type"] == "string" + assert properties["radius"] == {"type": "string"} assert properties["sub_industry"]["items"] == {"type": "string"} + assert "drop it from PENDING_PROPERTIES" in capsys.readouterr().out def test_apply_overlays_pins_sub_industry_over_a_spec_enum(gen) -> None: @@ -233,18 +236,12 @@ 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"}}}, - } +def test_apply_overlays_pins_the_brief_checkpoint_modes_the_spec_hides(gen) -> None: + kept = {"ProspectingBrief": {"type": "object", "properties": {"brief": {"type": "string"}}}} - overlaid = gen.apply_overlays(kept=kept) + brief = gen.apply_overlays(kept=kept)["ProspectingBrief"]["properties"]["checkpoints"] - 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: diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index 4a5674f..0948355 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -293,3 +293,43 @@ def test_a_run_from_a_newer_server_still_parses(make_client: ClientFactory) -> N def test_a_summary_keeps_a_brief_longer_than_the_list_preview() -> None: summary = module.ProspectingRunSummary.model_validate(summary_payload() | {"brief": "x" * 500}) assert len(summary.brief) == 500 + + +def test_approve_sends_a_segment_selection_only_when_given(make_client: ClientFactory) -> None: + bodies: list[dict] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + bodies.append(json.loads(request.content)) + return httpx2.Response(202, json=payload()) + + with make_client(handler) as client: + client.prospecting.approve(RUN_ID, ProspectingApproveRequest(plan_version=1)) + client.prospecting.approve( + RUN_ID, ProspectingApproveRequest(plan_version=1, seed_segments=[1, 2], segment=True) + ) + assert bodies == [{"plan_version": 1}, {"plan_version": 1, "seed_segments": [1, 2], "segment": True}] + with pytest.raises(ValidationError): + ProspectingApproveRequest(plan_version=1, seed_segments=[]) + + +def test_a_seeded_brief_sends_and_reads_back_its_customers(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + customers = ["acme.com", "example.com"] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + body = payload("drafting") + return httpx2.Response( + 202, + json=body | {"brief": body["brief"] | {"customer_domains": customers, "selected_seed_segments": [1]}}, + ) + + with make_client(handler) as client: + run = client.prospecting.start( + ProspectingBrief(brief="Lookalikes of our customers and their CTOs", customer_domains=customers), + idempotency_key="seeded", + ) + assert json.loads(seen[0].content)["customer_domains"] == customers + assert (run.brief.customer_domains, run.brief.selected_seed_segments) == (customers, [1]) + with pytest.raises(ValidationError): + ProspectingBrief(brief="Lookalikes of our customers", customer_domains=["acme.com"] * 1001) diff --git a/scripts/gen_requests.py b/scripts/gen_requests.py index de2be57..236c5b6 100644 --- a/scripts/gen_requests.py +++ b/scripts/gen_requests.py @@ -66,56 +66,6 @@ "parent category is unambiguous adds that parent to the category filter. Call list-industry-categories for " "the label list." ) -_RADIUS_DESCRIPTION = ( - "Search radius around lat/lon: a number optionally suffixed with km or mi (50km, 30mi, 50). A bare number is " - "kilometres. Defaults to 50km when lat/lon are supplied, maximum 1000km." -) -_SHAPE_UNION_SENTENCE = ( - "Repeatable: every geo circle, every bbox and the lat/lon/radius centre are OR'd together, up to 10 " - "shapes in total." -) -_GEO_DESCRIPTION = ( - "A circular area to search, written lat,lon or lat,lon,radius (30.27,-97.74 or 30.27,-97.74,30mi). The " - "radius is a number optionally suffixed with km or mi, a bare number meaning kilometres; it defaults to " - "50km and may not exceed 1000km. " + _SHAPE_UNION_SENTENCE -) -_BBOX_DESCRIPTION = ( - "Bounding box as min_lat,min_lon,max_lat,max_lon. Longitudes may wrap the antimeridian (min_lon above " - "max_lon). " + _SHAPE_UNION_SENTENCE -) -_GEO_PROPERTIES: dict[str, dict[str, Any]] = { - "lat": { - "type": "number", - "minimum": -90.0, - "maximum": 90.0, - "nullable": True, - "description": "Latitude of the search centre. Must be supplied together with lon.", - "title": "Lat", - }, - "lon": { - "type": "number", - "minimum": -180.0, - "maximum": 180.0, - "nullable": True, - "description": "Longitude of the search centre. Must be supplied together with lat.", - "title": "Lon", - }, - "radius": {"type": "string", "nullable": True, "description": _RADIUS_DESCRIPTION, "title": "Radius"}, - "geo": { - "type": "array", - "items": {"type": "string"}, - "nullable": True, - "description": _GEO_DESCRIPTION, - "title": "Geo", - }, - "bbox": { - "type": "array", - "items": {"type": "string"}, - "nullable": True, - "description": _BBOX_DESCRIPTION, - "title": "Bbox", - }, -} _SUB_INDUSTRY_PROPERTIES: dict[str, dict[str, Any]] = { "sub_industry": { "type": "array", @@ -148,11 +98,7 @@ # 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}}, -} +PENDING_PROPERTIES: dict[str, dict[str, dict[str, Any]]] = {} # 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 From 39185f611726135ef7a713d1300211e2271d1ea2 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Mon, 28 Sep 2026 16:36:01 -0700 Subject: [PATCH 17/21] Default ProspectingBrief to 1000 companies / 1 contact each The agentic prospecting default is moving from 25 companies and 2 contacts per company to 1000 and 1, matching the platform's own fallback when a brief omits both and planning can't infer a count. Unset fields are still dropped from the wire, so this only changes what an unset ProspectingBrief reports locally. _generated/requests.py is hand-edited ahead of a spec regen: the platform OpenAPI spec still carries the old defaults, so the next `gen_requests.py` run reproduces this once that spec change lands. Also fixes stale doc claims from the task-17 review: the CHANGELOG's seed_segments=None description said it keeps "every segment but Other" when it actually keeps the run's current selection (the plan card's default, or whatever a chat edit set) -- the approve() docstrings already had this right. --- CHANGELOG.md | 3 ++- README.md | 2 +- packages/discolike-cli/src/discolike_cli/prospecting.py | 4 ++-- packages/discolike/src/discolike/_generated/requests.py | 4 ++-- packages/discolike/tests/test_prospecting.py | 1 + 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52d6fc1..2e4dcd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,10 @@ - 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`. - 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: seeded prospecting. `ProspectingBrief.customer_domains` (up to 1,000, not with `domains` or `company_names`) groups your customers into segments and finds lookalikes of each; `ProspectingApproveRequest.seed_segments` picks which of the plan card's segments to expand, by the ids listed in the latest `kind="plan"` message's `data.seed_segments` (`None` keeps the default, every segment but Other) and `segment` turns results grouping on or off. `ProspectingRun.brief.customer_domains` reads them back and `brief.selected_seed_segments` the chosen segment ids; the new stage value `seed_segment` appears while customers are grouped. CLI: `prospecting start --customers-file`, `prospecting approve --seed-segment N` (repeatable) and `--segment/--no-segment`. +- SDK/CLI: seeded prospecting. `ProspectingBrief.customer_domains` (up to 1,000, not with `domains` or `company_names`) groups your customers into segments and finds lookalikes of each; `ProspectingApproveRequest.seed_segments` picks which of the plan card's segments to expand, by the ids listed in the latest `kind="plan"` message's `data.seed_segments` (`None` keeps the run's current selection, the plan card's default or whatever a chat edit set) and `segment` turns results grouping on or off. `ProspectingRun.brief.customer_domains` reads them back and `brief.selected_seed_segments` the chosen segment ids; the new stage value `seed_segment` appears while customers are grouped. CLI: `prospecting start --customers-file`, `prospecting approve --seed-segment N` (repeatable) and `--segment/--no-segment`. - 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. +- **Breaking:** `ProspectingBrief` defaults change from `target_companies=25` / `contacts_per_company=2` to `target_companies=1000` / `contacts_per_company=1`, matching the API's own fallback when a brief omits both and planning can't infer a count. Unset fields are still omitted from the wire, so this only changes what an unset `ProspectingBrief` reports locally; the server-side fallback moves too. CLI `prospecting start --target-companies`/`--contacts-per-company` help text updated to match. ## 0.4.1 (2026-09-23) diff --git a/README.md b/README.md index e480030..ea025ff 100644 --- a/README.md +++ b/README.md @@ -462,6 +462,6 @@ The async client exposes the same methods with `await`. Starts and messages requ Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and checks again, then stops with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief. A search drifting off target is dropped, a run 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. +Initial planning extracts company counts and contacts per company from the brief. Omitted settings keep that inference available, falling back to 1,000 companies and 1 contact per company. Explicit settings, including explicit defaults, override the text. Targets support 1–10,000 companies and 1–5 contacts per company. Candidate and action caps default to automatic (`0`); explicit maxima are 100,000 candidates and 10,000 actions. Result pages support up to 500 rows; recent-run lists support up to 50. Approved runs expose a stable `saved_query_id` for saved results. 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/src/discolike_cli/prospecting.py b/packages/discolike-cli/src/discolike_cli/prospecting.py index 64624b3..fa668cb 100644 --- a/packages/discolike-cli/src/discolike_cli/prospecting.py +++ b/packages/discolike-cli/src/discolike_cli/prospecting.py @@ -175,14 +175,14 @@ def start_command( "--target-companies", min=1, max=10000, - help="Override the company count in the brief; otherwise inferred, default 25.", + help="Override the company count in the brief; otherwise inferred, default 1000.", ), contacts_per_company: int | None = typer.Option( None, "--contacts-per-company", min=1, max=5, - help="Override contacts per company; otherwise inferred, default 2.", + help="Override contacts per company; otherwise inferred, default 1.", ), max_candidates: int | None = typer.Option( None, "--max-candidates", min=0, max=100000, help="Candidate work cap; omitted or 0 means automatic." diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 16780ec..84e3b5c 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -2932,8 +2932,8 @@ class ProspectingBrief(DiscolikeRequest): ), ] = None exclude_domains: Annotated[list[str] | None, Field(max_length=1000, title="Exclude Domains")] = None - target_companies: Annotated[int | None, Field(ge=1, le=10000, title="Target Companies")] = 25 - contacts_per_company: Annotated[int | None, Field(ge=1, le=5, title="Contacts Per Company")] = 2 + target_companies: Annotated[int | None, Field(ge=1, le=10000, title="Target Companies")] = 1000 + contacts_per_company: Annotated[int | None, Field(ge=1, le=5, title="Contacts Per Company")] = 1 max_candidates: Annotated[int | None, Field(ge=0, le=100000, title="Max Candidates")] = 0 max_actions: Annotated[int | None, Field(ge=0, le=10000, title="Max Actions")] = 0 validation_integration_id: Annotated[str | None, Field(max_length=128, title="Validation Integration Id")] = None diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index 0948355..ea53f77 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -221,6 +221,7 @@ def test_request_defaults_preserve_explicit_quantity_intent() -> None: explicit = ProspectingBrief( brief=implicit.brief, target_companies=25, contacts_per_company=2, max_actions=0, max_candidates=0 ) + assert (implicit.target_companies, implicit.contacts_per_company) == (1000, 1) assert implicit.to_wire() == {"brief": implicit.brief} assert explicit.to_wire() == { "brief": implicit.brief, From dbec50fbbab5e40133c634e7c2c8f75078dac4aa Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Mon, 28 Sep 2026 22:08:57 -0700 Subject: [PATCH 18/21] Document that reply_pending holds through results grouping The API now keeps reply_pending true while a "segment these" request on a finished run is in flight, which can last more than an hour. Callers waiting on the flag need to know the acknowledgement is not the end, and that the flag expires after about 90 minutes if grouping dies. --- CHANGELOG.md | 1 + README.md | 2 +- packages/discolike-cli/README.md | 2 +- packages/discolike/src/discolike/resources/prospecting.py | 7 ++++++- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e4dcd2..0cb8247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - 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. - **Breaking:** `ProspectingBrief` defaults change from `target_companies=25` / `contacts_per_company=2` to `target_companies=1000` / `contacts_per_company=1`, matching the API's own fallback when a brief omits both and planning can't infer a count. Unset fields are still omitted from the wire, so this only changes what an unset `ProspectingBrief` reports locally; the server-side fallback moves too. CLI `prospecting start --target-companies`/`--contacts-per-company` help text updated to match. +- SDK/CLI: `ProspectingRun.reply_pending` also stays true while a "segment these" request on a finished run is in progress, past the acknowledgement and until the segments message is posted (which can take more than an hour); it clears on its own after about 90 minutes if grouping stops without an outcome. Code that waits for it to go false should allow for that. No SDK code change. ## 0.4.1 (2026-09-23) diff --git a/README.md b/README.md index ea025ff..6407881 100644 --- a/README.md +++ b/README.md @@ -458,7 +458,7 @@ page = client.prospecting.get( 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()`. +`wait()` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`. Inspect `status`, `stop_reason`, and `error`; completion does not guarantee the target was reached. A local timeout stops polling only. Partial results remain available. Use `get()` with event and message cursors to receive the agent's reply after sending a message; `reply_pending` indicates a pending reply. After a "segment these" request on a finished run it stays true past the acknowledgement until the segments message is posted, which can take more than an hour, and clears on its own after about 90 minutes if grouping stops without an outcome. A `needs_input` question can be answered with `message()`. Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and checks again, then stops with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief. A search drifting off target is dropped, a run short of candidates finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`). The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. diff --git a/packages/discolike-cli/README.md b/packages/discolike-cli/README.md index 296675f..7f17a91 100644 --- a/packages/discolike-cli/README.md +++ b/packages/discolike-cli/README.md @@ -63,7 +63,7 @@ discolike prospecting status RUN_ID --events-after 12 --messages-after 8 --limit 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`. +`wait` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`. A timeout stops polling only. Inspect status and stop reason; completion does not guarantee full coverage. Message replies arrive through `status --messages-after`; follow `next_message_seq` and `reply_pending`. A "segment these" request keeps `reply_pending` true until the segments message is posted, which can take more than an hour. If grouping stops without an outcome, the flag clears on its own after about 90 minutes. `start` and `approve` default to pausing at checkpoints, like the web chat: a pilot check on large lists (`pilot`), a search drifting off target (`tail_quality`), candidates running out short of the target (`short`), and the target being met (`target_reached`). Pass `--auto` to never pause; a poor pilot is then sharpened once and the run continues with a notice, stopping with `pilot_failed` only if the re-pilot fit is still under 20%. If sharpening itself fails, the run continues on the original criteria. On a terminal, `wait` shows the question, any sample companies and numbered replies at a checkpoint, sends your pick or your own text, and keeps waiting. Without a terminal, or with `--no-input`, it prints the run on stdout, a `needs_input` envelope (`message`, `stop_reason`, `suggested_replies`, `sample`) on stderr, and exits 7; answer with `prospecting message --text ""` and run `wait` again. diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index 5a636d0..a1c104a 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -141,7 +141,12 @@ class ProspectingRun(DiscolikeModel): in_flight: list[ProspectingInFlight] = Field(default_factory=list) fit_companies: int = 0 emails_found: int = 0 - reply_pending: bool = False + reply_pending: bool = Field( + default=False, + description="The agent still owes a reply to a user message. After a 'segment these' request on a " + "finished run it stays true past the acknowledgement until the segments message is posted, which can " + "take more than an hour, and clears on its own after about 90 minutes if grouping stops without an outcome.", + ) chat_closed: bool = Field( default=False, description="The chat was closed for off-topic use: every new message gets the same fixed reply. " From 635f2cf611830521f1bcb42ed05de3c827e1674e Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Tue, 29 Sep 2026 09:21:51 -0700 Subject: [PATCH 19/21] Collapse the unreleased changelog to what a release adds Prospecting has never shipped, so its within-release iterations, server-only notes and a 'breaking' default change to an unreleased model were noise; one entry describes it. The daily conversation limit is a silent guardrail and stays out of public notes. --- CHANGELOG.md | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cb8247..283efd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,23 +3,9 @@ ## Unreleased - **Breaking:** Python 3.10 is no longer supported (end of life October 2026); the SDK and CLI require Python 3.11+. -- **Breaking:** the polling deadline on `Job.wait()`, `EmailJob.wait()`, `EmailBatch.results()` and `prospecting.wait()` (sync and async) is renamed from `timeout=` to `max_wait=`, and `signup()` / `async_signup()` take `request_timeout=` instead of `timeout=`. Behavior is unchanged: `max_wait` still raises `JobTimeoutError` while the work keeps running server-side. The client constructor and `with_options(timeout=...)` keep their name. The CLI's `--timeout` flag is unchanged. -- SDK: response enums are open. `JobStatus.title_validation` and the prospecting `status`, `stage`, message `role`/`kind`, event `kind` and in-flight `state` accept values a newer API adds instead of failing to parse, and `ProspectingRun.brief` is a `ProspectingRunBrief` without the request-side limits, so a run started with a higher server-side cap still parses. Compare against the documented values and treat anything else as unknown. -- 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. -- 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 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`. -- 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: seeded prospecting. `ProspectingBrief.customer_domains` (up to 1,000, not with `domains` or `company_names`) groups your customers into segments and finds lookalikes of each; `ProspectingApproveRequest.seed_segments` picks which of the plan card's segments to expand, by the ids listed in the latest `kind="plan"` message's `data.seed_segments` (`None` keeps the run's current selection, the plan card's default or whatever a chat edit set) and `segment` turns results grouping on or off. `ProspectingRun.brief.customer_domains` reads them back and `brief.selected_seed_segments` the chosen segment ids; the new stage value `seed_segment` appears while customers are grouped. CLI: `prospecting start --customers-file`, `prospecting approve --seed-segment N` (repeatable) and `--segment/--no-segment`. - -- 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. -- **Breaking:** `ProspectingBrief` defaults change from `target_companies=25` / `contacts_per_company=2` to `target_companies=1000` / `contacts_per_company=1`, matching the API's own fallback when a brief omits both and planning can't infer a count. Unset fields are still omitted from the wire, so this only changes what an unset `ProspectingBrief` reports locally; the server-side fallback moves too. CLI `prospecting start --target-companies`/`--contacts-per-company` help text updated to match. -- SDK/CLI: `ProspectingRun.reply_pending` also stays true while a "segment these" request on a finished run is in progress, past the acknowledgement and until the segments message is posted (which can take more than an hour); it clears on its own after about 90 minutes if grouping stops without an outcome. Code that waits for it to go false should allow for that. No SDK code change. +- **Breaking:** the polling deadline on `Job.wait()`, `EmailJob.wait()` and `EmailBatch.results()` (sync and async) is renamed from `timeout=` to `max_wait=`, and `signup()` / `async_signup()` take `request_timeout=` instead of `timeout=`. Behavior is unchanged; the client constructor, `with_options(timeout=...)` and the CLI's `--timeout` keep their names. +- SDK: response enums such as `JobStatus` are open, so a value a newer API adds parses instead of failing. Compare against the documented values and treat anything else as unknown. +- SDK/CLI: managed prospecting. `client.prospecting` starts, reads, lists, approves, messages, cancels and waits on runs (sync and async), with typed plans, chat, progress and saved contact lists (`saved_query_ids`, split into parts for large results). `ProspectingBrief.checkpoints="ask"` pauses the run at a pilot, a drifting search, a shortfall or the target for you to answer; `customer_domains` groups your customers into segments and finds lookalikes of each, picked on approval with `seed_segments`. The CLI adds `prospecting start/status/list/approve/message/cancel/wait`, asks at checkpoints on a terminal, and exits 7 with the question on stderr under `--no-input`. ## 0.4.1 (2026-09-23) From 50c938527b9c09801f63ca58b68351e044038ecd Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Tue, 29 Sep 2026 14:13:50 -0700 Subject: [PATCH 20/21] Cancel prospecting runs via POST /cancel, add delete The API now uses DELETE on a run to remove the chat (cancelling it first if active), so cancel() moved to POST .../cancel and delete() wraps the new behaviour. No released version has prospecting. --- CHANGELOG.md | 2 +- README.md | 3 +- .../tests/test_prospecting_cli.py | 4 +- packages/discolike/README.md | 2 +- .../src/discolike/resources/prospecting.py | 24 ++++++-- packages/discolike/tests/test_gen_requests.py | 2 +- packages/discolike/tests/test_prospecting.py | 57 ++++++++++++++++++- 7 files changed, 81 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 283efd6..f86b0f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - **Breaking:** Python 3.10 is no longer supported (end of life October 2026); the SDK and CLI require Python 3.11+. - **Breaking:** the polling deadline on `Job.wait()`, `EmailJob.wait()` and `EmailBatch.results()` (sync and async) is renamed from `timeout=` to `max_wait=`, and `signup()` / `async_signup()` take `request_timeout=` instead of `timeout=`. Behavior is unchanged; the client constructor, `with_options(timeout=...)` and the CLI's `--timeout` keep their names. - SDK: response enums such as `JobStatus` are open, so a value a newer API adds parses instead of failing. Compare against the documented values and treat anything else as unknown. -- SDK/CLI: managed prospecting. `client.prospecting` starts, reads, lists, approves, messages, cancels and waits on runs (sync and async), with typed plans, chat, progress and saved contact lists (`saved_query_ids`, split into parts for large results). `ProspectingBrief.checkpoints="ask"` pauses the run at a pilot, a drifting search, a shortfall or the target for you to answer; `customer_domains` groups your customers into segments and finds lookalikes of each, picked on approval with `seed_segments`. The CLI adds `prospecting start/status/list/approve/message/cancel/wait`, asks at checkpoints on a terminal, and exits 7 with the question on stderr under `--no-input`. +- SDK/CLI: managed prospecting. `client.prospecting` starts, reads, lists, approves, messages, cancels, deletes and waits on runs (sync and async), with typed plans, chat, progress and saved contact lists (`saved_query_ids`, split into parts for large results). `ProspectingBrief.checkpoints="ask"` pauses the run at a pilot, a drifting search, a shortfall or the target for you to answer; `customer_domains` groups your customers into segments and finds lookalikes of each, picked on approval with `seed_segments`. `cancel()` stops a run and keeps it; `delete()` cancels an active run, then removes it from `list()` and `get()` (charges already incurred stay). The CLI adds `prospecting start/status/list/approve/message/cancel/wait`, asks at checkpoints on a terminal, and exits 7 with the question on stderr under `--no-input`. ## 0.4.1 (2026-09-23) diff --git a/README.md b/README.md index 6407881..6f6051b 100644 --- a/README.md +++ b/README.md @@ -453,7 +453,8 @@ page = client.prospecting.get( run.run_id, ProspectingGetParams(offset=0, limit=100, events_after=run.next_event_seq, messages_after=run.next_message_seq), ) -# client.prospecting.cancel(run.run_id) +# client.prospecting.cancel(run.run_id) # Stop the run; it and its results stay readable. +# client.prospecting.delete(run.run_id) # Cancel if active, then remove it from list() and get(). ``` 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. diff --git a/packages/discolike-cli/tests/test_prospecting_cli.py b/packages/discolike-cli/tests/test_prospecting_cli.py index 4a4a778..bdd8b3f 100644 --- a/packages/discolike-cli/tests/test_prospecting_cli.py +++ b/packages/discolike-cli/tests/test_prospecting_cli.py @@ -79,7 +79,7 @@ def test_cancel_and_wait_preserve_terminal_outcomes(install_build_client: Callab methods = [] def handler(request: httpx2.Request) -> httpx2.Response: - methods.append(request.method) + methods.append((request.method, request.url.path)) return httpx2.Response(200, json=run_payload("cancelled")) install_build_client(handler) @@ -87,7 +87,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: 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"] + assert methods == [("POST", f"/v1/prospecting/runs/{RUN_ID}/cancel"), ("GET", f"/v1/prospecting/runs/{RUN_ID}")] def test_start_omits_unspecified_quantities_and_caps(install_build_client: Callable[[Handler], None]) -> None: diff --git a/packages/discolike/README.md b/packages/discolike/README.md index d25de6a..cdf916c 100644 --- a/packages/discolike/README.md +++ b/packages/discolike/README.md @@ -126,7 +126,7 @@ Two errors are specific to the native engine: a 400 `ValidationError` when the I `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`. +`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. `cancel(run_id)` stops a run and returns it; `delete(run_id)` cancels an active run, then removes it so `list()` omits it and `get()` raises `NotFoundError` (charges already incurred stay). The async client has the same methods with `await`. Import these request models from `discolike.requests`. Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and continues the run with a notice. Only if the re-pilot fit is still under 20% does it stop with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief in that case. If sharpening itself fails, the run continues on the original criteria. A search drifting off target is dropped, a run short of candidates finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`). The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index a1c104a..5c3c215 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -226,11 +226,19 @@ def get(self, run_id: str | UUID, params: ProspectingGetParams | None = None) -> 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}") + @api_route("POST", "/prospecting/runs/{run_id}/cancel") def cancel(self, run_id: str | UUID) -> ProspectingRun: - response = self._transport.request("DELETE", _path(run_id)) + response = self._transport.request("POST", _path(run_id) + "/cancel") return ProspectingRun.model_validate(response.json()) + @api_route("DELETE", "/prospecting/runs/{run_id}") + def delete(self, run_id: str | UUID) -> None: + """Cancel the run if it is still active, then hide it; charges already incurred stay. + + The run is gone from list() and get() afterwards, which raise NotFoundError for it. + """ + self._transport.request("DELETE", _path(run_id)) + def wait(self, run_id: str | UUID, *, max_wait: float = 3600, poll_interval: float = 5) -> ProspectingRun: """Return the first page when approval, input, or a terminal outcome is ready. @@ -300,11 +308,19 @@ async def get(self, run_id: str | UUID, params: ProspectingGetParams | None = No 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}") + @api_route("POST", "/prospecting/runs/{run_id}/cancel") async def cancel(self, run_id: str | UUID) -> ProspectingRun: - response = await self._transport.request("DELETE", _path(run_id)) + response = await self._transport.request("POST", _path(run_id) + "/cancel") return ProspectingRun.model_validate(response.json()) + @api_route("DELETE", "/prospecting/runs/{run_id}") + async def delete(self, run_id: str | UUID) -> None: + """Cancel the run if it is still active, then hide it; charges already incurred stay. + + The run is gone from list() and get() afterwards, which raise NotFoundError for it. + """ + await self._transport.request("DELETE", _path(run_id)) + async def wait(self, run_id: str | UUID, *, max_wait: float = 3600, poll_interval: float = 5) -> ProspectingRun: """Return the first page on proposed/needs_input/completed/failed/cancelled; inspect status. diff --git a/packages/discolike/tests/test_gen_requests.py b/packages/discolike/tests/test_gen_requests.py index b2c4327..70ddb27 100644 --- a/packages/discolike/tests/test_gen_requests.py +++ b/packages/discolike/tests/test_gen_requests.py @@ -274,5 +274,5 @@ def test_compare_prints_a_diff_and_returns_one_on_drift(gen, capsys) -> None: def test_collect_routes_covers_every_stamped_sync_route(gen) -> None: routes = gen.collect_routes() - assert len(routes) == 54 + assert len(routes) == 55 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 ea53f77..bc01277 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -10,6 +10,7 @@ import discolike.resources.prospecting as module from discolike import CHECKPOINT_STOP_REASONS from discolike import JobTimeoutError +from discolike import NotFoundError from discolike.requests import ProspectingApproveRequest from discolike.requests import ProspectingBrief from discolike.requests import ProspectingGetParams @@ -82,10 +83,12 @@ async def test_async_start_wait_and_cancel(make_async_client: AsyncClientFactory seen = [] def handler(request: httpx2.Request) -> httpx2.Response: - seen.append(request.method) + seen.append((request.method, request.url.path)) + if request.url.path.endswith("/cancel"): + return httpx2.Response(200, json=payload("cancelled")) if request.method == "POST": assert request.headers["Idempotency-Key"] == "async-key" - return httpx2.Response(200, json=payload("cancelled" if request.method == "DELETE" else "completed")) + return httpx2.Response(200, json=payload("completed")) async with make_async_client(handler) as client: run = await client.prospecting.start( @@ -93,7 +96,55 @@ 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"] + assert seen == [ + ("POST", "/v1/prospecting/runs"), + ("GET", f"/v1/prospecting/runs/{RUN_ID}"), + ("POST", f"/v1/prospecting/runs/{RUN_ID}/cancel"), + ] + + +def test_cancel_posts_to_the_cancel_route(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json=payload("cancelled")) + + with make_client(handler) as client: + assert client.prospecting.cancel(RUN_ID).status == "cancelled" + assert [(r.method, r.url.path) for r in seen] == [("POST", f"/v1/prospecting/runs/{RUN_ID}/cancel")] + assert seen[0].content == b"" + + +def test_delete_hides_the_run_and_returns_nothing(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + if request.method == "DELETE": + return httpx2.Response(204) + return httpx2.Response(404, json={"detail": "Prospecting run not found"}) + + with make_client(handler) as client: + assert client.prospecting.delete(RUN_ID) is None + with pytest.raises(NotFoundError): + client.prospecting.get(RUN_ID) + assert [(r.method, r.url.path) for r in seen] == [ + ("DELETE", f"/v1/prospecting/runs/{RUN_ID}"), + ("GET", f"/v1/prospecting/runs/{RUN_ID}"), + ] + + +async def test_async_delete_returns_nothing(make_async_client: AsyncClientFactory) -> None: + seen: list[tuple[str, str]] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append((request.method, request.url.path)) + return httpx2.Response(204) + + async with make_async_client(handler) as client: + assert await client.prospecting.delete(RUN_ID) is None + assert seen == [("DELETE", f"/v1/prospecting/runs/{RUN_ID}")] def test_wait_returns_a_proposed_plan(make_client: ClientFactory) -> None: From 312661987ba8e379e48e6589a50bcba93db4f9eb Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Tue, 29 Sep 2026 15:43:54 -0700 Subject: [PATCH 21/21] Add prospecting.rename for PATCH /prospecting/runs/{run_id} The API can now retitle a prospecting chat in any status, so SDK users get the same control the app has. Takes a generated ProspectingRunUpdate like the other request-taking methods and returns the list() summary. No CLI command, matching delete(). Regenerated request models from the dev spec, which also picks up the reworded customer_domains description. --- CHANGELOG.md | 2 +- README.md | 3 +- packages/discolike/README.md | 2 +- .../src/discolike/_generated/requests.py | 6 ++- packages/discolike/src/discolike/requests.py | 2 + .../src/discolike/resources/prospecting.py | 21 +++++++++ packages/discolike/tests/test_gen_requests.py | 2 +- packages/discolike/tests/test_prospecting.py | 44 +++++++++++++++++++ 8 files changed, 77 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f86b0f8..2f060a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - **Breaking:** Python 3.10 is no longer supported (end of life October 2026); the SDK and CLI require Python 3.11+. - **Breaking:** the polling deadline on `Job.wait()`, `EmailJob.wait()` and `EmailBatch.results()` (sync and async) is renamed from `timeout=` to `max_wait=`, and `signup()` / `async_signup()` take `request_timeout=` instead of `timeout=`. Behavior is unchanged; the client constructor, `with_options(timeout=...)` and the CLI's `--timeout` keep their names. - SDK: response enums such as `JobStatus` are open, so a value a newer API adds parses instead of failing. Compare against the documented values and treat anything else as unknown. -- SDK/CLI: managed prospecting. `client.prospecting` starts, reads, lists, approves, messages, cancels, deletes and waits on runs (sync and async), with typed plans, chat, progress and saved contact lists (`saved_query_ids`, split into parts for large results). `ProspectingBrief.checkpoints="ask"` pauses the run at a pilot, a drifting search, a shortfall or the target for you to answer; `customer_domains` groups your customers into segments and finds lookalikes of each, picked on approval with `seed_segments`. `cancel()` stops a run and keeps it; `delete()` cancels an active run, then removes it from `list()` and `get()` (charges already incurred stay). The CLI adds `prospecting start/status/list/approve/message/cancel/wait`, asks at checkpoints on a terminal, and exits 7 with the question on stderr under `--no-input`. +- SDK/CLI: managed prospecting. `client.prospecting` starts, reads, lists, approves, messages, renames, cancels, deletes and waits on runs (sync and async), with typed plans, chat, progress and saved contact lists (`saved_query_ids`, split into parts for large results). `ProspectingBrief.checkpoints="ask"` pauses the run at a pilot, a drifting search, a shortfall or the target for you to answer; `customer_domains` groups your customers into segments and finds lookalikes of each, picked on approval with `seed_segments`. `rename()` retitles a run in any status with `ProspectingRunUpdate(title=...)` and returns its `list()` summary; result lists it already saved keep their names. `cancel()` stops a run and keeps it; `delete()` cancels an active run, then removes it from `list()` and `get()` (charges already incurred stay). The CLI adds `prospecting start/status/list/approve/message/cancel/wait`, asks at checkpoints on a terminal, and exits 7 with the question on stderr under `--no-input`. ## 0.4.1 (2026-09-23) diff --git a/README.md b/README.md index 6f6051b..dd04922 100644 --- a/README.md +++ b/README.md @@ -431,7 +431,7 @@ REST starts in `drafting`, then waits at `proposed` for approval. Review the pla ```python from discolike.requests import ( ProspectingApproveRequest, ProspectingBrief, ProspectingGetParams, - ProspectingListParams, ProspectingMessageRequest, + ProspectingListParams, ProspectingMessageRequest, ProspectingRunUpdate, ) run = client.prospecting.start( @@ -453,6 +453,7 @@ 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.rename(run.run_id, ProspectingRunUpdate(title="Logistics ops leaders")) # Any status. # client.prospecting.cancel(run.run_id) # Stop the run; it and its results stay readable. # client.prospecting.delete(run.run_id) # Cancel if active, then remove it from list() and get(). ``` diff --git a/packages/discolike/README.md b/packages/discolike/README.md index cdf916c..76052d0 100644 --- a/packages/discolike/README.md +++ b/packages/discolike/README.md @@ -126,7 +126,7 @@ Two errors are specific to the native engine: a 400 `ValidationError` when the I `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. `cancel(run_id)` stops a run and returns it; `delete(run_id)` cancels an active run, then removes it so `list()` omits it and `get()` raises `NotFoundError` (charges already incurred stay). The async client has the same methods with `await`. Import these request models from `discolike.requests`. +`wait()` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`; timeout stops local polling only. Use `message(run_id, ProspectingMessageRequest(text="..."), idempotency_key="...")` to steer or answer a question, and `get(run_id, ProspectingGetParams(events_after=..., messages_after=...))` for new events and replies. `list(ProspectingListParams(limit=20))` lists recent organization runs, up to 50. `rename(run_id, ProspectingRunUpdate(title="..."))` retitles a run in any status and returns its `list()` summary (titles are 1-80 characters after trimming; result lists it already saved keep their names). `cancel(run_id)` stops a run and returns it; `delete(run_id)` cancels an active run, then removes it so `list()` omits it and `get()` raises `NotFoundError` (charges already incurred stay). The async client has the same methods with `await`. Import these request models from `discolike.requests`. Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and continues the run with a notice. Only if the re-pilot fit is still under 20% does it stop with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief in that case. If sharpening itself fails, the run continues on the original criteria. A search drifting off target is dropped, a run short of candidates finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`). The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 84e3b5c..c1a38ee 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -2926,7 +2926,7 @@ class ProspectingBrief(DiscolikeRequest): customer_domains: Annotated[ list[str] | None, Field( - description="Your customers' domains, grouped into segments; lookalikes of each are found. Not with domains.", + description="Your customers' domains, grouped into segments; lookalikes of each are found. Cannot be combined with domains or company_names.", max_length=1000, title="Customer Domains", ), @@ -2956,6 +2956,10 @@ class ProspectingGetParams(DiscolikeRequest): messages_after: Annotated[int | None, Field(ge=0, title="Messages After")] = 0 +class ProspectingRunUpdate(DiscolikeRequest): + title: Annotated[str, Field(max_length=80, min_length=1, title="Title")] + + class LLMProviderCreateRequest(DiscolikeRequest): integration_name: Annotated[ str, diff --git a/packages/discolike/src/discolike/requests.py b/packages/discolike/src/discolike/requests.py index 55b173d..39ef20f 100644 --- a/packages/discolike/src/discolike/requests.py +++ b/packages/discolike/src/discolike/requests.py @@ -33,6 +33,7 @@ from discolike._generated.requests import ProspectingGetParams from discolike._generated.requests import ProspectingListParams from discolike._generated.requests import ProspectingMessageRequest +from discolike._generated.requests import ProspectingRunUpdate from discolike._generated.requests import QueriesListParams from discolike._generated.requests import SaveResultsRequest from discolike._generated.requests import SearchProviderRequest @@ -75,6 +76,7 @@ "ProspectingGetParams", "ProspectingListParams", "ProspectingMessageRequest", + "ProspectingRunUpdate", "QueriesListParams", "SaveResultsRequest", "SearchProviderRequest", diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index 5c3c215..d49499e 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -18,6 +18,7 @@ from discolike.requests import ProspectingGetParams from discolike.requests import ProspectingListParams from discolike.requests import ProspectingMessageRequest +from discolike.requests import ProspectingRunUpdate from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route @@ -231,6 +232,16 @@ def cancel(self, run_id: str | UUID) -> ProspectingRun: response = self._transport.request("POST", _path(run_id) + "/cancel") return ProspectingRun.model_validate(response.json()) + @api_route("PATCH", "/prospecting/runs/{run_id}") + def rename(self, run_id: str | UUID, request: ProspectingRunUpdate) -> ProspectingRunSummary: + """Rename the run in any status; returns its summary as list() shows it. + + The server strips the title, which must then be 1-80 characters. A title set before the agent + plans is kept. Result lists the run already saved keep their names. + """ + response = self._transport.request("PATCH", _path(run_id), json_body=request.to_wire()) + return ProspectingRunSummary.model_validate(response.json()) + @api_route("DELETE", "/prospecting/runs/{run_id}") def delete(self, run_id: str | UUID) -> None: """Cancel the run if it is still active, then hide it; charges already incurred stay. @@ -313,6 +324,16 @@ async def cancel(self, run_id: str | UUID) -> ProspectingRun: response = await self._transport.request("POST", _path(run_id) + "/cancel") return ProspectingRun.model_validate(response.json()) + @api_route("PATCH", "/prospecting/runs/{run_id}") + async def rename(self, run_id: str | UUID, request: ProspectingRunUpdate) -> ProspectingRunSummary: + """Rename the run in any status; returns its summary as list() shows it. + + The server strips the title, which must then be 1-80 characters. A title set before the agent + plans is kept. Result lists the run already saved keep their names. + """ + response = await self._transport.request("PATCH", _path(run_id), json_body=request.to_wire()) + return ProspectingRunSummary.model_validate(response.json()) + @api_route("DELETE", "/prospecting/runs/{run_id}") async def delete(self, run_id: str | UUID) -> None: """Cancel the run if it is still active, then hide it; charges already incurred stay. diff --git a/packages/discolike/tests/test_gen_requests.py b/packages/discolike/tests/test_gen_requests.py index 70ddb27..694a1af 100644 --- a/packages/discolike/tests/test_gen_requests.py +++ b/packages/discolike/tests/test_gen_requests.py @@ -274,5 +274,5 @@ def test_compare_prints_a_diff_and_returns_one_on_drift(gen, capsys) -> None: def test_collect_routes_covers_every_stamped_sync_route(gen) -> None: routes = gen.collect_routes() - assert len(routes) == 55 + assert len(routes) == 56 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 bc01277..e4c8e8a 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -16,6 +16,7 @@ from discolike.requests import ProspectingGetParams from discolike.requests import ProspectingListParams from discolike.requests import ProspectingMessageRequest +from discolike.requests import ProspectingRunUpdate from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory from discolike_testkit.prospecting import message_payload @@ -147,6 +148,49 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert seen == [("DELETE", f"/v1/prospecting/runs/{RUN_ID}")] +def test_rename_patches_the_title_and_returns_the_summary(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json=summary_payload() | {"title": "Logistics ops leaders"}) + + with make_client(handler) as client: + summary = client.prospecting.rename(RUN_ID, ProspectingRunUpdate(title=" Logistics ops leaders ")) + assert isinstance(summary, module.ProspectingRunSummary) + assert summary.title == "Logistics ops leaders" + assert [(r.method, r.url.path) for r in seen] == [("PATCH", f"/v1/prospecting/runs/{RUN_ID}")] + assert json.loads(seen[0].content) == {"title": " Logistics ops leaders "} + + +def test_rename_surfaces_a_missing_run(make_client: ClientFactory) -> None: + with ( + make_client(lambda request: httpx2.Response(404, json={"detail": "Prospecting run not found"})) as client, + pytest.raises(NotFoundError), + ): + client.prospecting.rename(RUN_ID, ProspectingRunUpdate(title="Renamed")) + + +def test_rename_rejects_an_overlong_title_before_sending() -> None: + with pytest.raises(ValidationError): + ProspectingRunUpdate(title="x" * 81) + + +async def test_async_rename_patches_the_title(make_async_client: AsyncClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json=summary_payload() | {"title": "Renamed"}) + + async with make_async_client(handler) as client: + summary = await client.prospecting.rename(RUN_ID, ProspectingRunUpdate(title="Renamed")) + assert summary.title == "Renamed" + assert summary.run_id == UUID(RUN_ID) + assert [(r.method, r.url.path) for r in seen] == [("PATCH", f"/v1/prospecting/runs/{RUN_ID}")] + assert json.loads(seen[0].content) == {"title": "Renamed"} + + 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"