From dcc8a43039da122adfe6d426ea26e2ee1b3b7378 Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Fri, 23 Oct 2026 12:28:44 -0500 Subject: [PATCH] feat(operator): thread ids on requests, run ids on results, and the audit run reads The operator facade sends an optional thread_id and every result carries its operation_id, so a caller can read the run back. Regenerated against the API's operator run endpoints: list_operator_threads, list_operator_runs and get_operator_run under the graph audit. Claude-Session: https://claude.ai/code/session_01AZDcFWJRoqZQpZ3ZguTVzu --- .../api/graph_audit/get_operator_run.py | 222 ++++++++++ .../api/graph_audit/list_operator_runs.py | 381 ++++++++++++++++++ .../api/graph_audit/list_operator_threads.py | 269 +++++++++++++ robosystems_client/clients/operator_client.py | 21 +- robosystems_client/models/__init__.py | 24 ++ .../list_operator_runs_status_type_0.py | 10 + robosystems_client/models/operator_request.py | 26 +- .../models/operator_run_detail.py | 308 ++++++++++++++ .../operator_run_detail_history_item.py | 47 +++ .../models/operator_run_detail_result.py | 50 +++ .../models/operator_run_detail_status.py | 10 + .../operator_run_detail_transcript_item.py | 47 +++ .../models/operator_run_list_response.py | 106 +++++ .../models/operator_run_summary.py | 229 +++++++++++ .../models/operator_run_summary_status.py | 10 + .../models/operator_thread_list_response.py | 106 +++++ .../models/operator_thread_summary.py | 121 ++++++ .../operator_thread_summary_last_status.py | 10 + tests/test_facade_wire_shapes.py | 46 +++ tests/test_operator_client.py | 18 + 20 files changed, 2056 insertions(+), 5 deletions(-) create mode 100644 robosystems_client/api/graph_audit/get_operator_run.py create mode 100644 robosystems_client/api/graph_audit/list_operator_runs.py create mode 100644 robosystems_client/api/graph_audit/list_operator_threads.py create mode 100644 robosystems_client/models/list_operator_runs_status_type_0.py create mode 100644 robosystems_client/models/operator_run_detail.py create mode 100644 robosystems_client/models/operator_run_detail_history_item.py create mode 100644 robosystems_client/models/operator_run_detail_result.py create mode 100644 robosystems_client/models/operator_run_detail_status.py create mode 100644 robosystems_client/models/operator_run_detail_transcript_item.py create mode 100644 robosystems_client/models/operator_run_list_response.py create mode 100644 robosystems_client/models/operator_run_summary.py create mode 100644 robosystems_client/models/operator_run_summary_status.py create mode 100644 robosystems_client/models/operator_thread_list_response.py create mode 100644 robosystems_client/models/operator_thread_summary.py create mode 100644 robosystems_client/models/operator_thread_summary_last_status.py diff --git a/robosystems_client/api/graph_audit/get_operator_run.py b/robosystems_client/api/graph_audit/get_operator_run.py new file mode 100644 index 00000000..c680104c --- /dev/null +++ b/robosystems_client/api/graph_audit/get_operator_run.py @@ -0,0 +1,222 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error_response import ErrorResponse +from ...models.http_validation_error import HTTPValidationError +from ...models.operator_run_detail import OperatorRunDetail +from ...types import Response + + +def _get_kwargs( + graph_id: str, + operation_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/v1/graphs/{graph_id}/audit/runs/{operation_id}".format( + graph_id=quote(str(graph_id), safe=""), + operation_id=quote(str(operation_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorResponse | HTTPValidationError | OperatorRunDetail | None: + if response.status_code == 200: + response_200 = OperatorRunDetail.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = ErrorResponse.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = ErrorResponse.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = ErrorResponse.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = ErrorResponse.from_dict(response.json()) + + return response_404 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = ErrorResponse.from_dict(response.json()) + + return response_429 + + if response.status_code == 500: + response_500 = ErrorResponse.from_dict(response.json()) + + return response_500 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorResponse | HTTPValidationError | OperatorRunDetail]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + graph_id: str, + operation_id: str, + *, + client: AuthenticatedClient, +) -> Response[ErrorResponse | HTTPValidationError | OperatorRunDetail]: + """Get Operator Run + + One operator run with its full paper trail: the history it was seeded with, every model call with + the tool calls it made and the results it read, the answer, and the response metadata. Readable by a + graph admin or by the user who made the run. + + Args: + graph_id (str): Graph identifier + operation_id (str): The run's operation id + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | HTTPValidationError | OperatorRunDetail] + """ + + kwargs = _get_kwargs( + graph_id=graph_id, + operation_id=operation_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + graph_id: str, + operation_id: str, + *, + client: AuthenticatedClient, +) -> ErrorResponse | HTTPValidationError | OperatorRunDetail | None: + """Get Operator Run + + One operator run with its full paper trail: the history it was seeded with, every model call with + the tool calls it made and the results it read, the answer, and the response metadata. Readable by a + graph admin or by the user who made the run. + + Args: + graph_id (str): Graph identifier + operation_id (str): The run's operation id + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | HTTPValidationError | OperatorRunDetail + """ + + return sync_detailed( + graph_id=graph_id, + operation_id=operation_id, + client=client, + ).parsed + + +async def asyncio_detailed( + graph_id: str, + operation_id: str, + *, + client: AuthenticatedClient, +) -> Response[ErrorResponse | HTTPValidationError | OperatorRunDetail]: + """Get Operator Run + + One operator run with its full paper trail: the history it was seeded with, every model call with + the tool calls it made and the results it read, the answer, and the response metadata. Readable by a + graph admin or by the user who made the run. + + Args: + graph_id (str): Graph identifier + operation_id (str): The run's operation id + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | HTTPValidationError | OperatorRunDetail] + """ + + kwargs = _get_kwargs( + graph_id=graph_id, + operation_id=operation_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + graph_id: str, + operation_id: str, + *, + client: AuthenticatedClient, +) -> ErrorResponse | HTTPValidationError | OperatorRunDetail | None: + """Get Operator Run + + One operator run with its full paper trail: the history it was seeded with, every model call with + the tool calls it made and the results it read, the answer, and the response metadata. Readable by a + graph admin or by the user who made the run. + + Args: + graph_id (str): Graph identifier + operation_id (str): The run's operation id + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | HTTPValidationError | OperatorRunDetail + """ + + return ( + await asyncio_detailed( + graph_id=graph_id, + operation_id=operation_id, + client=client, + ) + ).parsed diff --git a/robosystems_client/api/graph_audit/list_operator_runs.py b/robosystems_client/api/graph_audit/list_operator_runs.py new file mode 100644 index 00000000..ce4503f4 --- /dev/null +++ b/robosystems_client/api/graph_audit/list_operator_runs.py @@ -0,0 +1,381 @@ +import datetime +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error_response import ErrorResponse +from ...models.http_validation_error import HTTPValidationError +from ...models.list_operator_runs_status_type_0 import ListOperatorRunsStatusType0 +from ...models.operator_run_list_response import OperatorRunListResponse +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + graph_id: str, + *, + thread_id: None | str | Unset = UNSET, + user_id: None | str | Unset = UNSET, + operator_type: None | str | Unset = UNSET, + status: ListOperatorRunsStatusType0 | None | Unset = UNSET, + since: datetime.datetime | None | Unset = UNSET, + until: datetime.datetime | None | Unset = UNSET, + cursor: None | str | Unset = UNSET, + limit: int | Unset = 50, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + json_thread_id: None | str | Unset + if isinstance(thread_id, Unset): + json_thread_id = UNSET + else: + json_thread_id = thread_id + params["thread_id"] = json_thread_id + + json_user_id: None | str | Unset + if isinstance(user_id, Unset): + json_user_id = UNSET + else: + json_user_id = user_id + params["user_id"] = json_user_id + + json_operator_type: None | str | Unset + if isinstance(operator_type, Unset): + json_operator_type = UNSET + else: + json_operator_type = operator_type + params["operator_type"] = json_operator_type + + json_status: None | str | Unset + if isinstance(status, Unset): + json_status = UNSET + elif isinstance(status, ListOperatorRunsStatusType0): + json_status = status.value + else: + json_status = status + params["status"] = json_status + + json_since: None | str | Unset + if isinstance(since, Unset): + json_since = UNSET + elif isinstance(since, datetime.datetime): + json_since = since.isoformat() + else: + json_since = since + params["since"] = json_since + + json_until: None | str | Unset + if isinstance(until, Unset): + json_until = UNSET + elif isinstance(until, datetime.datetime): + json_until = until.isoformat() + else: + json_until = until + params["until"] = json_until + + json_cursor: None | str | Unset + if isinstance(cursor, Unset): + json_cursor = UNSET + else: + json_cursor = cursor + params["cursor"] = json_cursor + + params["limit"] = limit + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/v1/graphs/{graph_id}/audit/runs".format( + graph_id=quote(str(graph_id), safe=""), + ), + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorResponse | HTTPValidationError | OperatorRunListResponse | None: + if response.status_code == 200: + response_200 = OperatorRunListResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = ErrorResponse.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = ErrorResponse.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = ErrorResponse.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = ErrorResponse.from_dict(response.json()) + + return response_404 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = ErrorResponse.from_dict(response.json()) + + return response_429 + + if response.status_code == 500: + response_500 = ErrorResponse.from_dict(response.json()) + + return response_500 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorResponse | HTTPValidationError | OperatorRunListResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + graph_id: str, + *, + client: AuthenticatedClient, + thread_id: None | str | Unset = UNSET, + user_id: None | str | Unset = UNSET, + operator_type: None | str | Unset = UNSET, + status: ListOperatorRunsStatusType0 | None | Unset = UNSET, + since: datetime.datetime | None | Unset = UNSET, + until: datetime.datetime | None | Unset = UNSET, + cursor: None | str | Unset = UNSET, + limit: int | Unset = 50, +) -> Response[ErrorResponse | HTTPValidationError | OperatorRunListResponse]: + """List Operator Runs + + The in-app AI operator runs made on this graph, newest first: each run's question, answer, outcome, + cost and the tools it called. This is the record behind a mutation audit entry's `operation_id`, + kept after the operation itself has expired. A graph admin sees every user's runs; any other member + sees their own. Filter by `thread_id` for one conversation. + + Args: + graph_id (str): Graph identifier + thread_id (None | str | Unset): Only the runs of this thread + user_id (None | str | Unset): Only runs made as this user (admins; members see their own) + operator_type (None | str | Unset): Only runs of this operator + status (ListOperatorRunsStatusType0 | None | Unset): Only runs that ended this way + since (datetime.datetime | None | Unset): Only runs started at or after this time + until (datetime.datetime | None | Unset): Only runs started before this time + cursor (None | str | Unset): The `next_cursor` of the previous page + limit (int | Unset): Runs per page Default: 50. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | HTTPValidationError | OperatorRunListResponse] + """ + + kwargs = _get_kwargs( + graph_id=graph_id, + thread_id=thread_id, + user_id=user_id, + operator_type=operator_type, + status=status, + since=since, + until=until, + cursor=cursor, + limit=limit, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + graph_id: str, + *, + client: AuthenticatedClient, + thread_id: None | str | Unset = UNSET, + user_id: None | str | Unset = UNSET, + operator_type: None | str | Unset = UNSET, + status: ListOperatorRunsStatusType0 | None | Unset = UNSET, + since: datetime.datetime | None | Unset = UNSET, + until: datetime.datetime | None | Unset = UNSET, + cursor: None | str | Unset = UNSET, + limit: int | Unset = 50, +) -> ErrorResponse | HTTPValidationError | OperatorRunListResponse | None: + """List Operator Runs + + The in-app AI operator runs made on this graph, newest first: each run's question, answer, outcome, + cost and the tools it called. This is the record behind a mutation audit entry's `operation_id`, + kept after the operation itself has expired. A graph admin sees every user's runs; any other member + sees their own. Filter by `thread_id` for one conversation. + + Args: + graph_id (str): Graph identifier + thread_id (None | str | Unset): Only the runs of this thread + user_id (None | str | Unset): Only runs made as this user (admins; members see their own) + operator_type (None | str | Unset): Only runs of this operator + status (ListOperatorRunsStatusType0 | None | Unset): Only runs that ended this way + since (datetime.datetime | None | Unset): Only runs started at or after this time + until (datetime.datetime | None | Unset): Only runs started before this time + cursor (None | str | Unset): The `next_cursor` of the previous page + limit (int | Unset): Runs per page Default: 50. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | HTTPValidationError | OperatorRunListResponse + """ + + return sync_detailed( + graph_id=graph_id, + client=client, + thread_id=thread_id, + user_id=user_id, + operator_type=operator_type, + status=status, + since=since, + until=until, + cursor=cursor, + limit=limit, + ).parsed + + +async def asyncio_detailed( + graph_id: str, + *, + client: AuthenticatedClient, + thread_id: None | str | Unset = UNSET, + user_id: None | str | Unset = UNSET, + operator_type: None | str | Unset = UNSET, + status: ListOperatorRunsStatusType0 | None | Unset = UNSET, + since: datetime.datetime | None | Unset = UNSET, + until: datetime.datetime | None | Unset = UNSET, + cursor: None | str | Unset = UNSET, + limit: int | Unset = 50, +) -> Response[ErrorResponse | HTTPValidationError | OperatorRunListResponse]: + """List Operator Runs + + The in-app AI operator runs made on this graph, newest first: each run's question, answer, outcome, + cost and the tools it called. This is the record behind a mutation audit entry's `operation_id`, + kept after the operation itself has expired. A graph admin sees every user's runs; any other member + sees their own. Filter by `thread_id` for one conversation. + + Args: + graph_id (str): Graph identifier + thread_id (None | str | Unset): Only the runs of this thread + user_id (None | str | Unset): Only runs made as this user (admins; members see their own) + operator_type (None | str | Unset): Only runs of this operator + status (ListOperatorRunsStatusType0 | None | Unset): Only runs that ended this way + since (datetime.datetime | None | Unset): Only runs started at or after this time + until (datetime.datetime | None | Unset): Only runs started before this time + cursor (None | str | Unset): The `next_cursor` of the previous page + limit (int | Unset): Runs per page Default: 50. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | HTTPValidationError | OperatorRunListResponse] + """ + + kwargs = _get_kwargs( + graph_id=graph_id, + thread_id=thread_id, + user_id=user_id, + operator_type=operator_type, + status=status, + since=since, + until=until, + cursor=cursor, + limit=limit, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + graph_id: str, + *, + client: AuthenticatedClient, + thread_id: None | str | Unset = UNSET, + user_id: None | str | Unset = UNSET, + operator_type: None | str | Unset = UNSET, + status: ListOperatorRunsStatusType0 | None | Unset = UNSET, + since: datetime.datetime | None | Unset = UNSET, + until: datetime.datetime | None | Unset = UNSET, + cursor: None | str | Unset = UNSET, + limit: int | Unset = 50, +) -> ErrorResponse | HTTPValidationError | OperatorRunListResponse | None: + """List Operator Runs + + The in-app AI operator runs made on this graph, newest first: each run's question, answer, outcome, + cost and the tools it called. This is the record behind a mutation audit entry's `operation_id`, + kept after the operation itself has expired. A graph admin sees every user's runs; any other member + sees their own. Filter by `thread_id` for one conversation. + + Args: + graph_id (str): Graph identifier + thread_id (None | str | Unset): Only the runs of this thread + user_id (None | str | Unset): Only runs made as this user (admins; members see their own) + operator_type (None | str | Unset): Only runs of this operator + status (ListOperatorRunsStatusType0 | None | Unset): Only runs that ended this way + since (datetime.datetime | None | Unset): Only runs started at or after this time + until (datetime.datetime | None | Unset): Only runs started before this time + cursor (None | str | Unset): The `next_cursor` of the previous page + limit (int | Unset): Runs per page Default: 50. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | HTTPValidationError | OperatorRunListResponse + """ + + return ( + await asyncio_detailed( + graph_id=graph_id, + client=client, + thread_id=thread_id, + user_id=user_id, + operator_type=operator_type, + status=status, + since=since, + until=until, + cursor=cursor, + limit=limit, + ) + ).parsed diff --git a/robosystems_client/api/graph_audit/list_operator_threads.py b/robosystems_client/api/graph_audit/list_operator_threads.py new file mode 100644 index 00000000..38d768dc --- /dev/null +++ b/robosystems_client/api/graph_audit/list_operator_threads.py @@ -0,0 +1,269 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error_response import ErrorResponse +from ...models.http_validation_error import HTTPValidationError +from ...models.operator_thread_list_response import OperatorThreadListResponse +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + graph_id: str, + *, + user_id: None | str | Unset = UNSET, + cursor: None | str | Unset = UNSET, + limit: int | Unset = 50, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + json_user_id: None | str | Unset + if isinstance(user_id, Unset): + json_user_id = UNSET + else: + json_user_id = user_id + params["user_id"] = json_user_id + + json_cursor: None | str | Unset + if isinstance(cursor, Unset): + json_cursor = UNSET + else: + json_cursor = cursor + params["cursor"] = json_cursor + + params["limit"] = limit + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/v1/graphs/{graph_id}/audit/threads".format( + graph_id=quote(str(graph_id), safe=""), + ), + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorResponse | HTTPValidationError | OperatorThreadListResponse | None: + if response.status_code == 200: + response_200 = OperatorThreadListResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = ErrorResponse.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = ErrorResponse.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = ErrorResponse.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = ErrorResponse.from_dict(response.json()) + + return response_404 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = ErrorResponse.from_dict(response.json()) + + return response_429 + + if response.status_code == 500: + response_500 = ErrorResponse.from_dict(response.json()) + + return response_500 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorResponse | HTTPValidationError | OperatorThreadListResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + graph_id: str, + *, + client: AuthenticatedClient, + user_id: None | str | Unset = UNSET, + cursor: None | str | Unset = UNSET, + limit: int | Unset = 50, +) -> Response[ErrorResponse | HTTPValidationError | OperatorThreadListResponse]: + """List Operator Threads + + The conversations held with this graph's AI operators, most recently active first: every run made + under a `thread_id` grouped by it, with the first question as the title. A graph admin sees every + user's threads; any other member sees their own. Open a thread's runs at `/audit/runs`. + + Args: + graph_id (str): Graph identifier + user_id (None | str | Unset): Only this user's threads (admins; members see their own) + cursor (None | str | Unset): The `next_cursor` of the previous page + limit (int | Unset): Threads per page Default: 50. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | HTTPValidationError | OperatorThreadListResponse] + """ + + kwargs = _get_kwargs( + graph_id=graph_id, + user_id=user_id, + cursor=cursor, + limit=limit, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + graph_id: str, + *, + client: AuthenticatedClient, + user_id: None | str | Unset = UNSET, + cursor: None | str | Unset = UNSET, + limit: int | Unset = 50, +) -> ErrorResponse | HTTPValidationError | OperatorThreadListResponse | None: + """List Operator Threads + + The conversations held with this graph's AI operators, most recently active first: every run made + under a `thread_id` grouped by it, with the first question as the title. A graph admin sees every + user's threads; any other member sees their own. Open a thread's runs at `/audit/runs`. + + Args: + graph_id (str): Graph identifier + user_id (None | str | Unset): Only this user's threads (admins; members see their own) + cursor (None | str | Unset): The `next_cursor` of the previous page + limit (int | Unset): Threads per page Default: 50. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | HTTPValidationError | OperatorThreadListResponse + """ + + return sync_detailed( + graph_id=graph_id, + client=client, + user_id=user_id, + cursor=cursor, + limit=limit, + ).parsed + + +async def asyncio_detailed( + graph_id: str, + *, + client: AuthenticatedClient, + user_id: None | str | Unset = UNSET, + cursor: None | str | Unset = UNSET, + limit: int | Unset = 50, +) -> Response[ErrorResponse | HTTPValidationError | OperatorThreadListResponse]: + """List Operator Threads + + The conversations held with this graph's AI operators, most recently active first: every run made + under a `thread_id` grouped by it, with the first question as the title. A graph admin sees every + user's threads; any other member sees their own. Open a thread's runs at `/audit/runs`. + + Args: + graph_id (str): Graph identifier + user_id (None | str | Unset): Only this user's threads (admins; members see their own) + cursor (None | str | Unset): The `next_cursor` of the previous page + limit (int | Unset): Threads per page Default: 50. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | HTTPValidationError | OperatorThreadListResponse] + """ + + kwargs = _get_kwargs( + graph_id=graph_id, + user_id=user_id, + cursor=cursor, + limit=limit, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + graph_id: str, + *, + client: AuthenticatedClient, + user_id: None | str | Unset = UNSET, + cursor: None | str | Unset = UNSET, + limit: int | Unset = 50, +) -> ErrorResponse | HTTPValidationError | OperatorThreadListResponse | None: + """List Operator Threads + + The conversations held with this graph's AI operators, most recently active first: every run made + under a `thread_id` grouped by it, with the first question as the title. A graph admin sees every + user's threads; any other member sees their own. Open a thread's runs at `/audit/runs`. + + Args: + graph_id (str): Graph identifier + user_id (None | str | Unset): Only this user's threads (admins; members see their own) + cursor (None | str | Unset): The `next_cursor` of the previous page + limit (int | Unset): Threads per page Default: 50. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | HTTPValidationError | OperatorThreadListResponse + """ + + return ( + await asyncio_detailed( + graph_id=graph_id, + client=client, + user_id=user_id, + cursor=cursor, + limit=limit, + ) + ).parsed diff --git a/robosystems_client/clients/operator_client.py b/robosystems_client/clients/operator_client.py index 441c8f13..1fa552fc 100644 --- a/robosystems_client/clients/operator_client.py +++ b/robosystems_client/clients/operator_client.py @@ -44,6 +44,11 @@ class OperatorQueryRequest: mode: Optional[str] = None # 'quick', 'standard', 'extended', 'streaming' enable_rag: Optional[bool] = None force_extended_analysis: Optional[bool] = None + # Groups the run with the earlier runs of one conversation. Mint any + # URL-safe token (a UUID) on the first question and send it with each + # follow-up; with no `history` given, the server seeds the run from the + # thread's own record. Runs list by thread under the graph's audit. + thread_id: Optional[str] = None @dataclass @@ -75,9 +80,14 @@ class OperatorResult: # response (credit pre-flight, operator timeouts, cancelled runs); `content` # then carries the explanation rather than an answer. error_details: Optional[Dict[str, Any]] = None + # The run's operation id: the key of its record under the graph's audit + # (`get_operator_run`) and of every mutation it made. + operation_id: Optional[str] = None -def _operator_result(data: Dict[str, Any]) -> OperatorResult: +def _operator_result( + data: Dict[str, Any], operation_id: Optional[str] = None +) -> OperatorResult: """Shape an operator payload into the public result. The `operator_completed` event, the generic completion event's `result`, @@ -94,6 +104,7 @@ def _operator_result(data: Dict[str, Any]) -> OperatorResult: execution_time=data.get("execution_time"), timestamp=data.get("timestamp") or datetime.now().isoformat(), error_details=data.get("error_details"), + operation_id=operation_id or data.get("operation_id"), ) @@ -258,6 +269,7 @@ def execute_query( mode=request.mode, enable_rag=request.enable_rag, force_extended_analysis=request.force_extended_analysis, + thread_id=request.thread_id if request.thread_id is not None else UNSET, ) # Execute through the generated client, with the credential current now @@ -297,6 +309,7 @@ def execute_operator( mode=request.mode, enable_rag=request.enable_rag, force_extended_analysis=request.force_extended_analysis, + thread_id=request.thread_id if request.thread_id is not None else UNSET, ) # Execute through the generated client, with the credential current now @@ -382,14 +395,14 @@ def on_operator_initialized(data): def on_operator_completed(data): nonlocal result, completed - result = _operator_result(data) + result = _operator_result(data, operation_id) completed = True def on_completed(data): nonlocal result, completed if not result: # Fallback to generic completion event - result = _operator_result(data.get("result") or data) + result = _operator_result(data.get("result") or data, operation_id) completed = True def on_error(err): @@ -508,7 +521,7 @@ def _poll_for_completion( state = status.get("status") if state == "completed": - return _operator_result(status.get("result") or {}) + return _operator_result(status.get("result") or {}, operation_id) if state == "failed": raise OperatorRunError( status.get("error") or status.get("message") or "Operator run failed", diff --git a/robosystems_client/models/__init__.py b/robosystems_client/models/__init__.py index ce1914f3..c02b7bd5 100644 --- a/robosystems_client/models/__init__.py +++ b/robosystems_client/models/__init__.py @@ -421,6 +421,7 @@ from .link_entity_taxonomy_request_basis import LinkEntityTaxonomyRequestBasis from .list_connections_provider_type_0 import ListConnectionsProviderType0 from .list_graph_mutations_surface_type_0 import ListGraphMutationsSurfaceType0 +from .list_operator_runs_status_type_0 import ListOperatorRunsStatusType0 from .list_org_graphs_response_200_item import ListOrgGraphsResponse200Item from .list_subgraphs_response import ListSubgraphsResponse from .list_table_files_response import ListTableFilesResponse @@ -860,6 +861,17 @@ from .operator_response_error_details_type_0 import OperatorResponseErrorDetailsType0 from .operator_response_metadata_type_0 import OperatorResponseMetadataType0 from .operator_response_tokens_used_type_0 import OperatorResponseTokensUsedType0 +from .operator_run_detail import OperatorRunDetail +from .operator_run_detail_history_item import OperatorRunDetailHistoryItem +from .operator_run_detail_result import OperatorRunDetailResult +from .operator_run_detail_status import OperatorRunDetailStatus +from .operator_run_detail_transcript_item import OperatorRunDetailTranscriptItem +from .operator_run_list_response import OperatorRunListResponse +from .operator_run_summary import OperatorRunSummary +from .operator_run_summary_status import OperatorRunSummaryStatus +from .operator_thread_list_response import OperatorThreadListResponse +from .operator_thread_summary import OperatorThreadSummary +from .operator_thread_summary_last_status import OperatorThreadSummaryLastStatus from .org_detail_response import OrgDetailResponse from .org_detail_response_graphs_item import OrgDetailResponseGraphsItem from .org_detail_response_limits_type_0 import OrgDetailResponseLimitsType0 @@ -1588,6 +1600,7 @@ "LinkEntityTaxonomyRequestBasis", "ListConnectionsProviderType0", "ListGraphMutationsSurfaceType0", + "ListOperatorRunsStatusType0", "ListOrgGraphsResponse200Item", "ListSubgraphsResponse", "ListTableFilesResponse", @@ -1779,6 +1792,17 @@ "OperatorResponseErrorDetailsType0", "OperatorResponseMetadataType0", "OperatorResponseTokensUsedType0", + "OperatorRunDetail", + "OperatorRunDetailHistoryItem", + "OperatorRunDetailResult", + "OperatorRunDetailStatus", + "OperatorRunDetailTranscriptItem", + "OperatorRunListResponse", + "OperatorRunSummary", + "OperatorRunSummaryStatus", + "OperatorThreadListResponse", + "OperatorThreadSummary", + "OperatorThreadSummaryLastStatus", "OrgDetailResponse", "OrgDetailResponseGraphsItem", "OrgDetailResponseLimitsType0", diff --git a/robosystems_client/models/list_operator_runs_status_type_0.py b/robosystems_client/models/list_operator_runs_status_type_0.py new file mode 100644 index 00000000..b524c55f --- /dev/null +++ b/robosystems_client/models/list_operator_runs_status_type_0.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class ListOperatorRunsStatusType0(str, Enum): + CANCELLED = "cancelled" + COMPLETED = "completed" + FAILED = "failed" + + def __str__(self) -> str: + return str(self.value) diff --git a/robosystems_client/models/operator_request.py b/robosystems_client/models/operator_request.py index 4ae7d27c..c62f96e0 100644 --- a/robosystems_client/models/operator_request.py +++ b/robosystems_client/models/operator_request.py @@ -24,7 +24,12 @@ class OperatorRequest: Attributes: message (str): The query or message to process - history (list[OperatorMessage] | Unset): Conversation history + history (list[OperatorMessage] | Unset): Conversation history. Omit it on a threaded request and the run is + seeded from the thread's own record instead + thread_id (None | str | Unset): Groups this run with the earlier runs of one conversation. A caller mints the id + (any ULID or UUID) on the first question and sends it with each follow-up; the run is recorded under it, and + with no `history` given, the thread's last exchanges are the history. Runs list by thread at `GET + /v1/graphs/{graph_id}/audit/runs` context (None | OperatorRequestContextType0 | Unset): Additional context for analysis (e.g., enable_rag, include_schema) mode (None | OperatorMode | Unset): Execution mode Default: OperatorMode.STANDARD. @@ -40,6 +45,7 @@ class OperatorRequest: message: str history: list[OperatorMessage] | Unset = UNSET + thread_id: None | str | Unset = UNSET context: None | OperatorRequestContextType0 | Unset = UNSET mode: None | OperatorMode | Unset = OperatorMode.STANDARD operator_type: None | str | Unset = UNSET @@ -63,6 +69,12 @@ def to_dict(self) -> dict[str, Any]: history_item = history_item_data.to_dict() history.append(history_item) + thread_id: None | str | Unset + if isinstance(self.thread_id, Unset): + thread_id = UNSET + else: + thread_id = self.thread_id + context: dict[str, Any] | None | Unset if isinstance(self.context, Unset): context = UNSET @@ -114,6 +126,8 @@ def to_dict(self) -> dict[str, Any]: ) if history is not UNSET: field_dict["history"] = history + if thread_id is not UNSET: + field_dict["thread_id"] = thread_id if context is not UNSET: field_dict["context"] = context if mode is not UNSET: @@ -151,6 +165,15 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: history.append(history_item) + def _parse_thread_id(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + thread_id = _parse_thread_id(d.pop("thread_id", UNSET)) + def _parse_context(data: object) -> None | OperatorRequestContextType0 | Unset: if data is None: return data @@ -229,6 +252,7 @@ def _parse_max_credits(data: object) -> float | None | Unset: operator_request = cls( message=message, history=history, + thread_id=thread_id, context=context, mode=mode, operator_type=operator_type, diff --git a/robosystems_client/models/operator_run_detail.py b/robosystems_client/models/operator_run_detail.py new file mode 100644 index 00000000..ab170008 --- /dev/null +++ b/robosystems_client/models/operator_run_detail.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.operator_run_detail_status import OperatorRunDetailStatus +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.operator_run_detail_history_item import OperatorRunDetailHistoryItem + from ..models.operator_run_detail_result import OperatorRunDetailResult + from ..models.operator_run_detail_transcript_item import ( + OperatorRunDetailTranscriptItem, + ) + + +T = TypeVar("T", bound="OperatorRunDetail") + + +@_attrs_define +class OperatorRunDetail: + """A run with its full paper trail: the history it was given, every model + call, and the response envelope's metadata. + + Attributes: + operation_id (str): The run's operation id; every mutation audit entry the run made carries it as `operation_id` + user_id (str): The user the run ran as + operator_type (str): The operator that ran + mode (str): The execution mode the run used + status (OperatorRunDetailStatus): How the run ended + question (str): The message the run was given + started_at (datetime.datetime): When the run started on the worker + finished_at (datetime.datetime): When the run ended + duration_ms (float): How long the run took + thread_id (None | str | Unset): The conversation the run belongs to, when the caller threaded it; null for a + standalone API call + answer (None | str | Unset): The operator's answer; null when the run did not reach one + error (None | str | Unset): Why a failed run failed + credits_consumed (float | Unset): Credits the run's model calls cost Default: 0.0. + call_count (int | Unset): Model calls the run made Default: 0. + tools_called (list[str] | Unset): The tools the run called, each named once + write_count (int | Unset): Writes the run made to the graph Default: 0. + history (list[OperatorRunDetailHistoryItem] | Unset): The prior turns the run was given, as role/content pairs + transcript (list[OperatorRunDetailTranscriptItem] | Unset): One entry per model call, in order: the input added + since the previous call (the question, then each turn's tool results), the model's output blocks (text and tool + calls), stop reason, token usage and credits. The first entry also carries the system prompt's SHA-256 and size + and the tool names offered; later entries repeat them only when they change + result (OperatorRunDetailResult | Unset): The response envelope's metadata without its result rows: tokens, + credits, the Cypher run, the writes made, confidence and loop flags + """ + + operation_id: str + user_id: str + operator_type: str + mode: str + status: OperatorRunDetailStatus + question: str + started_at: datetime.datetime + finished_at: datetime.datetime + duration_ms: float + thread_id: None | str | Unset = UNSET + answer: None | str | Unset = UNSET + error: None | str | Unset = UNSET + credits_consumed: float | Unset = 0.0 + call_count: int | Unset = 0 + tools_called: list[str] | Unset = UNSET + write_count: int | Unset = 0 + history: list[OperatorRunDetailHistoryItem] | Unset = UNSET + transcript: list[OperatorRunDetailTranscriptItem] | Unset = UNSET + result: OperatorRunDetailResult | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + operation_id = self.operation_id + + user_id = self.user_id + + operator_type = self.operator_type + + mode = self.mode + + status = self.status.value + + question = self.question + + started_at = self.started_at.isoformat() + + finished_at = self.finished_at.isoformat() + + duration_ms = self.duration_ms + + thread_id: None | str | Unset + if isinstance(self.thread_id, Unset): + thread_id = UNSET + else: + thread_id = self.thread_id + + answer: None | str | Unset + if isinstance(self.answer, Unset): + answer = UNSET + else: + answer = self.answer + + error: None | str | Unset + if isinstance(self.error, Unset): + error = UNSET + else: + error = self.error + + credits_consumed = self.credits_consumed + + call_count = self.call_count + + tools_called: list[str] | Unset = UNSET + if not isinstance(self.tools_called, Unset): + tools_called = self.tools_called + + write_count = self.write_count + + history: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.history, Unset): + history = [] + for history_item_data in self.history: + history_item = history_item_data.to_dict() + history.append(history_item) + + transcript: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.transcript, Unset): + transcript = [] + for transcript_item_data in self.transcript: + transcript_item = transcript_item_data.to_dict() + transcript.append(transcript_item) + + result: dict[str, Any] | Unset = UNSET + if not isinstance(self.result, Unset): + result = self.result.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "operation_id": operation_id, + "user_id": user_id, + "operator_type": operator_type, + "mode": mode, + "status": status, + "question": question, + "started_at": started_at, + "finished_at": finished_at, + "duration_ms": duration_ms, + } + ) + if thread_id is not UNSET: + field_dict["thread_id"] = thread_id + if answer is not UNSET: + field_dict["answer"] = answer + if error is not UNSET: + field_dict["error"] = error + if credits_consumed is not UNSET: + field_dict["credits_consumed"] = credits_consumed + if call_count is not UNSET: + field_dict["call_count"] = call_count + if tools_called is not UNSET: + field_dict["tools_called"] = tools_called + if write_count is not UNSET: + field_dict["write_count"] = write_count + if history is not UNSET: + field_dict["history"] = history + if transcript is not UNSET: + field_dict["transcript"] = transcript + if result is not UNSET: + field_dict["result"] = result + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.operator_run_detail_history_item import OperatorRunDetailHistoryItem + from ..models.operator_run_detail_result import OperatorRunDetailResult + from ..models.operator_run_detail_transcript_item import ( + OperatorRunDetailTranscriptItem, + ) + + d = dict(src_dict) + operation_id = d.pop("operation_id") + + user_id = d.pop("user_id") + + operator_type = d.pop("operator_type") + + mode = d.pop("mode") + + status = OperatorRunDetailStatus(d.pop("status")) + + question = d.pop("question") + + started_at = datetime.datetime.fromisoformat(d.pop("started_at")) + + finished_at = datetime.datetime.fromisoformat(d.pop("finished_at")) + + duration_ms = d.pop("duration_ms") + + def _parse_thread_id(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + thread_id = _parse_thread_id(d.pop("thread_id", UNSET)) + + def _parse_answer(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + answer = _parse_answer(d.pop("answer", UNSET)) + + def _parse_error(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + error = _parse_error(d.pop("error", UNSET)) + + credits_consumed = d.pop("credits_consumed", UNSET) + + call_count = d.pop("call_count", UNSET) + + tools_called = cast(list[str], d.pop("tools_called", UNSET)) + + write_count = d.pop("write_count", UNSET) + + _history = d.pop("history", UNSET) + history: list[OperatorRunDetailHistoryItem] | Unset = UNSET + if _history is not UNSET: + history = [] + for history_item_data in _history: + history_item = OperatorRunDetailHistoryItem.from_dict(history_item_data) + + history.append(history_item) + + _transcript = d.pop("transcript", UNSET) + transcript: list[OperatorRunDetailTranscriptItem] | Unset = UNSET + if _transcript is not UNSET: + transcript = [] + for transcript_item_data in _transcript: + transcript_item = OperatorRunDetailTranscriptItem.from_dict( + transcript_item_data + ) + + transcript.append(transcript_item) + + _result = d.pop("result", UNSET) + result: OperatorRunDetailResult | Unset + if isinstance(_result, Unset): + result = UNSET + else: + result = OperatorRunDetailResult.from_dict(_result) + + operator_run_detail = cls( + operation_id=operation_id, + user_id=user_id, + operator_type=operator_type, + mode=mode, + status=status, + question=question, + started_at=started_at, + finished_at=finished_at, + duration_ms=duration_ms, + thread_id=thread_id, + answer=answer, + error=error, + credits_consumed=credits_consumed, + call_count=call_count, + tools_called=tools_called, + write_count=write_count, + history=history, + transcript=transcript, + result=result, + ) + + operator_run_detail.additional_properties = d + return operator_run_detail + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/operator_run_detail_history_item.py b/robosystems_client/models/operator_run_detail_history_item.py new file mode 100644 index 00000000..4bd58a98 --- /dev/null +++ b/robosystems_client/models/operator_run_detail_history_item.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="OperatorRunDetailHistoryItem") + + +@_attrs_define +class OperatorRunDetailHistoryItem: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + operator_run_detail_history_item = cls() + + operator_run_detail_history_item.additional_properties = d + return operator_run_detail_history_item + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/operator_run_detail_result.py b/robosystems_client/models/operator_run_detail_result.py new file mode 100644 index 00000000..5c608428 --- /dev/null +++ b/robosystems_client/models/operator_run_detail_result.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="OperatorRunDetailResult") + + +@_attrs_define +class OperatorRunDetailResult: + """The response envelope's metadata without its result rows: tokens, credits, the Cypher run, the writes made, + confidence and loop flags + + """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + operator_run_detail_result = cls() + + operator_run_detail_result.additional_properties = d + return operator_run_detail_result + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/operator_run_detail_status.py b/robosystems_client/models/operator_run_detail_status.py new file mode 100644 index 00000000..f24974f8 --- /dev/null +++ b/robosystems_client/models/operator_run_detail_status.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class OperatorRunDetailStatus(str, Enum): + CANCELLED = "cancelled" + COMPLETED = "completed" + FAILED = "failed" + + def __str__(self) -> str: + return str(self.value) diff --git a/robosystems_client/models/operator_run_detail_transcript_item.py b/robosystems_client/models/operator_run_detail_transcript_item.py new file mode 100644 index 00000000..03cedda0 --- /dev/null +++ b/robosystems_client/models/operator_run_detail_transcript_item.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="OperatorRunDetailTranscriptItem") + + +@_attrs_define +class OperatorRunDetailTranscriptItem: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + operator_run_detail_transcript_item = cls() + + operator_run_detail_transcript_item.additional_properties = d + return operator_run_detail_transcript_item + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/operator_run_list_response.py b/robosystems_client/models/operator_run_list_response.py new file mode 100644 index 00000000..eebf6eff --- /dev/null +++ b/robosystems_client/models/operator_run_list_response.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.operator_run_summary import OperatorRunSummary + + +T = TypeVar("T", bound="OperatorRunListResponse") + + +@_attrs_define +class OperatorRunListResponse: + """A page of the graph's operator runs, newest first. + + Attributes: + graph_id (str): + runs (list[OperatorRunSummary]): + next_cursor (None | str | Unset): Pass as `cursor` for the next, older page; null on the last page + """ + + graph_id: str + runs: list[OperatorRunSummary] + next_cursor: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + graph_id = self.graph_id + + runs = [] + for runs_item_data in self.runs: + runs_item = runs_item_data.to_dict() + runs.append(runs_item) + + next_cursor: None | str | Unset + if isinstance(self.next_cursor, Unset): + next_cursor = UNSET + else: + next_cursor = self.next_cursor + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "graph_id": graph_id, + "runs": runs, + } + ) + if next_cursor is not UNSET: + field_dict["next_cursor"] = next_cursor + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.operator_run_summary import OperatorRunSummary + + d = dict(src_dict) + graph_id = d.pop("graph_id") + + runs = [] + _runs = d.pop("runs") + for runs_item_data in _runs: + runs_item = OperatorRunSummary.from_dict(runs_item_data) + + runs.append(runs_item) + + def _parse_next_cursor(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + next_cursor = _parse_next_cursor(d.pop("next_cursor", UNSET)) + + operator_run_list_response = cls( + graph_id=graph_id, + runs=runs, + next_cursor=next_cursor, + ) + + operator_run_list_response.additional_properties = d + return operator_run_list_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/operator_run_summary.py b/robosystems_client/models/operator_run_summary.py new file mode 100644 index 00000000..6f6b73a0 --- /dev/null +++ b/robosystems_client/models/operator_run_summary.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.operator_run_summary_status import OperatorRunSummaryStatus +from ..types import UNSET, Unset + +T = TypeVar("T", bound="OperatorRunSummary") + + +@_attrs_define +class OperatorRunSummary: + """One in-app AI operator run: what it was asked and what it answered. + + Attributes: + operation_id (str): The run's operation id; every mutation audit entry the run made carries it as `operation_id` + user_id (str): The user the run ran as + operator_type (str): The operator that ran + mode (str): The execution mode the run used + status (OperatorRunSummaryStatus): How the run ended + question (str): The message the run was given + started_at (datetime.datetime): When the run started on the worker + finished_at (datetime.datetime): When the run ended + duration_ms (float): How long the run took + thread_id (None | str | Unset): The conversation the run belongs to, when the caller threaded it; null for a + standalone API call + answer (None | str | Unset): The operator's answer; null when the run did not reach one + error (None | str | Unset): Why a failed run failed + credits_consumed (float | Unset): Credits the run's model calls cost Default: 0.0. + call_count (int | Unset): Model calls the run made Default: 0. + tools_called (list[str] | Unset): The tools the run called, each named once + write_count (int | Unset): Writes the run made to the graph Default: 0. + """ + + operation_id: str + user_id: str + operator_type: str + mode: str + status: OperatorRunSummaryStatus + question: str + started_at: datetime.datetime + finished_at: datetime.datetime + duration_ms: float + thread_id: None | str | Unset = UNSET + answer: None | str | Unset = UNSET + error: None | str | Unset = UNSET + credits_consumed: float | Unset = 0.0 + call_count: int | Unset = 0 + tools_called: list[str] | Unset = UNSET + write_count: int | Unset = 0 + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + operation_id = self.operation_id + + user_id = self.user_id + + operator_type = self.operator_type + + mode = self.mode + + status = self.status.value + + question = self.question + + started_at = self.started_at.isoformat() + + finished_at = self.finished_at.isoformat() + + duration_ms = self.duration_ms + + thread_id: None | str | Unset + if isinstance(self.thread_id, Unset): + thread_id = UNSET + else: + thread_id = self.thread_id + + answer: None | str | Unset + if isinstance(self.answer, Unset): + answer = UNSET + else: + answer = self.answer + + error: None | str | Unset + if isinstance(self.error, Unset): + error = UNSET + else: + error = self.error + + credits_consumed = self.credits_consumed + + call_count = self.call_count + + tools_called: list[str] | Unset = UNSET + if not isinstance(self.tools_called, Unset): + tools_called = self.tools_called + + write_count = self.write_count + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "operation_id": operation_id, + "user_id": user_id, + "operator_type": operator_type, + "mode": mode, + "status": status, + "question": question, + "started_at": started_at, + "finished_at": finished_at, + "duration_ms": duration_ms, + } + ) + if thread_id is not UNSET: + field_dict["thread_id"] = thread_id + if answer is not UNSET: + field_dict["answer"] = answer + if error is not UNSET: + field_dict["error"] = error + if credits_consumed is not UNSET: + field_dict["credits_consumed"] = credits_consumed + if call_count is not UNSET: + field_dict["call_count"] = call_count + if tools_called is not UNSET: + field_dict["tools_called"] = tools_called + if write_count is not UNSET: + field_dict["write_count"] = write_count + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + operation_id = d.pop("operation_id") + + user_id = d.pop("user_id") + + operator_type = d.pop("operator_type") + + mode = d.pop("mode") + + status = OperatorRunSummaryStatus(d.pop("status")) + + question = d.pop("question") + + started_at = datetime.datetime.fromisoformat(d.pop("started_at")) + + finished_at = datetime.datetime.fromisoformat(d.pop("finished_at")) + + duration_ms = d.pop("duration_ms") + + def _parse_thread_id(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + thread_id = _parse_thread_id(d.pop("thread_id", UNSET)) + + def _parse_answer(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + answer = _parse_answer(d.pop("answer", UNSET)) + + def _parse_error(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + error = _parse_error(d.pop("error", UNSET)) + + credits_consumed = d.pop("credits_consumed", UNSET) + + call_count = d.pop("call_count", UNSET) + + tools_called = cast(list[str], d.pop("tools_called", UNSET)) + + write_count = d.pop("write_count", UNSET) + + operator_run_summary = cls( + operation_id=operation_id, + user_id=user_id, + operator_type=operator_type, + mode=mode, + status=status, + question=question, + started_at=started_at, + finished_at=finished_at, + duration_ms=duration_ms, + thread_id=thread_id, + answer=answer, + error=error, + credits_consumed=credits_consumed, + call_count=call_count, + tools_called=tools_called, + write_count=write_count, + ) + + operator_run_summary.additional_properties = d + return operator_run_summary + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/operator_run_summary_status.py b/robosystems_client/models/operator_run_summary_status.py new file mode 100644 index 00000000..91b80296 --- /dev/null +++ b/robosystems_client/models/operator_run_summary_status.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class OperatorRunSummaryStatus(str, Enum): + CANCELLED = "cancelled" + COMPLETED = "completed" + FAILED = "failed" + + def __str__(self) -> str: + return str(self.value) diff --git a/robosystems_client/models/operator_thread_list_response.py b/robosystems_client/models/operator_thread_list_response.py new file mode 100644 index 00000000..9000b51a --- /dev/null +++ b/robosystems_client/models/operator_thread_list_response.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.operator_thread_summary import OperatorThreadSummary + + +T = TypeVar("T", bound="OperatorThreadListResponse") + + +@_attrs_define +class OperatorThreadListResponse: + """A page of the graph's operator threads, most recently active first. + + Attributes: + graph_id (str): + threads (list[OperatorThreadSummary]): + next_cursor (None | str | Unset): Pass as `cursor` for the next, older page; null on the last page + """ + + graph_id: str + threads: list[OperatorThreadSummary] + next_cursor: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + graph_id = self.graph_id + + threads = [] + for threads_item_data in self.threads: + threads_item = threads_item_data.to_dict() + threads.append(threads_item) + + next_cursor: None | str | Unset + if isinstance(self.next_cursor, Unset): + next_cursor = UNSET + else: + next_cursor = self.next_cursor + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "graph_id": graph_id, + "threads": threads, + } + ) + if next_cursor is not UNSET: + field_dict["next_cursor"] = next_cursor + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.operator_thread_summary import OperatorThreadSummary + + d = dict(src_dict) + graph_id = d.pop("graph_id") + + threads = [] + _threads = d.pop("threads") + for threads_item_data in _threads: + threads_item = OperatorThreadSummary.from_dict(threads_item_data) + + threads.append(threads_item) + + def _parse_next_cursor(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + next_cursor = _parse_next_cursor(d.pop("next_cursor", UNSET)) + + operator_thread_list_response = cls( + graph_id=graph_id, + threads=threads, + next_cursor=next_cursor, + ) + + operator_thread_list_response.additional_properties = d + return operator_thread_list_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/operator_thread_summary.py b/robosystems_client/models/operator_thread_summary.py new file mode 100644 index 00000000..03f5531d --- /dev/null +++ b/robosystems_client/models/operator_thread_summary.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.operator_thread_summary_last_status import OperatorThreadSummaryLastStatus + +T = TypeVar("T", bound="OperatorThreadSummary") + + +@_attrs_define +class OperatorThreadSummary: + """One console conversation: the runs that share a thread id. + + Attributes: + thread_id (str): The thread id the runs were made under + user_id (str): The user whose conversation it is + title (str): The conversation's first question, clipped + run_count (int): Runs in the thread + first_run_at (datetime.datetime): When the first run started + last_run_at (datetime.datetime): When the latest run started + last_operator_type (str): The operator the latest run used + last_status (OperatorThreadSummaryLastStatus): How the latest run ended + """ + + thread_id: str + user_id: str + title: str + run_count: int + first_run_at: datetime.datetime + last_run_at: datetime.datetime + last_operator_type: str + last_status: OperatorThreadSummaryLastStatus + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + thread_id = self.thread_id + + user_id = self.user_id + + title = self.title + + run_count = self.run_count + + first_run_at = self.first_run_at.isoformat() + + last_run_at = self.last_run_at.isoformat() + + last_operator_type = self.last_operator_type + + last_status = self.last_status.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "thread_id": thread_id, + "user_id": user_id, + "title": title, + "run_count": run_count, + "first_run_at": first_run_at, + "last_run_at": last_run_at, + "last_operator_type": last_operator_type, + "last_status": last_status, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + thread_id = d.pop("thread_id") + + user_id = d.pop("user_id") + + title = d.pop("title") + + run_count = d.pop("run_count") + + first_run_at = datetime.datetime.fromisoformat(d.pop("first_run_at")) + + last_run_at = datetime.datetime.fromisoformat(d.pop("last_run_at")) + + last_operator_type = d.pop("last_operator_type") + + last_status = OperatorThreadSummaryLastStatus(d.pop("last_status")) + + operator_thread_summary = cls( + thread_id=thread_id, + user_id=user_id, + title=title, + run_count=run_count, + first_run_at=first_run_at, + last_run_at=last_run_at, + last_operator_type=last_operator_type, + last_status=last_status, + ) + + operator_thread_summary.additional_properties = d + return operator_thread_summary + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/operator_thread_summary_last_status.py b/robosystems_client/models/operator_thread_summary_last_status.py new file mode 100644 index 00000000..c401caec --- /dev/null +++ b/robosystems_client/models/operator_thread_summary_last_status.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class OperatorThreadSummaryLastStatus(str, Enum): + CANCELLED = "cancelled" + COMPLETED = "completed" + FAILED = "failed" + + def __str__(self) -> str: + return str(self.value) diff --git a/tests/test_facade_wire_shapes.py b/tests/test_facade_wire_shapes.py index b79882ae..6b72be05 100644 --- a/tests/test_facade_wire_shapes.py +++ b/tests/test_facade_wire_shapes.py @@ -731,3 +731,49 @@ def test_list_information_blocks_sends_scenario_id(self, httpx_mock): payload = json.loads(httpx_mock.get_requests()[0].content) assert payload["variables"] == {"scenarioId": "str_fc"} assert "$scenarioId: String" in payload["query"] + + +# ── Threaded runs ──────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestThreadedOperatorRuns: + def test_the_thread_id_rides_the_request_and_the_result_names_the_run( + self, httpx_mock + ): + httpx_mock.add_response( + method="POST", url=OPERATOR_URL, status_code=202, json=OPERATOR_QUEUED + ) + httpx_mock.add_response( + url=_stream_url(), + headers={"content-type": "text/event-stream"}, + content=_sse( + ("operation_completed", {"message": "done", "result": OPERATOR_RESULT}), + ), + ) + + result = OperatorClient(CONFIG).execute_query( + GRAPH, OperatorQueryRequest(message="and August?", thread_id="thr_01ABC") + ) + + sent = json.loads(httpx_mock.get_requests()[0].content) + assert sent["thread_id"] == "thr_01ABC" + assert "history" not in sent or sent["history"] == [] + assert result.operation_id == OP + + def test_an_unthreaded_request_sends_no_thread(self, httpx_mock): + httpx_mock.add_response( + method="POST", + url=OPERATOR_URL, + status_code=200, + json={**OPERATOR_RESULT, "operation_id": OP}, + ) + + result = OperatorClient(CONFIG).execute_query( + GRAPH, OperatorQueryRequest(message="burn?"), OperatorOptions(mode="sync") + ) + + sent = json.loads(httpx_mock.get_requests()[0].content) + assert "thread_id" not in sent + # A sync answer names its run from the body. + assert result.operation_id == OP diff --git a/tests/test_operator_client.py b/tests/test_operator_client.py index bc4a0491..f4191a5b 100644 --- a/tests/test_operator_client.py +++ b/tests/test_operator_client.py @@ -34,6 +34,24 @@ def test_operator_query_request(self): assert request.enable_rag is True assert request.force_extended_analysis is False + def test_operator_query_request_threads(self): + request = OperatorQueryRequest(message="and August?", thread_id="thr_01ABC") + assert request.thread_id == "thr_01ABC" + assert OperatorQueryRequest(message="hi").thread_id is None + + def test_operator_result_names_its_run(self): + from robosystems_client.clients.operator_client import _operator_result + + assert ( + OperatorResult(content="c", operator_used="o", mode_used="m").operation_id is None + ) + carried = _operator_result({"content": "c", "operator_used": "o"}, "op_1") + assert carried.operation_id == "op_1" + from_body = _operator_result( + {"content": "c", "operator_used": "o", "operation_id": "op_2"} + ) + assert from_body.operation_id == "op_2" + def test_operator_query_request_defaults(self): """Test OperatorQueryRequest default values.""" request = OperatorQueryRequest(message="Simple query")