From 107137a4365c416587c3fbc1e58be07c8bdd6a26 Mon Sep 17 00:00:00 2001
From: aiyakitori <16633065+chiang-daniel@users.noreply.github.com>
Date: Mon, 1 Jun 2026 15:51:28 -0700
Subject: [PATCH 01/36] chore: re-vendor kiln_server SDK from
dchiang/synthetic-user@1d57386
Removes the /respond SDK module and its supporting wire types
(RespondRequest/Response, SyntheticUserDriverConfig, ConversationTurn,
the nested SyntheticUserInfo model). Per-turn synthetic-user invocation
moves to OSS at libs/core/kiln_ai/synthetic_user/ in a subsequent commit.
Collapses SyntheticUserCase.synthetic_user_info to a single tagged blob
string:
.........
The server treats the blob as opaque; the local player parses it.
Adds a typed `code` literal on /generate's 502 response
(llm_unavailable | upstream_invalid_output) so callers can discriminate
between transient model failures and unparseable model output.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
...jobs_prompt_optimization_job_start_post.py | 2 +
...ample_job_v1_jobs_sample_job_start_post.py | 1 -
.../api/synthetic_user/__init__.py | 1 +
...enerate_v1_synthetic_user_generate_post.py | 253 ++++++++++++++++++
.../kiln_ai_server_client/models/__init__.py | 18 ++
...pletion_assistant_message_param_wrapper.py | 53 ++++
.../models/chat_session_list_item.py | 3 +-
.../generate_synthetic_users_request.py | 85 ++++++
.../generate_synthetic_users_response.py | 82 ++++++
...nthetic_user_generate_post_response_401.py | 72 +++++
...nthetic_user_generate_post_response_500.py | 72 +++++
...nthetic_user_generate_post_response_502.py | 73 +++++
...ic_user_generate_post_response_502_code.py | 9 +
.../models/kiln_base_model.py | 3 +-
.../models/message_usage.py | 164 ++++++++++++
.../models/synthetic_user_case.py | 72 +++++
.../models/task_output.py | 3 +-
.../models/task_output_rating.py | 3 +-
.../kiln_ai_server_client/models/task_run.py | 38 ++-
.../kiln_ai_server_client/models/usage.py | 44 ++-
20 files changed, 1031 insertions(+), 20 deletions(-)
create mode 100644 app/desktop/studio_server/api_client/kiln_ai_server_client/api/synthetic_user/__init__.py
create mode 100644 app/desktop/studio_server/api_client/kiln_ai_server_client/api/synthetic_user/generate_v1_synthetic_user_generate_post.py
create mode 100644 app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_synthetic_users_request.py
create mode 100644 app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_synthetic_users_response.py
create mode 100644 app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_v1_synthetic_user_generate_post_response_401.py
create mode 100644 app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_v1_synthetic_user_generate_post_response_500.py
create mode 100644 app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_v1_synthetic_user_generate_post_response_502.py
create mode 100644 app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_v1_synthetic_user_generate_post_response_502_code.py
create mode 100644 app/desktop/studio_server/api_client/kiln_ai_server_client/models/message_usage.py
create mode 100644 app/desktop/studio_server/api_client/kiln_ai_server_client/models/synthetic_user_case.py
diff --git a/app/desktop/studio_server/api_client/kiln_ai_server_client/api/jobs/start_prompt_optimization_job_v1_jobs_prompt_optimization_job_start_post.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/api/jobs/start_prompt_optimization_job_v1_jobs_prompt_optimization_job_start_post.py
index 8b282ed871..ba1ec9502d 100644
--- a/app/desktop/studio_server/api_client/kiln_ai_server_client/api/jobs/start_prompt_optimization_job_v1_jobs_prompt_optimization_job_start_post.py
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/api/jobs/start_prompt_optimization_job_v1_jobs_prompt_optimization_job_start_post.py
@@ -26,6 +26,8 @@ def _get_kwargs(
_kwargs["files"] = body.to_multipart()
+ headers["Content-Type"] = "multipart/form-data; boundary=+++"
+
_kwargs["headers"] = headers
return _kwargs
diff --git a/app/desktop/studio_server/api_client/kiln_ai_server_client/api/jobs/start_sample_job_v1_jobs_sample_job_start_post.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/api/jobs/start_sample_job_v1_jobs_sample_job_start_post.py
index 0cf5080c05..69abed29c0 100644
--- a/app/desktop/studio_server/api_client/kiln_ai_server_client/api/jobs/start_sample_job_v1_jobs_sample_job_start_post.py
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/api/jobs/start_sample_job_v1_jobs_sample_job_start_post.py
@@ -23,7 +23,6 @@ def _get_kwargs(
}
_kwargs["data"] = body.to_dict()
-
headers["Content-Type"] = "application/x-www-form-urlencoded"
_kwargs["headers"] = headers
diff --git a/app/desktop/studio_server/api_client/kiln_ai_server_client/api/synthetic_user/__init__.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/api/synthetic_user/__init__.py
new file mode 100644
index 0000000000..2d7c0b23da
--- /dev/null
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/api/synthetic_user/__init__.py
@@ -0,0 +1 @@
+"""Contains endpoint functions for accessing the API"""
diff --git a/app/desktop/studio_server/api_client/kiln_ai_server_client/api/synthetic_user/generate_v1_synthetic_user_generate_post.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/api/synthetic_user/generate_v1_synthetic_user_generate_post.py
new file mode 100644
index 0000000000..23d2dd4051
--- /dev/null
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/api/synthetic_user/generate_v1_synthetic_user_generate_post.py
@@ -0,0 +1,253 @@
+from http import HTTPStatus
+from typing import Any
+
+import httpx
+
+from ... import errors
+from ...client import AuthenticatedClient, Client
+from ...models.generate_synthetic_users_request import GenerateSyntheticUsersRequest
+from ...models.generate_synthetic_users_response import GenerateSyntheticUsersResponse
+from ...models.generate_v1_synthetic_user_generate_post_response_401 import (
+ GenerateV1SyntheticUserGeneratePostResponse401,
+)
+from ...models.generate_v1_synthetic_user_generate_post_response_500 import (
+ GenerateV1SyntheticUserGeneratePostResponse500,
+)
+from ...models.generate_v1_synthetic_user_generate_post_response_502 import (
+ GenerateV1SyntheticUserGeneratePostResponse502,
+)
+from ...models.http_validation_error import HTTPValidationError
+from ...types import Response
+
+
+def _get_kwargs(
+ *,
+ body: GenerateSyntheticUsersRequest,
+) -> dict[str, Any]:
+ headers: dict[str, Any] = {}
+
+ _kwargs: dict[str, Any] = {
+ "method": "post",
+ "url": "/v1/synthetic_user/generate",
+ }
+
+ _kwargs["json"] = body.to_dict()
+
+ headers["Content-Type"] = "application/json"
+
+ _kwargs["headers"] = headers
+ return _kwargs
+
+
+def _parse_response(
+ *, client: AuthenticatedClient | Client, response: httpx.Response
+) -> (
+ GenerateSyntheticUsersResponse
+ | GenerateV1SyntheticUserGeneratePostResponse401
+ | GenerateV1SyntheticUserGeneratePostResponse500
+ | GenerateV1SyntheticUserGeneratePostResponse502
+ | HTTPValidationError
+ | None
+):
+ if response.status_code == 200:
+ response_200 = GenerateSyntheticUsersResponse.from_dict(response.json())
+
+ return response_200
+
+ if response.status_code == 401:
+ response_401 = GenerateV1SyntheticUserGeneratePostResponse401.from_dict(response.json())
+
+ return response_401
+
+ if response.status_code == 422:
+ response_422 = HTTPValidationError.from_dict(response.json())
+
+ return response_422
+
+ if response.status_code == 500:
+ response_500 = GenerateV1SyntheticUserGeneratePostResponse500.from_dict(response.json())
+
+ return response_500
+
+ if response.status_code == 502:
+ response_502 = GenerateV1SyntheticUserGeneratePostResponse502.from_dict(response.json())
+
+ return response_502
+
+ 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[
+ GenerateSyntheticUsersResponse
+ | GenerateV1SyntheticUserGeneratePostResponse401
+ | GenerateV1SyntheticUserGeneratePostResponse500
+ | GenerateV1SyntheticUserGeneratePostResponse502
+ | HTTPValidationError
+]:
+ return Response(
+ status_code=HTTPStatus(response.status_code),
+ content=response.content,
+ headers=response.headers,
+ parsed=_parse_response(client=client, response=response),
+ )
+
+
+def sync_detailed(
+ *,
+ client: AuthenticatedClient,
+ body: GenerateSyntheticUsersRequest,
+) -> Response[
+ GenerateSyntheticUsersResponse
+ | GenerateV1SyntheticUserGeneratePostResponse401
+ | GenerateV1SyntheticUserGeneratePostResponse500
+ | GenerateV1SyntheticUserGeneratePostResponse502
+ | HTTPValidationError
+]:
+ """Generate
+
+ Return `num_cases` synthetic-user cases for the authoring UX.
+
+ Args:
+ body (GenerateSyntheticUsersRequest): Request body for POST /v1/synthetic_user/generate.
+
+ Generates `num_cases` synthetic-user cases designed to probe
+ `target_specification` against the agent described by `target_task_prompt`,
+ across multi-turn conversations.
+
+ 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[GenerateSyntheticUsersResponse | GenerateV1SyntheticUserGeneratePostResponse401 | GenerateV1SyntheticUserGeneratePostResponse500 | GenerateV1SyntheticUserGeneratePostResponse502 | HTTPValidationError]
+ """
+
+ kwargs = _get_kwargs(
+ body=body,
+ )
+
+ response = client.get_httpx_client().request(
+ **kwargs,
+ )
+
+ return _build_response(client=client, response=response)
+
+
+def sync(
+ *,
+ client: AuthenticatedClient,
+ body: GenerateSyntheticUsersRequest,
+) -> (
+ GenerateSyntheticUsersResponse
+ | GenerateV1SyntheticUserGeneratePostResponse401
+ | GenerateV1SyntheticUserGeneratePostResponse500
+ | GenerateV1SyntheticUserGeneratePostResponse502
+ | HTTPValidationError
+ | None
+):
+ """Generate
+
+ Return `num_cases` synthetic-user cases for the authoring UX.
+
+ Args:
+ body (GenerateSyntheticUsersRequest): Request body for POST /v1/synthetic_user/generate.
+
+ Generates `num_cases` synthetic-user cases designed to probe
+ `target_specification` against the agent described by `target_task_prompt`,
+ across multi-turn conversations.
+
+ 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:
+ GenerateSyntheticUsersResponse | GenerateV1SyntheticUserGeneratePostResponse401 | GenerateV1SyntheticUserGeneratePostResponse500 | GenerateV1SyntheticUserGeneratePostResponse502 | HTTPValidationError
+ """
+
+ return sync_detailed(
+ client=client,
+ body=body,
+ ).parsed
+
+
+async def asyncio_detailed(
+ *,
+ client: AuthenticatedClient,
+ body: GenerateSyntheticUsersRequest,
+) -> Response[
+ GenerateSyntheticUsersResponse
+ | GenerateV1SyntheticUserGeneratePostResponse401
+ | GenerateV1SyntheticUserGeneratePostResponse500
+ | GenerateV1SyntheticUserGeneratePostResponse502
+ | HTTPValidationError
+]:
+ """Generate
+
+ Return `num_cases` synthetic-user cases for the authoring UX.
+
+ Args:
+ body (GenerateSyntheticUsersRequest): Request body for POST /v1/synthetic_user/generate.
+
+ Generates `num_cases` synthetic-user cases designed to probe
+ `target_specification` against the agent described by `target_task_prompt`,
+ across multi-turn conversations.
+
+ 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[GenerateSyntheticUsersResponse | GenerateV1SyntheticUserGeneratePostResponse401 | GenerateV1SyntheticUserGeneratePostResponse500 | GenerateV1SyntheticUserGeneratePostResponse502 | HTTPValidationError]
+ """
+
+ kwargs = _get_kwargs(
+ body=body,
+ )
+
+ response = await client.get_async_httpx_client().request(**kwargs)
+
+ return _build_response(client=client, response=response)
+
+
+async def asyncio(
+ *,
+ client: AuthenticatedClient,
+ body: GenerateSyntheticUsersRequest,
+) -> (
+ GenerateSyntheticUsersResponse
+ | GenerateV1SyntheticUserGeneratePostResponse401
+ | GenerateV1SyntheticUserGeneratePostResponse500
+ | GenerateV1SyntheticUserGeneratePostResponse502
+ | HTTPValidationError
+ | None
+):
+ """Generate
+
+ Return `num_cases` synthetic-user cases for the authoring UX.
+
+ Args:
+ body (GenerateSyntheticUsersRequest): Request body for POST /v1/synthetic_user/generate.
+
+ Generates `num_cases` synthetic-user cases designed to probe
+ `target_specification` against the agent described by `target_task_prompt`,
+ across multi-turn conversations.
+
+ 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:
+ GenerateSyntheticUsersResponse | GenerateV1SyntheticUserGeneratePostResponse401 | GenerateV1SyntheticUserGeneratePostResponse500 | GenerateV1SyntheticUserGeneratePostResponse502 | HTTPValidationError
+ """
+
+ return (
+ await asyncio_detailed(
+ client=client,
+ body=body,
+ )
+ ).parsed
diff --git a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/__init__.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/__init__.py
index 2fc0ec05a9..b5814fc943 100644
--- a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/__init__.py
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/__init__.py
@@ -55,6 +55,14 @@
from .generate_batch_input import GenerateBatchInput
from .generate_batch_output import GenerateBatchOutput
from .generate_batch_output_data_by_topic import GenerateBatchOutputDataByTopic
+from .generate_synthetic_users_request import GenerateSyntheticUsersRequest
+from .generate_synthetic_users_response import GenerateSyntheticUsersResponse
+from .generate_v1_synthetic_user_generate_post_response_401 import GenerateV1SyntheticUserGeneratePostResponse401
+from .generate_v1_synthetic_user_generate_post_response_500 import GenerateV1SyntheticUserGeneratePostResponse500
+from .generate_v1_synthetic_user_generate_post_response_502 import GenerateV1SyntheticUserGeneratePostResponse502
+from .generate_v1_synthetic_user_generate_post_response_502_code import (
+ GenerateV1SyntheticUserGeneratePostResponse502Code,
+)
from .get_session_v1_chat_sessions_session_id_get_response_400 import GetSessionV1ChatSessionsSessionIdGetResponse400
from .get_session_v1_chat_sessions_session_id_get_response_404 import GetSessionV1ChatSessionsSessionIdGetResponse404
from .get_session_v1_chat_sessions_session_id_get_response_426 import GetSessionV1ChatSessionsSessionIdGetResponse426
@@ -83,6 +91,7 @@
from .mcp_tool_reference import MCPToolReference
from .mcp_tool_reference_input_schema_type_0 import MCPToolReferenceInputSchemaType0
from .mcp_tool_reference_output_schema_type_0 import MCPToolReferenceOutputSchemaType0
+from .message_usage import MessageUsage
from .model_provider_name import ModelProviderName
from .new_proposed_spec_edit_api import NewProposedSpecEditApi
from .output_file_info import OutputFileInfo
@@ -110,6 +119,7 @@
from .synthetic_data_generation_session_config_input import SyntheticDataGenerationSessionConfigInput
from .synthetic_data_generation_step_config import SyntheticDataGenerationStepConfig
from .synthetic_data_generation_step_config_input import SyntheticDataGenerationStepConfigInput
+from .synthetic_user_case import SyntheticUserCase
from .task_info import TaskInfo
from .task_metadata import TaskMetadata
from .task_output import TaskOutput
@@ -166,6 +176,12 @@
"GenerateBatchInput",
"GenerateBatchOutput",
"GenerateBatchOutputDataByTopic",
+ "GenerateSyntheticUsersRequest",
+ "GenerateSyntheticUsersResponse",
+ "GenerateV1SyntheticUserGeneratePostResponse401",
+ "GenerateV1SyntheticUserGeneratePostResponse500",
+ "GenerateV1SyntheticUserGeneratePostResponse502",
+ "GenerateV1SyntheticUserGeneratePostResponse502Code",
"GetSessionV1ChatSessionsSessionIdGetResponse400",
"GetSessionV1ChatSessionsSessionIdGetResponse404",
"GetSessionV1ChatSessionsSessionIdGetResponse426",
@@ -194,6 +210,7 @@
"MCPToolReference",
"MCPToolReferenceInputSchemaType0",
"MCPToolReferenceOutputSchemaType0",
+ "MessageUsage",
"ModelProviderName",
"NewProposedSpecEditApi",
"OutputFileInfo",
@@ -221,6 +238,7 @@
"SyntheticDataGenerationSessionConfigInput",
"SyntheticDataGenerationStepConfig",
"SyntheticDataGenerationStepConfigInput",
+ "SyntheticUserCase",
"TaskInfo",
"TaskMetadata",
"TaskOutput",
diff --git a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/chat_completion_assistant_message_param_wrapper.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/chat_completion_assistant_message_param_wrapper.py
index dba1f05d97..f9b0a4f0fe 100644
--- a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/chat_completion_assistant_message_param_wrapper.py
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/chat_completion_assistant_message_param_wrapper.py
@@ -14,6 +14,7 @@
from ..models.chat_completion_content_part_text_param import ChatCompletionContentPartTextParam
from ..models.chat_completion_message_function_tool_call_param import ChatCompletionMessageFunctionToolCallParam
from ..models.function_call import FunctionCall
+ from ..models.message_usage import MessageUsage
T = TypeVar("T", bound="ChatCompletionAssistantMessageParamWrapper")
@@ -37,6 +38,8 @@ class ChatCompletionAssistantMessageParamWrapper:
name (str | Unset):
refusal (None | str | Unset):
tool_calls (list[ChatCompletionMessageFunctionToolCallParam] | Unset):
+ latency_ms (int | None | Unset):
+ usage (MessageUsage | None | Unset):
"""
role: Literal["assistant"]
@@ -49,12 +52,15 @@ class ChatCompletionAssistantMessageParamWrapper:
name: str | Unset = UNSET
refusal: None | str | Unset = UNSET
tool_calls: list[ChatCompletionMessageFunctionToolCallParam] | Unset = UNSET
+ latency_ms: int | None | Unset = UNSET
+ usage: MessageUsage | None | Unset = UNSET
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
def to_dict(self) -> dict[str, Any]:
from ..models.audio import Audio
from ..models.chat_completion_content_part_text_param import ChatCompletionContentPartTextParam
from ..models.function_call import FunctionCall
+ from ..models.message_usage import MessageUsage
role = self.role
@@ -112,6 +118,20 @@ def to_dict(self) -> dict[str, Any]:
tool_calls_item = tool_calls_item_data.to_dict()
tool_calls.append(tool_calls_item)
+ latency_ms: int | None | Unset
+ if isinstance(self.latency_ms, Unset):
+ latency_ms = UNSET
+ else:
+ latency_ms = self.latency_ms
+
+ usage: dict[str, Any] | None | Unset
+ if isinstance(self.usage, Unset):
+ usage = UNSET
+ elif isinstance(self.usage, MessageUsage):
+ usage = self.usage.to_dict()
+ else:
+ usage = self.usage
+
field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update(
@@ -133,6 +153,10 @@ def to_dict(self) -> dict[str, Any]:
field_dict["refusal"] = refusal
if tool_calls is not UNSET:
field_dict["tool_calls"] = tool_calls
+ if latency_ms is not UNSET:
+ field_dict["latency_ms"] = latency_ms
+ if usage is not UNSET:
+ field_dict["usage"] = usage
return field_dict
@@ -143,6 +167,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
from ..models.chat_completion_content_part_text_param import ChatCompletionContentPartTextParam
from ..models.chat_completion_message_function_tool_call_param import ChatCompletionMessageFunctionToolCallParam
from ..models.function_call import FunctionCall
+ from ..models.message_usage import MessageUsage
d = dict(src_dict)
role = cast(Literal["assistant"], d.pop("role"))
@@ -257,6 +282,32 @@ def _parse_refusal(data: object) -> None | str | Unset:
tool_calls.append(tool_calls_item)
+ def _parse_latency_ms(data: object) -> int | None | Unset:
+ if data is None:
+ return data
+ if isinstance(data, Unset):
+ return data
+ return cast(int | None | Unset, data)
+
+ latency_ms = _parse_latency_ms(d.pop("latency_ms", UNSET))
+
+ def _parse_usage(data: object) -> MessageUsage | None | Unset:
+ if data is None:
+ return data
+ if isinstance(data, Unset):
+ return data
+ try:
+ if not isinstance(data, dict):
+ raise TypeError()
+ usage_type_0 = MessageUsage.from_dict(data)
+
+ return usage_type_0
+ except (TypeError, ValueError, AttributeError, KeyError):
+ pass
+ return cast(MessageUsage | None | Unset, data)
+
+ usage = _parse_usage(d.pop("usage", UNSET))
+
chat_completion_assistant_message_param_wrapper = cls(
role=role,
audio=audio,
@@ -266,6 +317,8 @@ def _parse_refusal(data: object) -> None | str | Unset:
name=name,
refusal=refusal,
tool_calls=tool_calls,
+ latency_ms=latency_ms,
+ usage=usage,
)
chat_completion_assistant_message_param_wrapper.additional_properties = d
diff --git a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/chat_session_list_item.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/chat_session_list_item.py
index 7a93d1a7d2..b0ae1f24f1 100644
--- a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/chat_session_list_item.py
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/chat_session_list_item.py
@@ -6,7 +6,6 @@
from attrs import define as _attrs_define
from attrs import field as _attrs_field
-from dateutil.parser import isoparse
T = TypeVar("T", bound="ChatSessionListItem")
@@ -54,7 +53,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
d = dict(src_dict)
id = d.pop("id")
- updated_at = isoparse(d.pop("updated_at"))
+ updated_at = datetime.datetime.fromisoformat(d.pop("updated_at"))
title = d.pop("title")
diff --git a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_synthetic_users_request.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_synthetic_users_request.py
new file mode 100644
index 0000000000..537e01d1a4
--- /dev/null
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_synthetic_users_request.py
@@ -0,0 +1,85 @@
+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="GenerateSyntheticUsersRequest")
+
+
+@_attrs_define
+class GenerateSyntheticUsersRequest:
+ """Request body for POST /v1/synthetic_user/generate.
+
+ Generates `num_cases` synthetic-user cases designed to probe
+ `target_specification` against the agent described by `target_task_prompt`,
+ across multi-turn conversations.
+
+ Attributes:
+ target_task_prompt (str): Complete prompt of the target task (the AI assistant under evaluation). Used as
+ material for designing realistic synthetic users; never executed by this endpoint.
+ target_specification (str): Behavior or issue to investigate about the target task (e.g. 'hallucinates tax-year-
+ specific rules for years before 2018'). Generated cases are designed so a multi-turn conversation will naturally
+ surface this behavior.
+ num_cases (int): Number of synthetic-user cases to generate (1-50).
+ """
+
+ target_task_prompt: str
+ target_specification: str
+ num_cases: int
+ additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ target_task_prompt = self.target_task_prompt
+
+ target_specification = self.target_specification
+
+ num_cases = self.num_cases
+
+ field_dict: dict[str, Any] = {}
+ field_dict.update(self.additional_properties)
+ field_dict.update(
+ {
+ "target_task_prompt": target_task_prompt,
+ "target_specification": target_specification,
+ "num_cases": num_cases,
+ }
+ )
+
+ return field_dict
+
+ @classmethod
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
+ d = dict(src_dict)
+ target_task_prompt = d.pop("target_task_prompt")
+
+ target_specification = d.pop("target_specification")
+
+ num_cases = d.pop("num_cases")
+
+ generate_synthetic_users_request = cls(
+ target_task_prompt=target_task_prompt,
+ target_specification=target_specification,
+ num_cases=num_cases,
+ )
+
+ generate_synthetic_users_request.additional_properties = d
+ return generate_synthetic_users_request
+
+ @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/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_synthetic_users_response.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_synthetic_users_response.py
new file mode 100644
index 0000000000..4ef08c2b99
--- /dev/null
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_synthetic_users_response.py
@@ -0,0 +1,82 @@
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import TYPE_CHECKING, Any, TypeVar
+
+from attrs import define as _attrs_define
+from attrs import field as _attrs_field
+
+if TYPE_CHECKING:
+ from ..models.synthetic_user_case import SyntheticUserCase
+
+
+T = TypeVar("T", bound="GenerateSyntheticUsersResponse")
+
+
+@_attrs_define
+class GenerateSyntheticUsersResponse:
+ """Response body for POST /v1/synthetic_user/generate.
+
+ `cases` is the strict-N batch contract: exactly `num_cases` cases on
+ success. If any single generated case is malformed (e.g. an empty
+ `synthetic_user_info` blob), the whole batch fails with HTTP 502
+ `upstream_invalid_output` — the server does not return a partial batch.
+ Clients should re-call to retry.
+
+ Attributes:
+ cases (list[SyntheticUserCase]):
+ """
+
+ cases: list[SyntheticUserCase]
+ additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ cases = []
+ for cases_item_data in self.cases:
+ cases_item = cases_item_data.to_dict()
+ cases.append(cases_item)
+
+ field_dict: dict[str, Any] = {}
+ field_dict.update(self.additional_properties)
+ field_dict.update(
+ {
+ "cases": cases,
+ }
+ )
+
+ return field_dict
+
+ @classmethod
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
+ from ..models.synthetic_user_case import SyntheticUserCase
+
+ d = dict(src_dict)
+ cases = []
+ _cases = d.pop("cases")
+ for cases_item_data in _cases:
+ cases_item = SyntheticUserCase.from_dict(cases_item_data)
+
+ cases.append(cases_item)
+
+ generate_synthetic_users_response = cls(
+ cases=cases,
+ )
+
+ generate_synthetic_users_response.additional_properties = d
+ return generate_synthetic_users_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/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_v1_synthetic_user_generate_post_response_401.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_v1_synthetic_user_generate_post_response_401.py
new file mode 100644
index 0000000000..449862a4e2
--- /dev/null
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_v1_synthetic_user_generate_post_response_401.py
@@ -0,0 +1,72 @@
+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
+
+from ..types import UNSET, Unset
+
+T = TypeVar("T", bound="GenerateV1SyntheticUserGeneratePostResponse401")
+
+
+@_attrs_define
+class GenerateV1SyntheticUserGeneratePostResponse401:
+ """
+ Attributes:
+ message (str):
+ code (str | Unset):
+ """
+
+ message: str
+ code: str | Unset = UNSET
+ additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ message = self.message
+
+ code = self.code
+
+ field_dict: dict[str, Any] = {}
+ field_dict.update(self.additional_properties)
+ field_dict.update(
+ {
+ "message": message,
+ }
+ )
+ if code is not UNSET:
+ field_dict["code"] = code
+
+ return field_dict
+
+ @classmethod
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
+ d = dict(src_dict)
+ message = d.pop("message")
+
+ code = d.pop("code", UNSET)
+
+ generate_v1_synthetic_user_generate_post_response_401 = cls(
+ message=message,
+ code=code,
+ )
+
+ generate_v1_synthetic_user_generate_post_response_401.additional_properties = d
+ return generate_v1_synthetic_user_generate_post_response_401
+
+ @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/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_v1_synthetic_user_generate_post_response_500.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_v1_synthetic_user_generate_post_response_500.py
new file mode 100644
index 0000000000..3dfa393ba9
--- /dev/null
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_v1_synthetic_user_generate_post_response_500.py
@@ -0,0 +1,72 @@
+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
+
+from ..types import UNSET, Unset
+
+T = TypeVar("T", bound="GenerateV1SyntheticUserGeneratePostResponse500")
+
+
+@_attrs_define
+class GenerateV1SyntheticUserGeneratePostResponse500:
+ """
+ Attributes:
+ message (str):
+ code (str | Unset):
+ """
+
+ message: str
+ code: str | Unset = UNSET
+ additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ message = self.message
+
+ code = self.code
+
+ field_dict: dict[str, Any] = {}
+ field_dict.update(self.additional_properties)
+ field_dict.update(
+ {
+ "message": message,
+ }
+ )
+ if code is not UNSET:
+ field_dict["code"] = code
+
+ return field_dict
+
+ @classmethod
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
+ d = dict(src_dict)
+ message = d.pop("message")
+
+ code = d.pop("code", UNSET)
+
+ generate_v1_synthetic_user_generate_post_response_500 = cls(
+ message=message,
+ code=code,
+ )
+
+ generate_v1_synthetic_user_generate_post_response_500.additional_properties = d
+ return generate_v1_synthetic_user_generate_post_response_500
+
+ @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/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_v1_synthetic_user_generate_post_response_502.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_v1_synthetic_user_generate_post_response_502.py
new file mode 100644
index 0000000000..fa17fda3a6
--- /dev/null
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_v1_synthetic_user_generate_post_response_502.py
@@ -0,0 +1,73 @@
+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
+
+from ..models.generate_v1_synthetic_user_generate_post_response_502_code import (
+ GenerateV1SyntheticUserGeneratePostResponse502Code,
+)
+
+T = TypeVar("T", bound="GenerateV1SyntheticUserGeneratePostResponse502")
+
+
+@_attrs_define
+class GenerateV1SyntheticUserGeneratePostResponse502:
+ """
+ Attributes:
+ message (str):
+ code (GenerateV1SyntheticUserGeneratePostResponse502Code):
+ """
+
+ message: str
+ code: GenerateV1SyntheticUserGeneratePostResponse502Code
+ additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ message = self.message
+
+ code = self.code.value
+
+ field_dict: dict[str, Any] = {}
+ field_dict.update(self.additional_properties)
+ field_dict.update(
+ {
+ "message": message,
+ "code": code,
+ }
+ )
+
+ return field_dict
+
+ @classmethod
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
+ d = dict(src_dict)
+ message = d.pop("message")
+
+ code = GenerateV1SyntheticUserGeneratePostResponse502Code(d.pop("code"))
+
+ generate_v1_synthetic_user_generate_post_response_502 = cls(
+ message=message,
+ code=code,
+ )
+
+ generate_v1_synthetic_user_generate_post_response_502.additional_properties = d
+ return generate_v1_synthetic_user_generate_post_response_502
+
+ @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/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_v1_synthetic_user_generate_post_response_502_code.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_v1_synthetic_user_generate_post_response_502_code.py
new file mode 100644
index 0000000000..d91cbe1e6b
--- /dev/null
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_v1_synthetic_user_generate_post_response_502_code.py
@@ -0,0 +1,9 @@
+from enum import Enum
+
+
+class GenerateV1SyntheticUserGeneratePostResponse502Code(str, Enum):
+ LLM_UNAVAILABLE = "llm_unavailable"
+ UPSTREAM_INVALID_OUTPUT = "upstream_invalid_output"
+
+ def __str__(self) -> str:
+ return str(self.value)
diff --git a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/kiln_base_model.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/kiln_base_model.py
index 7a03b636eb..74084a998a 100644
--- a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/kiln_base_model.py
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/kiln_base_model.py
@@ -6,7 +6,6 @@
from attrs import define as _attrs_define
from attrs import field as _attrs_field
-from dateutil.parser import isoparse
from ..types import UNSET, Unset
@@ -115,7 +114,7 @@ def _parse_path(data: object) -> None | str | Unset:
if isinstance(_created_at, Unset):
created_at = UNSET
else:
- created_at = isoparse(_created_at)
+ created_at = datetime.datetime.fromisoformat(_created_at)
created_by = d.pop("created_by", UNSET)
diff --git a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/message_usage.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/message_usage.py
new file mode 100644
index 0000000000..65157b11b8
--- /dev/null
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/message_usage.py
@@ -0,0 +1,164 @@
+from __future__ import annotations
+
+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 ..types import UNSET, Unset
+
+T = TypeVar("T", bound="MessageUsage")
+
+
+@_attrs_define
+class MessageUsage:
+ """Token usage and cost for a single LLM call or a multi-message sum.
+
+ Carries only the fields that are meaningfully aggregatable across
+ messages: token counts and cost. Per-call latency lives on the
+ individual message's ``latency_ms`` field; aggregating it across the
+ full trace would mix latencies from different points in time, so
+ ``MessageUsage`` does NOT carry ``total_llm_latency_ms``.
+
+ The :class:`Usage` subclass adds ``total_llm_latency_ms`` for the
+ in-flight per-run accumulator that tracks how long this run spent
+ waiting on LLM calls.
+
+ Attributes:
+ input_tokens (int | None | Unset): The number of input tokens used.
+ output_tokens (int | None | Unset): The number of output tokens used.
+ total_tokens (int | None | Unset): The total number of tokens used.
+ cost (float | None | Unset): The cost in US dollars, saved at runtime (prices can change over time).
+ cached_tokens (int | None | Unset): Number of tokens served from prompt cache. None if not reported.
+ """
+
+ input_tokens: int | None | Unset = UNSET
+ output_tokens: int | None | Unset = UNSET
+ total_tokens: int | None | Unset = UNSET
+ cost: float | None | Unset = UNSET
+ cached_tokens: int | None | Unset = UNSET
+ additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ input_tokens: int | None | Unset
+ if isinstance(self.input_tokens, Unset):
+ input_tokens = UNSET
+ else:
+ input_tokens = self.input_tokens
+
+ output_tokens: int | None | Unset
+ if isinstance(self.output_tokens, Unset):
+ output_tokens = UNSET
+ else:
+ output_tokens = self.output_tokens
+
+ total_tokens: int | None | Unset
+ if isinstance(self.total_tokens, Unset):
+ total_tokens = UNSET
+ else:
+ total_tokens = self.total_tokens
+
+ cost: float | None | Unset
+ if isinstance(self.cost, Unset):
+ cost = UNSET
+ else:
+ cost = self.cost
+
+ cached_tokens: int | None | Unset
+ if isinstance(self.cached_tokens, Unset):
+ cached_tokens = UNSET
+ else:
+ cached_tokens = self.cached_tokens
+
+ field_dict: dict[str, Any] = {}
+ field_dict.update(self.additional_properties)
+ field_dict.update({})
+ if input_tokens is not UNSET:
+ field_dict["input_tokens"] = input_tokens
+ if output_tokens is not UNSET:
+ field_dict["output_tokens"] = output_tokens
+ if total_tokens is not UNSET:
+ field_dict["total_tokens"] = total_tokens
+ if cost is not UNSET:
+ field_dict["cost"] = cost
+ if cached_tokens is not UNSET:
+ field_dict["cached_tokens"] = cached_tokens
+
+ return field_dict
+
+ @classmethod
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
+ d = dict(src_dict)
+
+ def _parse_input_tokens(data: object) -> int | None | Unset:
+ if data is None:
+ return data
+ if isinstance(data, Unset):
+ return data
+ return cast(int | None | Unset, data)
+
+ input_tokens = _parse_input_tokens(d.pop("input_tokens", UNSET))
+
+ def _parse_output_tokens(data: object) -> int | None | Unset:
+ if data is None:
+ return data
+ if isinstance(data, Unset):
+ return data
+ return cast(int | None | Unset, data)
+
+ output_tokens = _parse_output_tokens(d.pop("output_tokens", UNSET))
+
+ def _parse_total_tokens(data: object) -> int | None | Unset:
+ if data is None:
+ return data
+ if isinstance(data, Unset):
+ return data
+ return cast(int | None | Unset, data)
+
+ total_tokens = _parse_total_tokens(d.pop("total_tokens", UNSET))
+
+ def _parse_cost(data: object) -> float | None | Unset:
+ if data is None:
+ return data
+ if isinstance(data, Unset):
+ return data
+ return cast(float | None | Unset, data)
+
+ cost = _parse_cost(d.pop("cost", UNSET))
+
+ def _parse_cached_tokens(data: object) -> int | None | Unset:
+ if data is None:
+ return data
+ if isinstance(data, Unset):
+ return data
+ return cast(int | None | Unset, data)
+
+ cached_tokens = _parse_cached_tokens(d.pop("cached_tokens", UNSET))
+
+ message_usage = cls(
+ input_tokens=input_tokens,
+ output_tokens=output_tokens,
+ total_tokens=total_tokens,
+ cost=cost,
+ cached_tokens=cached_tokens,
+ )
+
+ message_usage.additional_properties = d
+ return message_usage
+
+ @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/app/desktop/studio_server/api_client/kiln_ai_server_client/models/synthetic_user_case.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/synthetic_user_case.py
new file mode 100644
index 0000000000..2940f82840
--- /dev/null
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/synthetic_user_case.py
@@ -0,0 +1,72 @@
+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="SyntheticUserCase")
+
+
+@_attrs_define
+class SyntheticUserCase:
+ """One generated synthetic-user case used to seed a probing conversation.
+
+ Attributes:
+ seed_prompt (str): Synthetic user's first message, written in their own voice.
+ synthetic_user_info (str): XML-tagged blob describing the synthetic user, in the format
+ `.........`. Parse client-side. Tag names
+ are stable; future versions may add new optional tags, but existing tags will not be renamed or removed.
+ """
+
+ seed_prompt: str
+ synthetic_user_info: str
+ additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ seed_prompt = self.seed_prompt
+
+ synthetic_user_info = self.synthetic_user_info
+
+ field_dict: dict[str, Any] = {}
+ field_dict.update(self.additional_properties)
+ field_dict.update(
+ {
+ "seed_prompt": seed_prompt,
+ "synthetic_user_info": synthetic_user_info,
+ }
+ )
+
+ return field_dict
+
+ @classmethod
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
+ d = dict(src_dict)
+ seed_prompt = d.pop("seed_prompt")
+
+ synthetic_user_info = d.pop("synthetic_user_info")
+
+ synthetic_user_case = cls(
+ seed_prompt=seed_prompt,
+ synthetic_user_info=synthetic_user_info,
+ )
+
+ synthetic_user_case.additional_properties = d
+ return synthetic_user_case
+
+ @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/app/desktop/studio_server/api_client/kiln_ai_server_client/models/task_output.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/task_output.py
index 5ad895f12c..d00a4b5397 100644
--- a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/task_output.py
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/task_output.py
@@ -6,7 +6,6 @@
from attrs import define as _attrs_define
from attrs import field as _attrs_field
-from dateutil.parser import isoparse
from ..types import UNSET, Unset
@@ -153,7 +152,7 @@ def _parse_path(data: object) -> None | str | Unset:
if isinstance(_created_at, Unset):
created_at = UNSET
else:
- created_at = isoparse(_created_at)
+ created_at = datetime.datetime.fromisoformat(_created_at)
created_by = d.pop("created_by", UNSET)
diff --git a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/task_output_rating.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/task_output_rating.py
index c8fcad6f53..787a4a5a2e 100644
--- a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/task_output_rating.py
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/task_output_rating.py
@@ -6,7 +6,6 @@
from attrs import define as _attrs_define
from attrs import field as _attrs_field
-from dateutil.parser import isoparse
from ..models.task_output_rating_type import TaskOutputRatingType
from ..types import UNSET, Unset
@@ -151,7 +150,7 @@ def _parse_path(data: object) -> None | str | Unset:
if isinstance(_created_at, Unset):
created_at = UNSET
else:
- created_at = isoparse(_created_at)
+ created_at = datetime.datetime.fromisoformat(_created_at)
created_by = d.pop("created_by", UNSET)
diff --git a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/task_run.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/task_run.py
index 51913716fe..25baeb286c 100644
--- a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/task_run.py
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/task_run.py
@@ -6,7 +6,6 @@
from attrs import define as _attrs_define
from attrs import field as _attrs_field
-from dateutil.parser import isoparse
from ..types import UNSET, Unset
@@ -18,6 +17,7 @@
from ..models.chat_completion_tool_message_param_wrapper import ChatCompletionToolMessageParamWrapper
from ..models.chat_completion_user_message_param import ChatCompletionUserMessageParam
from ..models.data_source import DataSource
+ from ..models.message_usage import MessageUsage
from ..models.task_output import TaskOutput
from ..models.task_run_intermediate_outputs_type_0 import TaskRunIntermediateOutputsType0
from ..models.usage import Usage
@@ -59,6 +59,9 @@ class TaskRun:
reporting.
usage (None | Unset | Usage): Usage information for the task run. This includes the number of input tokens,
output tokens, and total tokens used.
+ cumulative_usage (MessageUsage | None | Unset): Sum of per-message token usage and cost across the entire trace,
+ including any seeded prior trace. None on records created before this field existed. For a fresh (non-seeded)
+ run, the token / cost fields equal those of `usage`.
trace (list[ChatCompletionAssistantMessageParamWrapper | ChatCompletionDeveloperMessageParam |
ChatCompletionFunctionMessageParam | ChatCompletionSystemMessageParam | ChatCompletionToolMessageParamWrapper |
ChatCompletionUserMessageParam] | None | Unset): The trace of the task run in OpenAI format. This is the list of
@@ -81,6 +84,7 @@ class TaskRun:
intermediate_outputs: None | TaskRunIntermediateOutputsType0 | Unset = UNSET
tags: list[str] | Unset = UNSET
usage: None | Unset | Usage = UNSET
+ cumulative_usage: MessageUsage | None | Unset = UNSET
trace: (
list[
ChatCompletionAssistantMessageParamWrapper
@@ -103,6 +107,7 @@ def to_dict(self) -> dict[str, Any]:
from ..models.chat_completion_tool_message_param_wrapper import ChatCompletionToolMessageParamWrapper
from ..models.chat_completion_user_message_param import ChatCompletionUserMessageParam
from ..models.data_source import DataSource
+ from ..models.message_usage import MessageUsage
from ..models.task_output import TaskOutput
from ..models.task_run_intermediate_outputs_type_0 import TaskRunIntermediateOutputsType0
from ..models.usage import Usage
@@ -175,6 +180,14 @@ def to_dict(self) -> dict[str, Any]:
else:
usage = self.usage
+ cumulative_usage: dict[str, Any] | None | Unset
+ if isinstance(self.cumulative_usage, Unset):
+ cumulative_usage = UNSET
+ elif isinstance(self.cumulative_usage, MessageUsage):
+ cumulative_usage = self.cumulative_usage.to_dict()
+ else:
+ cumulative_usage = self.cumulative_usage
+
trace: list[dict[str, Any]] | None | Unset
if isinstance(self.trace, Unset):
trace = UNSET
@@ -237,6 +250,8 @@ def to_dict(self) -> dict[str, Any]:
field_dict["tags"] = tags
if usage is not UNSET:
field_dict["usage"] = usage
+ if cumulative_usage is not UNSET:
+ field_dict["cumulative_usage"] = cumulative_usage
if trace is not UNSET:
field_dict["trace"] = trace
if parent_task_run_id is not UNSET:
@@ -253,6 +268,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
from ..models.chat_completion_tool_message_param_wrapper import ChatCompletionToolMessageParamWrapper
from ..models.chat_completion_user_message_param import ChatCompletionUserMessageParam
from ..models.data_source import DataSource
+ from ..models.message_usage import MessageUsage
from ..models.task_output import TaskOutput
from ..models.task_run_intermediate_outputs_type_0 import TaskRunIntermediateOutputsType0
from ..models.usage import Usage
@@ -289,7 +305,7 @@ def _parse_path(data: object) -> None | str | Unset:
if isinstance(_created_at, Unset):
created_at = UNSET
else:
- created_at = isoparse(_created_at)
+ created_at = datetime.datetime.fromisoformat(_created_at)
created_by = d.pop("created_by", UNSET)
@@ -372,6 +388,23 @@ def _parse_usage(data: object) -> None | Unset | Usage:
usage = _parse_usage(d.pop("usage", UNSET))
+ def _parse_cumulative_usage(data: object) -> MessageUsage | None | Unset:
+ if data is None:
+ return data
+ if isinstance(data, Unset):
+ return data
+ try:
+ if not isinstance(data, dict):
+ raise TypeError()
+ cumulative_usage_type_0 = MessageUsage.from_dict(data)
+
+ return cumulative_usage_type_0
+ except (TypeError, ValueError, AttributeError, KeyError):
+ pass
+ return cast(MessageUsage | None | Unset, data)
+
+ cumulative_usage = _parse_cumulative_usage(d.pop("cumulative_usage", UNSET))
+
def _parse_trace(
data: object,
) -> (
@@ -500,6 +533,7 @@ def _parse_parent_task_run_id(data: object) -> None | str | Unset:
intermediate_outputs=intermediate_outputs,
tags=tags,
usage=usage,
+ cumulative_usage=cumulative_usage,
trace=trace,
parent_task_run_id=parent_task_run_id,
)
diff --git a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/usage.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/usage.py
index 970b2578e9..68e1de6951 100644
--- a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/usage.py
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/usage.py
@@ -13,15 +13,22 @@
@_attrs_define
class Usage:
- """Token usage and cost information for a task run.
-
- Attributes:
- input_tokens (int | None | Unset): The number of input tokens used in the task run.
- output_tokens (int | None | Unset): The number of output tokens used in the task run.
- total_tokens (int | None | Unset): The total number of tokens used in the task run.
- cost (float | None | Unset): The cost of the task run in US dollars, saved at runtime (prices can change over
- time).
- cached_tokens (int | None | Unset): Number of tokens served from prompt cache. None if not reported.
+ """Token usage, cost, and aggregate LLM latency for a per-run accumulator.
+
+ Extends :class:`MessageUsage` with ``total_llm_latency_ms``, which is
+ only meaningful while a single run is in flight (its model calls run
+ sequentially in real time). For per-message records and full-trace
+ sums use :class:`MessageUsage` — those values would mix latencies
+ from different points in time, so the field doesn't apply.
+
+ Attributes:
+ input_tokens (int | None | Unset): The number of input tokens used.
+ output_tokens (int | None | Unset): The number of output tokens used.
+ total_tokens (int | None | Unset): The total number of tokens used.
+ cost (float | None | Unset): The cost in US dollars, saved at runtime (prices can change over time).
+ cached_tokens (int | None | Unset): Number of tokens served from prompt cache. None if not reported.
+ total_llm_latency_ms (int | None | Unset): Total time spent waiting on LLM API calls in milliseconds. Sum of
+ per-call latencies, excludes tool execution time.
"""
input_tokens: int | None | Unset = UNSET
@@ -29,6 +36,7 @@ class Usage:
total_tokens: int | None | Unset = UNSET
cost: float | None | Unset = UNSET
cached_tokens: int | None | Unset = UNSET
+ total_llm_latency_ms: int | None | Unset = UNSET
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
def to_dict(self) -> dict[str, Any]:
@@ -62,6 +70,12 @@ def to_dict(self) -> dict[str, Any]:
else:
cached_tokens = self.cached_tokens
+ total_llm_latency_ms: int | None | Unset
+ if isinstance(self.total_llm_latency_ms, Unset):
+ total_llm_latency_ms = UNSET
+ else:
+ total_llm_latency_ms = self.total_llm_latency_ms
+
field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update({})
@@ -75,6 +89,8 @@ def to_dict(self) -> dict[str, Any]:
field_dict["cost"] = cost
if cached_tokens is not UNSET:
field_dict["cached_tokens"] = cached_tokens
+ if total_llm_latency_ms is not UNSET:
+ field_dict["total_llm_latency_ms"] = total_llm_latency_ms
return field_dict
@@ -127,12 +143,22 @@ def _parse_cached_tokens(data: object) -> int | None | Unset:
cached_tokens = _parse_cached_tokens(d.pop("cached_tokens", UNSET))
+ def _parse_total_llm_latency_ms(data: object) -> int | None | Unset:
+ if data is None:
+ return data
+ if isinstance(data, Unset):
+ return data
+ return cast(int | None | Unset, data)
+
+ total_llm_latency_ms = _parse_total_llm_latency_ms(d.pop("total_llm_latency_ms", UNSET))
+
usage = cls(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
cost=cost,
cached_tokens=cached_tokens,
+ total_llm_latency_ms=total_llm_latency_ms,
)
usage.additional_properties = d
From c122022627ba1b0c4bb299da524b3873a51c6ec1 Mon Sep 17 00:00:00 2001
From: aiyakitori <16633065+chiang-daniel@users.noreply.github.com>
Date: Mon, 1 Jun 2026 16:07:57 -0700
Subject: [PATCH 02/36] feat(libs/core): add synthetic_user player module
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
OSS-side per-turn synthetic-user invocation — the replacement for
kiln_server's removed /respond endpoint. Lives in
libs/core/kiln_ai/synthetic_user/ so the runner can call the LLM using
the user's own provider keys rather than a hosted endpoint.
Modules:
- models — Pydantic SyntheticUserInfo (parsed form) + SyntheticUserDriverConfig.
- parser — tagged-blob ↔ SyntheticUserInfo. Required: , ;
optional: . Unknown tags ignored (forward-compat).
- role_swap — flips eval-frame user/assistant labels into LLM-frame labels;
raises on system/tool roles (the driver filters those upstream) and on
non-string content.
- prompt — persona-playing system prompt. No / guidance:
drive loop is fixed-length; SU stays engaged across the conversation.
- driver — SyntheticUserDriver. Parses the blob once at construction,
renders the system prompt once, builds the adapter once. respond()
filters visible roles, role-swaps, prepends the system prompt as
prior_trace[0], calls adapter.invoke_returning_run_output (in-memory —
the SU never persists a TaskRun), returns the raw string.
56 unit tests covering: parser roundtrip / required-tag enforcement /
whitespace / unknown-tag forward-compat; role_swap empty/alternating/
preserves-order/raises-on-system-or-tool; prompt structural assertions
(persona/goal/conventions present, behavior_guidance only when set, no
/); driver happy path, role-swap shape, custom
visible_roles, ends-on-assistant invariant, non-string output guard,
parse-error on construction, adapter reuse across turns.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
libs/core/kiln_ai/synthetic_user/__init__.py | 37 +++
libs/core/kiln_ai/synthetic_user/driver.py | 128 ++++++++
libs/core/kiln_ai/synthetic_user/models.py | 48 +++
libs/core/kiln_ai/synthetic_user/parser.py | 60 ++++
libs/core/kiln_ai/synthetic_user/prompt.py | 34 +++
libs/core/kiln_ai/synthetic_user/role_swap.py | 61 ++++
.../kiln_ai/synthetic_user/test_driver.py | 278 ++++++++++++++++++
.../kiln_ai/synthetic_user/test_models.py | 69 +++++
.../kiln_ai/synthetic_user/test_parser.py | 145 +++++++++
.../kiln_ai/synthetic_user/test_prompt.py | 95 ++++++
.../kiln_ai/synthetic_user/test_role_swap.py | 77 +++++
11 files changed, 1032 insertions(+)
create mode 100644 libs/core/kiln_ai/synthetic_user/__init__.py
create mode 100644 libs/core/kiln_ai/synthetic_user/driver.py
create mode 100644 libs/core/kiln_ai/synthetic_user/models.py
create mode 100644 libs/core/kiln_ai/synthetic_user/parser.py
create mode 100644 libs/core/kiln_ai/synthetic_user/prompt.py
create mode 100644 libs/core/kiln_ai/synthetic_user/role_swap.py
create mode 100644 libs/core/kiln_ai/synthetic_user/test_driver.py
create mode 100644 libs/core/kiln_ai/synthetic_user/test_models.py
create mode 100644 libs/core/kiln_ai/synthetic_user/test_parser.py
create mode 100644 libs/core/kiln_ai/synthetic_user/test_prompt.py
create mode 100644 libs/core/kiln_ai/synthetic_user/test_role_swap.py
diff --git a/libs/core/kiln_ai/synthetic_user/__init__.py b/libs/core/kiln_ai/synthetic_user/__init__.py
new file mode 100644
index 0000000000..7676c14f60
--- /dev/null
+++ b/libs/core/kiln_ai/synthetic_user/__init__.py
@@ -0,0 +1,37 @@
+"""Local synthetic-user player.
+
+OSS-side per-turn invocation: replaces the removed kiln_server `/respond`
+HTTP endpoint. Calls the LLM using the user's own provider keys.
+
+Public surface:
+
+- `SyntheticUserDriver` — construct once per case, call `respond()` per turn.
+- `SyntheticUserInfo` / `SyntheticUserDriverConfig` — typed configs.
+- `parse_synthetic_user_info` / `build_synthetic_user_info` — tagged-blob codec.
+- `SyntheticUserInfoParseError` — raised on malformed blob.
+- `role_swap` — exposed for callers that drive the loop themselves.
+"""
+
+from kiln_ai.synthetic_user.driver import SyntheticUserDriver
+from kiln_ai.synthetic_user.models import (
+ SyntheticUserDriverConfig,
+ SyntheticUserInfo,
+ VisibleMessageRole,
+)
+from kiln_ai.synthetic_user.parser import (
+ SyntheticUserInfoParseError,
+ build_synthetic_user_info,
+ parse_synthetic_user_info,
+)
+from kiln_ai.synthetic_user.role_swap import role_swap
+
+__all__ = [
+ "SyntheticUserDriver",
+ "SyntheticUserDriverConfig",
+ "SyntheticUserInfo",
+ "SyntheticUserInfoParseError",
+ "VisibleMessageRole",
+ "build_synthetic_user_info",
+ "parse_synthetic_user_info",
+ "role_swap",
+]
diff --git a/libs/core/kiln_ai/synthetic_user/driver.py b/libs/core/kiln_ai/synthetic_user/driver.py
new file mode 100644
index 0000000000..dee2625d79
--- /dev/null
+++ b/libs/core/kiln_ai/synthetic_user/driver.py
@@ -0,0 +1,128 @@
+"""Per-turn synthetic-user driver — the OSS-side replacement for kiln_server's
+removed `/respond` route.
+
+Wraps a kiln_ai LiteLLM adapter and exposes a single async `respond()` that:
+1. Filters the eval-frame conversation to `visible_message_roles`.
+2. Role-swaps user/assistant so the LLM is generating the SU's reply.
+3. Calls the adapter with the persona system prompt prepended as
+ `prior_trace` and the latest swapped user turn as `input`.
+
+The driver does NOT persist `TaskRun`s — it uses
+`adapter.invoke_returning_run_output(...)` which builds an in-memory run
+without writing to disk. The eval-dataset chain consists only of *target*
+TaskRuns (created elsewhere by `adapter.invoke(...)`). The SU is an
+orchestration component, not an eval-dataset row.
+"""
+
+from kiln_ai.adapters.adapter_registry import adapter_for_task
+from kiln_ai.datamodel.datamodel_enums import StructuredOutputMode
+from kiln_ai.datamodel.run_config import KilnAgentRunConfigProperties, ToolsRunConfig
+from kiln_ai.datamodel.task import Task
+from kiln_ai.synthetic_user.models import (
+ SyntheticUserDriverConfig,
+ SyntheticUserInfo,
+)
+from kiln_ai.synthetic_user.parser import parse_synthetic_user_info
+from kiln_ai.synthetic_user.prompt import render_system_prompt
+from kiln_ai.synthetic_user.role_swap import role_swap
+from kiln_ai.utils.open_ai_types import (
+ ChatCompletionMessageParam,
+ ChatCompletionSystemMessageParam,
+)
+
+
+class SyntheticUserDriver:
+ """Plays one synthetic user across multiple turns.
+
+ Constructed once per case from the case's tagged blob + driver config;
+ `respond(conversation)` is called once per turn. The adapter is built
+ at construction time and reused across all turns of the case.
+ """
+
+ def __init__(
+ self,
+ synthetic_user_info_blob: str,
+ driver_config: SyntheticUserDriverConfig,
+ ):
+ # Parse the blob once at construction — fail fast on a malformed case.
+ self._info: SyntheticUserInfo = parse_synthetic_user_info(
+ synthetic_user_info_blob
+ )
+ self._driver_config = driver_config
+ self._system_prompt: str = render_system_prompt(self._info)
+
+ # In-memory Task; nothing is persisted. The persona-playing system
+ # prompt rides on `prior_trace[0]` each call, so this `instruction`
+ # is effectively unused at runtime — kiln_ai's MultiturnFormatter
+ # uses the first system message in `prior_trace` and skips the
+ # task's instruction when `prior_trace` is non-empty. The Task
+ # model requires a non-empty instruction, hence the placeholder.
+ self._task = Task(
+ name="synthetic_user_driver",
+ description="In-memory SU player. Not persisted.",
+ instruction=(
+ "Placeholder — the persona-playing system prompt is supplied "
+ "via prior_trace on every adapter call."
+ ),
+ )
+ # Same RunConfigProperties shape used elsewhere; `structured_output_mode`
+ # is `default` because SU output is free text (no JSON schema).
+ self._run_config = KilnAgentRunConfigProperties(
+ model_name=driver_config.model_name,
+ model_provider_name=driver_config.model_provider_name,
+ prompt_id="simple_prompt_builder",
+ structured_output_mode=StructuredOutputMode.default,
+ tools_config=ToolsRunConfig(tools=[]),
+ )
+ self._adapter = adapter_for_task(self._task, self._run_config)
+
+ async def respond(self, conversation: list[ChatCompletionMessageParam]) -> str:
+ """Return the SU's next message.
+
+ `conversation` is in the eval frame and must end on an `assistant`
+ (target) turn — the SU is responding to that turn. Drive-loop
+ termination is the caller's concern; this just produces one reply.
+ """
+ # 1) Filter to visible roles (drop system/tool if present).
+ visible = [
+ m
+ for m in conversation
+ if m["role"] in self._driver_config.visible_message_roles
+ ]
+ # 2) Invariants this driver enforces (moved from kiln_server's
+ # removed /respond route validator).
+ if not visible:
+ raise ValueError("No LLM-visible messages in conversation.")
+ if visible[-1]["role"] != "assistant":
+ raise ValueError(
+ "Conversation must end on an assistant (target) turn — the SU "
+ "is responding to that turn."
+ )
+
+ # 3) Role-swap then assemble prior_trace + input. The last swapped
+ # turn becomes the LLM `input`; everything before it goes into
+ # `prior_trace` with a system message prepended.
+ swapped = role_swap(visible)
+ last = swapped[-1]
+ user_input = last["content"]
+ if not isinstance(user_input, str):
+ raise RuntimeError(
+ "synthetic user input must be a plain string after role_swap"
+ )
+
+ system_msg: ChatCompletionSystemMessageParam = {
+ "role": "system",
+ "content": self._system_prompt,
+ }
+ prior_trace: list[ChatCompletionMessageParam] = [system_msg, *swapped[:-1]]
+
+ # 4) Adapter call. invoke_returning_run_output returns
+ # (TaskRun, RunOutput) without writing the TaskRun to disk.
+ _task_run, run_output = await self._adapter.invoke_returning_run_output(
+ user_input, prior_trace=prior_trace
+ )
+ raw = run_output.output
+ if not isinstance(raw, str):
+ raise RuntimeError("synthetic user returned non-string output")
+
+ return raw
diff --git a/libs/core/kiln_ai/synthetic_user/models.py b/libs/core/kiln_ai/synthetic_user/models.py
new file mode 100644
index 0000000000..e532a7fe3b
--- /dev/null
+++ b/libs/core/kiln_ai/synthetic_user/models.py
@@ -0,0 +1,48 @@
+"""Internal models for the synthetic-user player.
+
+`SyntheticUserInfo` is the parsed form of the tagged blob the server sends
+on `/generate`; the parser produces it and `prompt.render_system_prompt`
+consumes it. `SyntheticUserDriverConfig` carries the per-eval runtime
+config — model, provider, role visibility — moved here from the kiln_server
+wire (where it used to live on the now-deleted `/respond` request).
+"""
+
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+from kiln_ai.datamodel.datamodel_enums import ModelProviderName
+
+VisibleMessageRole = Literal["user", "assistant"]
+
+
+class SyntheticUserInfo(BaseModel):
+ """Parsed form of the tagged synthetic_user_info blob.
+
+ Built by `parser.parse_synthetic_user_info` from the wire string.
+ Used by `prompt.render_system_prompt` to assemble the per-request
+ system prompt.
+
+ Extend with new fields as the server-side generator emits new tags —
+ the parser ignores unknown tags so this is forward-compat by default.
+ """
+
+ persona: str
+ goal: str
+ behavior_guidance: str | None = None
+
+
+class SyntheticUserDriverConfig(BaseModel):
+ """Per-eval runtime config for the SU's LLM driver.
+
+ No `temperature` field — runs at the chosen model's default. The driver
+ intentionally does not own temperature: the persona-playing prompt and
+ `behavior_guidance` carry style; temperature is a model-level concern
+ surfaced elsewhere when it matters.
+ """
+
+ model_name: str
+ model_provider_name: ModelProviderName
+ visible_message_roles: list[VisibleMessageRole] = Field(
+ default_factory=lambda: ["user", "assistant"]
+ )
diff --git a/libs/core/kiln_ai/synthetic_user/parser.py b/libs/core/kiln_ai/synthetic_user/parser.py
new file mode 100644
index 0000000000..0d40c7b4d4
--- /dev/null
+++ b/libs/core/kiln_ai/synthetic_user/parser.py
@@ -0,0 +1,60 @@
+"""Parse / build the tagged synthetic_user_info blob.
+
+Server-side this is the wire format; OSS-side this is the on-storage form.
+Both ends call these helpers — single source of truth for the tag schema.
+
+Tag rules:
+- Greedy first match per known tag; nested tags not supported.
+- Whitespace trimmed from each tag's content.
+- Unknown tags are ignored (forward-compat for future generator output).
+- `` and `` are required; parse raises if missing or empty.
+- `` is optional; missing → that section is omitted
+ from the rendered system prompt.
+"""
+
+import re
+
+from kiln_ai.synthetic_user.models import SyntheticUserInfo
+
+
+class SyntheticUserInfoParseError(ValueError):
+ """The blob is missing a required tag, or all required tags are empty."""
+
+
+def _extract(blob: str, tag: str) -> str | None:
+ """Greedy first-match. Returns the trimmed content, or None if no match."""
+ m = re.search(rf"<{tag}>(.*?){tag}>", blob, re.DOTALL)
+ if m is None:
+ return None
+ return m.group(1).strip()
+
+
+def parse_synthetic_user_info(blob: str) -> SyntheticUserInfo:
+ """Parse the tagged blob. Strict on required tags; lenient on optional."""
+ persona = _extract(blob, "persona")
+ if not persona:
+ raise SyntheticUserInfoParseError(
+ "Missing or empty required tag in synthetic_user_info blob."
+ )
+ goal = _extract(blob, "goal")
+ if not goal:
+ raise SyntheticUserInfoParseError(
+ "Missing or empty required tag in synthetic_user_info blob."
+ )
+ behavior_guidance = _extract(blob, "behavior_guidance") or None
+ return SyntheticUserInfo(
+ persona=persona,
+ goal=goal,
+ behavior_guidance=behavior_guidance,
+ )
+
+
+def build_synthetic_user_info(info: SyntheticUserInfo) -> str:
+ """Inverse of parse — for tests and any callers that need to construct a blob."""
+ parts = [
+ f"{info.persona}",
+ f"{info.goal}",
+ ]
+ if info.behavior_guidance:
+ parts.append(f"{info.behavior_guidance}")
+ return "".join(parts)
diff --git a/libs/core/kiln_ai/synthetic_user/prompt.py b/libs/core/kiln_ai/synthetic_user/prompt.py
new file mode 100644
index 0000000000..f9d0521d56
--- /dev/null
+++ b/libs/core/kiln_ai/synthetic_user/prompt.py
@@ -0,0 +1,34 @@
+"""Persona-playing system prompt rendered per request for the synthetic user.
+
+The prompt is built once per case (when the driver is constructed) and
+reused across all turns. No early-termination guidance — the drive loop
+runs for a fixed number of turns; the SU is told to stay engaged across
+the whole conversation rather than try to wrap up early.
+"""
+
+from kiln_ai.synthetic_user.models import SyntheticUserInfo
+
+_OPENING = (
+ "You are playing the role of a user interacting with an AI assistant. "
+ "Stay in character — respond as the user would, not as the assistant."
+)
+
+_CONVENTIONS = """## Conversation style
+- Reply naturally and conversationally, as a real user would.
+- React to what the assistant actually said in their last message.
+- Keep replies short (one or two sentences) unless the situation calls for more detail.
+- Do not narrate your own behavior ("As a user, I will now…"). Speak as the user.
+- Stay engaged across the whole conversation. The drive loop runs for a fixed number of turns; do not try to wrap up, conclude, or end the conversation early. Keep asking follow-ups, varying approach as needed."""
+
+
+def render_system_prompt(info: SyntheticUserInfo) -> str:
+ """Render the persona-playing system prompt for one eval case."""
+ sections = [
+ _OPENING,
+ f"## Your persona\n{info.persona}",
+ f"## Your goal in this conversation\n{info.goal}",
+ ]
+ if info.behavior_guidance:
+ sections.append(f"## How you behave\n{info.behavior_guidance}")
+ sections.append(_CONVENTIONS)
+ return "\n\n".join(sections)
diff --git a/libs/core/kiln_ai/synthetic_user/role_swap.py b/libs/core/kiln_ai/synthetic_user/role_swap.py
new file mode 100644
index 0000000000..2ee60e66ec
--- /dev/null
+++ b/libs/core/kiln_ai/synthetic_user/role_swap.py
@@ -0,0 +1,61 @@
+"""Role-swap eval-frame conversation roles into LLM-frame roles.
+
+Chat models are trained to generate `assistant`-labeled responses, so we
+flip the eval-frame labels before the call: the LLM's "assistant" turn IS
+the synthetic user.
+
+ Eval frame: LLM frame (post-swap):
+ user = synthetic user assistant = synthetic user
+ assistant = target agent user = target agent
+
+The driver filters `visible_message_roles` upstream, so a system or tool
+turn reaching here is an internal invariant violation — fail loud rather
+than silently drop.
+"""
+
+from kiln_ai.utils.open_ai_types import (
+ ChatCompletionAssistantMessageParamWrapper,
+ ChatCompletionMessageParam,
+ ChatCompletionUserMessageParam,
+)
+
+
+def role_swap(
+ conversation: list[ChatCompletionMessageParam],
+) -> list[ChatCompletionMessageParam]:
+ """Flip eval-frame user/assistant labels into LLM-frame labels.
+
+ Only `user` and `assistant` are handled. The driver is expected to
+ have filtered out other roles before calling this.
+ """
+ result: list[ChatCompletionMessageParam] = []
+ for msg in conversation:
+ role = msg["role"]
+ if role not in ("user", "assistant"):
+ raise ValueError(
+ f"role_swap received unsupported role {role!r}; "
+ "the driver should have filtered it"
+ )
+ # The TypedDict union allows non-string content for multimodal /
+ # tool turns, but the synthetic user only ever sees plain text from
+ # the target. Narrowing here lets us assign into the swapped wrapper
+ # type without a cast.
+ content = msg["content"]
+ if not isinstance(content, str):
+ raise ValueError(
+ f"role_swap requires string content for role {role!r}; "
+ f"got {type(content).__name__}"
+ )
+ if role == "user":
+ assistant_msg: ChatCompletionAssistantMessageParamWrapper = {
+ "role": "assistant",
+ "content": content,
+ }
+ result.append(assistant_msg)
+ else: # role == "assistant"
+ user_msg: ChatCompletionUserMessageParam = {
+ "role": "user",
+ "content": content,
+ }
+ result.append(user_msg)
+ return result
diff --git a/libs/core/kiln_ai/synthetic_user/test_driver.py b/libs/core/kiln_ai/synthetic_user/test_driver.py
new file mode 100644
index 0000000000..754ebcceb9
--- /dev/null
+++ b/libs/core/kiln_ai/synthetic_user/test_driver.py
@@ -0,0 +1,278 @@
+"""Unit tests for SyntheticUserDriver.
+
+`adapter_for_task` is monkeypatched to return a fake adapter whose
+`invoke_returning_run_output` is an AsyncMock — no real LLM calls. Tests
+focus on the driver's per-turn responsibilities: input shaping
+(filtering, role swap, system prompt prepend) and output validation.
+"""
+
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+
+from kiln_ai.adapters.run_output import RunOutput
+from kiln_ai.datamodel.datamodel_enums import ModelProviderName
+from kiln_ai.datamodel.task_run import TaskRun
+from kiln_ai.synthetic_user import driver as driver_mod
+from kiln_ai.synthetic_user.driver import SyntheticUserDriver
+from kiln_ai.synthetic_user.models import SyntheticUserDriverConfig
+from kiln_ai.synthetic_user.parser import SyntheticUserInfoParseError
+from kiln_ai.utils.open_ai_types import ChatCompletionMessageParam
+
+_BLOB = (
+ "A 30-something professional"
+ "Find the 2016 RRSP limit"
+ "Press for specifics"
+)
+
+_DRIVER_CONFIG = SyntheticUserDriverConfig(
+ model_name="claude_4_5_haiku",
+ model_provider_name=ModelProviderName.openrouter,
+)
+
+
+def _fake_run_output(text: str | dict = "hi from the SU") -> RunOutput:
+ return RunOutput(output=text, intermediate_outputs=None)
+
+
+def _patch_adapter(monkeypatch: pytest.MonkeyPatch, return_value: RunOutput) -> Mock:
+ """Replace adapter_for_task with a stub returning a mock adapter whose
+ invoke_returning_run_output yields (Mock(spec=TaskRun), return_value).
+ Returns the mock adapter so tests can assert call args.
+ """
+ adapter = Mock()
+ adapter.invoke_returning_run_output = AsyncMock(
+ return_value=(Mock(spec=TaskRun), return_value)
+ )
+ monkeypatch.setattr(
+ driver_mod, "adapter_for_task", lambda task, run_config: adapter
+ )
+ return adapter
+
+
+# ───────────────────────── construction ─────────────────────────
+
+
+def test_construction_parses_blob_and_stores_info(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _patch_adapter(monkeypatch, _fake_run_output())
+ drv = SyntheticUserDriver(_BLOB, _DRIVER_CONFIG)
+ assert drv._info.persona == "A 30-something professional"
+ assert drv._info.goal == "Find the 2016 RRSP limit"
+ assert drv._info.behavior_guidance == "Press for specifics"
+
+
+def test_construction_renders_system_prompt_once(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _patch_adapter(monkeypatch, _fake_run_output())
+ drv = SyntheticUserDriver(_BLOB, _DRIVER_CONFIG)
+ # System prompt is rendered on construction and reused.
+ assert "A 30-something professional" in drv._system_prompt
+ assert "Find the 2016 RRSP limit" in drv._system_prompt
+ assert "Press for specifics" in drv._system_prompt
+
+
+def test_construction_raises_on_malformed_blob(monkeypatch: pytest.MonkeyPatch) -> None:
+ _patch_adapter(monkeypatch, _fake_run_output())
+ with pytest.raises(SyntheticUserInfoParseError):
+ SyntheticUserDriver("no tags here at all", _DRIVER_CONFIG)
+
+
+def test_construction_raises_on_empty_required_tag(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _patch_adapter(monkeypatch, _fake_run_output())
+ with pytest.raises(SyntheticUserInfoParseError):
+ SyntheticUserDriver("G", _DRIVER_CONFIG)
+
+
+# ───────────────────────── respond — happy path ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_respond_returns_adapter_output(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ adapter = _patch_adapter(monkeypatch, _fake_run_output("the SU's reply"))
+ drv = SyntheticUserDriver(_BLOB, _DRIVER_CONFIG)
+ conversation: list[ChatCompletionMessageParam] = [
+ {"role": "user", "content": "u1"},
+ {"role": "assistant", "content": "a1"},
+ ]
+
+ out = await drv.respond(conversation)
+
+ assert out == "the SU's reply"
+ adapter.invoke_returning_run_output.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_respond_role_swaps_and_prepends_system_prompt(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """End-to-end shape check: input is the role-swapped LAST user turn;
+ prior_trace is [system, ...role-swapped earlier turns].
+ """
+ adapter = _patch_adapter(monkeypatch, _fake_run_output("ok"))
+ drv = SyntheticUserDriver(_BLOB, _DRIVER_CONFIG)
+ conversation: list[ChatCompletionMessageParam] = [
+ {"role": "user", "content": "seed-user-1"},
+ {"role": "assistant", "content": "target-reply-1"},
+ {"role": "user", "content": "seed-user-2"},
+ {"role": "assistant", "content": "target-reply-2"},
+ ]
+
+ await drv.respond(conversation)
+
+ call = adapter.invoke_returning_run_output.await_args
+ # `input` is the role-swapped LAST eval-frame turn — was "assistant" with
+ # content "target-reply-2", becomes "user" content "target-reply-2".
+ positional = call.args
+ keyword = call.kwargs
+ # invoke_returning_run_output(user_input, prior_trace=...)
+ assert positional[0] == "target-reply-2"
+
+ prior_trace = keyword["prior_trace"]
+ # prior_trace = [system, role-swapped turns 0..-2]
+ assert prior_trace[0]["role"] == "system"
+ assert "A 30-something professional" in prior_trace[0]["content"]
+ # The three earlier turns, all role-swapped.
+ assert [m["role"] for m in prior_trace[1:]] == ["assistant", "user", "assistant"]
+ assert prior_trace[1]["content"] == "seed-user-1"
+ assert prior_trace[2]["content"] == "target-reply-1"
+ assert prior_trace[3]["content"] == "seed-user-2"
+
+
+@pytest.mark.asyncio
+async def test_respond_filters_visible_message_roles(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A system turn in the conversation must be dropped before role-swap."""
+ adapter = _patch_adapter(monkeypatch, _fake_run_output("ok"))
+ drv = SyntheticUserDriver(_BLOB, _DRIVER_CONFIG)
+ conversation: list[ChatCompletionMessageParam] = [
+ {"role": "system", "content": "should be dropped"},
+ {"role": "user", "content": "u1"},
+ {"role": "assistant", "content": "a1"},
+ ]
+
+ await drv.respond(conversation)
+
+ call = adapter.invoke_returning_run_output.await_args
+ prior_trace = call.kwargs["prior_trace"]
+ # prior_trace = [our system prompt, role-swapped user from u1]
+ # The "system" eval-frame turn is filtered out.
+ assert len(prior_trace) == 2
+ assert prior_trace[0]["content"] == drv._system_prompt
+ assert prior_trace[1] == {"role": "assistant", "content": "u1"}
+
+
+@pytest.mark.asyncio
+async def test_respond_with_custom_visible_roles(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Custom visibility set is honored — the driver doesn't hardcode the default."""
+ adapter = _patch_adapter(monkeypatch, _fake_run_output("ok"))
+ drv = SyntheticUserDriver(
+ _BLOB,
+ SyntheticUserDriverConfig(
+ model_name="x",
+ model_provider_name=ModelProviderName.openrouter,
+ visible_message_roles=["assistant"],
+ ),
+ )
+ # Only assistant turns visible — the user turn gets filtered out, leaving
+ # only the assistant for /respond. After filter, the conversation is a
+ # single "assistant" message, which IS the required ends-on-assistant
+ # shape: visible=[asst]; swap→[user]; last is input; prior_trace=[sys].
+ conversation: list[ChatCompletionMessageParam] = [
+ {"role": "user", "content": "u1"},
+ {"role": "assistant", "content": "a1"},
+ ]
+
+ await drv.respond(conversation)
+
+ call = adapter.invoke_returning_run_output.await_args
+ assert call.args[0] == "a1"
+ assert len(call.kwargs["prior_trace"]) == 1 # just the system prompt
+
+
+# ───────────────────────── respond — invariants ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_respond_raises_when_no_visible_messages(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _patch_adapter(monkeypatch, _fake_run_output())
+ drv = SyntheticUserDriver(_BLOB, _DRIVER_CONFIG)
+ # All messages filtered out by visible_message_roles.
+ conversation: list[ChatCompletionMessageParam] = [
+ {"role": "system", "content": "sys"},
+ ]
+ with pytest.raises(ValueError, match="No LLM-visible"):
+ await drv.respond(conversation)
+
+
+@pytest.mark.asyncio
+async def test_respond_raises_when_empty_conversation(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _patch_adapter(monkeypatch, _fake_run_output())
+ drv = SyntheticUserDriver(_BLOB, _DRIVER_CONFIG)
+ with pytest.raises(ValueError, match="No LLM-visible"):
+ await drv.respond([])
+
+
+@pytest.mark.asyncio
+async def test_respond_raises_when_ends_on_user(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _patch_adapter(monkeypatch, _fake_run_output())
+ drv = SyntheticUserDriver(_BLOB, _DRIVER_CONFIG)
+ conversation: list[ChatCompletionMessageParam] = [
+ {"role": "assistant", "content": "a1"},
+ {"role": "user", "content": "u1"},
+ ]
+ with pytest.raises(ValueError, match="end on an assistant"):
+ await drv.respond(conversation)
+
+
+@pytest.mark.asyncio
+async def test_respond_raises_on_non_string_adapter_output(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """RunOutput.output is typed as Dict | str — only str is valid for the SU."""
+ _patch_adapter(monkeypatch, _fake_run_output({"unexpected": "structured output"}))
+ drv = SyntheticUserDriver(_BLOB, _DRIVER_CONFIG)
+ conversation: list[ChatCompletionMessageParam] = [
+ {"role": "user", "content": "u1"},
+ {"role": "assistant", "content": "a1"},
+ ]
+ with pytest.raises(RuntimeError, match="non-string output"):
+ await drv.respond(conversation)
+
+
+# ───────────────────────── respond — reuse ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_respond_reuses_adapter_across_turns(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The adapter is built once in the constructor and reused per turn."""
+ adapter = _patch_adapter(monkeypatch, _fake_run_output("reply"))
+ drv = SyntheticUserDriver(_BLOB, _DRIVER_CONFIG)
+
+ conversation: list[ChatCompletionMessageParam] = [
+ {"role": "user", "content": "u1"},
+ {"role": "assistant", "content": "a1"},
+ ]
+ await drv.respond(conversation)
+ await drv.respond(conversation)
+ await drv.respond(conversation)
+
+ assert adapter.invoke_returning_run_output.await_count == 3
+ # The driver's _adapter reference doesn't change between calls.
diff --git a/libs/core/kiln_ai/synthetic_user/test_models.py b/libs/core/kiln_ai/synthetic_user/test_models.py
new file mode 100644
index 0000000000..1bcc9413cb
--- /dev/null
+++ b/libs/core/kiln_ai/synthetic_user/test_models.py
@@ -0,0 +1,69 @@
+"""Unit tests for SyntheticUserInfo / SyntheticUserDriverConfig."""
+
+import pytest
+from pydantic import ValidationError
+
+from kiln_ai.datamodel.datamodel_enums import ModelProviderName
+from kiln_ai.synthetic_user.models import (
+ SyntheticUserDriverConfig,
+ SyntheticUserInfo,
+)
+
+
+def test_synthetic_user_info_required_fields() -> None:
+ info = SyntheticUserInfo(persona="p", goal="g")
+ assert info.persona == "p"
+ assert info.goal == "g"
+ assert info.behavior_guidance is None
+
+
+def test_synthetic_user_info_accepts_behavior_guidance() -> None:
+ info = SyntheticUserInfo(persona="p", goal="g", behavior_guidance="b")
+ assert info.behavior_guidance == "b"
+
+
+def test_synthetic_user_info_rejects_missing_persona() -> None:
+ with pytest.raises(ValidationError):
+ SyntheticUserInfo(goal="g") # type: ignore[call-arg]
+
+
+def test_synthetic_user_info_rejects_missing_goal() -> None:
+ with pytest.raises(ValidationError):
+ SyntheticUserInfo(persona="p") # type: ignore[call-arg]
+
+
+def test_driver_config_default_visible_roles() -> None:
+ cfg = SyntheticUserDriverConfig(
+ model_name="x", model_provider_name=ModelProviderName.openrouter
+ )
+ assert cfg.visible_message_roles == ["user", "assistant"]
+
+
+def test_driver_config_default_is_per_instance() -> None:
+ # Pydantic v2 + default_factory must not share the list across instances.
+ a = SyntheticUserDriverConfig(
+ model_name="x", model_provider_name=ModelProviderName.openrouter
+ )
+ b = SyntheticUserDriverConfig(
+ model_name="y", model_provider_name=ModelProviderName.openrouter
+ )
+ a.visible_message_roles.append("user") # type: ignore[arg-type]
+ assert b.visible_message_roles == ["user", "assistant"]
+
+
+def test_driver_config_accepts_explicit_visible_roles() -> None:
+ cfg = SyntheticUserDriverConfig(
+ model_name="x",
+ model_provider_name=ModelProviderName.openrouter,
+ visible_message_roles=["assistant"],
+ )
+ assert cfg.visible_message_roles == ["assistant"]
+
+
+def test_driver_config_rejects_invalid_role_literal() -> None:
+ with pytest.raises(ValidationError):
+ SyntheticUserDriverConfig(
+ model_name="x",
+ model_provider_name=ModelProviderName.openrouter,
+ visible_message_roles=["system"], # type: ignore[list-item]
+ )
diff --git a/libs/core/kiln_ai/synthetic_user/test_parser.py b/libs/core/kiln_ai/synthetic_user/test_parser.py
new file mode 100644
index 0000000000..b392180f80
--- /dev/null
+++ b/libs/core/kiln_ai/synthetic_user/test_parser.py
@@ -0,0 +1,145 @@
+"""Unit tests for the tagged blob parser/builder."""
+
+import pytest
+
+from kiln_ai.synthetic_user.models import SyntheticUserInfo
+from kiln_ai.synthetic_user.parser import (
+ SyntheticUserInfoParseError,
+ build_synthetic_user_info,
+ parse_synthetic_user_info,
+)
+
+# ───────────────────────── parse ─────────────────────────
+
+
+def test_parse_all_three_tags() -> None:
+ blob = "PGB"
+ info = parse_synthetic_user_info(blob)
+ assert info.persona == "P"
+ assert info.goal == "G"
+ assert info.behavior_guidance == "B"
+
+
+def test_parse_only_required_tags() -> None:
+ blob = "PG"
+ info = parse_synthetic_user_info(blob)
+ assert info.behavior_guidance is None
+
+
+def test_parse_strips_whitespace() -> None:
+ blob = (
+ " P with spaces "
+ "\n\tG with newlines\n"
+ "\n B\n"
+ )
+ info = parse_synthetic_user_info(blob)
+ assert info.persona == "P with spaces"
+ assert info.goal == "G with newlines"
+ assert info.behavior_guidance == "B"
+
+
+def test_parse_dotall_allows_multiline_content() -> None:
+ blob = "line 1\nline 2\nline 3G"
+ info = parse_synthetic_user_info(blob)
+ assert info.persona == "line 1\nline 2\nline 3"
+
+
+def test_parse_ignores_unknown_tags() -> None:
+ # Forward-compat: a future generator may add new tags like .
+ blob = (
+ "P"
+ "G"
+ "cheerful"
+ "expert"
+ )
+ info = parse_synthetic_user_info(blob)
+ assert info.persona == "P"
+ assert info.goal == "G"
+ # Unknown tags don't end up anywhere.
+ assert not hasattr(info, "tone")
+
+
+def test_parse_ignores_preamble_and_trailing() -> None:
+ blob = "preamble text PG trailing text"
+ info = parse_synthetic_user_info(blob)
+ assert info.persona == "P"
+ assert info.goal == "G"
+
+
+def test_parse_first_match_wins_for_duplicate_tag() -> None:
+ blob = "firstsecondG"
+ info = parse_synthetic_user_info(blob)
+ assert info.persona == "first"
+
+
+def test_parse_missing_persona_raises() -> None:
+ with pytest.raises(SyntheticUserInfoParseError, match=""):
+ parse_synthetic_user_info("G")
+
+
+def test_parse_missing_goal_raises() -> None:
+ with pytest.raises(SyntheticUserInfoParseError, match=""):
+ parse_synthetic_user_info("P")
+
+
+def test_parse_empty_persona_raises() -> None:
+ with pytest.raises(SyntheticUserInfoParseError, match=""):
+ parse_synthetic_user_info("G")
+
+
+def test_parse_whitespace_only_persona_raises() -> None:
+ # After trim, content is empty — should be treated as missing.
+ with pytest.raises(SyntheticUserInfoParseError, match=""):
+ parse_synthetic_user_info(" \n G")
+
+
+def test_parse_completely_unstructured_blob_raises() -> None:
+ with pytest.raises(SyntheticUserInfoParseError):
+ parse_synthetic_user_info("just plain text with no tags at all")
+
+
+# ───────────────────────── build ─────────────────────────
+
+
+def test_build_all_three_tags() -> None:
+ info = SyntheticUserInfo(persona="P", goal="G", behavior_guidance="B")
+ assert (
+ build_synthetic_user_info(info)
+ == "PGB"
+ )
+
+
+def test_build_omits_behavior_guidance_when_none() -> None:
+ info = SyntheticUserInfo(persona="P", goal="G")
+ assert build_synthetic_user_info(info) == "PG"
+
+
+def test_build_omits_empty_behavior_guidance() -> None:
+ # The model permits None for behavior_guidance; build's truthiness check
+ # also skips empty strings, which matches the parser's "missing → None".
+ info = SyntheticUserInfo(persona="P", goal="G", behavior_guidance="")
+ assert build_synthetic_user_info(info) == "PG"
+
+
+# ───────────────────────── roundtrip ─────────────────────────
+
+
+def test_roundtrip_all_three() -> None:
+ info = SyntheticUserInfo(persona="P\nmultiline", goal="G", behavior_guidance="B")
+ assert parse_synthetic_user_info(build_synthetic_user_info(info)) == info
+
+
+def test_roundtrip_required_only() -> None:
+ info = SyntheticUserInfo(persona="P", goal="G")
+ assert parse_synthetic_user_info(build_synthetic_user_info(info)) == info
+
+
+def test_roundtrip_preserves_internal_whitespace() -> None:
+ # Whitespace WITHIN content (after the leading/trailing trim) is preserved.
+ info = SyntheticUserInfo(
+ persona="word1 word2 word3", goal="G", behavior_guidance=None
+ )
+ assert (
+ parse_synthetic_user_info(build_synthetic_user_info(info)).persona
+ == info.persona
+ )
diff --git a/libs/core/kiln_ai/synthetic_user/test_prompt.py b/libs/core/kiln_ai/synthetic_user/test_prompt.py
new file mode 100644
index 0000000000..f6e21dd00e
--- /dev/null
+++ b/libs/core/kiln_ai/synthetic_user/test_prompt.py
@@ -0,0 +1,95 @@
+"""Unit tests for the persona-playing system prompt template.
+
+These are structural assertions — we don't check exact wording (that's
+allowed to drift as we tune the persona prompt) but we DO check the
+prompt has every required section and is free of the removed termination
+guidance.
+"""
+
+from kiln_ai.synthetic_user.models import SyntheticUserInfo
+from kiln_ai.synthetic_user.prompt import render_system_prompt
+
+
+def _full_info() -> SyntheticUserInfo:
+ return SyntheticUserInfo(
+ persona="A 30-something professional, anxious about taxes.",
+ goal="Find the 2016 RRSP contribution limit.",
+ behavior_guidance="Press for specific numbers if the agent gives vague answers.",
+ )
+
+
+def test_includes_persona_text() -> None:
+ info = _full_info()
+ rendered = render_system_prompt(info)
+ assert info.persona in rendered
+
+
+def test_includes_goal_text() -> None:
+ info = _full_info()
+ rendered = render_system_prompt(info)
+ assert info.goal in rendered
+
+
+def test_includes_behavior_guidance_when_set() -> None:
+ info = _full_info()
+ rendered = render_system_prompt(info)
+ assert info.behavior_guidance is not None
+ assert info.behavior_guidance in rendered
+ assert "How you behave" in rendered
+
+
+def test_omits_behavior_guidance_section_when_none() -> None:
+ info = SyntheticUserInfo(persona="P", goal="G") # behavior_guidance = None
+ rendered = render_system_prompt(info)
+ assert "How you behave" not in rendered
+
+
+def test_includes_conventions_block() -> None:
+ rendered = render_system_prompt(_full_info())
+ assert "Conversation style" in rendered
+
+
+def test_does_not_include_termination_sentinels() -> None:
+ # Decision: drive loop runs for fixed `turns`; SU must NOT try to end
+ # the conversation. The template should carry no /
+ # guidance.
+ rendered = render_system_prompt(_full_info())
+ assert "" not in rendered
+ assert "" not in rendered
+ assert "DONE" not in rendered # also no bare-word leak
+ assert "CANCEL" not in rendered
+
+
+def test_section_order_opening_persona_goal_behavior_conventions() -> None:
+ info = _full_info()
+ rendered = render_system_prompt(info)
+ assert info.behavior_guidance is not None
+
+ # Use distinctive substrings from each section.
+ opening_marker = "Stay in character"
+ persona_section = "Your persona"
+ goal_section = "Your goal in this conversation"
+ behavior_section = "How you behave"
+ conventions_marker = "Conversation style"
+
+ indexes = [
+ rendered.index(opening_marker),
+ rendered.index(persona_section),
+ rendered.index(goal_section),
+ rendered.index(behavior_section),
+ rendered.index(conventions_marker),
+ ]
+ assert indexes == sorted(indexes), f"Sections out of expected order: {indexes}"
+
+
+def test_section_order_skips_behavior_when_none() -> None:
+ info = SyntheticUserInfo(persona="P", goal="G")
+ rendered = render_system_prompt(info)
+
+ indexes = [
+ rendered.index("Stay in character"),
+ rendered.index("Your persona"),
+ rendered.index("Your goal in this conversation"),
+ rendered.index("Conversation style"),
+ ]
+ assert indexes == sorted(indexes)
diff --git a/libs/core/kiln_ai/synthetic_user/test_role_swap.py b/libs/core/kiln_ai/synthetic_user/test_role_swap.py
new file mode 100644
index 0000000000..4681d900a2
--- /dev/null
+++ b/libs/core/kiln_ai/synthetic_user/test_role_swap.py
@@ -0,0 +1,77 @@
+"""Unit tests for the eval-frame ↔ LLM-frame role swap."""
+
+import pytest
+
+from kiln_ai.synthetic_user.role_swap import role_swap
+from kiln_ai.utils.open_ai_types import ChatCompletionMessageParam
+
+
+def test_empty_conversation() -> None:
+ assert role_swap([]) == []
+
+
+def test_single_user_becomes_assistant() -> None:
+ src: list[ChatCompletionMessageParam] = [{"role": "user", "content": "hi"}]
+ assert role_swap(src) == [{"role": "assistant", "content": "hi"}]
+
+
+def test_single_assistant_becomes_user() -> None:
+ src: list[ChatCompletionMessageParam] = [{"role": "assistant", "content": "hello"}]
+ assert role_swap(src) == [{"role": "user", "content": "hello"}]
+
+
+def test_alternating_conversation_flips_both_directions() -> None:
+ src: list[ChatCompletionMessageParam] = [
+ {"role": "user", "content": "u1"},
+ {"role": "assistant", "content": "a1"},
+ {"role": "user", "content": "u2"},
+ {"role": "assistant", "content": "a2"},
+ ]
+ expected: list[ChatCompletionMessageParam] = [
+ {"role": "assistant", "content": "u1"},
+ {"role": "user", "content": "a1"},
+ {"role": "assistant", "content": "u2"},
+ {"role": "user", "content": "a2"},
+ ]
+ assert role_swap(src) == expected
+
+
+def test_order_preserved() -> None:
+ src: list[ChatCompletionMessageParam] = [
+ {"role": "user", "content": f"m{i}"} for i in range(5)
+ ]
+ out = role_swap(src)
+ assert [m["content"] for m in out] == [f"m{i}" for i in range(5)]
+
+
+def test_multiline_content_passes_through() -> None:
+ src: list[ChatCompletionMessageParam] = [
+ {"role": "user", "content": "line 1\nline 2\nline 3"}
+ ]
+ assert role_swap(src) == [
+ {"role": "assistant", "content": "line 1\nline 2\nline 3"}
+ ]
+
+
+def test_system_role_raises() -> None:
+ src: list[ChatCompletionMessageParam] = [{"role": "system", "content": "sys"}]
+ with pytest.raises(ValueError, match="system"):
+ role_swap(src)
+
+
+def test_tool_role_raises() -> None:
+ src: list[ChatCompletionMessageParam] = [
+ {"role": "tool", "content": "{}", "tool_call_id": "x"}
+ ]
+ with pytest.raises(ValueError, match="tool"):
+ role_swap(src)
+
+
+def test_does_not_mutate_input() -> None:
+ src: list[ChatCompletionMessageParam] = [
+ {"role": "user", "content": "u1"},
+ {"role": "assistant", "content": "a1"},
+ ]
+ snapshot = [dict(m) for m in src]
+ role_swap(src)
+ assert [dict(m) for m in src] == snapshot
From b3337daa819578df673cc344196705321acc323b Mon Sep 17 00:00:00 2001
From: aiyakitori <16633065+chiang-daniel@users.noreply.github.com>
Date: Mon, 1 Jun 2026 16:38:38 -0700
Subject: [PATCH 03/36] chore: re-vendor SDK from kiln_server@acbd0ce
Picks up an OpenAPI description on GenerateSyntheticUsersResponse.cases
documenting the strict-N batch contract. No shape change.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../models/generate_synthetic_users_response.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_synthetic_users_response.py b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_synthetic_users_response.py
index 4ef08c2b99..2c0887017b 100644
--- a/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_synthetic_users_response.py
+++ b/app/desktop/studio_server/api_client/kiln_ai_server_client/models/generate_synthetic_users_response.py
@@ -24,7 +24,8 @@ class GenerateSyntheticUsersResponse:
Clients should re-call to retry.
Attributes:
- cases (list[SyntheticUserCase]):
+ cases (list[SyntheticUserCase]): Generated synthetic-user cases. On success, contains exactly `num_cases` items
+ (the strict-N batch contract — see class docstring).
"""
cases: list[SyntheticUserCase]
From bb002b962f87873faa33496fe74ae3769a1cc30c Mon Sep 17 00:00:00 2001
From: aiyakitori <16633065+chiang-daniel@users.noreply.github.com>
Date: Mon, 1 Jun 2026 16:45:35 -0700
Subject: [PATCH 04/36] feat(studio_server): SyntheticUserClient for /generate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Thin async wrapper around the SDK's /v1/synthetic_user/generate
endpoint. The SDK now parses 401/422/500/502 into typed response
models, so the wrapper switches on the parsed type rather than
reading raw bytes — 502 surfaces its typed `code` literal
(llm_unavailable | upstream_invalid_output) directly to callers.
No retry loop. /generate is a once-per-batch authoring call;
kiln_server's pipeline already retries transient provider failures
internally before returning 502, so a 502 reaching us is a genuine
per-batch failure that should propagate. Drops the v1 client's
SyntheticUserTransientError + backoff machinery.
No /respond. Per-turn synthetic-user invocation lives at
libs/core/kiln_ai/synthetic_user/ and runs locally with the user's keys.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../studio_server/synthetic_user/__init__.py | 22 ++
.../studio_server/synthetic_user/client.py | 187 ++++++++++
.../synthetic_user/test_client.py | 352 ++++++++++++++++++
3 files changed, 561 insertions(+)
create mode 100644 app/desktop/studio_server/synthetic_user/__init__.py
create mode 100644 app/desktop/studio_server/synthetic_user/client.py
create mode 100644 app/desktop/studio_server/synthetic_user/test_client.py
diff --git a/app/desktop/studio_server/synthetic_user/__init__.py b/app/desktop/studio_server/synthetic_user/__init__.py
new file mode 100644
index 0000000000..72b84653f6
--- /dev/null
+++ b/app/desktop/studio_server/synthetic_user/__init__.py
@@ -0,0 +1,22 @@
+"""Studio_server-side wrapper for the kiln_server synthetic-user `/generate`
+endpoint, plus the multi-turn synthetic-data-generation drive loop.
+
+Per-turn synthetic-user invocation itself lives in
+`libs/core/kiln_ai/synthetic_user/` (calls the LLM with the user's keys);
+this module covers only the authoring HTTP call and the runner that
+orchestrates target invocation + SU response across multiple cases.
+"""
+
+from app.desktop.studio_server.synthetic_user.client import (
+ SyntheticUserClient,
+ SyntheticUserError,
+ SyntheticUserRequestError,
+ SyntheticUserServerError,
+)
+
+__all__ = [
+ "SyntheticUserClient",
+ "SyntheticUserError",
+ "SyntheticUserRequestError",
+ "SyntheticUserServerError",
+]
diff --git a/app/desktop/studio_server/synthetic_user/client.py b/app/desktop/studio_server/synthetic_user/client.py
new file mode 100644
index 0000000000..c42c88187d
--- /dev/null
+++ b/app/desktop/studio_server/synthetic_user/client.py
@@ -0,0 +1,187 @@
+"""Thin async wrapper over the vendored kiln_server SDK for `/generate`.
+
+Owns two concerns the SDK leaves to callers:
+
+1. Error classification. The SDK parses 200/401/422/500/502 into typed
+ models; we translate those into the wrapper's typed exception hierarchy
+ so callers never inspect raw HTTP status codes.
+2. No retry. `/generate` is a once-per-batch authoring call (not a per-turn
+ hot loop), and kiln_server's own pipeline already retries transient
+ provider failures once before returning 502. A 502 reaching us is a
+ genuine per-batch failure that should surface, not a transient to retry.
+
+No `/respond` here. Per-turn synthetic-user invocation lives in
+`libs/core/kiln_ai/synthetic_user/`. This wrapper exists only for the
+authoring call.
+"""
+
+import logging
+
+from app.desktop.studio_server.api_client.kiln_ai_server_client.api.synthetic_user import (
+ generate_v1_synthetic_user_generate_post,
+)
+from app.desktop.studio_server.api_client.kiln_ai_server_client.client import (
+ AuthenticatedClient,
+)
+from app.desktop.studio_server.api_client.kiln_ai_server_client.models import (
+ GenerateSyntheticUsersRequest,
+ GenerateSyntheticUsersResponse,
+ GenerateV1SyntheticUserGeneratePostResponse401,
+ GenerateV1SyntheticUserGeneratePostResponse500,
+ GenerateV1SyntheticUserGeneratePostResponse502,
+ HTTPValidationError,
+ SyntheticUserCase,
+ ValidationError,
+)
+from app.desktop.studio_server.api_client.kiln_ai_server_client.types import (
+ UNSET,
+ Response,
+)
+from app.desktop.studio_server.api_client.kiln_server_client import (
+ get_authenticated_client,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class SyntheticUserError(Exception):
+ """Base class for SyntheticUserClient errors. Carries the kiln_server
+ error code (e.g. `llm_unavailable`, `upstream_invalid_output`) when
+ available, plus the HTTP status for debugging.
+ """
+
+ def __init__(self, code: str, message: str, status_code: int | None = None):
+ prefix = f"{code}: " if code else ""
+ super().__init__(f"{prefix}{message}")
+ self.code = code
+ self.message = message
+ self.status_code = status_code
+
+
+class SyntheticUserRequestError(SyntheticUserError):
+ """Raised on 4xx. Either we sent a bad body (422 — runner bug) or the
+ caller's credentials don't work (401). Not retryable — fix inputs.
+ """
+
+
+class SyntheticUserServerError(SyntheticUserError):
+ """Raised on 5xx. 500 is a server bug; 502 is the kiln_server pipeline
+ giving up on the upstream provider after its own internal retry. Not
+ retryable at this layer — bubble up as a per-batch failure.
+ """
+
+
+class SyntheticUserClient:
+ """Async client for kiln_server's `/v1/synthetic_user/generate`.
+
+ Construction is cheap — no network until a method is called. Pass the
+ same instance to as many coroutines as you want; the underlying httpx
+ client is async-safe.
+ """
+
+ def __init__(self, *, api_key: str):
+ self._client: AuthenticatedClient = get_authenticated_client(api_key=api_key)
+
+ async def generate(
+ self,
+ *,
+ target_task_prompt: str,
+ target_specification: str,
+ num_cases: int,
+ ) -> list[SyntheticUserCase]:
+ """POST /v1/synthetic_user/generate. Returns the SDK's case models
+ as-is; each carries `seed_prompt` and `synthetic_user_info` (the
+ tagged blob — see kiln_ai.synthetic_user.parser for the schema).
+ """
+ body = GenerateSyntheticUsersRequest(
+ target_task_prompt=target_task_prompt,
+ target_specification=target_specification,
+ num_cases=num_cases,
+ )
+ response = await generate_v1_synthetic_user_generate_post.asyncio_detailed(
+ client=self._client, body=body
+ )
+ return self._extract_cases_or_raise(response)
+
+ @staticmethod
+ def _extract_cases_or_raise(response: Response) -> list[SyntheticUserCase]:
+ """Translate the SDK's parsed response into either the case list
+ (on 2xx) or a typed exception (on anything else).
+ """
+ parsed = response.parsed
+ status = int(response.status_code)
+
+ if isinstance(parsed, GenerateSyntheticUsersResponse):
+ return list(parsed.cases)
+
+ # Typed error bodies the SDK parses for us.
+ if isinstance(parsed, GenerateV1SyntheticUserGeneratePostResponse502):
+ # `code` is a typed enum on 502; surface its string value so
+ # downstream callers can discriminate llm_unavailable from
+ # upstream_invalid_output without importing the SDK type.
+ raise SyntheticUserServerError(
+ code=parsed.code.value,
+ message=parsed.message,
+ status_code=status,
+ )
+ if isinstance(parsed, GenerateV1SyntheticUserGeneratePostResponse500):
+ raise SyntheticUserServerError(
+ code=_code_or_default(parsed.code, f"http_{status}"),
+ message=parsed.message,
+ status_code=status,
+ )
+ if isinstance(parsed, GenerateV1SyntheticUserGeneratePostResponse401):
+ raise SyntheticUserRequestError(
+ code=_code_or_default(parsed.code, "unauthorized"),
+ message=parsed.message,
+ status_code=status,
+ )
+ if isinstance(parsed, HTTPValidationError):
+ # 422 — we sent a body the server's pydantic validator rejected.
+ # Indicates a runner bug; surface enough detail to debug.
+ raise SyntheticUserRequestError(
+ code="http_422",
+ message=_format_validation_detail(parsed),
+ status_code=status,
+ )
+
+ # Fallback: SDK couldn't parse a body, or the response is otherwise
+ # unexpected. Pick a coarse classification by status range.
+ if 400 <= status < 500:
+ raise SyntheticUserRequestError(
+ code=f"http_{status}",
+ message="Unexpected client-error response from kiln_server.",
+ status_code=status,
+ )
+ raise SyntheticUserServerError(
+ code=f"http_{status}",
+ message="Unexpected response from kiln_server.",
+ status_code=status,
+ )
+
+
+def _code_or_default(code: object, default: str) -> str:
+ """Resolve a typed-model `code` field that may be `UNSET` or a string."""
+ if isinstance(code, str) and code:
+ return code
+ if code is UNSET or code is None:
+ return default
+ return default
+
+
+def _format_validation_detail(error: HTTPValidationError) -> str:
+ """Render a FastAPI HTTPValidationError into a single-line message
+ useful for debugging which field the runner sent wrong.
+ """
+ detail = error.detail
+ if not isinstance(detail, list):
+ return "Validation error (no detail)."
+ parts: list[str] = []
+ for item in detail:
+ if not isinstance(item, ValidationError):
+ continue
+ loc = ".".join(str(x) for x in item.loc)
+ parts.append(f"{loc}: {item.msg}")
+ if not parts:
+ return "Validation error (no detail)."
+ return "Validation error: " + "; ".join(parts)
diff --git a/app/desktop/studio_server/synthetic_user/test_client.py b/app/desktop/studio_server/synthetic_user/test_client.py
new file mode 100644
index 0000000000..0d8637ad54
--- /dev/null
+++ b/app/desktop/studio_server/synthetic_user/test_client.py
@@ -0,0 +1,352 @@
+"""Unit tests for SyntheticUserClient (the `/generate` wrapper).
+
+The SDK's `asyncio_detailed` is patched per-test so no real network call
+happens. Tests cover each status the SDK models (200, 401, 422, 500, 502)
++ the fallback paths for unparseable bodies.
+"""
+
+from http import HTTPStatus
+from unittest.mock import AsyncMock
+
+import pytest
+
+from app.desktop.studio_server.api_client.kiln_ai_server_client.models import (
+ GenerateSyntheticUsersResponse,
+ GenerateV1SyntheticUserGeneratePostResponse401,
+ GenerateV1SyntheticUserGeneratePostResponse500,
+ GenerateV1SyntheticUserGeneratePostResponse502,
+ GenerateV1SyntheticUserGeneratePostResponse502Code,
+ HTTPValidationError,
+ SyntheticUserCase,
+ ValidationError,
+)
+from app.desktop.studio_server.api_client.kiln_ai_server_client.types import (
+ UNSET,
+ Response,
+)
+from app.desktop.studio_server.synthetic_user import client as client_mod
+from app.desktop.studio_server.synthetic_user.client import (
+ SyntheticUserClient,
+ SyntheticUserRequestError,
+ SyntheticUserServerError,
+)
+
+
+def _make_client() -> SyntheticUserClient:
+ return SyntheticUserClient(api_key="test-key")
+
+
+def _patch_generate(monkeypatch: pytest.MonkeyPatch, mock: AsyncMock) -> None:
+ monkeypatch.setattr(
+ client_mod.generate_v1_synthetic_user_generate_post,
+ "asyncio_detailed",
+ mock,
+ )
+
+
+def _ok_response(num_cases: int = 1) -> Response:
+ cases = [
+ SyntheticUserCase(
+ seed_prompt=f"seed-{i}",
+ synthetic_user_info=(
+ f"persona-{i}"
+ f"goal-{i}"
+ f"guidance-{i}"
+ ),
+ )
+ for i in range(num_cases)
+ ]
+ return Response(
+ status_code=HTTPStatus.OK,
+ content=b"{}",
+ headers={},
+ parsed=GenerateSyntheticUsersResponse(cases=cases),
+ )
+
+
+def _err_response(status: int, parsed: object) -> Response:
+ return Response(
+ status_code=HTTPStatus(status),
+ content=b"{}",
+ headers={},
+ parsed=parsed, # type: ignore[arg-type]
+ )
+
+
+# ───────────────────────── happy path ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_generate_happy_path_returns_cases(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _patch_generate(monkeypatch, AsyncMock(return_value=_ok_response(num_cases=3)))
+
+ cases = await _make_client().generate(
+ target_task_prompt="prompt",
+ target_specification="spec",
+ num_cases=3,
+ )
+
+ assert len(cases) == 3
+ assert cases[0].seed_prompt == "seed-0"
+ # synthetic_user_info is the tagged blob — opaque at this layer.
+ assert "persona-0" in cases[0].synthetic_user_info
+
+
+@pytest.mark.asyncio
+async def test_generate_passes_request_body_correctly(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ captured: list = []
+
+ async def _capture(*, client, body): # noqa: ARG001
+ captured.append(body)
+ return _ok_response()
+
+ _patch_generate(monkeypatch, AsyncMock(side_effect=_capture))
+
+ await _make_client().generate(
+ target_task_prompt="my task prompt",
+ target_specification="my spec",
+ num_cases=5,
+ )
+
+ assert len(captured) == 1
+ sent = captured[0]
+ assert sent.target_task_prompt == "my task prompt"
+ assert sent.target_specification == "my spec"
+ assert sent.num_cases == 5
+
+
+# ───────────────────────── 502 (typed code) ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_generate_502_llm_unavailable_surfaces_typed_code(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ parsed = GenerateV1SyntheticUserGeneratePostResponse502(
+ message="provider timed out",
+ code=GenerateV1SyntheticUserGeneratePostResponse502Code.LLM_UNAVAILABLE,
+ )
+ _patch_generate(monkeypatch, AsyncMock(return_value=_err_response(502, parsed)))
+
+ with pytest.raises(SyntheticUserServerError) as exc:
+ await _make_client().generate(
+ target_task_prompt="p", target_specification="s", num_cases=1
+ )
+
+ assert exc.value.code == "llm_unavailable"
+ assert exc.value.message == "provider timed out"
+ assert exc.value.status_code == 502
+
+
+@pytest.mark.asyncio
+async def test_generate_502_upstream_invalid_output_surfaces_typed_code(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ parsed = GenerateV1SyntheticUserGeneratePostResponse502(
+ message="model returned unparseable output",
+ code=GenerateV1SyntheticUserGeneratePostResponse502Code.UPSTREAM_INVALID_OUTPUT,
+ )
+ _patch_generate(monkeypatch, AsyncMock(return_value=_err_response(502, parsed)))
+
+ with pytest.raises(SyntheticUserServerError) as exc:
+ await _make_client().generate(
+ target_task_prompt="p", target_specification="s", num_cases=1
+ )
+
+ assert exc.value.code == "upstream_invalid_output"
+
+
+# ───────────────────────── 500 ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_generate_500_with_code(monkeypatch: pytest.MonkeyPatch) -> None:
+ parsed = GenerateV1SyntheticUserGeneratePostResponse500(
+ message="kaboom",
+ code="internal_error",
+ )
+ _patch_generate(monkeypatch, AsyncMock(return_value=_err_response(500, parsed)))
+
+ with pytest.raises(SyntheticUserServerError) as exc:
+ await _make_client().generate(
+ target_task_prompt="p", target_specification="s", num_cases=1
+ )
+
+ assert exc.value.code == "internal_error"
+ assert exc.value.message == "kaboom"
+ assert exc.value.status_code == 500
+
+
+@pytest.mark.asyncio
+async def test_generate_500_with_unset_code_falls_back(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ parsed = GenerateV1SyntheticUserGeneratePostResponse500(
+ message="kaboom",
+ code=UNSET, # type: ignore[arg-type]
+ )
+ _patch_generate(monkeypatch, AsyncMock(return_value=_err_response(500, parsed)))
+
+ with pytest.raises(SyntheticUserServerError) as exc:
+ await _make_client().generate(
+ target_task_prompt="p", target_specification="s", num_cases=1
+ )
+
+ assert exc.value.code == "http_500"
+
+
+# ───────────────────────── 401 ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_generate_401_surfaces_as_request_error(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ parsed = GenerateV1SyntheticUserGeneratePostResponse401(
+ message="invalid api key",
+ code="unauthorized",
+ )
+ _patch_generate(monkeypatch, AsyncMock(return_value=_err_response(401, parsed)))
+
+ with pytest.raises(SyntheticUserRequestError) as exc:
+ await _make_client().generate(
+ target_task_prompt="p", target_specification="s", num_cases=1
+ )
+
+ assert exc.value.code == "unauthorized"
+ assert exc.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_generate_401_with_unset_code_uses_default(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ parsed = GenerateV1SyntheticUserGeneratePostResponse401(
+ message="invalid api key",
+ code=UNSET, # type: ignore[arg-type]
+ )
+ _patch_generate(monkeypatch, AsyncMock(return_value=_err_response(401, parsed)))
+
+ with pytest.raises(SyntheticUserRequestError) as exc:
+ await _make_client().generate(
+ target_task_prompt="p", target_specification="s", num_cases=1
+ )
+
+ assert exc.value.code == "unauthorized"
+
+
+# ───────────────────────── 422 (HTTPValidationError) ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_generate_422_renders_validation_detail(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ parsed = HTTPValidationError(
+ detail=[
+ ValidationError(
+ loc=["body", "num_cases"],
+ msg="value is greater than 50",
+ type_="value_error",
+ ),
+ ValidationError(
+ loc=["body", "target_specification"],
+ msg="field required",
+ type_="missing",
+ ),
+ ]
+ )
+ _patch_generate(monkeypatch, AsyncMock(return_value=_err_response(422, parsed)))
+
+ with pytest.raises(SyntheticUserRequestError) as exc:
+ await _make_client().generate(
+ target_task_prompt="p", target_specification="s", num_cases=999
+ )
+
+ # Code is the http_422 sentinel; message carries the structured detail.
+ assert exc.value.code == "http_422"
+ assert "num_cases" in exc.value.message
+ assert "value is greater than 50" in exc.value.message
+ assert "target_specification" in exc.value.message
+
+
+@pytest.mark.asyncio
+async def test_generate_422_with_no_detail_returns_generic(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # SDK's HTTPValidationError accepts an Unset detail; we render a
+ # generic message rather than crashing.
+ parsed = HTTPValidationError(detail=UNSET) # type: ignore[arg-type]
+ _patch_generate(monkeypatch, AsyncMock(return_value=_err_response(422, parsed)))
+
+ with pytest.raises(SyntheticUserRequestError) as exc:
+ await _make_client().generate(
+ target_task_prompt="p", target_specification="s", num_cases=1
+ )
+
+ assert exc.value.code == "http_422"
+ assert "no detail" in exc.value.message.lower()
+
+
+# ───────────────────────── unparseable / unexpected ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_generate_unparseable_4xx_falls_back_to_request_error(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """parsed=None on a 4xx (SDK didn't recognize the body) — generic mapping."""
+ _patch_generate(monkeypatch, AsyncMock(return_value=_err_response(400, None)))
+
+ with pytest.raises(SyntheticUserRequestError) as exc:
+ await _make_client().generate(
+ target_task_prompt="p", target_specification="s", num_cases=1
+ )
+
+ assert exc.value.code == "http_400"
+ assert exc.value.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_generate_unparseable_5xx_falls_back_to_server_error(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _patch_generate(monkeypatch, AsyncMock(return_value=_err_response(503, None)))
+
+ with pytest.raises(SyntheticUserServerError) as exc:
+ await _make_client().generate(
+ target_task_prompt="p", target_specification="s", num_cases=1
+ )
+
+ assert exc.value.code == "http_503"
+ assert exc.value.status_code == 503
+
+
+# ───────────────────────── no retry surface ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_generate_does_not_retry_on_502(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Unlike the v1 client, /generate has no retry loop — a 502 surfaces
+ immediately as a per-batch failure.
+ """
+ parsed = GenerateV1SyntheticUserGeneratePostResponse502(
+ message="boom",
+ code=GenerateV1SyntheticUserGeneratePostResponse502Code.LLM_UNAVAILABLE,
+ )
+ mock = AsyncMock(return_value=_err_response(502, parsed))
+ _patch_generate(monkeypatch, mock)
+
+ with pytest.raises(SyntheticUserServerError):
+ await _make_client().generate(
+ target_task_prompt="p", target_specification="s", num_cases=1
+ )
+
+ # Exactly one call — no retry budget consumed.
+ assert mock.await_count == 1
From 322d815e739035df096679fbb25b6d576fa15329 Mon Sep 17 00:00:00 2001
From: aiyakitori <16633065+chiang-daniel@users.noreply.github.com>
Date: Mon, 1 Jun 2026 16:52:56 -0700
Subject: [PATCH 05/36] feat(libs/core/synthetic_user): tighten persona prompt
opening
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds an explicit "your entire output is the user's next message, verbatim
and nothing else: no narration, no meta-commentary, no quotes, no labels
like 'User:'" clause to the persona-playing system prompt.
A team running similar SU-driven evals reported the persona-playing
model frequently breaks character — narrating ("I would now ask..."),
self-evaluating, or labeling its output. This clause pins that down at
the prompt boundary so we don't end up reaching for post-processing
band-aids later.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
libs/core/kiln_ai/synthetic_user/prompt.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/libs/core/kiln_ai/synthetic_user/prompt.py b/libs/core/kiln_ai/synthetic_user/prompt.py
index f9d0521d56..2576cf4b3b 100644
--- a/libs/core/kiln_ai/synthetic_user/prompt.py
+++ b/libs/core/kiln_ai/synthetic_user/prompt.py
@@ -10,7 +10,10 @@
_OPENING = (
"You are playing the role of a user interacting with an AI assistant. "
- "Stay in character — respond as the user would, not as the assistant."
+ "Stay in character — respond as the user would, not as the assistant. "
+ "Your entire output is the user's next message, verbatim and nothing "
+ "else: no narration, no meta-commentary, no quotes, no labels like "
+ '"User:". Just the message text the user would type.'
)
_CONVENTIONS = """## Conversation style
From ab74931a3c8a4a6ca42b7597aa3c648a1e52874e Mon Sep 17 00:00:00 2001
From: aiyakitori <16633065+chiang-daniel@users.noreply.github.com>
Date: Mon, 1 Jun 2026 17:26:46 -0700
Subject: [PATCH 06/36] feat(studio_server): drive_case + run_cases_batch with
local SU driver
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
drive_loop.py:
- drive_case(*, case, target_invoker, su_driver, turns, on_turn) runs the
loop for exactly `turns` iterations — no early termination, no
stop_signal plumbing. Returns DriveCaseResult(chain) with the persisted
TaskRun chain.
- TargetInvoker + TurnHook Protocols. The SU driver does all role
filtering / role swap / invariant checks internally; the drive loop
passes the cumulative trace as-is.
runner.py:
- run_cases_batch is an async generator yielding typed BatchEvents
(BatchStartedEvent / TurnCompletedEvent / CaseCompletedEvent /
CaseFailedEvent / BatchCompletedEvent). No stop_signal/stop_reason
fields — drive loop is fixed-length.
- Constructs a SyntheticUserDriver per case; a malformed
synthetic_user_info blob surfaces as a CaseFailedEvent for that case
alone (other cases continue).
- _make_target_invoker / _build_input_source / _tag_leaf patterns kept
from the prior v1 commits (target persistence + SU attribution
unchanged). input_source now carries the opaque blob on the root run
+ slim {batch_tag, turn_index} on subsequent turns.
- Per-case try/except now WRAPS _tag_leaf too, so a save_to_file failure
surfaces as case_failed instead of silently disappearing into
asyncio.gather(return_exceptions=True). Same try also wraps the
target_invoker construction.
- Case tasks are kicked off before the first BatchStartedEvent yield and
the entire drain loop is inside a try/finally that cancels them on
consumer disconnect — fixes the v1 issue where browser disconnect kept
the request alive for the full duration of every in-flight case.
14 tests cover: input validation, happy-path event stream, leaf tagging,
auto-generated batch_tag, malformed blob → case_failed, target invoke
failure → case_failed, tag-save failure → case_failed, concurrency
semaphore enforcing max-in-flight, root vs slim input_source
attribution, and consumer cancellation propagating to case tasks.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../synthetic_user/drive_loop.py | 119 ++++
.../studio_server/synthetic_user/runner.py | 431 +++++++++++++
.../synthetic_user/test_drive_loop.py | 301 +++++++++
.../synthetic_user/test_runner.py | 576 ++++++++++++++++++
4 files changed, 1427 insertions(+)
create mode 100644 app/desktop/studio_server/synthetic_user/drive_loop.py
create mode 100644 app/desktop/studio_server/synthetic_user/runner.py
create mode 100644 app/desktop/studio_server/synthetic_user/test_drive_loop.py
create mode 100644 app/desktop/studio_server/synthetic_user/test_runner.py
diff --git a/app/desktop/studio_server/synthetic_user/drive_loop.py b/app/desktop/studio_server/synthetic_user/drive_loop.py
new file mode 100644
index 0000000000..93dea6cd11
--- /dev/null
+++ b/app/desktop/studio_server/synthetic_user/drive_loop.py
@@ -0,0 +1,119 @@
+"""Single-case drive loop for the multi-turn synthetic-user runner.
+
+`drive_case` alternates the target task adapter with the local
+`SyntheticUserDriver`, producing a chain of persisted `TaskRun`s on the
+target side. The loop runs for a fixed `turns` count — no early
+termination, no `` / `` sentinels — by design (see spec).
+
+Persistence is fully delegated to the adapter: every TaskRun in the
+returned chain has already been written to disk by `target_invoker(...)`,
+with `trace`, `parent_task_run_id`, and `cumulative_usage` populated.
+The SU side is in-memory only and produces no TaskRuns.
+"""
+
+from dataclasses import dataclass
+from typing import Protocol
+
+from kiln_ai.datamodel.task_run import TaskRun
+from kiln_ai.synthetic_user.driver import SyntheticUserDriver
+from kiln_ai.utils.open_ai_types import ChatCompletionMessageParam
+
+from app.desktop.studio_server.api_client.kiln_ai_server_client.models import (
+ SyntheticUserCase,
+)
+
+
+class TargetInvoker(Protocol):
+ """Callable that invokes the target task for one turn. Phase 3 wraps
+ `adapter_for_task(task, run_config).invoke` to satisfy this; tests
+ pass in a fake. Keeps the drive loop target-agnostic — it just cares
+ about the persisted TaskRun that comes back.
+ """
+
+ async def __call__(
+ self,
+ *,
+ input: str,
+ prior_trace: list[ChatCompletionMessageParam] | None,
+ parent_task_run: TaskRun | None,
+ ) -> TaskRun: ...
+
+
+class TurnHook(Protocol):
+ """Optional callback invoked once per turn after the SU has replied.
+
+ The runner uses this to translate per-turn outcomes into BatchEvents
+ without coupling drive_case to the event shape. The hook fires after
+ both the assistant turn is persisted AND the SU's next message is
+ produced, so callers have all per-turn signal in one place.
+ """
+
+ async def __call__(self, *, run: TaskRun, su_message: str) -> None: ...
+
+
+@dataclass(frozen=True)
+class DriveCaseResult:
+ """Outcome of one drive_case run.
+
+ `chain` is the list of persisted TaskRuns the adapter produced (leaf
+ last). There is no stop_reason field — every case ends after exactly
+ `turns` iterations by design.
+ """
+
+ chain: list[TaskRun]
+
+
+async def drive_case(
+ *,
+ case: SyntheticUserCase,
+ target_invoker: TargetInvoker,
+ su_driver: SyntheticUserDriver,
+ turns: int,
+ on_turn: TurnHook | None = None,
+) -> DriveCaseResult:
+ """Drive one synthetic-user case for `turns` turns.
+
+ Args:
+ case: the generated SU case (carries seed_prompt + the
+ opaque synthetic_user_info blob the driver was built from).
+ target_invoker: how to call the target task; produces a persisted TaskRun.
+ su_driver: pre-built SU driver for this case. Caller is responsible
+ for construction (so a malformed blob fails at the runner layer,
+ not here).
+ turns: exact number of assistant turns to produce. The loop runs
+ `range(turns)` and always completes all iterations — no early
+ stop.
+ on_turn: optional async hook called once per turn after `su_driver.respond`
+ returns. The runner plugs in here to emit TurnCompletedEvent.
+
+ Returns:
+ DriveCaseResult with the chain of TaskRuns produced (leaf last).
+ """
+ if turns < 1:
+ raise ValueError(f"turns must be >= 1, got {turns}")
+
+ user_msg: str = case.seed_prompt
+ prev_run: TaskRun | None = None
+ prev_trace: list[ChatCompletionMessageParam] | None = None
+ chain: list[TaskRun] = []
+
+ for _ in range(turns):
+ new_run = await target_invoker(
+ input=user_msg,
+ prior_trace=prev_trace,
+ parent_task_run=prev_run,
+ )
+ chain.append(new_run)
+
+ # The SU driver does the role filtering / role swap / invariant
+ # checks itself. We pass the new run's cumulative trace as-is.
+ su_message = await su_driver.respond(new_run.trace or [])
+
+ if on_turn is not None:
+ await on_turn(run=new_run, su_message=su_message)
+
+ user_msg = su_message
+ prev_run = new_run
+ prev_trace = new_run.trace
+
+ return DriveCaseResult(chain=chain)
diff --git a/app/desktop/studio_server/synthetic_user/runner.py b/app/desktop/studio_server/synthetic_user/runner.py
new file mode 100644
index 0000000000..fc5a31b414
--- /dev/null
+++ b/app/desktop/studio_server/synthetic_user/runner.py
@@ -0,0 +1,431 @@
+"""Batch runner — fans drive_case out across N cases, streams BatchEvents.
+
+`run_cases_batch` is an async generator yielding typed events so the
+upcoming SSE endpoint can map each to a `data:` frame without
+re-instrumenting. Cases run concurrently under an asyncio.Semaphore.
+
+Responsibilities owned here (not in drive_case):
+- Building the per-case `SyntheticUserDriver` (catches malformed
+ `synthetic_user_info` blob → `CaseFailedEvent` for that case only).
+- Building the per-case `TargetInvoker` that wraps `adapter.invoke` with
+ the SU-attribution `input_source` — opaque blob on the root run, slim
+ `{batch_tag, turn_index}` on subsequent turns.
+- Tagging the leaf TaskRun for downstream eval-dataset discovery.
+- Per-case error isolation (typed driver / loop / unexpected exceptions
+ → `CaseFailedEvent` without affecting other in-flight cases).
+- Bounded cleanup on consumer disconnect — case tasks are cancelled
+ before the closer awaits them, so a browser disconnect doesn't keep
+ the request alive for the duration of every in-flight case.
+"""
+
+import asyncio
+import contextlib
+import logging
+import uuid
+from dataclasses import dataclass
+from typing import AsyncIterator
+
+from kiln_ai.adapters.adapter_registry import adapter_for_task
+from kiln_ai.datamodel.run_config import KilnAgentRunConfigProperties
+from kiln_ai.datamodel.task import Task
+from kiln_ai.datamodel.task_output import DataSource, DataSourceType
+from kiln_ai.datamodel.task_run import TaskRun
+from kiln_ai.synthetic_user.driver import SyntheticUserDriver
+from kiln_ai.synthetic_user.models import SyntheticUserDriverConfig
+from kiln_ai.synthetic_user.parser import SyntheticUserInfoParseError
+from kiln_ai.utils.open_ai_types import ChatCompletionMessageParam
+
+from app.desktop.studio_server.api_client.kiln_ai_server_client.models import (
+ SyntheticUserCase,
+)
+from app.desktop.studio_server.synthetic_user.drive_loop import (
+ TargetInvoker,
+ drive_case,
+)
+
+logger = logging.getLogger(__name__)
+
+# Module constants. Sized for an MVP beta — easy to bump in one place.
+NUM_CASES_MAX = 10
+DEFAULT_TURNS = 5
+CONCURRENCY = 4
+
+# Tag scheme. `_TAG_SU_CASE` lets the dataset view filter to all SU-generated
+# leaves; `_TAG_PREFIX_SU_BATCH` (+ batch_tag) groups one batch for review.
+_TAG_SU_CASE = "synthetic_user_case"
+_TAG_PREFIX_SU_BATCH = "synthetic_user_batch:"
+
+# Identifies this code path in `input_source.properties.adapter_name` so a
+# reader looking at a TaskRun can tell who created it.
+_RUNNER_ADAPTER_NAME = "kiln_synthetic_user_runner"
+
+
+# ───────────────────────── BatchEvent dataclasses ─────────────────────────
+
+
+@dataclass(frozen=True)
+class BatchStartedEvent:
+ batch_tag: str
+ num_cases: int
+
+
+@dataclass(frozen=True)
+class TurnCompletedEvent:
+ case_index: int
+ assistant_run_id: str
+ su_next_message: str
+ cumulative_cost: float
+ # Cumulative OpenAI-format trace at this point (system + all turns so far).
+ # Lets the UI render the live conversation without a follow-up fetch.
+ trace: list[ChatCompletionMessageParam]
+
+
+@dataclass(frozen=True)
+class CaseCompletedEvent:
+ case_index: int
+ chain_run_ids: list[str]
+ leaf_run_id: str
+ total_turns: int
+ total_cost: float
+
+
+@dataclass(frozen=True)
+class CaseFailedEvent:
+ case_index: int
+ error_code: str
+ message: str
+
+
+@dataclass(frozen=True)
+class BatchCompletedEvent:
+ successful: int
+ failed: int
+ batch_tag: str
+ total_cost: float
+
+
+BatchEvent = (
+ BatchStartedEvent
+ | TurnCompletedEvent
+ | CaseCompletedEvent
+ | CaseFailedEvent
+ | BatchCompletedEvent
+)
+
+
+# ───────────────────────── public entry point ─────────────────────────
+
+
+async def run_cases_batch(
+ *,
+ cases: list[SyntheticUserCase],
+ target_task: Task,
+ target_run_config: KilnAgentRunConfigProperties,
+ su_driver_config: SyntheticUserDriverConfig,
+ turns: int = DEFAULT_TURNS,
+ concurrency: int = CONCURRENCY,
+ batch_tag: str | None = None,
+) -> AsyncIterator[BatchEvent]:
+ """Drive `cases` concurrently against `target_task`, streaming progress.
+
+ Each case runs in its own coroutine under an `asyncio.Semaphore` so the
+ target-task LLM and the SU LLM aren't both hammered at unbounded fan-out.
+ Events from different cases interleave; ordering WITHIN a case is
+ `turn_completed`* → `case_completed | case_failed`.
+
+ Yields:
+ BatchStartedEvent — once, before any case runs.
+ TurnCompletedEvent — one per assistant turn within a case.
+ CaseCompletedEvent — one per case that ran to completion.
+ CaseFailedEvent — one per case that hit a typed error.
+ BatchCompletedEvent — once, after all cases finish.
+ """
+ if not cases:
+ raise ValueError("cases cannot be empty")
+ if turns < 1:
+ raise ValueError("turns must be >= 1")
+ if concurrency < 1:
+ raise ValueError("concurrency must be >= 1")
+
+ resolved_batch_tag = batch_tag or _new_batch_tag()
+ semaphore = asyncio.Semaphore(concurrency)
+ # `None` is the end-of-stream sentinel pushed when all cases finish.
+ queue: asyncio.Queue[BatchEvent | None] = asyncio.Queue()
+
+ async def _drive_one(case_index: int, case: SyntheticUserCase) -> None:
+ async with semaphore:
+ await _drive_one_case_and_emit(
+ case_index=case_index,
+ case=case,
+ target_task=target_task,
+ target_run_config=target_run_config,
+ su_driver_config=su_driver_config,
+ turns=turns,
+ batch_tag=resolved_batch_tag,
+ queue=queue,
+ )
+
+ # Kick the cases off BEFORE the first yield so they start running
+ # concurrently with consumer setup. If the consumer disconnects between
+ # BatchStartedEvent and the drain loop, the `finally` below still
+ # cancels them.
+ case_tasks = [asyncio.create_task(_drive_one(i, c)) for i, c in enumerate(cases)]
+
+ async def _close_when_done() -> None:
+ # `return_exceptions=True` so a stray bug doesn't leave the queue
+ # draining forever; we surface failures via per-case CaseFailedEvent.
+ await asyncio.gather(*case_tasks, return_exceptions=True)
+ await queue.put(None)
+
+ closer = asyncio.create_task(_close_when_done())
+
+ successful = 0
+ failed = 0
+ total_cost = 0.0
+ try:
+ yield BatchStartedEvent(batch_tag=resolved_batch_tag, num_cases=len(cases))
+
+ while True:
+ event = await queue.get()
+ if event is None:
+ break
+ yield event
+ if isinstance(event, CaseCompletedEvent):
+ successful += 1
+ total_cost += event.total_cost
+ elif isinstance(event, CaseFailedEvent):
+ failed += 1
+
+ yield BatchCompletedEvent(
+ successful=successful,
+ failed=failed,
+ batch_tag=resolved_batch_tag,
+ total_cost=total_cost,
+ )
+ finally:
+ # Cancel any in-flight case tasks before awaiting the closer. Without
+ # this, a consumer disconnect (e.g. browser closes the SSE) leaves
+ # workers running to completion writing to a dead queue — the request
+ # stays "alive" for the full duration of every case.
+ for task in case_tasks:
+ if not task.done():
+ task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await closer
+
+
+# ───────────────────────── per-case orchestration ─────────────────────────
+
+
+async def _drive_one_case_and_emit(
+ *,
+ case_index: int,
+ case: SyntheticUserCase,
+ target_task: Task,
+ target_run_config: KilnAgentRunConfigProperties,
+ su_driver_config: SyntheticUserDriverConfig,
+ turns: int,
+ batch_tag: str,
+ queue: asyncio.Queue[BatchEvent | None],
+) -> None:
+ """Run drive_case for one case, translating per-turn outcomes to events
+ on `queue` and ending with either CaseCompletedEvent or CaseFailedEvent.
+
+ All upstream work — SyntheticUserDriver construction, target_invoker
+ construction, drive_case execution, leaf tagging — runs inside a single
+ try/except so any failure surfaces as a CaseFailedEvent rather than
+ escaping into `asyncio.gather(return_exceptions=True)` (which would
+ silently drop the case from the event stream).
+ """
+ try:
+ # Construct the per-case SU driver. Parses the blob immediately;
+ # a malformed blob fails this case without affecting others.
+ try:
+ su_driver = SyntheticUserDriver(case.synthetic_user_info, su_driver_config)
+ except SyntheticUserInfoParseError as e:
+ await queue.put(
+ CaseFailedEvent(
+ case_index=case_index,
+ error_code="bad_synthetic_user_info",
+ message=str(e),
+ )
+ )
+ return
+
+ target_invoker = _make_target_invoker(
+ case=case,
+ target_task=target_task,
+ target_run_config=target_run_config,
+ su_driver_config=su_driver_config,
+ batch_tag=batch_tag,
+ )
+
+ async def _on_turn(*, run: TaskRun, su_message: str) -> None:
+ await queue.put(
+ TurnCompletedEvent(
+ case_index=case_index,
+ assistant_run_id=str(run.id) if run.id is not None else "",
+ su_next_message=su_message,
+ cumulative_cost=_cumulative_cost(run),
+ # Snapshot the cumulative trace so the UI can render
+ # the live conversation without a follow-up fetch.
+ trace=list(run.trace) if run.trace else [],
+ )
+ )
+
+ result = await drive_case(
+ case=case,
+ target_invoker=target_invoker,
+ su_driver=su_driver,
+ turns=turns,
+ on_turn=_on_turn,
+ )
+
+ # Tag the leaf so eval-time loaders can find it. Inside the try
+ # so a tag-save failure (full disk, validator rejection on a
+ # malformed batch_tag) surfaces as case_failed, not a silent drop.
+ leaf = result.chain[-1]
+ _tag_leaf(leaf, batch_tag)
+
+ await queue.put(
+ CaseCompletedEvent(
+ case_index=case_index,
+ chain_run_ids=[
+ str(r.id) if r.id is not None else "" for r in result.chain
+ ],
+ leaf_run_id=str(leaf.id) if leaf.id is not None else "",
+ total_turns=len(result.chain),
+ total_cost=_cumulative_cost(leaf),
+ )
+ )
+ except Exception as e: # noqa: BLE001 — beta error surface
+ # Adapter network errors, model misconfig, save_to_file blow-up,
+ # anything unexpected. Log with full traceback; emit a structured
+ # failure so the batch invariant holds (every case gets one event).
+ logger.exception(
+ "synthetic_user runner: unexpected error in case %d", case_index
+ )
+ await queue.put(
+ CaseFailedEvent(
+ case_index=case_index,
+ error_code="unexpected_error",
+ message=f"{type(e).__name__}: {e}",
+ )
+ )
+
+
+# ───────────────────────── target invoker construction ─────────────────────
+
+
+def _make_target_invoker(
+ *,
+ case: SyntheticUserCase,
+ target_task: Task,
+ target_run_config: KilnAgentRunConfigProperties,
+ su_driver_config: SyntheticUserDriverConfig,
+ batch_tag: str,
+) -> TargetInvoker:
+ """Build a per-case TargetInvoker over the real adapter.
+
+ The closure tracks `turn_index` so the root run carries the full SU
+ attribution context in `input_source.properties` while subsequent
+ runs carry only the slim `{batch_tag, turn_index}` — the case is
+ recoverable by walking `parent_task_run_id` to the root.
+ """
+ adapter = adapter_for_task(target_task, target_run_config)
+ turn_index = 0
+
+ async def _invoker(
+ *,
+ input: str,
+ prior_trace: list[ChatCompletionMessageParam] | None,
+ parent_task_run: TaskRun | None,
+ ) -> TaskRun:
+ nonlocal turn_index
+ turn_index += 1
+ input_source = _build_input_source(
+ case=case,
+ su_driver_config=su_driver_config,
+ batch_tag=batch_tag,
+ turn_index=turn_index,
+ is_root=(turn_index == 1),
+ )
+ return await adapter.invoke(
+ input=input,
+ input_source=input_source,
+ prior_trace=prior_trace,
+ parent_task_run=parent_task_run,
+ )
+
+ return _invoker
+
+
+def _build_input_source(
+ *,
+ case: SyntheticUserCase,
+ su_driver_config: SyntheticUserDriverConfig,
+ batch_tag: str,
+ turn_index: int,
+ is_root: bool,
+) -> DataSource:
+ """Attribute the user-side input on this turn to the SU driver model.
+
+ Reuses `DataSourceType.synthetic` (a model produced this text) rather
+ than inventing a new type — the existing validator accepts arbitrary
+ extra property keys alongside the required `model_name` /
+ `model_provider` / `adapter_name`.
+
+ Root run carries the full opaque blob + seed_prompt so anyone landing
+ on this run can recover the case context without walking the chain.
+ Subsequent runs carry only the slim batch_tag/turn_index pair.
+ """
+ props: dict[str, str | int | float] = {
+ "model_name": su_driver_config.model_name,
+ "model_provider": su_driver_config.model_provider_name.value,
+ "adapter_name": _RUNNER_ADAPTER_NAME,
+ "batch_tag": batch_tag,
+ "turn_index": turn_index,
+ }
+ if is_root:
+ props["synthetic_user_info"] = case.synthetic_user_info
+ props["seed_prompt"] = case.seed_prompt
+
+ # The validator rejects empty string property values. Strip any that
+ # would trip it (shouldn't happen for required fields in practice, but
+ # this protects against an empty seed_prompt or a malformed config).
+ filtered = {k: v for k, v in props.items() if not (isinstance(v, str) and v == "")}
+ return DataSource(type=DataSourceType.synthetic, properties=filtered)
+
+
+# ───────────────────────── small utilities ─────────────────────────
+
+
+def _new_batch_tag() -> str:
+ """12-char hex tag from uuid4 — short enough to read; long enough to
+ avoid collisions across batches a user runs in the same session.
+ """
+ return uuid.uuid4().hex[:12]
+
+
+def _cumulative_cost(run: TaskRun) -> float:
+ """Read the rolled-up cost from a TaskRun, defaulting to 0 if usage is
+ missing (defensive against fakes in unit tests that don't populate it).
+ """
+ usage = getattr(run, "cumulative_usage", None)
+ if usage is None:
+ return 0.0
+ return float(getattr(usage, "cost", None) or 0.0)
+
+
+def _tag_leaf(leaf: TaskRun, batch_tag: str) -> None:
+ """Add the runner's discovery tags to the leaf TaskRun and persist.
+
+ Tags are deduplicated (treated as a set then sorted) so re-runs
+ against an already-tagged leaf are idempotent. A save_to_file
+ exception surfaces to the caller (which converts to CaseFailedEvent).
+ """
+ tags = set(leaf.tags or [])
+ tags.add(_TAG_SU_CASE)
+ tags.add(f"{_TAG_PREFIX_SU_BATCH}{batch_tag}")
+ leaf.tags = sorted(tags)
+ leaf.save_to_file()
diff --git a/app/desktop/studio_server/synthetic_user/test_drive_loop.py b/app/desktop/studio_server/synthetic_user/test_drive_loop.py
new file mode 100644
index 0000000000..d4e9f2f420
--- /dev/null
+++ b/app/desktop/studio_server/synthetic_user/test_drive_loop.py
@@ -0,0 +1,301 @@
+"""Unit tests for drive_case.
+
+The TargetInvoker is replaced with a hand-written fake; the
+SyntheticUserDriver is replaced with a `Mock(spec=)` whose `respond()`
+is an AsyncMock returning canned strings. No real model calls.
+"""
+
+from typing import Any
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+
+from kiln_ai.datamodel.task_run import TaskRun
+from kiln_ai.synthetic_user.driver import SyntheticUserDriver
+
+from app.desktop.studio_server.api_client.kiln_ai_server_client.models import (
+ SyntheticUserCase,
+)
+from app.desktop.studio_server.synthetic_user.drive_loop import (
+ DriveCaseResult,
+ drive_case,
+)
+
+# ───────────────────────── helpers / fixtures ─────────────────────────
+
+
+_SYSTEM_PROMPT = "You are a target agent."
+
+
+def _case(seed: str = "hi there") -> SyntheticUserCase:
+ return SyntheticUserCase(
+ seed_prompt=seed,
+ synthetic_user_info=(
+ "frustrated customer"
+ "get a refund outside policy"
+ "be polite then escalate"
+ ),
+ )
+
+
+def _fake_run(trace: list[dict], run_id: str | None = None) -> Mock:
+ """Minimal stand-in for a persisted TaskRun — drive_case only reads
+ `.trace`; we add `.id` so tests can identify the chain order.
+ """
+ run = Mock(spec=TaskRun)
+ run.trace = trace
+ run.id = run_id or f"run-{len(trace)}"
+ return run
+
+
+class _FakeInvoker:
+ """Records each call's (input, prior_trace, parent_task_run) and
+ returns successive canned TaskRun mocks. Each call appends one
+ user + one assistant turn to the prior trace to mimic how
+ `adapter.invoke` builds cumulative traces.
+ """
+
+ def __init__(self, assistant_replies: list[str]):
+ self._replies = list(assistant_replies)
+ self.calls: list[dict[str, Any]] = []
+
+ async def __call__(
+ self,
+ *,
+ input: str,
+ prior_trace: list[dict] | None,
+ parent_task_run: TaskRun | None,
+ ) -> TaskRun:
+ self.calls.append(
+ {
+ "input": input,
+ "prior_trace": prior_trace,
+ "parent_task_run": parent_task_run,
+ }
+ )
+ if not self._replies:
+ raise AssertionError("FakeInvoker called more times than replies provided")
+ assistant_reply = self._replies.pop(0)
+ if prior_trace is None:
+ new_trace = [
+ {"role": "system", "content": _SYSTEM_PROMPT},
+ {"role": "user", "content": input},
+ {"role": "assistant", "content": assistant_reply},
+ ]
+ else:
+ new_trace = [
+ *prior_trace,
+ {"role": "user", "content": input},
+ {"role": "assistant", "content": assistant_reply},
+ ]
+ return _fake_run(new_trace, run_id=f"run-turn-{len(self.calls)}")
+
+
+def _su_driver_with_replies(replies: list[str]) -> Mock:
+ """Mock(spec=SyntheticUserDriver) with respond() returning canned strings."""
+ drv = Mock(spec=SyntheticUserDriver)
+ drv.respond = AsyncMock(side_effect=replies)
+ return drv
+
+
+# ───────────────────────── happy path ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_drive_case_runs_exactly_turns_iterations() -> None:
+ """No early termination — loop always completes `turns` iterations."""
+ invoker = _FakeInvoker(assistant_replies=["a1", "a2", "a3", "a4"])
+ su = _su_driver_with_replies(["u2", "u3", "u4", "u5"])
+
+ result = await drive_case(
+ case=_case(),
+ target_invoker=invoker,
+ su_driver=su,
+ turns=4,
+ )
+
+ assert isinstance(result, DriveCaseResult)
+ assert len(result.chain) == 4
+ assert len(invoker.calls) == 4
+ assert su.respond.await_count == 4
+
+
+@pytest.mark.asyncio
+async def test_drive_case_seeds_first_turn_with_case_seed_prompt() -> None:
+ invoker = _FakeInvoker(assistant_replies=["a1"])
+ su = _su_driver_with_replies(["next user msg"])
+
+ await drive_case(
+ case=_case(seed="custom seed"),
+ target_invoker=invoker,
+ su_driver=su,
+ turns=1,
+ )
+
+ first = invoker.calls[0]
+ assert first["input"] == "custom seed"
+ assert first["prior_trace"] is None
+ assert first["parent_task_run"] is None
+
+
+@pytest.mark.asyncio
+async def test_drive_case_threads_prior_trace_and_parent_run() -> None:
+ invoker = _FakeInvoker(assistant_replies=["r1", "r2", "r3"])
+ su = _su_driver_with_replies(["u2", "u3", "u4"])
+
+ result = await drive_case(
+ case=_case(seed="u1"),
+ target_invoker=invoker,
+ su_driver=su,
+ turns=3,
+ )
+
+ # Turn 2's invoke should receive turn 1's trace + TaskRun.
+ second = invoker.calls[1]
+ assert second["input"] == "u2"
+ assert second["prior_trace"] is result.chain[0].trace
+ assert second["parent_task_run"] is result.chain[0]
+
+ # Turn 3's invoke should receive turn 2's trace + TaskRun.
+ third = invoker.calls[2]
+ assert third["input"] == "u3"
+ assert third["prior_trace"] is result.chain[1].trace
+ assert third["parent_task_run"] is result.chain[1]
+
+
+@pytest.mark.asyncio
+async def test_drive_case_passes_full_trace_to_su_driver() -> None:
+ """The SU driver's `respond` receives the full cumulative trace —
+ the driver itself filters to visible_message_roles.
+ """
+ invoker = _FakeInvoker(assistant_replies=["a1", "a2"])
+ su = _su_driver_with_replies(["u2", "u3"])
+
+ result = await drive_case(
+ case=_case(seed="u1"),
+ target_invoker=invoker,
+ su_driver=su,
+ turns=2,
+ )
+
+ # First respond() got turn-1's trace ([sys, u1, a1]).
+ first_call_args = su.respond.await_args_list[0].args
+ assert first_call_args[0] == result.chain[0].trace
+ # Second respond() got turn-2's trace.
+ second_call_args = su.respond.await_args_list[1].args
+ assert second_call_args[0] == result.chain[1].trace
+
+
+# ───────────────────────── on_turn hook ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_drive_case_on_turn_hook_fires_once_per_turn() -> None:
+ invoker = _FakeInvoker(assistant_replies=["a1", "a2", "a3"])
+ su = _su_driver_with_replies(["u2", "u3", "u4"])
+
+ captured: list[tuple[TaskRun, str]] = []
+
+ async def _hook(*, run: TaskRun, su_message: str) -> None:
+ captured.append((run, su_message))
+
+ result = await drive_case(
+ case=_case(),
+ target_invoker=invoker,
+ su_driver=su,
+ turns=3,
+ on_turn=_hook,
+ )
+
+ assert len(captured) == 3
+ for i, (run, msg) in enumerate(captured):
+ assert run is result.chain[i]
+ # SU's replies are u2, u3, u4 per the fake.
+ assert msg == ["u2", "u3", "u4"][i]
+
+
+@pytest.mark.asyncio
+async def test_drive_case_works_without_on_turn_hook() -> None:
+ """on_turn is optional — the loop runs normally if it's None."""
+ invoker = _FakeInvoker(assistant_replies=["a1"])
+ su = _su_driver_with_replies(["u2"])
+
+ result = await drive_case(
+ case=_case(),
+ target_invoker=invoker,
+ su_driver=su,
+ turns=1,
+ on_turn=None,
+ )
+
+ assert len(result.chain) == 1
+
+
+# ───────────────────────── invariants ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_drive_case_rejects_invalid_turns() -> None:
+ invoker = _FakeInvoker(assistant_replies=[])
+ su = _su_driver_with_replies([])
+
+ with pytest.raises(ValueError, match="turns must be >= 1"):
+ await drive_case(
+ case=_case(),
+ target_invoker=invoker,
+ su_driver=su,
+ turns=0,
+ )
+
+
+@pytest.mark.asyncio
+async def test_drive_case_propagates_target_invoker_errors() -> None:
+ """If the target_invoker raises, the loop doesn't swallow — the
+ runner is responsible for per-case isolation, not drive_case.
+ """
+ bad_invoker = AsyncMock(side_effect=RuntimeError("target blew up"))
+ su = _su_driver_with_replies([])
+
+ with pytest.raises(RuntimeError, match="target blew up"):
+ await drive_case(
+ case=_case(),
+ target_invoker=bad_invoker,
+ su_driver=su,
+ turns=3,
+ )
+
+
+@pytest.mark.asyncio
+async def test_drive_case_propagates_su_driver_errors() -> None:
+ """If su_driver.respond raises, the loop doesn't swallow either."""
+ invoker = _FakeInvoker(assistant_replies=["a1"])
+ su = Mock(spec=SyntheticUserDriver)
+ su.respond = AsyncMock(side_effect=ValueError("bad conversation"))
+
+ with pytest.raises(ValueError, match="bad conversation"):
+ await drive_case(
+ case=_case(),
+ target_invoker=invoker,
+ su_driver=su,
+ turns=3,
+ )
+
+
+# ───────────────────────── chain order ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_drive_case_returns_chain_in_order_leaf_last() -> None:
+ invoker = _FakeInvoker(assistant_replies=["a1", "a2", "a3"])
+ su = _su_driver_with_replies(["u2", "u3", "u4"])
+
+ result = await drive_case(
+ case=_case(seed="u1"),
+ target_invoker=invoker,
+ su_driver=su,
+ turns=3,
+ )
+
+ assert [r.id for r in result.chain] == ["run-turn-1", "run-turn-2", "run-turn-3"]
+ # Leaf's trace is the longest — covers all three round-trips.
+ assert len(result.chain[-1].trace) > len(result.chain[0].trace)
diff --git a/app/desktop/studio_server/synthetic_user/test_runner.py b/app/desktop/studio_server/synthetic_user/test_runner.py
new file mode 100644
index 0000000000..4de3e05505
--- /dev/null
+++ b/app/desktop/studio_server/synthetic_user/test_runner.py
@@ -0,0 +1,576 @@
+"""Unit tests for run_cases_batch.
+
+`adapter_for_task` is monkeypatched to return a fake adapter whose
+`.invoke` is an AsyncMock; `SyntheticUserDriver` is replaced with a
+patched class returning a mocked instance whose `respond()` yields
+canned strings. Tests collect the event stream into a list and inspect
+ordering / per-case bookkeeping.
+"""
+
+import asyncio
+from typing import Any
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+
+from kiln_ai.datamodel.datamodel_enums import (
+ ModelProviderName,
+ StructuredOutputMode,
+)
+from kiln_ai.datamodel.run_config import KilnAgentRunConfigProperties, ToolsRunConfig
+from kiln_ai.datamodel.task import Task
+from kiln_ai.datamodel.task_run import TaskRun
+from kiln_ai.synthetic_user.driver import SyntheticUserDriver
+from kiln_ai.synthetic_user.models import SyntheticUserDriverConfig
+from kiln_ai.synthetic_user.parser import SyntheticUserInfoParseError
+
+from app.desktop.studio_server.api_client.kiln_ai_server_client.models import (
+ SyntheticUserCase,
+)
+from app.desktop.studio_server.synthetic_user import runner as runner_mod
+from app.desktop.studio_server.synthetic_user.runner import (
+ BatchCompletedEvent,
+ BatchEvent,
+ BatchStartedEvent,
+ CaseCompletedEvent,
+ CaseFailedEvent,
+ TurnCompletedEvent,
+ run_cases_batch,
+)
+
+
+# ───────────────────────── helpers / fixtures ─────────────────────────
+
+
+def _case(idx: int = 0) -> SyntheticUserCase:
+ return SyntheticUserCase(
+ seed_prompt=f"seed-{idx}",
+ synthetic_user_info=(
+ f"persona-{idx}"
+ f"goal-{idx}"
+ f"guidance-{idx}"
+ ),
+ )
+
+
+def _su_driver_config() -> SyntheticUserDriverConfig:
+ return SyntheticUserDriverConfig(
+ model_name="claude_4_5_haiku",
+ model_provider_name=ModelProviderName.openrouter,
+ )
+
+
+def _target_run_config() -> KilnAgentRunConfigProperties:
+ return KilnAgentRunConfigProperties(
+ model_name="gpt_5_5",
+ model_provider_name=ModelProviderName.openrouter,
+ prompt_id="simple_prompt_builder",
+ structured_output_mode=StructuredOutputMode.default,
+ tools_config=ToolsRunConfig(tools=[]),
+ )
+
+
+def _fake_run(run_id: str, cost: float = 0.0) -> Mock:
+ """Stand-in for a persisted TaskRun. drive_case reads `.trace`; runner
+ reads `.id` + `.cumulative_usage.cost`; `_tag_leaf` writes `.tags`
+ and calls `.save_to_file()`.
+ """
+ run = Mock(spec=TaskRun)
+ run.id = run_id
+ run.trace = [
+ {"role": "system", "content": "sys"},
+ {"role": "user", "content": "u"},
+ {"role": "assistant", "content": f"a-{run_id}"},
+ ]
+ run.cumulative_usage = Mock(cost=cost)
+ run.tags = []
+ run.save_to_file = Mock()
+ return run
+
+
+def _patch_adapter_for_task(
+ monkeypatch: pytest.MonkeyPatch, invoke_side_effect: Any
+) -> AsyncMock:
+ """Make `adapter_for_task` return an adapter whose .invoke yields the
+ given side_effect (list of TaskRuns or a callable).
+ """
+ adapter = Mock()
+ adapter.invoke = AsyncMock(side_effect=invoke_side_effect)
+ monkeypatch.setattr(
+ runner_mod, "adapter_for_task", lambda task, run_config: adapter
+ )
+ return adapter.invoke
+
+
+def _patch_su_driver(
+ monkeypatch: pytest.MonkeyPatch, replies_per_case: dict[int, list[str]] | list[str]
+) -> None:
+ """Replace SyntheticUserDriver with a stub that returns canned strings.
+
+ Pass a list to give every case the same reply schedule; pass a dict
+ keyed by case_index for per-case schedules.
+ """
+ call_counter = {"i": 0}
+
+ def _ctor(blob, config): # noqa: ARG001
+ idx = call_counter["i"]
+ call_counter["i"] += 1
+ replies = (
+ replies_per_case[idx]
+ if isinstance(replies_per_case, dict)
+ else list(replies_per_case)
+ )
+ instance = Mock(spec=SyntheticUserDriver)
+ instance.respond = AsyncMock(side_effect=replies)
+ return instance
+
+ monkeypatch.setattr(runner_mod, "SyntheticUserDriver", _ctor)
+
+
+def _patch_su_driver_factory(monkeypatch: pytest.MonkeyPatch, factory: Any) -> None:
+ """For when tests need full control (e.g., raise on construction)."""
+ monkeypatch.setattr(runner_mod, "SyntheticUserDriver", factory)
+
+
+@pytest.fixture
+def fake_task() -> Mock:
+ return Mock(spec=Task)
+
+
+async def _collect(gen) -> list[BatchEvent]:
+ out: list[BatchEvent] = []
+ async for ev in gen:
+ out.append(ev)
+ return out
+
+
+# ───────────────────────── input validation ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_empty_cases_raises(fake_task: Mock) -> None:
+ with pytest.raises(ValueError, match="cases cannot be empty"):
+ async for _ in run_cases_batch(
+ cases=[],
+ target_task=fake_task,
+ target_run_config=_target_run_config(),
+ su_driver_config=_su_driver_config(),
+ ):
+ pass
+
+
+@pytest.mark.asyncio
+async def test_invalid_turns_raises(fake_task: Mock) -> None:
+ with pytest.raises(ValueError, match="turns must be >= 1"):
+ async for _ in run_cases_batch(
+ cases=[_case()],
+ target_task=fake_task,
+ target_run_config=_target_run_config(),
+ su_driver_config=_su_driver_config(),
+ turns=0,
+ ):
+ pass
+
+
+@pytest.mark.asyncio
+async def test_invalid_concurrency_raises(fake_task: Mock) -> None:
+ with pytest.raises(ValueError, match="concurrency must be >= 1"):
+ async for _ in run_cases_batch(
+ cases=[_case()],
+ target_task=fake_task,
+ target_run_config=_target_run_config(),
+ su_driver_config=_su_driver_config(),
+ concurrency=0,
+ ):
+ pass
+
+
+# ───────────────────────── happy path ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_three_cases_produce_full_event_stream(
+ fake_task: Mock, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ cases = [_case(0), _case(1), _case(2)]
+ _patch_adapter_for_task(
+ monkeypatch,
+ [
+ _fake_run("r0", cost=0.01),
+ _fake_run("r1", cost=0.02),
+ _fake_run("r2", cost=0.04),
+ ],
+ )
+ _patch_su_driver(monkeypatch, replies_per_case=["u-next"])
+
+ events = await _collect(
+ run_cases_batch(
+ cases=cases,
+ target_task=fake_task,
+ target_run_config=_target_run_config(),
+ su_driver_config=_su_driver_config(),
+ turns=1,
+ concurrency=4,
+ batch_tag="testbatch",
+ )
+ )
+
+ assert isinstance(events[0], BatchStartedEvent)
+ assert events[0].batch_tag == "testbatch"
+ assert events[0].num_cases == 3
+
+ assert isinstance(events[-1], BatchCompletedEvent)
+ assert events[-1].successful == 3
+ assert events[-1].failed == 0
+ assert events[-1].batch_tag == "testbatch"
+ assert events[-1].total_cost == pytest.approx(0.07)
+
+ turn_events = [e for e in events if isinstance(e, TurnCompletedEvent)]
+ case_done = [e for e in events if isinstance(e, CaseCompletedEvent)]
+ assert len(turn_events) == 3
+ assert len(case_done) == 3
+ assert {e.case_index for e in case_done} == {0, 1, 2}
+ for ev in case_done:
+ assert ev.total_turns == 1
+
+
+@pytest.mark.asyncio
+async def test_turn_completed_event_carries_su_message_and_trace(
+ fake_task: Mock, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ _patch_adapter_for_task(monkeypatch, [_fake_run("r0", cost=0.01)])
+ _patch_su_driver(monkeypatch, replies_per_case=["the SU's reply"])
+
+ events = await _collect(
+ run_cases_batch(
+ cases=[_case()],
+ target_task=fake_task,
+ target_run_config=_target_run_config(),
+ su_driver_config=_su_driver_config(),
+ turns=1,
+ )
+ )
+
+ turn = next(e for e in events if isinstance(e, TurnCompletedEvent))
+ assert turn.su_next_message == "the SU's reply"
+ assert turn.cumulative_cost == pytest.approx(0.01)
+ # Trace is whatever the fake run carried.
+ assert any(m.get("role") == "assistant" for m in turn.trace)
+
+
+@pytest.mark.asyncio
+async def test_leaf_is_tagged_with_synthetic_user_case_and_batch_tag(
+ fake_task: Mock, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ leaf = _fake_run("leaf")
+ _patch_adapter_for_task(monkeypatch, [leaf])
+ _patch_su_driver(monkeypatch, replies_per_case=["x"])
+
+ await _collect(
+ run_cases_batch(
+ cases=[_case()],
+ target_task=fake_task,
+ target_run_config=_target_run_config(),
+ su_driver_config=_su_driver_config(),
+ turns=1,
+ batch_tag="abc123",
+ )
+ )
+
+ assert "synthetic_user_case" in leaf.tags
+ assert "synthetic_user_batch:abc123" in leaf.tags
+ leaf.save_to_file.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_auto_generates_batch_tag_when_not_provided(
+ fake_task: Mock, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ _patch_adapter_for_task(monkeypatch, [_fake_run("r")])
+ _patch_su_driver(monkeypatch, replies_per_case=["x"])
+
+ events = await _collect(
+ run_cases_batch(
+ cases=[_case()],
+ target_task=fake_task,
+ target_run_config=_target_run_config(),
+ su_driver_config=_su_driver_config(),
+ turns=1,
+ )
+ )
+
+ started = next(e for e in events if isinstance(e, BatchStartedEvent))
+ assert len(started.batch_tag) == 12
+ assert all(c in "0123456789abcdef" for c in started.batch_tag)
+
+
+# ───────────────────────── per-case failure isolation ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_malformed_blob_surfaces_as_case_failed(
+ fake_task: Mock, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """SyntheticUserDriver construction raises on a bad blob — that case
+ fails alone; the others run normally.
+ """
+ call_counter = {"i": 0}
+
+ def _ctor(blob, config): # noqa: ARG001
+ idx = call_counter["i"]
+ call_counter["i"] += 1
+ if idx == 1:
+ raise SyntheticUserInfoParseError("bad blob")
+ instance = Mock(spec=SyntheticUserDriver)
+ instance.respond = AsyncMock(return_value="ok")
+ return instance
+
+ _patch_su_driver_factory(monkeypatch, _ctor)
+ _patch_adapter_for_task(monkeypatch, [_fake_run("r0"), _fake_run("r2")])
+
+ events = await _collect(
+ run_cases_batch(
+ cases=[_case(0), _case(1), _case(2)],
+ target_task=fake_task,
+ target_run_config=_target_run_config(),
+ su_driver_config=_su_driver_config(),
+ turns=1,
+ concurrency=1, # serialize so the SU driver factory is called in case order
+ )
+ )
+
+ case_done = [e for e in events if isinstance(e, CaseCompletedEvent)]
+ case_failed = [e for e in events if isinstance(e, CaseFailedEvent)]
+ assert {e.case_index for e in case_done} == {0, 2}
+ assert {e.case_index for e in case_failed} == {1}
+ assert case_failed[0].error_code == "bad_synthetic_user_info"
+
+
+@pytest.mark.asyncio
+async def test_target_invoke_failure_surfaces_as_case_failed(
+ fake_task: Mock, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """If adapter.invoke raises, the case is marked failed — but other
+ in-flight cases keep running.
+ """
+ _patch_adapter_for_task(monkeypatch, RuntimeError("kaboom"))
+ _patch_su_driver(monkeypatch, replies_per_case=["x"])
+
+ events = await _collect(
+ run_cases_batch(
+ cases=[_case()],
+ target_task=fake_task,
+ target_run_config=_target_run_config(),
+ su_driver_config=_su_driver_config(),
+ turns=1,
+ )
+ )
+
+ failed = next(e for e in events if isinstance(e, CaseFailedEvent))
+ assert failed.error_code == "unexpected_error"
+ assert "RuntimeError" in failed.message
+ assert "kaboom" in failed.message
+
+
+@pytest.mark.asyncio
+async def test_tag_leaf_failure_surfaces_as_case_failed(
+ fake_task: Mock, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """If save_to_file fails (e.g., disk full, validator rejects the tag),
+ the case becomes case_failed instead of silently vanishing.
+ """
+ bad_leaf = _fake_run("bad-leaf")
+ bad_leaf.save_to_file = Mock(side_effect=OSError("disk full"))
+ _patch_adapter_for_task(monkeypatch, [bad_leaf])
+ _patch_su_driver(monkeypatch, replies_per_case=["x"])
+
+ events = await _collect(
+ run_cases_batch(
+ cases=[_case()],
+ target_task=fake_task,
+ target_run_config=_target_run_config(),
+ su_driver_config=_su_driver_config(),
+ turns=1,
+ )
+ )
+
+ failed = next(e for e in events if isinstance(e, CaseFailedEvent))
+ assert failed.error_code == "unexpected_error"
+ assert "disk full" in failed.message
+
+
+# ───────────────────────── concurrency ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_concurrency_semaphore_caps_in_flight_cases(
+ fake_task: Mock, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """With concurrency=2 and 5 cases, max-in-flight should be exactly 2."""
+ in_flight = 0
+ max_seen = 0
+ fan_out_event = asyncio.Event()
+ lock = asyncio.Lock()
+
+ async def slow_invoke(**_kwargs: Any) -> Mock:
+ nonlocal in_flight, max_seen
+ async with lock:
+ in_flight += 1
+ max_seen = max(max_seen, in_flight)
+ if in_flight >= 2:
+ fan_out_event.set()
+ # Block until fan-out has been observed to actually happen.
+ try:
+ await asyncio.wait_for(fan_out_event.wait(), timeout=1.0)
+ except asyncio.TimeoutError:
+ pass
+ async with lock:
+ in_flight -= 1
+ return _fake_run("r")
+
+ _patch_adapter_for_task(monkeypatch, slow_invoke)
+ _patch_su_driver(monkeypatch, replies_per_case=["x"])
+
+ await _collect(
+ run_cases_batch(
+ cases=[_case(i) for i in range(5)],
+ target_task=fake_task,
+ target_run_config=_target_run_config(),
+ su_driver_config=_su_driver_config(),
+ turns=1,
+ concurrency=2,
+ )
+ )
+
+ # Exactly 2 — not <=2 — so a regression that serialized everything
+ # wouldn't pass vacuously.
+ assert max_seen == 2
+
+
+# ───────────────────────── input_source attribution ─────────────────────
+
+
+@pytest.mark.asyncio
+async def test_root_input_source_carries_blob_and_seed_prompt(
+ fake_task: Mock, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """First adapter.invoke call gets an input_source with the full SU
+ case context: the opaque blob + seed_prompt.
+ """
+ captured: list[dict[str, Any]] = []
+
+ async def _capture(**kwargs: Any) -> Mock:
+ captured.append(kwargs)
+ return _fake_run(f"r-{len(captured)}")
+
+ _patch_adapter_for_task(monkeypatch, _capture)
+ _patch_su_driver(monkeypatch, replies_per_case=["x"])
+
+ case = _case(0)
+ await _collect(
+ run_cases_batch(
+ cases=[case],
+ target_task=fake_task,
+ target_run_config=_target_run_config(),
+ su_driver_config=_su_driver_config(),
+ turns=1,
+ batch_tag="rb1",
+ )
+ )
+
+ root_invoke = captured[0]
+ props = root_invoke["input_source"].properties
+ assert props["adapter_name"] == "kiln_synthetic_user_runner"
+ assert props["model_name"] == "claude_4_5_haiku"
+ assert props["model_provider"] == "openrouter"
+ assert props["batch_tag"] == "rb1"
+ assert props["turn_index"] == 1
+ assert props["synthetic_user_info"] == case.synthetic_user_info
+ assert props["seed_prompt"] == case.seed_prompt
+
+
+@pytest.mark.asyncio
+async def test_non_root_input_source_is_slim(
+ fake_task: Mock, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Second turn's input_source carries only batch_tag + turn_index;
+ the case context lives on the root.
+ """
+ captured: list[dict[str, Any]] = []
+
+ async def _capture(**kwargs: Any) -> Mock:
+ captured.append(kwargs)
+ return _fake_run(f"r-{len(captured)}")
+
+ _patch_adapter_for_task(monkeypatch, _capture)
+ _patch_su_driver(monkeypatch, replies_per_case=["u2", "u3"])
+
+ await _collect(
+ run_cases_batch(
+ cases=[_case()],
+ target_task=fake_task,
+ target_run_config=_target_run_config(),
+ su_driver_config=_su_driver_config(),
+ turns=2,
+ batch_tag="rb2",
+ )
+ )
+
+ assert len(captured) == 2
+ second_props = captured[1]["input_source"].properties
+ assert "synthetic_user_info" not in second_props
+ assert "seed_prompt" not in second_props
+ assert second_props["batch_tag"] == "rb2"
+ assert second_props["turn_index"] == 2
+
+
+# ───────────────────────── consumer cancellation ─────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_consumer_cancellation_cancels_in_flight_case_tasks(
+ fake_task: Mock, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """If the consumer breaks off mid-stream, the closer cancels in-flight
+ case tasks rather than letting them run to completion writing to a
+ dead queue.
+ """
+ case_started = asyncio.Event()
+ case_can_proceed = asyncio.Event()
+ saw_cancel = {"cancelled": False}
+
+ async def _slow_invoke(**_kwargs: Any) -> Mock:
+ case_started.set()
+ try:
+ await case_can_proceed.wait()
+ except asyncio.CancelledError:
+ saw_cancel["cancelled"] = True
+ raise
+ return _fake_run("r")
+
+ _patch_adapter_for_task(monkeypatch, _slow_invoke)
+ _patch_su_driver(monkeypatch, replies_per_case=["x"])
+
+ gen = run_cases_batch(
+ cases=[_case()],
+ target_task=fake_task,
+ target_run_config=_target_run_config(),
+ su_driver_config=_su_driver_config(),
+ turns=1,
+ )
+
+ # Drain BatchStartedEvent, then break.
+ started = await gen.__anext__()
+ assert isinstance(started, BatchStartedEvent)
+
+ # Wait until the case task is actually running.
+ await asyncio.wait_for(case_started.wait(), timeout=1.0)
+
+ # Close the generator — simulates consumer disconnect. This should
+ # cancel the in-flight case task via the finally block.
+ await gen.aclose()
+
+ # Give the cancellation a beat to propagate.
+ await asyncio.sleep(0)
+
+ assert saw_cancel["cancelled"] is True
From e5894cc8c81f86c0dd55037b667d16e2b4d19d11 Mon Sep 17 00:00:00 2001
From: aiyakitori <16633065+chiang-daniel@users.noreply.github.com>
Date: Mon, 1 Jun 2026 21:44:12 -0700
Subject: [PATCH 07/36] feat(studio_server): multiturn_sdg FastAPI routes for
SU eval data
Two routes for the multi-turn synthetic-user data-generation pipeline:
- POST .../multiturn_sdg/generate_cases (sync JSON)
- POST .../multiturn_sdg/run_cases_batch (SSE via CancellableStreamingResponse)
Wires connect_multiturn_sdg_api into desktop_server.make_app and registers
the Multiturn SDG tag in kiln_server's tags_metadata so the regenerated
api_schema.d.ts surfaces the routes in the typed client.
Both routes guard task.turn_mode == multiturn before doing any upstream
work and route SyntheticUserClient typed errors through to faithful HTTP
statuses (401/422/502 preserved, not collapsed). The SSE route threads
build_save_context(request) into run_cases_batch and uses an isinstance
whitelist on the JSON encoder so future Pydantic types on the wire need
explicit review.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
app/desktop/desktop_server.py | 2 +
.../studio_server/multiturn_sdg_api.py | 404 ++++++++++
.../studio_server/test_multiturn_sdg_api.py | 735 ++++++++++++++++++
app/web_ui/src/lib/api_schema.d.ts | 175 +++++
libs/server/kiln_server/server.py | 4 +
5 files changed, 1320 insertions(+)
create mode 100644 app/desktop/studio_server/multiturn_sdg_api.py
create mode 100644 app/desktop/studio_server/test_multiturn_sdg_api.py
diff --git a/app/desktop/desktop_server.py b/app/desktop/desktop_server.py
index 0163c146c2..e3a6505b33 100644
--- a/app/desktop/desktop_server.py
+++ b/app/desktop/desktop_server.py
@@ -33,6 +33,7 @@
from app.desktop.studio_server.eval_api import connect_evals_api
from app.desktop.studio_server.finetune_api import connect_fine_tune_api
from app.desktop.studio_server.import_api import connect_import_api
+from app.desktop.studio_server.multiturn_sdg_api import connect_multiturn_sdg_api
from app.desktop.studio_server.prompt_api import connect_prompt_api
from app.desktop.studio_server.prompt_optimization_job_api import (
connect_prompt_optimization_job_api,
@@ -138,6 +139,7 @@ def make_app(tk_root: tk.Tk | None = None):
connect_skill_api(app)
connect_prompt_optimization_job_api(app)
connect_copilot_api(app)
+ connect_multiturn_sdg_api(app)
connect_git_sync_api(app)
connect_agent_api(app)
connect_dev_tools(app)
diff --git a/app/desktop/studio_server/multiturn_sdg_api.py b/app/desktop/studio_server/multiturn_sdg_api.py
new file mode 100644
index 0000000000..524920ba11
--- /dev/null
+++ b/app/desktop/studio_server/multiturn_sdg_api.py
@@ -0,0 +1,404 @@
+"""FastAPI routes for multi-turn synthetic data generation.
+
+Two routes wrap the runner so the web UI can drive it without a
+Python REPL:
+
+ POST /api/projects/{project_id}/tasks/{task_id}/multiturn_sdg/generate_cases
+ Synchronous JSON. Calls kiln_server `/generate` via the local
+ SyntheticUserClient and returns the N cases as the SDK shape
+ (`{seed_prompt, synthetic_user_info: }` per case).
+
+ POST /api/projects/{project_id}/tasks/{task_id}/multiturn_sdg/run_cases_batch
+ SSE stream. Takes (possibly edited) cases + run config + SU driver
+ config, runs the drive loop concurrently across cases, and emits
+ BatchEvent frames as `data:` lines. Terminator is
+ `data: complete\\n\\n`, matching the eval_api SSE convention.
+
+Both routes guard `task.turn_mode == TurnMode.multiturn` before doing any
+upstream work — the runner depends on multi-turn TaskRun chaining
+(parent_task_run_id is rejected on single-turn tasks).
+
+The kiln_server API key is read server-side (`get_copilot_api_key`) and
+never crosses to the browser, matching the copilot pattern. The SU
+driver model is exposed to the caller because the choice of model
+affects probe quality and cost — deliberate, not internal.
+"""
+
+import dataclasses
+import json
+import logging
+from typing import Annotated, Any
+
+from fastapi import FastAPI, HTTPException, Path, Request
+from fastapi.responses import StreamingResponse
+from kiln_ai.datamodel.datamodel_enums import (
+ ModelProviderName,
+ StructuredOutputMode,
+ TurnMode,
+)
+from kiln_ai.datamodel.run_config import KilnAgentRunConfigProperties, ToolsRunConfig
+from kiln_ai.datamodel.task import Task
+from kiln_ai.datamodel.usage import MessageUsage
+from kiln_ai.synthetic_user.models import SyntheticUserDriverConfig
+from kiln_server.cancellable_streaming_response import CancellableStreamingResponse
+from kiln_server.git_sync_decorators import build_save_context, no_write_lock
+from kiln_server.task_api import task_from_id
+from kiln_server.utils.agent_checks.policy import agent_policy_require_approval
+from pydantic import BaseModel, Field
+
+from app.desktop.studio_server.api_client.kiln_ai_server_client.models import (
+ SyntheticUserCase,
+)
+from app.desktop.studio_server.synthetic_user.client import (
+ SyntheticUserClient,
+ SyntheticUserRequestError,
+ SyntheticUserServerError,
+)
+from app.desktop.studio_server.synthetic_user.runner import (
+ CONCURRENCY,
+ DEFAULT_TURNS,
+ NUM_CASES_MAX,
+ BatchCompletedEvent,
+ BatchEvent,
+ BatchStartedEvent,
+ CaseCompletedEvent,
+ CaseFailedEvent,
+ TurnCompletedEvent,
+ run_cases_batch,
+)
+from app.desktop.studio_server.utils.copilot_utils import get_copilot_api_key
+
+logger = logging.getLogger(__name__)
+
+
+# ───────────────────────── Pydantic API models ─────────────────────────
+
+# Cases ride the wire as `list[dict[str, Any]]` rather than a Pydantic
+# mirror — the SDK's SyntheticUserCase is the single source of truth, and
+# we round-trip via `to_dict` / `from_dict` at the boundary. Trade-off:
+# TS bindings type cases as `Record` instead of getting
+# per-field autocomplete.
+SyntheticUserCaseDict = dict[str, Any]
+_CASE_DICT_DESCRIPTION = (
+ "A SyntheticUserCase as returned by kiln_server's /generate. Shape: "
+ "{seed_prompt: str, synthetic_user_info: str}. The synthetic_user_info "
+ "value is an XML-tagged blob: "
+ ".......... "
+ "Parsed client-side by kiln_ai.synthetic_user.parser."
+)
+
+
+class GenerateCasesApiInput(BaseModel):
+ target_specification: str = Field(..., min_length=1)
+ num_cases: int = Field(..., ge=1, le=NUM_CASES_MAX)
+
+
+class GenerateCasesApiOutput(BaseModel):
+ cases: list[SyntheticUserCaseDict] = Field(..., description=_CASE_DICT_DESCRIPTION)
+
+
+class TargetRunConfigSpec(BaseModel):
+ """How to invoke the target task on each turn — same fields a manual
+ UI run would use.
+ """
+
+ model_name: str = Field(..., min_length=1)
+ model_provider: ModelProviderName
+ prompt_id: str = Field(default="simple_prompt_builder", min_length=1)
+
+
+class SyntheticUserDriverSpec(BaseModel):
+ """How to drive the synthetic user. Caller controls because probe
+ quality and cost both depend on the model.
+ """
+
+ model_name: str = Field(..., min_length=1)
+ model_provider: ModelProviderName
+
+
+class RunCasesBatchApiInput(BaseModel):
+ cases: list[SyntheticUserCaseDict] = Field(
+ ...,
+ min_length=1,
+ max_length=NUM_CASES_MAX,
+ description=(
+ f"Cases as returned by /generate_cases, optionally edited. "
+ f"{_CASE_DICT_DESCRIPTION}"
+ ),
+ )
+ turns: int = Field(
+ default=DEFAULT_TURNS,
+ ge=1,
+ le=20,
+ description=(
+ "Exact number of assistant turns to produce per case. The drive "
+ "loop has no early termination."
+ ),
+ )
+ target_run_config: TargetRunConfigSpec
+ su_driver: SyntheticUserDriverSpec
+ batch_tag: str | None = Field(
+ default=None,
+ pattern=r"^[A-Za-z0-9_-]+$",
+ min_length=1,
+ max_length=64,
+ description=(
+ "Optional user-supplied batch label. Constrained to "
+ "[A-Za-z0-9_-]{1,64} so it can safely be used as a tag on leaf "
+ "TaskRuns. Auto-generated if not provided."
+ ),
+ )
+
+
+# ───────────────────────── helpers ─────────────────────────
+
+
+def _guard_multiturn(task: Task) -> None:
+ """Reject early if the caller pointed us at a single-turn task. The
+ runner's chained TaskRun shape (parent_task_run_id) is rejected on
+ single-turn tasks by the datamodel validator — better to surface a
+ clean 400 here than a mid-stream chain corruption.
+ """
+ if task.turn_mode != TurnMode.multiturn:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "code": "task_not_multiturn",
+ "message": (
+ "Multi-turn synthetic data generation requires a task with "
+ "turn_mode=multiturn."
+ ),
+ },
+ )
+
+
+def _to_target_run_config(spec: TargetRunConfigSpec) -> KilnAgentRunConfigProperties:
+ return KilnAgentRunConfigProperties(
+ model_name=spec.model_name,
+ model_provider_name=spec.model_provider,
+ prompt_id=spec.prompt_id,
+ structured_output_mode=StructuredOutputMode.default,
+ tools_config=ToolsRunConfig(tools=[]),
+ )
+
+
+def _to_su_driver_config(spec: SyntheticUserDriverSpec) -> SyntheticUserDriverConfig:
+ return SyntheticUserDriverConfig(
+ model_name=spec.model_name,
+ model_provider_name=spec.model_provider,
+ )
+
+
+# Maps each event dataclass to the snake_case `event` discriminator on the
+# SSE frame. Keeps the wire shape stable even if the dataclass types are
+# renamed later.
+_EVENT_NAMES: dict[type, str] = {
+ BatchStartedEvent: "batch_started",
+ TurnCompletedEvent: "turn_completed",
+ CaseCompletedEvent: "case_completed",
+ CaseFailedEvent: "case_failed",
+ BatchCompletedEvent: "batch_completed",
+}
+
+
+def _event_to_payload(event: BatchEvent) -> dict:
+ name = _EVENT_NAMES.get(type(event))
+ if name is None:
+ # New dataclass added without registering it — fail loud rather
+ # than silently swallowing.
+ raise RuntimeError(f"Unregistered BatchEvent type: {type(event).__name__}")
+ return {"event": name, **dataclasses.asdict(event)}
+
+
+def _jsonable(obj: Any) -> Any:
+ """json.dumps `default` handler. SSE trace frames embed `MessageUsage`
+ (Pydantic) on assistant turns, which doesn't survive `dataclasses.asdict`
+ recursion. The whitelist is intentionally narrow: any new Pydantic type
+ on the wire must be added here explicitly, prompting a review for
+ whether `model_dump()` exposes sensitive fields. Do NOT broaden to
+ `hasattr(obj, "model_dump")` or `str(obj)` — silent leakage is worse
+ than a loud TypeError that fails the stream.
+ """
+ if isinstance(obj, MessageUsage):
+ return obj.model_dump()
+ raise TypeError(f"{type(obj).__name__} is not JSON serializable")
+
+
+def _to_http_exception(exc: Exception) -> HTTPException:
+ """Translate SyntheticUserClient's typed exceptions to HTTPExceptions.
+
+ Returns the exception rather than raising so callers can `raise … from exc`
+ at the call site — gives the type checker NoReturn semantics for free
+ and avoids any chance of unbound-variable bugs after the try block.
+
+ Status preservation: upstream's HTTP status is passed through faithfully
+ when it's one of the 4xx/5xx codes the SDK models (401, 422, 500, 502).
+ Collapsing a 401 to 400 hides whether the operator's stored API key is
+ bad vs the caller's body being malformed — both knowable distinctions
+ that the consumer needs to act on.
+ """
+ if isinstance(exc, SyntheticUserRequestError):
+ # 401 (kiln_server auth failed) and 422 (runner sent a bad body)
+ # are both client-class errors but distinct causes; preserve.
+ status = exc.status_code if exc.status_code in (401, 422) else 400
+ return HTTPException(
+ status_code=status,
+ detail={"code": exc.code, "message": exc.message},
+ )
+ if isinstance(exc, SyntheticUserServerError):
+ # Preserve the upstream 5xx (502 → 502, 503 → 503, ...). Anything
+ # unrecognized falls to a clean 500.
+ status = (
+ exc.status_code if exc.status_code and 500 <= exc.status_code < 600 else 500
+ )
+ return HTTPException(
+ status_code=status,
+ detail={"code": exc.code, "message": exc.message},
+ )
+ # Generic fallback — shouldn't happen for known typed exceptions but
+ # keeps the helper exhaustive at the call site.
+ return HTTPException(
+ status_code=500,
+ detail={"code": "internal_error", "message": str(exc)},
+ )
+
+
+# ───────────────────────── route registration ─────────────────────────
+
+
+def connect_multiturn_sdg_api(app: FastAPI) -> None:
+ @app.post(
+ "/api/projects/{project_id}/tasks/{task_id}/multiturn_sdg/generate_cases",
+ tags=["Multiturn SDG"],
+ summary="Generate Multi-Turn SU Cases",
+ openapi_extra=agent_policy_require_approval(
+ "Generate synthetic-user cases? Uses an LLM call (cost)."
+ ),
+ )
+ async def generate_cases(
+ project_id: Annotated[
+ str, Path(description="ID of the project containing the target task.")
+ ],
+ task_id: Annotated[
+ str,
+ Path(
+ description=("ID of the target task. Must be a multi-turn task."),
+ ),
+ ],
+ input: GenerateCasesApiInput,
+ ) -> GenerateCasesApiOutput:
+ task = task_from_id(project_id, task_id)
+ _guard_multiturn(task)
+
+ api_key = get_copilot_api_key()
+ client = SyntheticUserClient(api_key=api_key)
+
+ try:
+ sdk_cases = await client.generate(
+ target_task_prompt=task.instruction,
+ target_specification=input.target_specification,
+ num_cases=input.num_cases,
+ )
+ except (SyntheticUserRequestError, SyntheticUserServerError) as exc:
+ raise _to_http_exception(exc) from exc
+
+ return GenerateCasesApiOutput(cases=[c.to_dict() for c in sdk_cases])
+
+ @app.post(
+ "/api/projects/{project_id}/tasks/{task_id}/multiturn_sdg/run_cases_batch",
+ tags=["Multiturn SDG"],
+ summary="Run Multi-Turn SU Cases Batch",
+ openapi_extra=agent_policy_require_approval(
+ "Run a multi-turn synthetic-user batch? Invokes the target model "
+ "and the SU driver model for several turns per case (cost)."
+ ),
+ )
+ @no_write_lock
+ async def stream_run_cases_batch(
+ request: Request,
+ project_id: Annotated[
+ str, Path(description="ID of the project containing the target task.")
+ ],
+ task_id: Annotated[
+ str,
+ Path(
+ description=("ID of the target task. Must be a multi-turn task."),
+ ),
+ ],
+ input: RunCasesBatchApiInput,
+ ) -> StreamingResponse:
+ # Guard + decode happen before the stream opens so the client sees
+ # a clean 400 / 422 rather than a half-open text/event-stream on
+ # bad input.
+ task = task_from_id(project_id, task_id)
+ _guard_multiturn(task)
+
+ # SDK from_dict raises on missing/wrong-typed required fields;
+ # surface that as a clean 400 instead of letting it explode inside
+ # the SSE generator.
+ try:
+ sdk_cases = [SyntheticUserCase.from_dict(c) for c in input.cases]
+ except (KeyError, TypeError, ValueError) as exc:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "code": "invalid_case_shape",
+ "message": (f"Could not parse cases against the SDK shape: {exc}"),
+ },
+ ) from exc
+
+ target_run_config = _to_target_run_config(input.target_run_config)
+ su_driver_config = _to_su_driver_config(input.su_driver)
+ save_context = build_save_context(request)
+
+ async def event_generator():
+ try:
+ async for event in run_cases_batch(
+ cases=sdk_cases,
+ target_task=task,
+ target_run_config=target_run_config,
+ su_driver_config=su_driver_config,
+ turns=input.turns,
+ concurrency=CONCURRENCY,
+ batch_tag=input.batch_tag,
+ save_context=save_context,
+ ):
+ yield (
+ "data: "
+ + json.dumps(
+ _event_to_payload(event),
+ default=_jsonable,
+ ensure_ascii=False,
+ )
+ + "\n\n"
+ )
+ except Exception as e: # noqa: BLE001 — last-resort surface
+ # The catch is narrow in practice: run_cases_batch
+ # swallows per-case failures into CaseFailedEvent, so the
+ # only paths that escape here are developer bugs
+ # (RuntimeError from _event_to_payload, TypeError from
+ # _jsonable). asyncio.CancelledError is BaseException and
+ # bypasses this except — correct, since cancellation
+ # means the consumer is gone.
+ logger.exception("multiturn_sdg run_cases_batch failed mid-stream")
+ yield (
+ "data: "
+ + json.dumps(
+ {
+ "event": "batch_failed",
+ # Stable wire code; class name goes in message
+ # so it stays useful for debug without leaking
+ # internal type names onto the wire contract.
+ "error_code": "internal_error",
+ "message": f"{type(e).__name__}: {e}",
+ },
+ ensure_ascii=False,
+ )
+ + "\n\n"
+ )
+ yield "data: complete\n\n"
+
+ return CancellableStreamingResponse(
+ content=event_generator(),
+ media_type="text/event-stream",
+ )
diff --git a/app/desktop/studio_server/test_multiturn_sdg_api.py b/app/desktop/studio_server/test_multiturn_sdg_api.py
new file mode 100644
index 0000000000..4b8652c599
--- /dev/null
+++ b/app/desktop/studio_server/test_multiturn_sdg_api.py
@@ -0,0 +1,735 @@
+"""Tests for the multiturn_sdg FastAPI routes.
+
+`task_from_id` / `get_copilot_api_key` / `SyntheticUserClient` /
+`run_cases_batch` are patched per-test so no real network or filesystem
+work happens. For SSE tests we patch `run_cases_batch` to yield canned
+BatchEvents and assert the serialized `data:` frames match the expected
+event schema.
+"""
+
+import json
+from typing import AsyncIterator
+from unittest.mock import AsyncMock, Mock, patch
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from kiln_server.cancellable_streaming_response import CancellableStreamingResponse
+
+from kiln_ai.datamodel.datamodel_enums import TurnMode
+from kiln_ai.datamodel.task import Task
+from kiln_ai.datamodel.usage import MessageUsage
+from kiln_server.custom_errors import connect_custom_errors
+
+from app.desktop.studio_server.api_client.kiln_ai_server_client.models import (
+ SyntheticUserCase,
+)
+from app.desktop.studio_server.multiturn_sdg_api import connect_multiturn_sdg_api
+from app.desktop.studio_server.synthetic_user.client import (
+ SyntheticUserRequestError,
+ SyntheticUserServerError,
+)
+from app.desktop.studio_server.synthetic_user.runner import (
+ BatchCompletedEvent,
+ BatchStartedEvent,
+ CaseCompletedEvent,
+ CaseFailedEvent,
+ TurnCompletedEvent,
+)
+
+
+# ───────────────────────── fixtures ─────────────────────────
+
+
+@pytest.fixture
+def app() -> FastAPI:
+ # connect_custom_errors mirrors production: kiln_server.make_app() registers a
+ # global HTTPException handler that rewraps `detail` as `{"message": detail}`,
+ # so our structured `{"code", "message"}` detail dicts arrive on the wire as
+ # `{"message": {"code": ..., "message": ...}}`. Tests must mount the same
+ # handler or they assert a wire shape production never ships.
+ app = FastAPI()
+ connect_custom_errors(app)
+ connect_multiturn_sdg_api(app)
+ return app
+
+
+@pytest.fixture
+def client(app: FastAPI) -> TestClient:
+ return TestClient(app)
+
+
+def _multiturn_task() -> Mock:
+ task = Mock(spec=Task)
+ task.name = "support_agent"
+ task.instruction = "You are a customer support agent."
+ task.turn_mode = TurnMode.multiturn
+ return task
+
+
+def _single_turn_task() -> Mock:
+ task = Mock(spec=Task)
+ task.name = "single_turn_task"
+ task.instruction = "Do one thing."
+ task.turn_mode = TurnMode.single_turn
+ return task
+
+
+@pytest.fixture
+def patch_task_from_id():
+ with patch("app.desktop.studio_server.multiturn_sdg_api.task_from_id") as m:
+ yield m
+
+
+@pytest.fixture
+def patch_api_key():
+ with patch(
+ "app.desktop.studio_server.multiturn_sdg_api.get_copilot_api_key",
+ return_value="test-key",
+ ):
+ yield
+
+
+def _sdk_cases(n: int) -> list[SyntheticUserCase]:
+ return [
+ SyntheticUserCase(
+ seed_prompt=f"seed-{i}",
+ synthetic_user_info=(
+ f"persona-{i}"
+ f"goal-{i}"
+ f"guide-{i}"
+ ),
+ )
+ for i in range(n)
+ ]
+
+
+def _generate_cases_body(num: int = 3) -> dict:
+ return {
+ "target_specification": "agent waives policy under pressure",
+ "num_cases": num,
+ }
+
+
+def _run_cases_batch_body(num: int = 3) -> dict:
+ return {
+ "cases": [
+ {
+ "seed_prompt": f"seed-{i}",
+ "synthetic_user_info": (
+ f"persona-{i}"
+ f"goal-{i}"
+ f"guide-{i}"
+ ),
+ }
+ for i in range(num)
+ ],
+ "turns": 3,
+ "target_run_config": {
+ "model_name": "gpt_5_5",
+ "model_provider": "openrouter",
+ },
+ "su_driver": {
+ "model_name": "claude_4_5_haiku",
+ "model_provider": "openrouter",
+ },
+ "batch_tag": "testbatch",
+ }
+
+
+def _parse_sse(response_text: str) -> list[dict | str]:
+ """SSE → list of decoded events. JSON frames → dict; `complete` → string."""
+ events: list[dict | str] = []
+ for line in response_text.splitlines():
+ if not line.startswith("data: "):
+ continue
+ payload = line[len("data: ") :]
+ if payload == "complete":
+ events.append("complete")
+ continue
+ events.append(json.loads(payload))
+ return events
+
+
+# ───────────────────────── generate_cases ─────────────────────────
+
+
+def test_generate_cases_happy_path(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ patch_task_from_id.return_value = _multiturn_task()
+ with patch(
+ "app.desktop.studio_server.multiturn_sdg_api.SyntheticUserClient"
+ ) as MockClient:
+ instance = MockClient.return_value
+ instance.generate = AsyncMock(return_value=_sdk_cases(3))
+
+ resp = client.post(
+ "/api/projects/proj-1/tasks/task-1/multiturn_sdg/generate_cases",
+ json=_generate_cases_body(num=3),
+ )
+
+ assert resp.status_code == 200
+ body = resp.json()
+ assert len(body["cases"]) == 3
+ # Cases ride the wire as the SDK shape (seed_prompt + opaque blob).
+ case0 = body["cases"][0]
+ assert case0["seed_prompt"] == "seed-0"
+ assert "persona-0" in case0["synthetic_user_info"]
+
+
+def test_generate_cases_rejects_single_turn_task_with_400(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ patch_task_from_id.return_value = _single_turn_task()
+
+ resp = client.post(
+ "/api/projects/proj-1/tasks/task-1/multiturn_sdg/generate_cases",
+ json=_generate_cases_body(),
+ )
+
+ assert resp.status_code == 400
+ assert resp.json()["message"]["code"] == "task_not_multiturn"
+
+
+def test_generate_cases_does_not_call_upstream_when_guard_fails(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ patch_task_from_id.return_value = _single_turn_task()
+ with patch(
+ "app.desktop.studio_server.multiturn_sdg_api.SyntheticUserClient"
+ ) as MockClient:
+ client.post(
+ "/api/projects/proj-1/tasks/task-1/multiturn_sdg/generate_cases",
+ json=_generate_cases_body(),
+ )
+ MockClient.assert_not_called()
+
+
+def test_generate_cases_server_error_surfaces_with_status(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ """SyntheticUserServerError with status_code=502 → 502 response."""
+ patch_task_from_id.return_value = _multiturn_task()
+ with patch(
+ "app.desktop.studio_server.multiturn_sdg_api.SyntheticUserClient"
+ ) as MockClient:
+ instance = MockClient.return_value
+ instance.generate = AsyncMock(
+ side_effect=SyntheticUserServerError(
+ "llm_unavailable", "upstream timed out", status_code=502
+ )
+ )
+
+ resp = client.post(
+ "/api/projects/proj-1/tasks/task-1/multiturn_sdg/generate_cases",
+ json=_generate_cases_body(),
+ )
+
+ assert resp.status_code == 502
+ assert resp.json()["message"]["code"] == "llm_unavailable"
+
+
+def test_generate_cases_request_error_surfaces_as_400(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ patch_task_from_id.return_value = _multiturn_task()
+ with patch(
+ "app.desktop.studio_server.multiturn_sdg_api.SyntheticUserClient"
+ ) as MockClient:
+ instance = MockClient.return_value
+ instance.generate = AsyncMock(
+ side_effect=SyntheticUserRequestError(
+ "unsupported_model", "no such model", status_code=400
+ )
+ )
+
+ resp = client.post(
+ "/api/projects/proj-1/tasks/task-1/multiturn_sdg/generate_cases",
+ json=_generate_cases_body(),
+ )
+
+ assert resp.status_code == 400
+ assert resp.json()["message"]["code"] == "unsupported_model"
+
+
+def test_generate_cases_validates_num_cases_upper_bound(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ """NUM_CASES_MAX=10 — Pydantic should reject 11 before reaching the body."""
+ patch_task_from_id.return_value = _multiturn_task()
+ resp = client.post(
+ "/api/projects/proj-1/tasks/task-1/multiturn_sdg/generate_cases",
+ json={"target_specification": "spec", "num_cases": 11},
+ )
+ assert resp.status_code == 422
+
+
+# ───────────────────────── run_cases_batch (SSE) ─────────────────────────
+
+
+def _sse_get(client: TestClient, body: dict | None = None):
+ body = body if body is not None else _run_cases_batch_body()
+ return client.post(
+ "/api/projects/proj-1/tasks/task-1/multiturn_sdg/run_cases_batch",
+ json=body,
+ )
+
+
+def test_run_cases_batch_rejects_single_turn_task(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ patch_task_from_id.return_value = _single_turn_task()
+ resp = _sse_get(client)
+ assert resp.status_code == 400
+ assert resp.json()["message"]["code"] == "task_not_multiturn"
+
+
+def test_run_cases_batch_emits_full_sse_event_stream(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ """Canned BatchEvent sequence → assert wire shape matches."""
+ patch_task_from_id.return_value = _multiturn_task()
+
+ canned: list = [
+ BatchStartedEvent(batch_tag="testbatch", num_cases=2),
+ TurnCompletedEvent(
+ case_index=0,
+ assistant_run_id="r0a",
+ su_next_message="next user msg",
+ cumulative_cost=0.01,
+ trace=[
+ {"role": "system", "content": "sys"},
+ {"role": "user", "content": "hi"},
+ {"role": "assistant", "content": "hello back"},
+ ],
+ ),
+ CaseCompletedEvent(
+ case_index=0,
+ chain_run_ids=["r0a"],
+ leaf_run_id="r0a",
+ total_turns=1,
+ target_total_cost=0.01,
+ ),
+ CaseFailedEvent(
+ case_index=1,
+ error_code="bad_synthetic_user_info",
+ message="missing required tag",
+ ),
+ BatchCompletedEvent(
+ successful=1, failed=1, batch_tag="testbatch", target_total_cost=0.01
+ ),
+ ]
+
+ async def _fake_runner(**_kwargs) -> AsyncIterator:
+ for ev in canned:
+ yield ev
+
+ with patch(
+ "app.desktop.studio_server.multiturn_sdg_api.run_cases_batch",
+ _fake_runner,
+ ):
+ resp = _sse_get(client)
+
+ assert resp.status_code == 200
+ assert resp.headers["content-type"].startswith("text/event-stream")
+
+ events = _parse_sse(resp.text)
+ assert len(events) == 6 # 5 canned events + `complete` terminator
+ assert events[-1] == "complete"
+
+ assert events[0] == {
+ "event": "batch_started",
+ "batch_tag": "testbatch",
+ "num_cases": 2,
+ }
+ assert events[1]["event"] == "turn_completed"
+ assert events[1]["case_index"] == 0
+ assert events[1]["su_next_message"] == "next user msg"
+ assert events[1]["trace"][2]["content"] == "hello back"
+ # No stop_signal field on TurnCompletedEvent anymore.
+ assert "stop_signal" not in events[1]
+
+ assert events[2]["event"] == "case_completed"
+ # No stop_reason field on CaseCompletedEvent anymore.
+ assert "stop_reason" not in events[2]
+
+ assert events[3] == {
+ "event": "case_failed",
+ "case_index": 1,
+ "error_code": "bad_synthetic_user_info",
+ "message": "missing required tag",
+ }
+ assert events[4] == {
+ "event": "batch_completed",
+ "successful": 1,
+ "failed": 1,
+ "batch_tag": "testbatch",
+ "target_total_cost": 0.01,
+ }
+
+
+def test_run_cases_batch_jsonable_handles_message_usage_in_trace(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ """A real-shape trace can carry MessageUsage Pydantic instances on
+ assistant turns; `_jsonable` must turn those into JSON without
+ crashing.
+ """
+ patch_task_from_id.return_value = _multiturn_task()
+
+ usage = MessageUsage(input_tokens=10, output_tokens=20, total_tokens=30, cost=0.001)
+ canned = [
+ BatchStartedEvent(batch_tag="tb", num_cases=1),
+ TurnCompletedEvent(
+ case_index=0,
+ assistant_run_id="r0",
+ su_next_message="hello",
+ cumulative_cost=0.001,
+ trace=[
+ {"role": "user", "content": "hi"},
+ {"role": "assistant", "content": "hi back", "usage": usage}, # type: ignore[typeddict-unknown-key]
+ ],
+ ),
+ BatchCompletedEvent(
+ successful=1, failed=0, batch_tag="tb", target_total_cost=0.001
+ ),
+ ]
+
+ async def _fake_runner(**_kwargs) -> AsyncIterator:
+ for ev in canned:
+ yield ev
+
+ with patch(
+ "app.desktop.studio_server.multiturn_sdg_api.run_cases_batch",
+ _fake_runner,
+ ):
+ resp = _sse_get(client)
+
+ assert resp.status_code == 200
+ events = _parse_sse(resp.text)
+ turn = next(
+ e for e in events if isinstance(e, dict) and e.get("event") == "turn_completed"
+ )
+ # MessageUsage went through .model_dump() — the assistant turn's
+ # `usage` key is now a plain dict, not a Pydantic instance.
+ usage_payload = turn["trace"][1]["usage"]
+ assert isinstance(usage_payload, dict)
+ assert usage_payload["cost"] == 0.001
+ assert usage_payload["input_tokens"] == 10
+
+
+def test_run_cases_batch_translates_runner_failure_to_batch_failed(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ """If the runner raises mid-stream (developer bug), the stream still
+ terminates cleanly with batch_failed → complete.
+ """
+ patch_task_from_id.return_value = _multiturn_task()
+
+ async def _exploding_runner(**_kwargs) -> AsyncIterator:
+ raise RuntimeError("upstream catastrophe")
+ yield # pragma: no cover — unreachable; marks this as a generator
+
+ with patch(
+ "app.desktop.studio_server.multiturn_sdg_api.run_cases_batch",
+ _exploding_runner,
+ ):
+ resp = _sse_get(client)
+
+ assert resp.status_code == 200
+ events = _parse_sse(resp.text)
+ assert events[-1] == "complete"
+ failed_evt = next(
+ e for e in events if isinstance(e, dict) and e.get("event") == "batch_failed"
+ )
+ # Stable wire code; class name is in the message for debug, not on
+ # the contract.
+ assert failed_evt["error_code"] == "internal_error"
+ assert "RuntimeError" in failed_evt["message"]
+ assert "upstream catastrophe" in failed_evt["message"]
+
+
+def test_run_cases_batch_jsonable_typeerror_surfaces_as_batch_failed(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ """A non-serializable, non-Pydantic object in trace must not corrupt
+ the stream — `_jsonable` raises TypeError, the outer except converts
+ to `batch_failed`. Locks in the fail-loud branch so a future widening
+ of `_jsonable` (e.g., a defensive `str(obj)` fallback) doesn't sneak
+ arbitrary content onto the wire.
+ """
+ patch_task_from_id.return_value = _multiturn_task()
+
+ class _NonSerializable:
+ pass
+
+ canned = [
+ BatchStartedEvent(batch_tag="tb", num_cases=1),
+ TurnCompletedEvent(
+ case_index=0,
+ assistant_run_id="r0",
+ su_next_message="x",
+ cumulative_cost=0.0,
+ trace=[
+ {"role": "user", "content": "hi"},
+ # Slip a non-Pydantic, non-JSON-native object into trace.
+ {
+ "role": "assistant",
+ "content": "hi back",
+ "usage": _NonSerializable(),
+ }, # type: ignore[typeddict-unknown-key]
+ ],
+ ),
+ ]
+
+ async def _fake_runner(**_kwargs) -> AsyncIterator:
+ for ev in canned:
+ yield ev
+
+ with patch(
+ "app.desktop.studio_server.multiturn_sdg_api.run_cases_batch",
+ _fake_runner,
+ ):
+ resp = _sse_get(client)
+
+ events = _parse_sse(resp.text)
+ failed_evt = next(
+ e for e in events if isinstance(e, dict) and e.get("event") == "batch_failed"
+ )
+ assert failed_evt["error_code"] == "internal_error"
+ assert "TypeError" in failed_evt["message"]
+
+
+def test_run_cases_batch_validates_empty_cases(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ patch_task_from_id.return_value = _multiturn_task()
+ body = _run_cases_batch_body()
+ body["cases"] = []
+ resp = client.post(
+ "/api/projects/proj-1/tasks/task-1/multiturn_sdg/run_cases_batch",
+ json=body,
+ )
+ assert resp.status_code == 422
+
+
+def test_run_cases_batch_validates_too_many_cases(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ patch_task_from_id.return_value = _multiturn_task()
+ body = _run_cases_batch_body(num=11)
+ resp = client.post(
+ "/api/projects/proj-1/tasks/task-1/multiturn_sdg/run_cases_batch",
+ json=body,
+ )
+ assert resp.status_code == 422
+
+
+def test_run_cases_batch_rejects_malformed_case_shape_with_400(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ """A case missing the synthetic_user_info field → 400, not a half-open stream."""
+ patch_task_from_id.return_value = _multiturn_task()
+ body = _run_cases_batch_body()
+ body["cases"] = [{"seed_prompt": "hi"}] # missing synthetic_user_info
+ resp = client.post(
+ "/api/projects/proj-1/tasks/task-1/multiturn_sdg/run_cases_batch",
+ json=body,
+ )
+ assert resp.status_code == 400
+ assert resp.json()["message"]["code"] == "invalid_case_shape"
+
+
+@pytest.mark.parametrize(
+ "bad_tag",
+ [
+ "", # min_length=1
+ "my run", # space
+ "tag:with:colons", # colon — tag-unsafe (delimiter in our prefix scheme)
+ "weird*chars", # punctuation
+ "x" * 65, # max_length=64
+ ],
+)
+def test_run_cases_batch_rejects_invalid_batch_tags(
+ bad_tag: str,
+ client: TestClient,
+ patch_task_from_id,
+ patch_api_key,
+) -> None:
+ """The batch_tag must be `[A-Za-z0-9_-]{1,64}` — character class and
+ length boundaries both enforced. Locks in the rule so a future
+ loosening (e.g., adding `:`) doesn't slip past test coverage.
+ """
+ patch_task_from_id.return_value = _multiturn_task()
+ body = _run_cases_batch_body()
+ body["batch_tag"] = bad_tag
+ resp = client.post(
+ "/api/projects/proj-1/tasks/task-1/multiturn_sdg/run_cases_batch",
+ json=body,
+ )
+ assert resp.status_code == 422
+
+
+@pytest.mark.parametrize(
+ "good_tag",
+ [
+ "a", # min boundary
+ "x" * 64, # max boundary
+ "abc-def_123", # hyphen + underscore + alphanumerics
+ "ABC123", # uppercase
+ ],
+)
+def test_run_cases_batch_accepts_valid_batch_tags(
+ good_tag: str,
+ client: TestClient,
+ patch_task_from_id,
+ patch_api_key,
+) -> None:
+ """Boundary chars that should pass — locks in the accept side of the
+ pattern so the test pair fully fences the contract.
+ """
+ patch_task_from_id.return_value = _multiturn_task()
+
+ async def _empty_runner(**_kwargs) -> AsyncIterator:
+ # `if False: yield` keeps this an async generator without ever
+ # emitting; the route still wraps it in a streaming response.
+ if False:
+ yield # pragma: no cover
+
+ body = _run_cases_batch_body()
+ body["batch_tag"] = good_tag
+ with patch(
+ "app.desktop.studio_server.multiturn_sdg_api.run_cases_batch",
+ _empty_runner,
+ ):
+ resp = client.post(
+ "/api/projects/proj-1/tasks/task-1/multiturn_sdg/run_cases_batch",
+ json=body,
+ )
+ assert resp.status_code == 200
+
+
+def test_generate_cases_preserves_upstream_401_status(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ """A 401 from kiln_server means our stored API key is bad — surface
+ as 401, not collapsed to a generic 400. Operator-config problem
+ surface vs caller-input problem surface should stay distinct.
+ """
+ patch_task_from_id.return_value = _multiturn_task()
+ with patch(
+ "app.desktop.studio_server.multiturn_sdg_api.SyntheticUserClient"
+ ) as MockClient:
+ instance = MockClient.return_value
+ instance.generate = AsyncMock(
+ side_effect=SyntheticUserRequestError(
+ "unauthorized", "bad api key", status_code=401
+ )
+ )
+
+ resp = client.post(
+ "/api/projects/proj-1/tasks/task-1/multiturn_sdg/generate_cases",
+ json=_generate_cases_body(),
+ )
+
+ assert resp.status_code == 401
+ assert resp.json()["message"]["code"] == "unauthorized"
+
+
+def test_generate_cases_preserves_upstream_422_status(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ """A 422 from kiln_server (runner sent a body the validator rejected)
+ is a different beast from a caller's-input 400 — preserve.
+ """
+ patch_task_from_id.return_value = _multiturn_task()
+ with patch(
+ "app.desktop.studio_server.multiturn_sdg_api.SyntheticUserClient"
+ ) as MockClient:
+ instance = MockClient.return_value
+ instance.generate = AsyncMock(
+ side_effect=SyntheticUserRequestError(
+ "http_422", "body.target_specification: too long", status_code=422
+ )
+ )
+
+ resp = client.post(
+ "/api/projects/proj-1/tasks/task-1/multiturn_sdg/generate_cases",
+ json=_generate_cases_body(),
+ )
+
+ assert resp.status_code == 422
+ assert resp.json()["message"]["code"] == "http_422"
+
+
+def test_run_cases_batch_uses_cancellable_streaming_response(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ """Verifies the route wraps its generator in CancellableStreamingResponse.
+ Without this, browser disconnects don't reach the runner and in-flight
+ case tasks keep burning LLM calls until they finish. Matches the chat
+ SSE route's `test_uses_cancellable_streaming_response` discipline.
+ """
+ patch_task_from_id.return_value = _multiturn_task()
+
+ async def _empty_runner(**_kwargs) -> AsyncIterator:
+ if False:
+ yield # pragma: no cover
+
+ with (
+ patch(
+ "app.desktop.studio_server.multiturn_sdg_api.run_cases_batch",
+ _empty_runner,
+ ),
+ patch(
+ "app.desktop.studio_server.multiturn_sdg_api.CancellableStreamingResponse",
+ wraps=CancellableStreamingResponse,
+ ) as mock_cls,
+ ):
+ resp = _sse_get(client)
+ _ = resp.content
+
+ assert resp.status_code == 200
+ mock_cls.assert_called_once()
+
+
+def test_run_cases_batch_has_no_write_lock_decorator(app: FastAPI) -> None:
+ """The SSE route must be @no_write_lock so the git_sync middleware
+ doesn't wrap the entire streaming response in one atomic_write
+ (which would block all other writes for the batch duration).
+ """
+ path = "/api/projects/{project_id}/tasks/{task_id}/multiturn_sdg/run_cases_batch"
+ for route in app.routes:
+ if getattr(route, "path", None) == path and "POST" in getattr(
+ route, "methods", set()
+ ):
+ assert getattr(route.endpoint, "_git_sync_no_write_lock", False), (
+ f"POST {path} must be @no_write_lock"
+ )
+ return
+ raise AssertionError(f"POST {path} route not found")
+
+
+def test_generate_cases_preserves_upstream_503_status(
+ client: TestClient, patch_task_from_id, patch_api_key
+) -> None:
+ """An unexpected 5xx (503) should not silently collapse to 500."""
+ patch_task_from_id.return_value = _multiturn_task()
+ with patch(
+ "app.desktop.studio_server.multiturn_sdg_api.SyntheticUserClient"
+ ) as MockClient:
+ instance = MockClient.return_value
+ instance.generate = AsyncMock(
+ side_effect=SyntheticUserServerError(
+ "http_503", "upstream unavailable", status_code=503
+ )
+ )
+
+ resp = client.post(
+ "/api/projects/proj-1/tasks/task-1/multiturn_sdg/generate_cases",
+ json=_generate_cases_body(),
+ )
+
+ assert resp.status_code == 503
+ assert resp.json()["message"]["code"] == "http_503"
diff --git a/app/web_ui/src/lib/api_schema.d.ts b/app/web_ui/src/lib/api_schema.d.ts
index 2cc2dfbcf5..6d1d7a7850 100644
--- a/app/web_ui/src/lib/api_schema.d.ts
+++ b/app/web_ui/src/lib/api_schema.d.ts
@@ -2760,6 +2760,40 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/projects/{project_id}/tasks/{task_id}/multiturn_sdg/generate_cases": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Generate Multi-Turn SU Cases */
+ post: operations["generate_cases_api_projects__project_id__tasks__task_id__multiturn_sdg_generate_cases_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/projects/{project_id}/tasks/{task_id}/multiturn_sdg/run_cases_batch": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Run Multi-Turn SU Cases Batch */
+ post: operations["stream_run_cases_batch_api_projects__project_id__tasks__task_id__multiturn_sdg_run_cases_batch_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/git_sync/test_access": {
parameters: {
query?: never;
@@ -6581,6 +6615,23 @@ export interface components {
[key: string]: components["schemas"]["SampleApi"][];
};
};
+ /** GenerateCasesApiInput */
+ GenerateCasesApiInput: {
+ /** Target Specification */
+ target_specification: string;
+ /** Num Cases */
+ num_cases: number;
+ };
+ /** GenerateCasesApiOutput */
+ GenerateCasesApiOutput: {
+ /**
+ * Cases
+ * @description A SyntheticUserCase as returned by kiln_server's /generate. Shape: {seed_prompt: str, synthetic_user_info: str}. The synthetic_user_info value is an XML-tagged blob: .......... Parsed client-side by kiln_ai.synthetic_user.parser.
+ */
+ cases: {
+ [key: string]: unknown;
+ }[];
+ };
/** GetRagConfigProgressRequest */
GetRagConfigProgressRequest: {
/**
@@ -8520,6 +8571,29 @@ export interface components {
/** Feedback */
feedback: string;
};
+ /** RunCasesBatchApiInput */
+ RunCasesBatchApiInput: {
+ /**
+ * Cases
+ * @description Cases as returned by /generate_cases, optionally edited. A SyntheticUserCase as returned by kiln_server's /generate. Shape: {seed_prompt: str, synthetic_user_info: str}. The synthetic_user_info value is an XML-tagged blob: .......... Parsed client-side by kiln_ai.synthetic_user.parser.
+ */
+ cases: {
+ [key: string]: unknown;
+ }[];
+ /**
+ * Turns
+ * @description Exact number of assistant turns to produce per case. The drive loop has no early termination.
+ * @default 5
+ */
+ turns: number;
+ target_run_config: components["schemas"]["TargetRunConfigSpec"];
+ su_driver: components["schemas"]["SyntheticUserDriverSpec"];
+ /**
+ * Batch Tag
+ * @description Optional user-supplied batch label. Constrained to [A-Za-z0-9_-]{1,64} so it can safely be used as a tag on leaf TaskRuns. Auto-generated if not provided.
+ */
+ batch_tag?: string | null;
+ };
/**
* RunChainEntry
* @description A single entry in a multi-turn run's conversation chain.
@@ -9286,6 +9360,16 @@ export interface components {
/** Prompt */
prompt: string;
};
+ /**
+ * SyntheticUserDriverSpec
+ * @description How to drive the synthetic user. Caller controls because probe
+ * quality and cost both depend on the model.
+ */
+ SyntheticUserDriverSpec: {
+ /** Model Name */
+ model_name: string;
+ model_provider: components["schemas"]["ModelProviderName"];
+ };
/** TabooProperties */
TabooProperties: {
/**
@@ -9298,6 +9382,21 @@ export interface components {
/** Taboo Examples */
taboo_examples: string;
};
+ /**
+ * TargetRunConfigSpec
+ * @description How to invoke the target task on each turn — same fields a manual
+ * UI run would use.
+ */
+ TargetRunConfigSpec: {
+ /** Model Name */
+ model_name: string;
+ model_provider: components["schemas"]["ModelProviderName"];
+ /**
+ * Prompt Id
+ * @default simple_prompt_builder
+ */
+ prompt_id: string;
+ };
/**
* Task
* @description Represents a specific task to be performed, with associated requirements and validation rules.
@@ -16939,6 +17038,82 @@ export interface operations {
};
};
};
+ generate_cases_api_projects__project_id__tasks__task_id__multiturn_sdg_generate_cases_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description ID of the project containing the target task. */
+ project_id: string;
+ /** @description ID of the target task. Must be a multi-turn task. */
+ task_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["GenerateCasesApiInput"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["GenerateCasesApiOutput"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ stream_run_cases_batch_api_projects__project_id__tasks__task_id__multiturn_sdg_run_cases_batch_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description ID of the project containing the target task. */
+ project_id: string;
+ /** @description ID of the target task. Must be a multi-turn task. */
+ task_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["RunCasesBatchApiInput"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
api_test_access_api_git_sync_test_access_post: {
parameters: {
query?: never;
diff --git a/libs/server/kiln_server/server.py b/libs/server/kiln_server/server.py
index 5efb8e9635..92bbc71f7c 100644
--- a/libs/server/kiln_server/server.py
+++ b/libs/server/kiln_server/server.py
@@ -68,6 +68,10 @@ def _get_version() -> str:
"name": "Synthetic Data",
"description": "Generate synthetic data for evals and fine-tuning.",
},
+ {
+ "name": "Multiturn SDG",
+ "description": "Generate multi-turn synthetic-user conversation datasets for eval.",
+ },
{
"name": "Fine-tuning",
"description": "Create and manage fine-tuning jobs.",
From edb27f0794299d2543963e072ab8a2503a3afb01 Mon Sep 17 00:00:00 2001
From: aiyakitori <16633065+chiang-daniel@users.noreply.github.com>
Date: Mon, 1 Jun 2026 21:45:12 -0700
Subject: [PATCH 08/36] refactor(studio_server/synthetic_user): driver loop
polish from review
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Rename total_cost -> target_total_cost on CaseCompletedEvent and
BatchCompletedEvent. The runner only sees target adapter spend; the SU
driver's per-turn cost isn't rolled up here. Old name was misleading
in a beta where users pick the SU model.
- Thread an optional save_context through run_cases_batch and wrap the
leaf-tag save. Adapter writes inside adapter.invoke still bypass — a
kiln_ai-side gap shared with the chat SSE pattern, documented in the
runner docstring.
- Add a re-run idempotency test for _tag_leaf to lock in the spec's
"set-union + sort, preserves pre-existing tags" contract.
- Drop the dead UNSET/None branch in client._code_or_default; the
remaining one-liner has identical behavior.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../studio_server/synthetic_user/client.py | 7 +---
.../studio_server/synthetic_user/runner.py | 34 ++++++++++++++----
.../synthetic_user/test_runner.py | 36 ++++++++++++++++++-
3 files changed, 63 insertions(+), 14 deletions(-)
diff --git a/app/desktop/studio_server/synthetic_user/client.py b/app/desktop/studio_server/synthetic_user/client.py
index c42c88187d..1cd2125251 100644
--- a/app/desktop/studio_server/synthetic_user/client.py
+++ b/app/desktop/studio_server/synthetic_user/client.py
@@ -34,7 +34,6 @@
ValidationError,
)
from app.desktop.studio_server.api_client.kiln_ai_server_client.types import (
- UNSET,
Response,
)
from app.desktop.studio_server.api_client.kiln_server_client import (
@@ -162,11 +161,7 @@ def _extract_cases_or_raise(response: Response) -> list[SyntheticUserCase]:
def _code_or_default(code: object, default: str) -> str:
"""Resolve a typed-model `code` field that may be `UNSET` or a string."""
- if isinstance(code, str) and code:
- return code
- if code is UNSET or code is None:
- return default
- return default
+ return code if isinstance(code, str) and code else default
def _format_validation_detail(error: HTTPValidationError) -> str:
diff --git a/app/desktop/studio_server/synthetic_user/runner.py b/app/desktop/studio_server/synthetic_user/runner.py
index fc5a31b414..1d6924516b 100644
--- a/app/desktop/studio_server/synthetic_user/runner.py
+++ b/app/desktop/studio_server/synthetic_user/runner.py
@@ -33,6 +33,7 @@
from kiln_ai.synthetic_user.driver import SyntheticUserDriver
from kiln_ai.synthetic_user.models import SyntheticUserDriverConfig
from kiln_ai.synthetic_user.parser import SyntheticUserInfoParseError
+from kiln_ai.utils.git_sync_protocols import SaveContext, default_save_context
from kiln_ai.utils.open_ai_types import ChatCompletionMessageParam
from app.desktop.studio_server.api_client.kiln_ai_server_client.models import (
@@ -86,7 +87,11 @@ class CaseCompletedEvent:
chain_run_ids: list[str]
leaf_run_id: str
total_turns: int
- total_cost: float
+ # Sum of the target adapter's cumulative_usage.cost across this case's
+ # turns. Excludes the SU driver's per-turn LLM spend — SU turns run via
+ # invoke_returning_run_output and never persist a TaskRun, so their cost
+ # isn't rolled up here. Rename in mind for when SU cost gets threaded.
+ target_total_cost: float
@dataclass(frozen=True)
@@ -101,7 +106,9 @@ class BatchCompletedEvent:
successful: int
failed: int
batch_tag: str
- total_cost: float
+ # Sum of CaseCompletedEvent.target_total_cost across successful cases.
+ # Same SU-exclusion caveat applies.
+ target_total_cost: float
BatchEvent = (
@@ -125,6 +132,7 @@ async def run_cases_batch(
turns: int = DEFAULT_TURNS,
concurrency: int = CONCURRENCY,
batch_tag: str | None = None,
+ save_context: SaveContext | None = None,
) -> AsyncIterator[BatchEvent]:
"""Drive `cases` concurrently against `target_task`, streaming progress.
@@ -133,6 +141,14 @@ async def run_cases_batch(
Events from different cases interleave; ordering WITHIN a case is
`turn_completed`* → `case_completed | case_failed`.
+ `save_context` wraps the leaf-tag save (the one write this runner
+ controls). The adapter's per-turn `run.save_to_file()` inside
+ `adapter.invoke` does NOT take a save_context — that's a kiln_ai-side
+ plumbing gap shared with the chat SSE pattern, not specific to this
+ runner. The route still uses `@no_write_lock` because wrapping the
+ full streaming response in one atomic_write would block all other
+ writes for the batch duration.
+
Yields:
BatchStartedEvent — once, before any case runs.
TurnCompletedEvent — one per assistant turn within a case.
@@ -148,6 +164,7 @@ async def run_cases_batch(
raise ValueError("concurrency must be >= 1")
resolved_batch_tag = batch_tag or _new_batch_tag()
+ save_ctx: SaveContext = save_context or default_save_context
semaphore = asyncio.Semaphore(concurrency)
# `None` is the end-of-stream sentinel pushed when all cases finish.
queue: asyncio.Queue[BatchEvent | None] = asyncio.Queue()
@@ -163,6 +180,7 @@ async def _drive_one(case_index: int, case: SyntheticUserCase) -> None:
turns=turns,
batch_tag=resolved_batch_tag,
queue=queue,
+ save_ctx=save_ctx,
)
# Kick the cases off BEFORE the first yield so they start running
@@ -181,7 +199,7 @@ async def _close_when_done() -> None:
successful = 0
failed = 0
- total_cost = 0.0
+ target_total_cost = 0.0
try:
yield BatchStartedEvent(batch_tag=resolved_batch_tag, num_cases=len(cases))
@@ -192,7 +210,7 @@ async def _close_when_done() -> None:
yield event
if isinstance(event, CaseCompletedEvent):
successful += 1
- total_cost += event.total_cost
+ target_total_cost += event.target_total_cost
elif isinstance(event, CaseFailedEvent):
failed += 1
@@ -200,7 +218,7 @@ async def _close_when_done() -> None:
successful=successful,
failed=failed,
batch_tag=resolved_batch_tag,
- total_cost=total_cost,
+ target_total_cost=target_total_cost,
)
finally:
# Cancel any in-flight case tasks before awaiting the closer. Without
@@ -227,6 +245,7 @@ async def _drive_one_case_and_emit(
turns: int,
batch_tag: str,
queue: asyncio.Queue[BatchEvent | None],
+ save_ctx: SaveContext,
) -> None:
"""Run drive_case for one case, translating per-turn outcomes to events
on `queue` and ending with either CaseCompletedEvent or CaseFailedEvent.
@@ -285,7 +304,8 @@ async def _on_turn(*, run: TaskRun, su_message: str) -> None:
# so a tag-save failure (full disk, validator rejection on a
# malformed batch_tag) surfaces as case_failed, not a silent drop.
leaf = result.chain[-1]
- _tag_leaf(leaf, batch_tag)
+ async with save_ctx():
+ _tag_leaf(leaf, batch_tag)
await queue.put(
CaseCompletedEvent(
@@ -295,7 +315,7 @@ async def _on_turn(*, run: TaskRun, su_message: str) -> None:
],
leaf_run_id=str(leaf.id) if leaf.id is not None else "",
total_turns=len(result.chain),
- total_cost=_cumulative_cost(leaf),
+ target_total_cost=_cumulative_cost(leaf),
)
)
except Exception as e: # noqa: BLE001 — beta error surface
diff --git a/app/desktop/studio_server/synthetic_user/test_runner.py b/app/desktop/studio_server/synthetic_user/test_runner.py
index 4de3e05505..a9bad80518 100644
--- a/app/desktop/studio_server/synthetic_user/test_runner.py
+++ b/app/desktop/studio_server/synthetic_user/test_runner.py
@@ -223,7 +223,7 @@ async def test_three_cases_produce_full_event_stream(
assert events[-1].successful == 3
assert events[-1].failed == 0
assert events[-1].batch_tag == "testbatch"
- assert events[-1].total_cost == pytest.approx(0.07)
+ assert events[-1].target_total_cost == pytest.approx(0.07)
turn_events = [e for e in events if isinstance(e, TurnCompletedEvent)]
case_done = [e for e in events if isinstance(e, CaseCompletedEvent)]
@@ -282,6 +282,40 @@ async def test_leaf_is_tagged_with_synthetic_user_case_and_batch_tag(
leaf.save_to_file.assert_called_once()
+@pytest.mark.asyncio
+async def test_leaf_tagging_is_idempotent_on_pre_tagged_leaf(
+ fake_task: Mock, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Re-tagging a leaf that already has the runner's tags + an unrelated
+ user tag preserves the user tag and dedupes the runner's tags. The
+ spec calls this out as the property that makes re-runs safe.
+ """
+ leaf = _fake_run("leaf")
+ leaf.tags = [
+ "synthetic_user_case",
+ "synthetic_user_batch:abc123",
+ "user_kept_this",
+ ]
+ _patch_adapter_for_task(monkeypatch, [leaf])
+ _patch_su_driver(monkeypatch, replies_per_case=["x"])
+
+ await _collect(
+ run_cases_batch(
+ cases=[_case()],
+ target_task=fake_task,
+ target_run_config=_target_run_config(),
+ su_driver_config=_su_driver_config(),
+ turns=1,
+ batch_tag="abc123",
+ )
+ )
+
+ assert leaf.tags.count("synthetic_user_case") == 1
+ assert leaf.tags.count("synthetic_user_batch:abc123") == 1
+ assert "user_kept_this" in leaf.tags
+ assert leaf.tags == sorted(leaf.tags)
+
+
@pytest.mark.asyncio
async def test_auto_generates_batch_tag_when_not_provided(
fake_task: Mock, monkeypatch: pytest.MonkeyPatch
From fbc1e5847f5fd0d77b9a8eb571ee18029ec09736 Mon Sep 17 00:00:00 2001
From: aiyakitori <16633065+chiang-daniel@users.noreply.github.com>
Date: Mon, 1 Jun 2026 22:36:32 -0700
Subject: [PATCH 09/36] chore(studio_server/synthetic_user): mild review polish
- Rename DEFAULT_TURNS -> MAX_TURNS_DEFAULT to match spec naming.
- Name asyncio.create_task instances so debug dumps point at this code.
- Pre-assert non-empty seed_prompt in drive_case (assert-loud invariant).
- Document invariants on _make_target_invoker (sequential-per-case),
_tag_leaf (one-writer-per-leaf), and _close_when_done (final put on
cancel path goes into the void).
- Drop the unreachable generic fallback in _to_http_exception; tighten
the param type to the two real subclasses so the type checker enforces
exhaustiveness at the call site.
- Log a warning in _format_validation_detail when every item is skipped
so a silent SDK shape drift surfaces.
- Tests: parameterize turns<1 with negatives, lock in
_event_to_payload's unregistered-event guard, and couple the
auto-batch_tag test to the public regex instead of the implementation.
- Stale "Phase 3" docstring scrub + f-string cosmetic.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../studio_server/multiturn_sdg_api.py | 31 +++++++----------
.../studio_server/synthetic_user/client.py | 13 +++++++
.../synthetic_user/drive_loop.py | 13 ++++---
.../studio_server/synthetic_user/runner.py | 34 ++++++++++++++++---
.../synthetic_user/test_drive_loop.py | 26 ++++++++++++--
.../synthetic_user/test_runner.py | 9 +++--
.../studio_server/test_multiturn_sdg_api.py | 19 +++++++++++
7 files changed, 114 insertions(+), 31 deletions(-)
diff --git a/app/desktop/studio_server/multiturn_sdg_api.py b/app/desktop/studio_server/multiturn_sdg_api.py
index 524920ba11..24a7c5c7e6 100644
--- a/app/desktop/studio_server/multiturn_sdg_api.py
+++ b/app/desktop/studio_server/multiturn_sdg_api.py
@@ -56,7 +56,7 @@
)
from app.desktop.studio_server.synthetic_user.runner import (
CONCURRENCY,
- DEFAULT_TURNS,
+ MAX_TURNS_DEFAULT,
NUM_CASES_MAX,
BatchCompletedEvent,
BatchEvent,
@@ -127,7 +127,7 @@ class RunCasesBatchApiInput(BaseModel):
),
)
turns: int = Field(
- default=DEFAULT_TURNS,
+ default=MAX_TURNS_DEFAULT,
ge=1,
le=20,
description=(
@@ -224,7 +224,9 @@ def _jsonable(obj: Any) -> Any:
raise TypeError(f"{type(obj).__name__} is not JSON serializable")
-def _to_http_exception(exc: Exception) -> HTTPException:
+def _to_http_exception(
+ exc: SyntheticUserRequestError | SyntheticUserServerError,
+) -> HTTPException:
"""Translate SyntheticUserClient's typed exceptions to HTTPExceptions.
Returns the exception rather than raising so callers can `raise … from exc`
@@ -245,21 +247,14 @@ def _to_http_exception(exc: Exception) -> HTTPException:
status_code=status,
detail={"code": exc.code, "message": exc.message},
)
- if isinstance(exc, SyntheticUserServerError):
- # Preserve the upstream 5xx (502 → 502, 503 → 503, ...). Anything
- # unrecognized falls to a clean 500.
- status = (
- exc.status_code if exc.status_code and 500 <= exc.status_code < 600 else 500
- )
- return HTTPException(
- status_code=status,
- detail={"code": exc.code, "message": exc.message},
- )
- # Generic fallback — shouldn't happen for known typed exceptions but
- # keeps the helper exhaustive at the call site.
+ # SyntheticUserServerError: preserve the upstream 5xx (502 → 502,
+ # 503 → 503, ...). Anything unrecognized falls to a clean 500.
+ status = (
+ exc.status_code if exc.status_code and 500 <= exc.status_code < 600 else 500
+ )
return HTTPException(
- status_code=500,
- detail={"code": "internal_error", "message": str(exc)},
+ status_code=status,
+ detail={"code": exc.code, "message": exc.message},
)
@@ -343,7 +338,7 @@ async def stream_run_cases_batch(
status_code=400,
detail={
"code": "invalid_case_shape",
- "message": (f"Could not parse cases against the SDK shape: {exc}"),
+ "message": f"Could not parse cases against the SDK shape: {exc}",
},
) from exc
diff --git a/app/desktop/studio_server/synthetic_user/client.py b/app/desktop/studio_server/synthetic_user/client.py
index 1cd2125251..a91a03ccdd 100644
--- a/app/desktop/studio_server/synthetic_user/client.py
+++ b/app/desktop/studio_server/synthetic_user/client.py
@@ -172,11 +172,24 @@ def _format_validation_detail(error: HTTPValidationError) -> str:
if not isinstance(detail, list):
return "Validation error (no detail)."
parts: list[str] = []
+ skipped = 0
for item in detail:
if not isinstance(item, ValidationError):
+ skipped += 1
continue
loc = ".".join(str(x) for x in item.loc)
parts.append(f"{loc}: {item.msg}")
if not parts:
+ # The SDK's HTTPValidationError.detail had items the SDK couldn't
+ # parse as ValidationError — a shape we don't expect today. Log
+ # so we can spot the discrepancy if it ever appears in the wild,
+ # instead of silently returning the empty fallback.
+ if skipped:
+ logger.warning(
+ "HTTPValidationError carried %d non-ValidationError detail item(s); "
+ "raw detail repr: %r",
+ skipped,
+ detail,
+ )
return "Validation error (no detail)."
return "Validation error: " + "; ".join(parts)
diff --git a/app/desktop/studio_server/synthetic_user/drive_loop.py b/app/desktop/studio_server/synthetic_user/drive_loop.py
index 93dea6cd11..c44a1ca146 100644
--- a/app/desktop/studio_server/synthetic_user/drive_loop.py
+++ b/app/desktop/studio_server/synthetic_user/drive_loop.py
@@ -24,10 +24,10 @@
class TargetInvoker(Protocol):
- """Callable that invokes the target task for one turn. Phase 3 wraps
- `adapter_for_task(task, run_config).invoke` to satisfy this; tests
- pass in a fake. Keeps the drive loop target-agnostic — it just cares
- about the persisted TaskRun that comes back.
+ """Callable that invokes the target task for one turn. The runner
+ wraps `adapter_for_task(task, run_config).invoke` to satisfy this;
+ tests pass in a fake. Keeps the drive loop target-agnostic — it just
+ cares about the persisted TaskRun that comes back.
"""
async def __call__(
@@ -91,6 +91,11 @@ async def drive_case(
"""
if turns < 1:
raise ValueError(f"turns must be >= 1, got {turns}")
+ # Assert-loud on missing seed. An empty string would silently flow
+ # into the target adapter and surface as a confusing model-side error
+ # rather than a clean "the case is malformed" signal.
+ if not case.seed_prompt:
+ raise ValueError("case.seed_prompt must be a non-empty string")
user_msg: str = case.seed_prompt
prev_run: TaskRun | None = None
diff --git a/app/desktop/studio_server/synthetic_user/runner.py b/app/desktop/studio_server/synthetic_user/runner.py
index 1d6924516b..48b9bfe02c 100644
--- a/app/desktop/studio_server/synthetic_user/runner.py
+++ b/app/desktop/studio_server/synthetic_user/runner.py
@@ -48,7 +48,7 @@
# Module constants. Sized for an MVP beta — easy to bump in one place.
NUM_CASES_MAX = 10
-DEFAULT_TURNS = 5
+MAX_TURNS_DEFAULT = 5
CONCURRENCY = 4
# Tag scheme. `_TAG_SU_CASE` lets the dataset view filter to all SU-generated
@@ -129,7 +129,7 @@ async def run_cases_batch(
target_task: Task,
target_run_config: KilnAgentRunConfigProperties,
su_driver_config: SyntheticUserDriverConfig,
- turns: int = DEFAULT_TURNS,
+ turns: int = MAX_TURNS_DEFAULT,
concurrency: int = CONCURRENCY,
batch_tag: str | None = None,
save_context: SaveContext | None = None,
@@ -186,16 +186,27 @@ async def _drive_one(case_index: int, case: SyntheticUserCase) -> None:
# Kick the cases off BEFORE the first yield so they start running
# concurrently with consumer setup. If the consumer disconnects between
# BatchStartedEvent and the drain loop, the `finally` below still
- # cancels them.
- case_tasks = [asyncio.create_task(_drive_one(i, c)) for i, c in enumerate(cases)]
+ # cancels them. Tasks are named so asyncio debug dumps and pending-
+ # task warnings point at this code path.
+ case_tasks = [
+ asyncio.create_task(
+ _drive_one(i, c), name=f"su_case_{i}_{resolved_batch_tag[:6]}"
+ )
+ for i, c in enumerate(cases)
+ ]
async def _close_when_done() -> None:
# `return_exceptions=True` so a stray bug doesn't leave the queue
# draining forever; we surface failures via per-case CaseFailedEvent.
+ # On the cancel path (consumer disconnect), the drain loop has
+ # already exited, so the final `put(None)` is into-the-void —
+ # harmless because the queue is unbounded and never re-read.
await asyncio.gather(*case_tasks, return_exceptions=True)
await queue.put(None)
- closer = asyncio.create_task(_close_when_done())
+ closer = asyncio.create_task(
+ _close_when_done(), name=f"su_closer_{resolved_batch_tag[:6]}"
+ )
successful = 0
failed = 0
@@ -351,6 +362,13 @@ def _make_target_invoker(
attribution context in `input_source.properties` while subsequent
runs carry only the slim `{batch_tag, turn_index}` — the case is
recoverable by walking `parent_task_run_id` to the root.
+
+ Concurrency: the returned closure is NOT safe to invoke concurrently.
+ `nonlocal turn_index` is incremented per call; concurrent callers
+ would race on the increment and the resulting `is_root` flag.
+ `drive_case` calls it sequentially within a single case (the
+ `for _ in range(turns)` loop), which is the contract; cases are
+ isolated by having their own closure with their own `turn_index`.
"""
adapter = adapter_for_task(target_task, target_run_config)
turn_index = 0
@@ -443,6 +461,12 @@ def _tag_leaf(leaf: TaskRun, batch_tag: str) -> None:
Tags are deduplicated (treated as a set then sorted) so re-runs
against an already-tagged leaf are idempotent. A save_to_file
exception surfaces to the caller (which converts to CaseFailedEvent).
+
+ Reentrancy: the read-modify-write on `leaf.tags` assumes a single
+ writer per leaf. The current call shape guarantees this (each case
+ has its own leaf), so concurrent tagging across cases hits four
+ different files. A future refactor that shares leaves across cases
+ would need to re-introduce locking here.
"""
tags = set(leaf.tags or [])
tags.add(_TAG_SU_CASE)
diff --git a/app/desktop/studio_server/synthetic_user/test_drive_loop.py b/app/desktop/studio_server/synthetic_user/test_drive_loop.py
index d4e9f2f420..bb994cad85 100644
--- a/app/desktop/studio_server/synthetic_user/test_drive_loop.py
+++ b/app/desktop/studio_server/synthetic_user/test_drive_loop.py
@@ -234,8 +234,12 @@ async def test_drive_case_works_without_on_turn_hook() -> None:
# ───────────────────────── invariants ─────────────────────────
+@pytest.mark.parametrize("bad_turns", [0, -1, -100])
@pytest.mark.asyncio
-async def test_drive_case_rejects_invalid_turns() -> None:
+async def test_drive_case_rejects_invalid_turns(bad_turns: int) -> None:
+ """Parameterized so a regression that mistyped `if turns == 0` instead of
+ `if turns < 1` would be caught by the negative cases.
+ """
invoker = _FakeInvoker(assistant_replies=[])
su = _su_driver_with_replies([])
@@ -244,7 +248,25 @@ async def test_drive_case_rejects_invalid_turns() -> None:
case=_case(),
target_invoker=invoker,
su_driver=su,
- turns=0,
+ turns=bad_turns,
+ )
+
+
+@pytest.mark.asyncio
+async def test_drive_case_rejects_empty_seed_prompt() -> None:
+ """An empty seed would silently flow into the target adapter and
+ surface as a confusing model-side error; assert-loud at the boundary
+ so this fails as the unambiguous "malformed case" it is.
+ """
+ invoker = _FakeInvoker(assistant_replies=[])
+ su = _su_driver_with_replies([])
+
+ with pytest.raises(ValueError, match="seed_prompt"):
+ await drive_case(
+ case=_case(seed=""),
+ target_invoker=invoker,
+ su_driver=su,
+ turns=1,
)
diff --git a/app/desktop/studio_server/synthetic_user/test_runner.py b/app/desktop/studio_server/synthetic_user/test_runner.py
index a9bad80518..41c4393a7c 100644
--- a/app/desktop/studio_server/synthetic_user/test_runner.py
+++ b/app/desktop/studio_server/synthetic_user/test_runner.py
@@ -8,6 +8,7 @@
"""
import asyncio
+import re
from typing import Any
from unittest.mock import AsyncMock, Mock
@@ -333,9 +334,13 @@ async def test_auto_generates_batch_tag_when_not_provided(
)
)
+ # Couple the assertion to the public contract — the auto-generated
+ # tag must satisfy the batch_tag input regex `[A-Za-z0-9_-]{1,64}` so
+ # it can be passed back as `batch_tag` on a subsequent run. Avoids
+ # restating the implementation (uuid4().hex[:12]), which would lock
+ # us in to a specific length/charset just because that's what we picked.
started = next(e for e in events if isinstance(e, BatchStartedEvent))
- assert len(started.batch_tag) == 12
- assert all(c in "0123456789abcdef" for c in started.batch_tag)
+ assert re.fullmatch(r"[A-Za-z0-9_-]{1,64}", started.batch_tag) is not None
# ───────────────────────── per-case failure isolation ─────────────────────────
diff --git a/app/desktop/studio_server/test_multiturn_sdg_api.py b/app/desktop/studio_server/test_multiturn_sdg_api.py
index 4b8652c599..7eb4a75503 100644
--- a/app/desktop/studio_server/test_multiturn_sdg_api.py
+++ b/app/desktop/studio_server/test_multiturn_sdg_api.py
@@ -450,6 +450,25 @@ async def _exploding_runner(**_kwargs) -> AsyncIterator:
assert "upstream catastrophe" in failed_evt["message"]
+def test_event_to_payload_raises_on_unregistered_event_type() -> None:
+ """If a new BatchEvent dataclass is added but not registered in
+ `_EVENT_NAMES`, `_event_to_payload` must fail loud at test time, not
+ silently emit a malformed SSE frame in production. Locks in the
+ defensive RuntimeError so a contributor adding a new event without
+ updating the map fails the build instead of shipping a wire bug.
+ """
+ from dataclasses import dataclass
+
+ from app.desktop.studio_server.multiturn_sdg_api import _event_to_payload
+
+ @dataclass(frozen=True)
+ class _UnregisteredEvent:
+ x: int = 1
+
+ with pytest.raises(RuntimeError, match="Unregistered BatchEvent type"):
+ _event_to_payload(_UnregisteredEvent()) # type: ignore[arg-type]
+
+
def test_run_cases_batch_jsonable_typeerror_surfaces_as_batch_failed(
client: TestClient, patch_task_from_id, patch_api_key
) -> None:
From 14304e76d3105b28842c78ccb017f8960f160ed0 Mon Sep 17 00:00:00 2001
From: aiyakitori <16633065+chiang-daniel@users.noreply.github.com>
Date: Mon, 1 Jun 2026 22:48:50 -0700
Subject: [PATCH 10/36] refactor(studio_server/synthetic_user): decompose root
input_source
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Root TaskRun's input_source.properties now carries the decomposed SU
case context — persona, goal, behavior_guidance (when present),
seed_prompt — instead of the opaque tagged blob.
Lets dataset readers and eval tooling inspect SU attribution by direct
property access rather than re-parsing the XML each time. The blob is
losslessly reconstructable from these fields via build_synthetic_user_info
if a downstream tool needs the original wire form.
Parse happens once per case in _build_input_source on the root turn; the
SU driver constructor already validated the blob, so the re-parse here
can't surface a new error class. behavior_guidance is omitted when the
parser returns None (the DataSource validator rejects empty strings).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../studio_server/synthetic_user/runner.py | 26 +++++++--
.../synthetic_user/test_runner.py | 53 +++++++++++++++++--
2 files changed, 70 insertions(+), 9 deletions(-)
diff --git a/app/desktop/studio_server/synthetic_user/runner.py b/app/desktop/studio_server/synthetic_user/runner.py
index 48b9bfe02c..3fe8c7ca03 100644
--- a/app/desktop/studio_server/synthetic_user/runner.py
+++ b/app/desktop/studio_server/synthetic_user/runner.py
@@ -32,7 +32,10 @@
from kiln_ai.datamodel.task_run import TaskRun
from kiln_ai.synthetic_user.driver import SyntheticUserDriver
from kiln_ai.synthetic_user.models import SyntheticUserDriverConfig
-from kiln_ai.synthetic_user.parser import SyntheticUserInfoParseError
+from kiln_ai.synthetic_user.parser import (
+ SyntheticUserInfoParseError,
+ parse_synthetic_user_info,
+)
from kiln_ai.utils.git_sync_protocols import SaveContext, default_save_context
from kiln_ai.utils.open_ai_types import ChatCompletionMessageParam
@@ -413,9 +416,15 @@ def _build_input_source(
extra property keys alongside the required `model_name` /
`model_provider` / `adapter_name`.
- Root run carries the full opaque blob + seed_prompt so anyone landing
- on this run can recover the case context without walking the chain.
- Subsequent runs carry only the slim batch_tag/turn_index pair.
+ Root run carries the decomposed case context (persona / goal /
+ behavior_guidance / seed_prompt) so a reader landing on the run can
+ inspect the SU configuration without re-parsing the blob. Subsequent
+ runs carry only the slim batch_tag/turn_index pair; the full context
+ is recoverable by walking `parent_task_run_id` to the root.
+
+ The blob itself is not persisted — it's losslessly reconstructable
+ from the decomposed fields via `build_synthetic_user_info` if any
+ downstream tool needs the original wire form.
"""
props: dict[str, str | int | float] = {
"model_name": su_driver_config.model_name,
@@ -425,7 +434,14 @@ def _build_input_source(
"turn_index": turn_index,
}
if is_root:
- props["synthetic_user_info"] = case.synthetic_user_info
+ # Parse is cheap (regex on a short string) and was already
+ # validated when the SU driver was built — re-parsing here can't
+ # surface a new error class.
+ info = parse_synthetic_user_info(case.synthetic_user_info)
+ props["persona"] = info.persona
+ props["goal"] = info.goal
+ if info.behavior_guidance:
+ props["behavior_guidance"] = info.behavior_guidance
props["seed_prompt"] = case.seed_prompt
# The validator rejects empty string property values. Strip any that
diff --git a/app/desktop/studio_server/synthetic_user/test_runner.py b/app/desktop/studio_server/synthetic_user/test_runner.py
index 41c4393a7c..712e2cc979 100644
--- a/app/desktop/studio_server/synthetic_user/test_runner.py
+++ b/app/desktop/studio_server/synthetic_user/test_runner.py
@@ -490,11 +490,13 @@ async def slow_invoke(**_kwargs: Any) -> Mock:
@pytest.mark.asyncio
-async def test_root_input_source_carries_blob_and_seed_prompt(
+async def test_root_input_source_carries_decomposed_case_context(
fake_task: Mock, monkeypatch: pytest.MonkeyPatch
) -> None:
- """First adapter.invoke call gets an input_source with the full SU
- case context: the opaque blob + seed_prompt.
+ """First adapter.invoke call gets an input_source with the decomposed
+ SU case context (persona/goal/behavior_guidance/seed_prompt) rather
+ than the opaque blob. Lets dataset readers inspect attribution
+ without re-parsing.
"""
captured: list[dict[str, Any]] = []
@@ -524,10 +526,53 @@ async def _capture(**kwargs: Any) -> Mock:
assert props["model_provider"] == "openrouter"
assert props["batch_tag"] == "rb1"
assert props["turn_index"] == 1
- assert props["synthetic_user_info"] == case.synthetic_user_info
+ # Decomposed — no opaque blob persisted.
+ assert "synthetic_user_info" not in props
+ assert props["persona"] == "persona-0"
+ assert props["goal"] == "goal-0"
+ assert props["behavior_guidance"] == "guidance-0"
assert props["seed_prompt"] == case.seed_prompt
+@pytest.mark.asyncio
+async def test_root_input_source_omits_behavior_guidance_when_absent(
+ fake_task: Mock, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """`behavior_guidance` is the only optional SU info field — when the
+ blob omits the tag (or has empty content), the property must be
+ absent rather than empty-stringed. The DataSource validator rejects
+ empty strings, so the filter in `_build_input_source` would otherwise
+ save us here, but we want the wire shape predictable either way.
+ """
+ captured: list[dict[str, Any]] = []
+
+ async def _capture(**kwargs: Any) -> Mock:
+ captured.append(kwargs)
+ return _fake_run(f"r-{len(captured)}")
+
+ _patch_adapter_for_task(monkeypatch, _capture)
+ _patch_su_driver(monkeypatch, replies_per_case=["x"])
+
+ case_no_guidance = SyntheticUserCase(
+ seed_prompt="hello",
+ synthetic_user_info="pg",
+ )
+ await _collect(
+ run_cases_batch(
+ cases=[case_no_guidance],
+ target_task=fake_task,
+ target_run_config=_target_run_config(),
+ su_driver_config=_su_driver_config(),
+ turns=1,
+ )
+ )
+
+ props = captured[0]["input_source"].properties
+ assert props["persona"] == "p"
+ assert props["goal"] == "g"
+ assert "behavior_guidance" not in props
+
+
@pytest.mark.asyncio
async def test_non_root_input_source_is_slim(
fake_task: Mock, monkeypatch: pytest.MonkeyPatch
From bdd7c4b569a1aa585cb9799cdee846c17ef82378 Mon Sep 17 00:00:00 2001
From: aiyakitori <16633065+chiang-daniel@users.noreply.github.com>
Date: Mon, 1 Jun 2026 23:09:01 -0700
Subject: [PATCH 11/36] refactor(synthetic_user): thread SU driver cost into
total_cost
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
SyntheticUserDriver.respond now returns (message, cost) — the per-call
cost is read from the in-memory TaskRun's usage.cost (the only place SU
spend surfaces, since SU turns aren't persisted as TaskRuns).
drive_case accumulates su_total_cost across turns and exposes it on
DriveCaseResult. The runner adds it to the leaf's cumulative_usage.cost
to produce an honest CaseCompletedEvent.total_cost — renamed from
target_total_cost since the field now reports total spend, not just the
target adapter's. BatchCompletedEvent.total_cost sums across successful
cases the same way.
Matters now because the SU model is user-selectable: someone picking
Sonnet for higher-quality probes would have had ~half their spend
invisible under the old target-only total.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../synthetic_user/drive_loop.py | 12 ++++-
.../studio_server/synthetic_user/runner.py | 23 ++++-----
.../synthetic_user/test_drive_loop.py | 9 ++--
.../synthetic_user/test_runner.py | 50 +++++++++++++++++--
.../studio_server/test_multiturn_sdg_api.py | 10 ++--
libs/core/kiln_ai/synthetic_user/driver.py | 24 +++++++--
.../kiln_ai/synthetic_user/test_driver.py | 45 +++++++++++++++--
7 files changed, 138 insertions(+), 35 deletions(-)
diff --git a/app/desktop/studio_server/synthetic_user/drive_loop.py b/app/desktop/studio_server/synthetic_user/drive_loop.py
index c44a1ca146..725c0a23b5 100644
--- a/app/desktop/studio_server/synthetic_user/drive_loop.py
+++ b/app/desktop/studio_server/synthetic_user/drive_loop.py
@@ -58,9 +58,15 @@ class DriveCaseResult:
`chain` is the list of persisted TaskRuns the adapter produced (leaf
last). There is no stop_reason field — every case ends after exactly
`turns` iterations by design.
+
+ `su_total_cost` sums the SU driver's per-turn LLM cost across the
+ case. SU turns aren't persisted as TaskRuns, so this is the only
+ place that spend surfaces — the runner adds it to the target's
+ `cumulative_usage.cost` to produce an honest total.
"""
chain: list[TaskRun]
+ su_total_cost: float
async def drive_case(
@@ -101,6 +107,7 @@ async def drive_case(
prev_run: TaskRun | None = None
prev_trace: list[ChatCompletionMessageParam] | None = None
chain: list[TaskRun] = []
+ su_total_cost: float = 0.0
for _ in range(turns):
new_run = await target_invoker(
@@ -112,7 +119,8 @@ async def drive_case(
# The SU driver does the role filtering / role swap / invariant
# checks itself. We pass the new run's cumulative trace as-is.
- su_message = await su_driver.respond(new_run.trace or [])
+ su_message, su_cost = await su_driver.respond(new_run.trace or [])
+ su_total_cost += su_cost
if on_turn is not None:
await on_turn(run=new_run, su_message=su_message)
@@ -121,4 +129,4 @@ async def drive_case(
prev_run = new_run
prev_trace = new_run.trace
- return DriveCaseResult(chain=chain)
+ return DriveCaseResult(chain=chain, su_total_cost=su_total_cost)
diff --git a/app/desktop/studio_server/synthetic_user/runner.py b/app/desktop/studio_server/synthetic_user/runner.py
index 3fe8c7ca03..9570f12e61 100644
--- a/app/desktop/studio_server/synthetic_user/runner.py
+++ b/app/desktop/studio_server/synthetic_user/runner.py
@@ -90,11 +90,11 @@ class CaseCompletedEvent:
chain_run_ids: list[str]
leaf_run_id: str
total_turns: int
- # Sum of the target adapter's cumulative_usage.cost across this case's
- # turns. Excludes the SU driver's per-turn LLM spend — SU turns run via
- # invoke_returning_run_output and never persist a TaskRun, so their cost
- # isn't rolled up here. Rename in mind for when SU cost gets threaded.
- target_total_cost: float
+ # Honest total spend for this case: target adapter's
+ # cumulative_usage.cost on the leaf + the SU driver's per-turn cost
+ # summed across turns. SU turns aren't persisted as TaskRuns; drive_case
+ # threads their cost through DriveCaseResult.su_total_cost.
+ total_cost: float
@dataclass(frozen=True)
@@ -109,9 +109,8 @@ class BatchCompletedEvent:
successful: int
failed: int
batch_tag: str
- # Sum of CaseCompletedEvent.target_total_cost across successful cases.
- # Same SU-exclusion caveat applies.
- target_total_cost: float
+ # Sum of CaseCompletedEvent.total_cost across successful cases.
+ total_cost: float
BatchEvent = (
@@ -213,7 +212,7 @@ async def _close_when_done() -> None:
successful = 0
failed = 0
- target_total_cost = 0.0
+ total_cost = 0.0
try:
yield BatchStartedEvent(batch_tag=resolved_batch_tag, num_cases=len(cases))
@@ -224,7 +223,7 @@ async def _close_when_done() -> None:
yield event
if isinstance(event, CaseCompletedEvent):
successful += 1
- target_total_cost += event.target_total_cost
+ total_cost += event.total_cost
elif isinstance(event, CaseFailedEvent):
failed += 1
@@ -232,7 +231,7 @@ async def _close_when_done() -> None:
successful=successful,
failed=failed,
batch_tag=resolved_batch_tag,
- target_total_cost=target_total_cost,
+ total_cost=total_cost,
)
finally:
# Cancel any in-flight case tasks before awaiting the closer. Without
@@ -329,7 +328,7 @@ async def _on_turn(*, run: TaskRun, su_message: str) -> None:
],
leaf_run_id=str(leaf.id) if leaf.id is not None else "",
total_turns=len(result.chain),
- target_total_cost=_cumulative_cost(leaf),
+ total_cost=_cumulative_cost(leaf) + result.su_total_cost,
)
)
except Exception as e: # noqa: BLE001 — beta error surface
diff --git a/app/desktop/studio_server/synthetic_user/test_drive_loop.py b/app/desktop/studio_server/synthetic_user/test_drive_loop.py
index bb994cad85..4083f37206 100644
--- a/app/desktop/studio_server/synthetic_user/test_drive_loop.py
+++ b/app/desktop/studio_server/synthetic_user/test_drive_loop.py
@@ -91,10 +91,13 @@ async def __call__(
return _fake_run(new_trace, run_id=f"run-turn-{len(self.calls)}")
-def _su_driver_with_replies(replies: list[str]) -> Mock:
- """Mock(spec=SyntheticUserDriver) with respond() returning canned strings."""
+def _su_driver_with_replies(replies: list[str], cost_per_reply: float = 0.0) -> Mock:
+ """Mock(spec=SyntheticUserDriver) with respond() returning canned
+ (message, cost) tuples. `cost_per_reply` lets cost-aware tests inject
+ a non-zero per-call cost; defaults to 0.0 for the legacy tests.
+ """
drv = Mock(spec=SyntheticUserDriver)
- drv.respond = AsyncMock(side_effect=replies)
+ drv.respond = AsyncMock(side_effect=[(r, cost_per_reply) for r in replies])
return drv
diff --git a/app/desktop/studio_server/synthetic_user/test_runner.py b/app/desktop/studio_server/synthetic_user/test_runner.py
index 712e2cc979..b400e0c849 100644
--- a/app/desktop/studio_server/synthetic_user/test_runner.py
+++ b/app/desktop/studio_server/synthetic_user/test_runner.py
@@ -122,7 +122,9 @@ def _ctor(blob, config): # noqa: ARG001
else list(replies_per_case)
)
instance = Mock(spec=SyntheticUserDriver)
- instance.respond = AsyncMock(side_effect=replies)
+ # respond() returns (message, cost). Tests that don't care about
+ # cost get 0.0 — the runner adds it to total_cost regardless.
+ instance.respond = AsyncMock(side_effect=[(r, 0.0) for r in replies])
return instance
monkeypatch.setattr(runner_mod, "SyntheticUserDriver", _ctor)
@@ -224,7 +226,7 @@ async def test_three_cases_produce_full_event_stream(
assert events[-1].successful == 3
assert events[-1].failed == 0
assert events[-1].batch_tag == "testbatch"
- assert events[-1].target_total_cost == pytest.approx(0.07)
+ assert events[-1].total_cost == pytest.approx(0.07)
turn_events = [e for e in events if isinstance(e, TurnCompletedEvent)]
case_done = [e for e in events if isinstance(e, CaseCompletedEvent)]
@@ -235,6 +237,48 @@ async def test_three_cases_produce_full_event_stream(
assert ev.total_turns == 1
+@pytest.mark.asyncio
+async def test_total_cost_sums_target_and_su_driver_spend(
+ fake_task: Mock, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """CaseCompletedEvent.total_cost = leaf.cumulative_usage.cost + sum of
+ SU driver's per-turn cost across the case. BatchCompletedEvent.total_cost
+ sums those across successful cases. Locks in the honest-totals contract.
+ """
+ leaf_a = _fake_run("a-leaf", cost=0.10)
+ leaf_b = _fake_run("b-leaf", cost=0.05)
+ _patch_adapter_for_task(
+ monkeypatch,
+ [_fake_run("a-1"), leaf_a, _fake_run("b-1"), leaf_b],
+ )
+
+ # Each case's driver gets two replies at $0.01 each → $0.02 SU per case.
+ def _ctor(blob, config): # noqa: ARG001
+ instance = Mock(spec=SyntheticUserDriver)
+ instance.respond = AsyncMock(side_effect=[("u2", 0.01), ("u3", 0.01)])
+ return instance
+
+ monkeypatch.setattr(runner_mod, "SyntheticUserDriver", _ctor)
+
+ events = await _collect(
+ run_cases_batch(
+ cases=[_case(0), _case(1)],
+ target_task=fake_task,
+ target_run_config=_target_run_config(),
+ su_driver_config=_su_driver_config(),
+ turns=2,
+ concurrency=1,
+ )
+ )
+
+ case_a, case_b = (e for e in events if isinstance(e, CaseCompletedEvent))
+ assert case_a.total_cost == pytest.approx(0.10 + 0.02)
+ assert case_b.total_cost == pytest.approx(0.05 + 0.02)
+
+ batch = next(e for e in events if isinstance(e, BatchCompletedEvent))
+ assert batch.total_cost == pytest.approx(0.12 + 0.07)
+
+
@pytest.mark.asyncio
async def test_turn_completed_event_carries_su_message_and_trace(
fake_task: Mock, monkeypatch: pytest.MonkeyPatch
@@ -361,7 +405,7 @@ def _ctor(blob, config): # noqa: ARG001
if idx == 1:
raise SyntheticUserInfoParseError("bad blob")
instance = Mock(spec=SyntheticUserDriver)
- instance.respond = AsyncMock(return_value="ok")
+ instance.respond = AsyncMock(return_value=("ok", 0.0))
return instance
_patch_su_driver_factory(monkeypatch, _ctor)
diff --git a/app/desktop/studio_server/test_multiturn_sdg_api.py b/app/desktop/studio_server/test_multiturn_sdg_api.py
index 7eb4a75503..b582746d0a 100644
--- a/app/desktop/studio_server/test_multiturn_sdg_api.py
+++ b/app/desktop/studio_server/test_multiturn_sdg_api.py
@@ -309,7 +309,7 @@ def test_run_cases_batch_emits_full_sse_event_stream(
chain_run_ids=["r0a"],
leaf_run_id="r0a",
total_turns=1,
- target_total_cost=0.01,
+ total_cost=0.01,
),
CaseFailedEvent(
case_index=1,
@@ -317,7 +317,7 @@ def test_run_cases_batch_emits_full_sse_event_stream(
message="missing required tag",
),
BatchCompletedEvent(
- successful=1, failed=1, batch_tag="testbatch", target_total_cost=0.01
+ successful=1, failed=1, batch_tag="testbatch", total_cost=0.01
),
]
@@ -365,7 +365,7 @@ async def _fake_runner(**_kwargs) -> AsyncIterator:
"successful": 1,
"failed": 1,
"batch_tag": "testbatch",
- "target_total_cost": 0.01,
+ "total_cost": 0.01,
}
@@ -391,9 +391,7 @@ def test_run_cases_batch_jsonable_handles_message_usage_in_trace(
{"role": "assistant", "content": "hi back", "usage": usage}, # type: ignore[typeddict-unknown-key]
],
),
- BatchCompletedEvent(
- successful=1, failed=0, batch_tag="tb", target_total_cost=0.001
- ),
+ BatchCompletedEvent(successful=1, failed=0, batch_tag="tb", total_cost=0.001),
]
async def _fake_runner(**_kwargs) -> AsyncIterator:
diff --git a/libs/core/kiln_ai/synthetic_user/driver.py b/libs/core/kiln_ai/synthetic_user/driver.py
index dee2625d79..aec3028404 100644
--- a/libs/core/kiln_ai/synthetic_user/driver.py
+++ b/libs/core/kiln_ai/synthetic_user/driver.py
@@ -76,12 +76,19 @@ def __init__(
)
self._adapter = adapter_for_task(self._task, self._run_config)
- async def respond(self, conversation: list[ChatCompletionMessageParam]) -> str:
- """Return the SU's next message.
+ async def respond(
+ self, conversation: list[ChatCompletionMessageParam]
+ ) -> tuple[str, float]:
+ """Return the SU's next message and the cost of producing it.
`conversation` is in the eval frame and must end on an `assistant`
(target) turn — the SU is responding to that turn. Drive-loop
termination is the caller's concern; this just produces one reply.
+
+ The cost is read from the in-memory TaskRun's `usage.cost`. SU
+ turns aren't persisted, so this is the only place the cost
+ surfaces — callers (e.g. drive_case) must thread it forward if
+ they want to report total spend.
"""
# 1) Filter to visible roles (drop system/tool if present).
visible = [
@@ -118,11 +125,20 @@ async def respond(self, conversation: list[ChatCompletionMessageParam]) -> str:
# 4) Adapter call. invoke_returning_run_output returns
# (TaskRun, RunOutput) without writing the TaskRun to disk.
- _task_run, run_output = await self._adapter.invoke_returning_run_output(
+ task_run, run_output = await self._adapter.invoke_returning_run_output(
user_input, prior_trace=prior_trace
)
raw = run_output.output
if not isinstance(raw, str):
raise RuntimeError("synthetic user returned non-string output")
- return raw
+ # Per-call cost from the in-memory TaskRun. None when the
+ # provider doesn't surface pricing (rare; defaults to 0.0 so the
+ # downstream sum stays well-defined).
+ cost = (
+ float(task_run.usage.cost)
+ if task_run.usage is not None and task_run.usage.cost is not None
+ else 0.0
+ )
+
+ return raw, cost
diff --git a/libs/core/kiln_ai/synthetic_user/test_driver.py b/libs/core/kiln_ai/synthetic_user/test_driver.py
index 754ebcceb9..50ba574e24 100644
--- a/libs/core/kiln_ai/synthetic_user/test_driver.py
+++ b/libs/core/kiln_ai/synthetic_user/test_driver.py
@@ -13,6 +13,7 @@
from kiln_ai.adapters.run_output import RunOutput
from kiln_ai.datamodel.datamodel_enums import ModelProviderName
from kiln_ai.datamodel.task_run import TaskRun
+from kiln_ai.datamodel.usage import Usage
from kiln_ai.synthetic_user import driver as driver_mod
from kiln_ai.synthetic_user.driver import SyntheticUserDriver
from kiln_ai.synthetic_user.models import SyntheticUserDriverConfig
@@ -35,14 +36,27 @@ def _fake_run_output(text: str | dict = "hi from the SU") -> RunOutput:
return RunOutput(output=text, intermediate_outputs=None)
-def _patch_adapter(monkeypatch: pytest.MonkeyPatch, return_value: RunOutput) -> Mock:
+def _patch_adapter(
+ monkeypatch: pytest.MonkeyPatch,
+ return_value: RunOutput,
+ cost: float | None = None,
+) -> Mock:
"""Replace adapter_for_task with a stub returning a mock adapter whose
invoke_returning_run_output yields (Mock(spec=TaskRun), return_value).
Returns the mock adapter so tests can assert call args.
+
+ Pass `cost` to populate the in-memory TaskRun's `.usage.cost`; when
+ omitted, `.usage` is None and `respond()` should report cost=0.0.
"""
+ task_run = Mock(spec=TaskRun)
+ task_run.usage = (
+ Usage(input_tokens=0, output_tokens=0, total_tokens=0, cost=cost)
+ if cost is not None
+ else None
+ )
adapter = Mock()
adapter.invoke_returning_run_output = AsyncMock(
- return_value=(Mock(spec=TaskRun), return_value)
+ return_value=(task_run, return_value)
)
monkeypatch.setattr(
driver_mod, "adapter_for_task", lambda task, run_config: adapter
@@ -92,9 +106,12 @@ def test_construction_raises_on_empty_required_tag(
@pytest.mark.asyncio
-async def test_respond_returns_adapter_output(
+async def test_respond_returns_adapter_output_and_zero_cost_when_unset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
+ """When the provider doesn't surface pricing, cost defaults to 0.0
+ so downstream sums stay well-defined.
+ """
adapter = _patch_adapter(monkeypatch, _fake_run_output("the SU's reply"))
drv = SyntheticUserDriver(_BLOB, _DRIVER_CONFIG)
conversation: list[ChatCompletionMessageParam] = [
@@ -102,12 +119,30 @@ async def test_respond_returns_adapter_output(
{"role": "assistant", "content": "a1"},
]
- out = await drv.respond(conversation)
+ message, cost = await drv.respond(conversation)
- assert out == "the SU's reply"
+ assert message == "the SU's reply"
+ assert cost == 0.0
adapter.invoke_returning_run_output.assert_awaited_once()
+@pytest.mark.asyncio
+async def test_respond_returns_cost_from_task_run_usage(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Per-call cost is read from the in-memory TaskRun's `usage.cost`."""
+ _patch_adapter(monkeypatch, _fake_run_output("hi"), cost=0.0123)
+ drv = SyntheticUserDriver(_BLOB, _DRIVER_CONFIG)
+ conversation: list[ChatCompletionMessageParam] = [
+ {"role": "user", "content": "u1"},
+ {"role": "assistant", "content": "a1"},
+ ]
+
+ _, cost = await drv.respond(conversation)
+
+ assert cost == pytest.approx(0.0123)
+
+
@pytest.mark.asyncio
async def test_respond_role_swaps_and_prepends_system_prompt(
monkeypatch: pytest.MonkeyPatch,
From c3cd3214a66eb38ef349b851bf335f88f5eb120a Mon Sep 17 00:00:00 2001
From: aiyakitori <16633065+chiang-daniel@users.noreply.github.com>
Date: Mon, 1 Jun 2026 23:14:59 -0700
Subject: [PATCH 12/36] chore(synthetic_user): drop dead empty-string filter in
_build_input_source
Every input to the filter has stronger upstream protection now:
seed_prompt is asserted non-empty in drive_case; persona and goal are
required-non-empty by parse_synthetic_user_info; behavior_guidance is
already conditionally skipped if None; the remaining keys are Pydantic-
validated or non-string. The filter was guarding nothing.
The DataSource validator stays as the real backstop.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
app/desktop/studio_server/synthetic_user/runner.py | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/app/desktop/studio_server/synthetic_user/runner.py b/app/desktop/studio_server/synthetic_user/runner.py
index 9570f12e61..ae5f5a8229 100644
--- a/app/desktop/studio_server/synthetic_user/runner.py
+++ b/app/desktop/studio_server/synthetic_user/runner.py
@@ -443,11 +443,7 @@ def _build_input_source(
props["behavior_guidance"] = info.behavior_guidance
props["seed_prompt"] = case.seed_prompt
- # The validator rejects empty string property values. Strip any that
- # would trip it (shouldn't happen for required fields in practice, but
- # this protects against an empty seed_prompt or a malformed config).
- filtered = {k: v for k, v in props.items() if not (isinstance(v, str) and v == "")}
- return DataSource(type=DataSourceType.synthetic, properties=filtered)
+ return DataSource(type=DataSourceType.synthetic, properties=props)
# ───────────────────────── small utilities ─────────────────────────
From 54ee96f26f97d3edd6e6af2ec09b015b6f70ee10 Mon Sep 17 00:00:00 2001
From: aiyakitori <16633065+chiang-daniel@users.noreply.github.com>
Date: Tue, 2 Jun 2026 00:24:08 -0700
Subject: [PATCH 13/36] refactor(synthetic_user): move runner + drive_loop into
the kiln-ai SDK
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Pure relocation + boundary update; behavior unchanged.
run_cases_batch and drive_case now live at
libs/core/kiln_ai/synthetic_user/{runner,drive_loop}.py alongside the
existing SyntheticUserDriver. Same neighborhood as EvalRunner /
RagJobRunner / ExtractorRunner — runners belong in libs/core.
To make libs/core SDK-agnostic, introduce a small
kiln_ai.synthetic_user.SyntheticUserCase Pydantic model (two fields,
field-identical to the kiln_server SDK's case shape). The
multiturn_sdg_api route validates dicts straight into the libs/core type
via Pydantic, so the runner never sees the SDK class. The SDK case is
still used for `/generate_cases` output via `to_dict()` — nothing
about that pro-gated authoring path changes.
Tests move with the code. studio_server keeps only the SDK-wrapper
SyntheticUserClient and the FastAPI route, which is exactly the
established shape for eval_api driving EvalRunner.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../studio_server/multiturn_sdg_api.py | 61 ++++++++++---------
.../studio_server/test_multiturn_sdg_api.py | 2 +-
app/web_ui/src/lib/api_schema.d.ts | 4 +-
libs/core/kiln_ai/synthetic_user/__init__.py | 3 +
libs/core/kiln_ai/synthetic_user/case.py | 25 ++++++++
.../kiln_ai}/synthetic_user/drive_loop.py | 5 +-
.../core/kiln_ai}/synthetic_user/runner.py | 12 +---
libs/core/kiln_ai/synthetic_user/test_case.py | 27 ++++++++
.../synthetic_user/test_drive_loop.py | 10 +--
.../kiln_ai}/synthetic_user/test_runner.py | 16 ++---
10 files changed, 101 insertions(+), 64 deletions(-)
create mode 100644 libs/core/kiln_ai/synthetic_user/case.py
rename {app/desktop/studio_server => libs/core/kiln_ai}/synthetic_user/drive_loop.py (97%)
rename {app/desktop/studio_server => libs/core/kiln_ai}/synthetic_user/runner.py (98%)
create mode 100644 libs/core/kiln_ai/synthetic_user/test_case.py
rename {app/desktop/studio_server => libs/core/kiln_ai}/synthetic_user/test_drive_loop.py (98%)
rename {app/desktop/studio_server => libs/core/kiln_ai}/synthetic_user/test_runner.py (98%)
diff --git a/app/desktop/studio_server/multiturn_sdg_api.py b/app/desktop/studio_server/multiturn_sdg_api.py
index 24a7c5c7e6..24809a5387 100644
--- a/app/desktop/studio_server/multiturn_sdg_api.py
+++ b/app/desktop/studio_server/multiturn_sdg_api.py
@@ -39,22 +39,9 @@
from kiln_ai.datamodel.run_config import KilnAgentRunConfigProperties, ToolsRunConfig
from kiln_ai.datamodel.task import Task
from kiln_ai.datamodel.usage import MessageUsage
+from kiln_ai.synthetic_user.case import SyntheticUserCase as RunnerCase
from kiln_ai.synthetic_user.models import SyntheticUserDriverConfig
-from kiln_server.cancellable_streaming_response import CancellableStreamingResponse
-from kiln_server.git_sync_decorators import build_save_context, no_write_lock
-from kiln_server.task_api import task_from_id
-from kiln_server.utils.agent_checks.policy import agent_policy_require_approval
-from pydantic import BaseModel, Field
-
-from app.desktop.studio_server.api_client.kiln_ai_server_client.models import (
- SyntheticUserCase,
-)
-from app.desktop.studio_server.synthetic_user.client import (
- SyntheticUserClient,
- SyntheticUserRequestError,
- SyntheticUserServerError,
-)
-from app.desktop.studio_server.synthetic_user.runner import (
+from kiln_ai.synthetic_user.runner import (
CONCURRENCY,
MAX_TURNS_DEFAULT,
NUM_CASES_MAX,
@@ -66,6 +53,17 @@
TurnCompletedEvent,
run_cases_batch,
)
+from kiln_server.cancellable_streaming_response import CancellableStreamingResponse
+from kiln_server.git_sync_decorators import build_save_context, no_write_lock
+from kiln_server.task_api import task_from_id
+from kiln_server.utils.agent_checks.policy import agent_policy_require_approval
+from pydantic import BaseModel, Field
+
+from app.desktop.studio_server.synthetic_user.client import (
+ SyntheticUserClient,
+ SyntheticUserRequestError,
+ SyntheticUserServerError,
+)
from app.desktop.studio_server.utils.copilot_utils import get_copilot_api_key
logger = logging.getLogger(__name__)
@@ -73,16 +71,16 @@
# ───────────────────────── Pydantic API models ─────────────────────────
-# Cases ride the wire as `list[dict[str, Any]]` rather than a Pydantic
-# mirror — the SDK's SyntheticUserCase is the single source of truth, and
-# we round-trip via `to_dict` / `from_dict` at the boundary. Trade-off:
-# TS bindings type cases as `Record` instead of getting
-# per-field autocomplete.
+# Cases ride the wire as `list[dict[str, Any]]`: the kiln_server SDK
+# emits cases as attrs models with `to_dict()` (used by `/generate_cases`
+# below) and the libs/core runner consumes `SyntheticUserCase` (Pydantic).
+# Both are field-identical; this route validates dicts straight into the
+# libs/core type via Pydantic. Trade-off: TS bindings type cases as
+# `Record` instead of getting per-field autocomplete.
SyntheticUserCaseDict = dict[str, Any]
_CASE_DICT_DESCRIPTION = (
- "A SyntheticUserCase as returned by kiln_server's /generate. Shape: "
- "{seed_prompt: str, synthetic_user_info: str}. The synthetic_user_info "
- "value is an XML-tagged blob: "
+ "A SyntheticUserCase. Shape: {seed_prompt: str, synthetic_user_info: str}. "
+ "The synthetic_user_info value is an XML-tagged blob: "
".......... "
"Parsed client-side by kiln_ai.synthetic_user.parser."
)
@@ -328,17 +326,20 @@ async def stream_run_cases_batch(
task = task_from_id(project_id, task_id)
_guard_multiturn(task)
- # SDK from_dict raises on missing/wrong-typed required fields;
- # surface that as a clean 400 instead of letting it explode inside
- # the SSE generator.
+ # Parse dict → libs/core RunnerCase. Pydantic raises ValidationError
+ # on missing keys or empty strings; surface as a clean 400 instead
+ # of letting it explode inside the SSE generator. We go straight to
+ # the libs/core type (skipping the SDK round-trip) because the two
+ # shapes are field-identical and the runner only needs the libs/core
+ # one.
try:
- sdk_cases = [SyntheticUserCase.from_dict(c) for c in input.cases]
- except (KeyError, TypeError, ValueError) as exc:
+ runner_cases = [RunnerCase.model_validate(c) for c in input.cases]
+ except Exception as exc: # noqa: BLE001 — Pydantic ValidationError + any future shape drift
raise HTTPException(
status_code=400,
detail={
"code": "invalid_case_shape",
- "message": f"Could not parse cases against the SDK shape: {exc}",
+ "message": f"Could not parse cases against the runner shape: {exc}",
},
) from exc
@@ -349,7 +350,7 @@ async def stream_run_cases_batch(
async def event_generator():
try:
async for event in run_cases_batch(
- cases=sdk_cases,
+ cases=runner_cases,
target_task=task,
target_run_config=target_run_config,
su_driver_config=su_driver_config,
diff --git a/app/desktop/studio_server/test_multiturn_sdg_api.py b/app/desktop/studio_server/test_multiturn_sdg_api.py
index b582746d0a..4e264a5cb1 100644
--- a/app/desktop/studio_server/test_multiturn_sdg_api.py
+++ b/app/desktop/studio_server/test_multiturn_sdg_api.py
@@ -29,7 +29,7 @@
SyntheticUserRequestError,
SyntheticUserServerError,
)
-from app.desktop.studio_server.synthetic_user.runner import (
+from kiln_ai.synthetic_user.runner import (
BatchCompletedEvent,
BatchStartedEvent,
CaseCompletedEvent,
diff --git a/app/web_ui/src/lib/api_schema.d.ts b/app/web_ui/src/lib/api_schema.d.ts
index 6d1d7a7850..f05dd26d78 100644
--- a/app/web_ui/src/lib/api_schema.d.ts
+++ b/app/web_ui/src/lib/api_schema.d.ts
@@ -6626,7 +6626,7 @@ export interface components {
GenerateCasesApiOutput: {
/**
* Cases
- * @description A SyntheticUserCase as returned by kiln_server's /generate. Shape: {seed_prompt: str, synthetic_user_info: str}. The synthetic_user_info value is an XML-tagged blob: .......... Parsed client-side by kiln_ai.synthetic_user.parser.
+ * @description A SyntheticUserCase. Shape: {seed_prompt: str, synthetic_user_info: str}. The synthetic_user_info value is an XML-tagged blob: .......... Parsed client-side by kiln_ai.synthetic_user.parser.
*/
cases: {
[key: string]: unknown;
@@ -8575,7 +8575,7 @@ export interface components {
RunCasesBatchApiInput: {
/**
* Cases
- * @description Cases as returned by /generate_cases, optionally edited. A SyntheticUserCase as returned by kiln_server's /generate. Shape: {seed_prompt: str, synthetic_user_info: str}. The synthetic_user_info value is an XML-tagged blob: .......... Parsed client-side by kiln_ai.synthetic_user.parser.
+ * @description Cases as returned by /generate_cases, optionally edited. A SyntheticUserCase. Shape: {seed_prompt: str, synthetic_user_info: str}. The synthetic_user_info value is an XML-tagged blob: .......... Parsed client-side by kiln_ai.synthetic_user.parser.
*/
cases: {
[key: string]: unknown;
diff --git a/libs/core/kiln_ai/synthetic_user/__init__.py b/libs/core/kiln_ai/synthetic_user/__init__.py
index 7676c14f60..72c8ee36b5 100644
--- a/libs/core/kiln_ai/synthetic_user/__init__.py
+++ b/libs/core/kiln_ai/synthetic_user/__init__.py
@@ -7,11 +7,13 @@
- `SyntheticUserDriver` — construct once per case, call `respond()` per turn.
- `SyntheticUserInfo` / `SyntheticUserDriverConfig` — typed configs.
+- `SyntheticUserCase` — input contract for the multi-turn drive loop.
- `parse_synthetic_user_info` / `build_synthetic_user_info` — tagged-blob codec.
- `SyntheticUserInfoParseError` — raised on malformed blob.
- `role_swap` — exposed for callers that drive the loop themselves.
"""
+from kiln_ai.synthetic_user.case import SyntheticUserCase
from kiln_ai.synthetic_user.driver import SyntheticUserDriver
from kiln_ai.synthetic_user.models import (
SyntheticUserDriverConfig,
@@ -26,6 +28,7 @@
from kiln_ai.synthetic_user.role_swap import role_swap
__all__ = [
+ "SyntheticUserCase",
"SyntheticUserDriver",
"SyntheticUserDriverConfig",
"SyntheticUserInfo",
diff --git a/libs/core/kiln_ai/synthetic_user/case.py b/libs/core/kiln_ai/synthetic_user/case.py
new file mode 100644
index 0000000000..c88567c462
--- /dev/null
+++ b/libs/core/kiln_ai/synthetic_user/case.py
@@ -0,0 +1,25 @@
+"""SyntheticUserCase — input contract for the multi-turn SU runner.
+
+Two-field shape: `seed_prompt` (the opening user message) and
+`synthetic_user_info` (an opaque tagged blob the SyntheticUserDriver
+parses on construction).
+
+Field-identical to the kiln_server SDK's `SyntheticUserCase`. Lives here
+in libs/core so the runner has no dependency on the vendored SDK in
+`app/desktop/`; studio_server's FastAPI route converts SDK case → this
+case at the wire boundary.
+"""
+
+from pydantic import BaseModel, Field
+
+
+class SyntheticUserCase(BaseModel):
+ """One case for the multi-turn SU drive loop.
+
+ `seed_prompt` is the first user-side message sent into the target
+ task. `synthetic_user_info` is the persona/goal/behavior_guidance
+ blob the driver parses to build the SU's system prompt.
+ """
+
+ seed_prompt: str = Field(..., min_length=1)
+ synthetic_user_info: str = Field(..., min_length=1)
diff --git a/app/desktop/studio_server/synthetic_user/drive_loop.py b/libs/core/kiln_ai/synthetic_user/drive_loop.py
similarity index 97%
rename from app/desktop/studio_server/synthetic_user/drive_loop.py
rename to libs/core/kiln_ai/synthetic_user/drive_loop.py
index 725c0a23b5..1587ec8538 100644
--- a/app/desktop/studio_server/synthetic_user/drive_loop.py
+++ b/libs/core/kiln_ai/synthetic_user/drive_loop.py
@@ -15,13 +15,10 @@
from typing import Protocol
from kiln_ai.datamodel.task_run import TaskRun
+from kiln_ai.synthetic_user.case import SyntheticUserCase
from kiln_ai.synthetic_user.driver import SyntheticUserDriver
from kiln_ai.utils.open_ai_types import ChatCompletionMessageParam
-from app.desktop.studio_server.api_client.kiln_ai_server_client.models import (
- SyntheticUserCase,
-)
-
class TargetInvoker(Protocol):
"""Callable that invokes the target task for one turn. The runner
diff --git a/app/desktop/studio_server/synthetic_user/runner.py b/libs/core/kiln_ai/synthetic_user/runner.py
similarity index 98%
rename from app/desktop/studio_server/synthetic_user/runner.py
rename to libs/core/kiln_ai/synthetic_user/runner.py
index ae5f5a8229..b8456c039b 100644
--- a/app/desktop/studio_server/synthetic_user/runner.py
+++ b/libs/core/kiln_ai/synthetic_user/runner.py
@@ -30,6 +30,8 @@
from kiln_ai.datamodel.task import Task
from kiln_ai.datamodel.task_output import DataSource, DataSourceType
from kiln_ai.datamodel.task_run import TaskRun
+from kiln_ai.synthetic_user.case import SyntheticUserCase
+from kiln_ai.synthetic_user.drive_loop import TargetInvoker, drive_case
from kiln_ai.synthetic_user.driver import SyntheticUserDriver
from kiln_ai.synthetic_user.models import SyntheticUserDriverConfig
from kiln_ai.synthetic_user.parser import (
@@ -39,14 +41,6 @@
from kiln_ai.utils.git_sync_protocols import SaveContext, default_save_context
from kiln_ai.utils.open_ai_types import ChatCompletionMessageParam
-from app.desktop.studio_server.api_client.kiln_ai_server_client.models import (
- SyntheticUserCase,
-)
-from app.desktop.studio_server.synthetic_user.drive_loop import (
- TargetInvoker,
- drive_case,
-)
-
logger = logging.getLogger(__name__)
# Module constants. Sized for an MVP beta — easy to bump in one place.
@@ -331,7 +325,7 @@ async def _on_turn(*, run: TaskRun, su_message: str) -> None:
total_cost=_cumulative_cost(leaf) + result.su_total_cost,
)
)
- except Exception as e: # noqa: BLE001 — beta error surface
+ except Exception as e:
# Adapter network errors, model misconfig, save_to_file blow-up,
# anything unexpected. Log with full traceback; emit a structured
# failure so the batch invariant holds (every case gets one event).
diff --git a/libs/core/kiln_ai/synthetic_user/test_case.py b/libs/core/kiln_ai/synthetic_user/test_case.py
new file mode 100644
index 0000000000..6c315bce89
--- /dev/null
+++ b/libs/core/kiln_ai/synthetic_user/test_case.py
@@ -0,0 +1,27 @@
+"""Unit tests for SyntheticUserCase."""
+
+import pytest
+from pydantic import ValidationError
+
+from kiln_ai.synthetic_user.case import SyntheticUserCase
+
+
+def test_case_accepts_valid_fields() -> None:
+ case = SyntheticUserCase(
+ seed_prompt="hi there",
+ synthetic_user_info="pg",
+ )
+ assert case.seed_prompt == "hi there"
+ assert "persona>p None:
+ with pytest.raises(ValidationError):
+ SyntheticUserCase(
+ seed_prompt="", synthetic_user_info="pg"
+ )
+
+
+def test_case_rejects_empty_synthetic_user_info() -> None:
+ with pytest.raises(ValidationError):
+ SyntheticUserCase(seed_prompt="hi", synthetic_user_info="")
diff --git a/app/desktop/studio_server/synthetic_user/test_drive_loop.py b/libs/core/kiln_ai/synthetic_user/test_drive_loop.py
similarity index 98%
rename from app/desktop/studio_server/synthetic_user/test_drive_loop.py
rename to libs/core/kiln_ai/synthetic_user/test_drive_loop.py
index 4083f37206..18fb99b1e0 100644
--- a/app/desktop/studio_server/synthetic_user/test_drive_loop.py
+++ b/libs/core/kiln_ai/synthetic_user/test_drive_loop.py
@@ -11,16 +11,10 @@
import pytest
from kiln_ai.datamodel.task_run import TaskRun
+from kiln_ai.synthetic_user.case import SyntheticUserCase
+from kiln_ai.synthetic_user.drive_loop import DriveCaseResult, drive_case
from kiln_ai.synthetic_user.driver import SyntheticUserDriver
-from app.desktop.studio_server.api_client.kiln_ai_server_client.models import (
- SyntheticUserCase,
-)
-from app.desktop.studio_server.synthetic_user.drive_loop import (
- DriveCaseResult,
- drive_case,
-)
-
# ───────────────────────── helpers / fixtures ─────────────────────────
diff --git a/app/desktop/studio_server/synthetic_user/test_runner.py b/libs/core/kiln_ai/synthetic_user/test_runner.py
similarity index 98%
rename from app/desktop/studio_server/synthetic_user/test_runner.py
rename to libs/core/kiln_ai/synthetic_user/test_runner.py
index b400e0c849..49a37ed666 100644
--- a/app/desktop/studio_server/synthetic_user/test_runner.py
+++ b/libs/core/kiln_ai/synthetic_user/test_runner.py
@@ -21,15 +21,12 @@
from kiln_ai.datamodel.run_config import KilnAgentRunConfigProperties, ToolsRunConfig
from kiln_ai.datamodel.task import Task
from kiln_ai.datamodel.task_run import TaskRun
+from kiln_ai.synthetic_user import runner as runner_mod
+from kiln_ai.synthetic_user.case import SyntheticUserCase
from kiln_ai.synthetic_user.driver import SyntheticUserDriver
from kiln_ai.synthetic_user.models import SyntheticUserDriverConfig
from kiln_ai.synthetic_user.parser import SyntheticUserInfoParseError
-
-from app.desktop.studio_server.api_client.kiln_ai_server_client.models import (
- SyntheticUserCase,
-)
-from app.desktop.studio_server.synthetic_user import runner as runner_mod
-from app.desktop.studio_server.synthetic_user.runner import (
+from kiln_ai.synthetic_user.runner import (
BatchCompletedEvent,
BatchEvent,
BatchStartedEvent,
@@ -39,7 +36,6 @@
run_cases_batch,
)
-
# ───────────────────────── helpers / fixtures ─────────────────────────
@@ -113,7 +109,7 @@ def _patch_su_driver(
"""
call_counter = {"i": 0}
- def _ctor(blob, config): # noqa: ARG001
+ def _ctor(blob, config):
idx = call_counter["i"]
call_counter["i"] += 1
replies = (
@@ -253,7 +249,7 @@ async def test_total_cost_sums_target_and_su_driver_spend(
)
# Each case's driver gets two replies at $0.01 each → $0.02 SU per case.
- def _ctor(blob, config): # noqa: ARG001
+ def _ctor(blob, config):
instance = Mock(spec=SyntheticUserDriver)
instance.respond = AsyncMock(side_effect=[("u2", 0.01), ("u3", 0.01)])
return instance
@@ -399,7 +395,7 @@ async def test_malformed_blob_surfaces_as_case_failed(
"""
call_counter = {"i": 0}
- def _ctor(blob, config): # noqa: ARG001
+ def _ctor(blob, config):
idx = call_counter["i"]
call_counter["i"] += 1
if idx == 1:
From ec1bb72bd079fb3fbf065f77ef4596e77a85809a Mon Sep 17 00:00:00 2001
From: aiyakitori <16633065+chiang-daniel@users.noreply.github.com>
Date: Tue, 2 Jun 2026 16:04:55 -0700
Subject: [PATCH 14/36] fix(synthetic_user): filter tool-dispatch-only
assistant turns before role_swap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Tool-using targets emit assistant turns with content=None and tool_calls
set — pure tool dispatches, not user-facing speech. Pre-this-fix, those
hit role_swap's strict-content invariant and crashed the SU run. Gemini's
suggestion (coerce None → "") would have let them through but degraded
the SU LLM's conversation view to consecutive user turns with empty
content — silently worse than the crash.
The right place to filter is at the driver, next to the existing
visible_message_roles filter — "what's visible to the SU" is the driver's
responsibility. role_swap stays strict on None content (the trip wire
for any caller bypassing the driver's filter).
Filter predicate: drop assistant turns where content is None. Keep
assistant turns that carry text alongside tool_calls — the text is
user-facing speech the SU should respond to.
Addresses gemini-code-assist comment on PR #1441 / role_swap.py without
applying the suggested empty-string coercion.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
libs/core/kiln_ai/synthetic_user/driver.py | 22 +++++-
.../kiln_ai/synthetic_user/test_driver.py | 77 +++++++++++++++++++
2 files changed, 97 insertions(+), 2 deletions(-)
diff --git a/libs/core/kiln_ai/synthetic_user/driver.py b/libs/core/kiln_ai/synthetic_user/driver.py
index aec3028404..5d54e605d3 100644
--- a/libs/core/kiln_ai/synthetic_user/driver.py
+++ b/libs/core/kiln_ai/synthetic_user/driver.py
@@ -31,6 +31,16 @@
)
+def _is_tool_dispatch_only(msg: ChatCompletionMessageParam) -> bool:
+ """True if `msg` is an assistant turn with no user-facing text (i.e.,
+ a pure tool-call dispatch). The SU LLM shouldn't see these — they're
+ actions the target took, not speech the SU is reacting to. Assistant
+ turns that carry text alongside tool_calls are NOT filtered out
+ (the text is the user-facing part).
+ """
+ return msg["role"] == "assistant" and msg.get("content") is None
+
+
class SyntheticUserDriver:
"""Plays one synthetic user across multiple turns.
@@ -96,7 +106,15 @@ async def respond(
for m in conversation
if m["role"] in self._driver_config.visible_message_roles
]
- # 2) Invariants this driver enforces (moved from kiln_server's
+ # 2) Drop tool-dispatch-only assistant turns (content=None). These
+ # represent the target calling a tool with no user-facing text —
+ # not speech the SU should "see" or respond to. Assistant turns
+ # that carry BOTH text and tool_calls are kept, since the text is
+ # user-facing. role_swap stays strict on None content (the trip
+ # wire); upstream filtering is what keeps it from firing on a
+ # tool-using target.
+ visible = [m for m in visible if not _is_tool_dispatch_only(m)]
+ # 3) Invariants this driver enforces (moved from kiln_server's
# removed /respond route validator).
if not visible:
raise ValueError("No LLM-visible messages in conversation.")
@@ -106,7 +124,7 @@ async def respond(
"is responding to that turn."
)
- # 3) Role-swap then assemble prior_trace + input. The last swapped
+ # 4) Role-swap then assemble prior_trace + input. The last swapped
# turn becomes the LLM `input`; everything before it goes into
# `prior_trace` with a system message prepended.
swapped = role_swap(visible)
diff --git a/libs/core/kiln_ai/synthetic_user/test_driver.py b/libs/core/kiln_ai/synthetic_user/test_driver.py
index 50ba574e24..2892482364 100644
--- a/libs/core/kiln_ai/synthetic_user/test_driver.py
+++ b/libs/core/kiln_ai/synthetic_user/test_driver.py
@@ -204,6 +204,83 @@ async def test_respond_filters_visible_message_roles(
assert prior_trace[1] == {"role": "assistant", "content": "u1"}
+@pytest.mark.asyncio
+async def test_respond_drops_tool_dispatch_only_assistant_turns(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A tool-using target produces assistant turns with content=None and
+ tool_calls set. Those are actions the target took, not speech the SU
+ should respond to. They must be filtered out before role_swap so the
+ swap's strict-content invariant doesn't fire and so the SU's view of
+ the conversation stays a coherent text-only dialog.
+ """
+ adapter = _patch_adapter(monkeypatch, _fake_run_output("ok"))
+ drv = SyntheticUserDriver(_BLOB, _DRIVER_CONFIG)
+ # Shape that comes out of a tool-using target: user → tool-dispatch
+ # assistant (content=None) → final text-response assistant.
+ conversation: list[ChatCompletionMessageParam] = [
+ {"role": "user", "content": "what's the weather?"},
+ {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [
+ {
+ "id": "call_1",
+ "type": "function",
+ "function": {"name": "get_weather", "arguments": "{}"},
+ }
+ ],
+ }, # type: ignore[typeddict-item]
+ {"role": "assistant", "content": "It's 72F."},
+ ]
+
+ await drv.respond(conversation)
+
+ call = adapter.invoke_returning_run_output.await_args
+ # The tool-dispatch turn (None content) was dropped. After role-swap:
+ # eval [user, assistant("It's 72F.")] → LLM [assistant, user("It's 72F.")]
+ # Last swapped turn becomes `input`; prior_trace is [sys, ...rest].
+ assert call.args[0] == "It's 72F."
+ prior_trace = call.kwargs["prior_trace"]
+ assert len(prior_trace) == 2 # [sys, swapped user 'what's the weather']
+ assert prior_trace[0]["content"] == drv._system_prompt
+ assert prior_trace[1] == {"role": "assistant", "content": "what's the weather?"}
+
+
+@pytest.mark.asyncio
+async def test_respond_keeps_assistant_turns_with_text_and_tool_calls(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Some providers emit assistant turns with BOTH text content AND
+ tool_calls in the same message ("Let me look that up." + dispatch).
+ The text is user-facing — the SU should see it. Only None-content
+ turns get dropped.
+ """
+ adapter = _patch_adapter(monkeypatch, _fake_run_output("ok"))
+ drv = SyntheticUserDriver(_BLOB, _DRIVER_CONFIG)
+ conversation: list[ChatCompletionMessageParam] = [
+ {"role": "user", "content": "what's the weather?"},
+ {
+ "role": "assistant",
+ "content": "Let me look that up.",
+ "tool_calls": [
+ {
+ "id": "call_1",
+ "type": "function",
+ "function": {"name": "get_weather", "arguments": "{}"},
+ }
+ ],
+ }, # type: ignore[typeddict-item]
+ ]
+
+ await drv.respond(conversation)
+
+ call = adapter.invoke_returning_run_output.await_args
+ # The combined text+tool_calls assistant turn is NOT filtered out —
+ # its text becomes the input the SU responds to.
+ assert call.args[0] == "Let me look that up."
+
+
@pytest.mark.asyncio
async def test_respond_with_custom_visible_roles(
monkeypatch: pytest.MonkeyPatch,
From edf3794363329d31539becd206bbe033eba22e77 Mon Sep 17 00:00:00 2001
From: aiyakitori <16633065+chiang-daniel@users.noreply.github.com>
Date: Wed, 3 Jun 2026 07:53:29 -0700
Subject: [PATCH 15/36] =?UTF-8?q?chore(synthetic=5Fuser):=20trim=20comment?=
=?UTF-8?q?s=20=E2=80=94=20fix=20inaccuracies,=20strip=20stale=20context?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fix comment numbering in driver.py (4→5), correct "greedy" to "non-greedy"
in parser.py, remove inaccurate drive-loop claim from studio_server __init__.
Strip historical /respond migration references, remove app-layer concerns
(SSE, @no_write_lock) from SDK-level docstrings, deduplicate cost-attribution
explanations across driver/runner/drive_loop.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../studio_server/synthetic_user/__init__.py | 7 +-
.../studio_server/synthetic_user/client.py | 11 +---
libs/core/kiln_ai/synthetic_user/driver.py | 40 ++++--------
libs/core/kiln_ai/synthetic_user/models.py | 3 +-
libs/core/kiln_ai/synthetic_user/parser.py | 2 +-
libs/core/kiln_ai/synthetic_user/runner.py | 65 ++++---------------
6 files changed, 30 insertions(+), 98 deletions(-)
diff --git a/app/desktop/studio_server/synthetic_user/__init__.py b/app/desktop/studio_server/synthetic_user/__init__.py
index 72b84653f6..3808145613 100644
--- a/app/desktop/studio_server/synthetic_user/__init__.py
+++ b/app/desktop/studio_server/synthetic_user/__init__.py
@@ -1,10 +1,5 @@
"""Studio_server-side wrapper for the kiln_server synthetic-user `/generate`
-endpoint, plus the multi-turn synthetic-data-generation drive loop.
-
-Per-turn synthetic-user invocation itself lives in
-`libs/core/kiln_ai/synthetic_user/` (calls the LLM with the user's keys);
-this module covers only the authoring HTTP call and the runner that
-orchestrates target invocation + SU response across multiple cases.
+endpoint. Exports the client and its typed exception hierarchy.
"""
from app.desktop.studio_server.synthetic_user.client import (
diff --git a/app/desktop/studio_server/synthetic_user/client.py b/app/desktop/studio_server/synthetic_user/client.py
index a91a03ccdd..8e1750b5c0 100644
--- a/app/desktop/studio_server/synthetic_user/client.py
+++ b/app/desktop/studio_server/synthetic_user/client.py
@@ -5,14 +5,9 @@
1. Error classification. The SDK parses 200/401/422/500/502 into typed
models; we translate those into the wrapper's typed exception hierarchy
so callers never inspect raw HTTP status codes.
-2. No retry. `/generate` is a once-per-batch authoring call (not a per-turn
- hot loop), and kiln_server's own pipeline already retries transient
- provider failures once before returning 502. A 502 reaching us is a
- genuine per-batch failure that should surface, not a transient to retry.
-
-No `/respond` here. Per-turn synthetic-user invocation lives in
-`libs/core/kiln_ai/synthetic_user/`. This wrapper exists only for the
-authoring call.
+2. No retry. `/generate` is a once-per-batch authoring call, and
+ kiln_server already retries transient provider failures internally
+ before returning 502. A 502 reaching us is a genuine failure.
"""
import logging
diff --git a/libs/core/kiln_ai/synthetic_user/driver.py b/libs/core/kiln_ai/synthetic_user/driver.py
index 5d54e605d3..0f8858ffe8 100644
--- a/libs/core/kiln_ai/synthetic_user/driver.py
+++ b/libs/core/kiln_ai/synthetic_user/driver.py
@@ -1,5 +1,4 @@
-"""Per-turn synthetic-user driver — the OSS-side replacement for kiln_server's
-removed `/respond` route.
+"""Per-turn synthetic-user driver.
Wraps a kiln_ai LiteLLM adapter and exposes a single async `respond()` that:
1. Filters the eval-frame conversation to `visible_message_roles`.
@@ -7,11 +6,8 @@
3. Calls the adapter with the persona system prompt prepended as
`prior_trace` and the latest swapped user turn as `input`.
-The driver does NOT persist `TaskRun`s — it uses
-`adapter.invoke_returning_run_output(...)` which builds an in-memory run
-without writing to disk. The eval-dataset chain consists only of *target*
-TaskRuns (created elsewhere by `adapter.invoke(...)`). The SU is an
-orchestration component, not an eval-dataset row.
+The driver does NOT persist `TaskRun`s — it builds in-memory runs only.
+The eval-dataset chain consists only of *target* TaskRuns.
"""
from kiln_ai.adapters.adapter_registry import adapter_for_task
@@ -89,16 +85,10 @@ def __init__(
async def respond(
self, conversation: list[ChatCompletionMessageParam]
) -> tuple[str, float]:
- """Return the SU's next message and the cost of producing it.
+ """Return the SU's next message and the per-call cost.
`conversation` is in the eval frame and must end on an `assistant`
- (target) turn — the SU is responding to that turn. Drive-loop
- termination is the caller's concern; this just produces one reply.
-
- The cost is read from the in-memory TaskRun's `usage.cost`. SU
- turns aren't persisted, so this is the only place the cost
- surfaces — callers (e.g. drive_case) must thread it forward if
- they want to report total spend.
+ (target) turn. Drive-loop termination is the caller's concern.
"""
# 1) Filter to visible roles (drop system/tool if present).
visible = [
@@ -106,16 +96,10 @@ async def respond(
for m in conversation
if m["role"] in self._driver_config.visible_message_roles
]
- # 2) Drop tool-dispatch-only assistant turns (content=None). These
- # represent the target calling a tool with no user-facing text —
- # not speech the SU should "see" or respond to. Assistant turns
- # that carry BOTH text and tool_calls are kept, since the text is
- # user-facing. role_swap stays strict on None content (the trip
- # wire); upstream filtering is what keeps it from firing on a
- # tool-using target.
+ # 2) Drop tool-dispatch-only assistant turns (content=None).
+ # See _is_tool_dispatch_only for rationale.
visible = [m for m in visible if not _is_tool_dispatch_only(m)]
- # 3) Invariants this driver enforces (moved from kiln_server's
- # removed /respond route validator).
+ # 3) Invariants.
if not visible:
raise ValueError("No LLM-visible messages in conversation.")
if visible[-1]["role"] != "assistant":
@@ -141,8 +125,7 @@ async def respond(
}
prior_trace: list[ChatCompletionMessageParam] = [system_msg, *swapped[:-1]]
- # 4) Adapter call. invoke_returning_run_output returns
- # (TaskRun, RunOutput) without writing the TaskRun to disk.
+ # 5) Adapter call (in-memory, no disk write).
task_run, run_output = await self._adapter.invoke_returning_run_output(
user_input, prior_trace=prior_trace
)
@@ -150,9 +133,8 @@ async def respond(
if not isinstance(raw, str):
raise RuntimeError("synthetic user returned non-string output")
- # Per-call cost from the in-memory TaskRun. None when the
- # provider doesn't surface pricing (rare; defaults to 0.0 so the
- # downstream sum stays well-defined).
+ # Per-call cost; defaults to 0.0 when the provider doesn't
+ # surface pricing.
cost = (
float(task_run.usage.cost)
if task_run.usage is not None and task_run.usage.cost is not None
diff --git a/libs/core/kiln_ai/synthetic_user/models.py b/libs/core/kiln_ai/synthetic_user/models.py
index e532a7fe3b..e67339960a 100644
--- a/libs/core/kiln_ai/synthetic_user/models.py
+++ b/libs/core/kiln_ai/synthetic_user/models.py
@@ -3,8 +3,7 @@
`SyntheticUserInfo` is the parsed form of the tagged blob the server sends
on `/generate`; the parser produces it and `prompt.render_system_prompt`
consumes it. `SyntheticUserDriverConfig` carries the per-eval runtime
-config — model, provider, role visibility — moved here from the kiln_server
-wire (where it used to live on the now-deleted `/respond` request).
+config — model, provider, role visibility.
"""
from typing import Literal
diff --git a/libs/core/kiln_ai/synthetic_user/parser.py b/libs/core/kiln_ai/synthetic_user/parser.py
index 0d40c7b4d4..bee8db464c 100644
--- a/libs/core/kiln_ai/synthetic_user/parser.py
+++ b/libs/core/kiln_ai/synthetic_user/parser.py
@@ -22,7 +22,7 @@ class SyntheticUserInfoParseError(ValueError):
def _extract(blob: str, tag: str) -> str | None:
- """Greedy first-match. Returns the trimmed content, or None if no match."""
+ """First match, non-greedy content. Returns trimmed content, or None."""
m = re.search(rf"<{tag}>(.*?){tag}>", blob, re.DOTALL)
if m is None:
return None
diff --git a/libs/core/kiln_ai/synthetic_user/runner.py b/libs/core/kiln_ai/synthetic_user/runner.py
index b8456c039b..84168e4f46 100644
--- a/libs/core/kiln_ai/synthetic_user/runner.py
+++ b/libs/core/kiln_ai/synthetic_user/runner.py
@@ -1,21 +1,8 @@
"""Batch runner — fans drive_case out across N cases, streams BatchEvents.
-`run_cases_batch` is an async generator yielding typed events so the
-upcoming SSE endpoint can map each to a `data:` frame without
-re-instrumenting. Cases run concurrently under an asyncio.Semaphore.
-
-Responsibilities owned here (not in drive_case):
-- Building the per-case `SyntheticUserDriver` (catches malformed
- `synthetic_user_info` blob → `CaseFailedEvent` for that case only).
-- Building the per-case `TargetInvoker` that wraps `adapter.invoke` with
- the SU-attribution `input_source` — opaque blob on the root run, slim
- `{batch_tag, turn_index}` on subsequent turns.
-- Tagging the leaf TaskRun for downstream eval-dataset discovery.
-- Per-case error isolation (typed driver / loop / unexpected exceptions
- → `CaseFailedEvent` without affecting other in-flight cases).
-- Bounded cleanup on consumer disconnect — case tasks are cancelled
- before the closer awaits them, so a browser disconnect doesn't keep
- the request alive for the duration of every in-flight case.
+`run_cases_batch` is an async generator yielding typed events. Cases run
+concurrently under an asyncio.Semaphore. Per-case failures surface as
+`CaseFailedEvent` without affecting other in-flight cases.
"""
import asyncio
@@ -43,7 +30,7 @@
logger = logging.getLogger(__name__)
-# Module constants. Sized for an MVP beta — easy to bump in one place.
+# Module constants.
NUM_CASES_MAX = 10
MAX_TURNS_DEFAULT = 5
CONCURRENCY = 4
@@ -84,10 +71,7 @@ class CaseCompletedEvent:
chain_run_ids: list[str]
leaf_run_id: str
total_turns: int
- # Honest total spend for this case: target adapter's
- # cumulative_usage.cost on the leaf + the SU driver's per-turn cost
- # summed across turns. SU turns aren't persisted as TaskRuns; drive_case
- # threads their cost through DriveCaseResult.su_total_cost.
+ # Target adapter cost + SU driver cost for this case.
total_cost: float
@@ -137,14 +121,6 @@ async def run_cases_batch(
Events from different cases interleave; ordering WITHIN a case is
`turn_completed`* → `case_completed | case_failed`.
- `save_context` wraps the leaf-tag save (the one write this runner
- controls). The adapter's per-turn `run.save_to_file()` inside
- `adapter.invoke` does NOT take a save_context — that's a kiln_ai-side
- plumbing gap shared with the chat SSE pattern, not specific to this
- runner. The route still uses `@no_write_lock` because wrapping the
- full streaming response in one atomic_write would block all other
- writes for the batch duration.
-
Yields:
BatchStartedEvent — once, before any case runs.
TurnCompletedEvent — one per assistant turn within a case.
@@ -254,18 +230,13 @@ async def _drive_one_case_and_emit(
queue: asyncio.Queue[BatchEvent | None],
save_ctx: SaveContext,
) -> None:
- """Run drive_case for one case, translating per-turn outcomes to events
- on `queue` and ending with either CaseCompletedEvent or CaseFailedEvent.
-
- All upstream work — SyntheticUserDriver construction, target_invoker
- construction, drive_case execution, leaf tagging — runs inside a single
- try/except so any failure surfaces as a CaseFailedEvent rather than
- escaping into `asyncio.gather(return_exceptions=True)` (which would
- silently drop the case from the event stream).
+ """Run drive_case for one case, emitting events on `queue`.
+
+ Everything runs inside a single try/except so any failure surfaces as
+ a CaseFailedEvent rather than silently dropping the case.
"""
try:
- # Construct the per-case SU driver. Parses the blob immediately;
- # a malformed blob fails this case without affecting others.
+ # Malformed blob fails this case without affecting others.
try:
su_driver = SyntheticUserDriver(case.synthetic_user_info, su_driver_config)
except SyntheticUserInfoParseError as e:
@@ -404,20 +375,10 @@ def _build_input_source(
) -> DataSource:
"""Attribute the user-side input on this turn to the SU driver model.
- Reuses `DataSourceType.synthetic` (a model produced this text) rather
- than inventing a new type — the existing validator accepts arbitrary
- extra property keys alongside the required `model_name` /
- `model_provider` / `adapter_name`.
-
Root run carries the decomposed case context (persona / goal /
- behavior_guidance / seed_prompt) so a reader landing on the run can
- inspect the SU configuration without re-parsing the blob. Subsequent
- runs carry only the slim batch_tag/turn_index pair; the full context
- is recoverable by walking `parent_task_run_id` to the root.
-
- The blob itself is not persisted — it's losslessly reconstructable
- from the decomposed fields via `build_synthetic_user_info` if any
- downstream tool needs the original wire form.
+ behavior_guidance / seed_prompt). Subsequent runs carry only the slim
+ batch_tag/turn_index pair — full context is recoverable by walking
+ parent_task_run_id to the root.
"""
props: dict[str, str | int | float] = {
"model_name": su_driver_config.model_name,
From d032dcf1853dabd7cd5f300107ec0791f4261c17 Mon Sep 17 00:00:00 2001
From: aiyakitori <16633065+chiang-daniel@users.noreply.github.com>
Date: Wed, 3 Jun 2026 08:35:12 -0700
Subject: [PATCH 16/36] chore(web_ui): strip zero-width space from
multiturn_composer comment
Stray U+200B (zero-width space) between "disables/" and "spinners" in a
comment tripped eslint no-irregular-whitespace. Likely a paste artifact
from Leonard's recent commit; fixed in passing during the merge.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
app/web_ui/src/lib/ui/conversation/multiturn_composer.svelte | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/web_ui/src/lib/ui/conversation/multiturn_composer.svelte b/app/web_ui/src/lib/ui/conversation/multiturn_composer.svelte
index 57b394792f..37465778a8 100644
--- a/app/web_ui/src/lib/ui/conversation/multiturn_composer.svelte
+++ b/app/web_ui/src/lib/ui/conversation/multiturn_composer.svelte
@@ -194,7 +194,7 @@
collapse via an empty-conditional slot without regressing append-mode
layout (full-width button vs. fork-mode's right-aligned button next
to Cancel). The cancel-button markup is the only difference. While
- submitting, FormContainer disables/spinners the Send button and we
+ submitting, FormContainer disables/spinners the Send button and we
disable the textarea — the area stays put rather than being hidden. -->
{#if mode === "fork" && on_cancel}
Date: Thu, 4 Jun 2026 13:02:53 -0700
Subject: [PATCH 17/36] feat(copilot): multi-turn save endpoint + classify-spec
stub
Extends spec_with_copilot to handle multi-turn synthetic-user batches.
When the request carries a `multi_turn.batch_tag`, the endpoint:
- finds existing chain leaves tagged synthetic_user_batch:
- applies the spec's eval + golden filter tags to them
- creates Eval with evaluation_data_type=full_trace and train_set_filter_id=None
- skips example synthesis and TaskRun creation
A request-shape validator enforces mutual exclusion with sdg_session_config
and requires evaluate_full_trace=True for the multi-turn path.
Also adds classify_spec_description as a stub endpoint that returns 501
until the kiln_server classifier ships.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
app/desktop/studio_server/copilot_api.py | 267 +++++++++++++-----
app/desktop/studio_server/test_copilot_api.py | 237 +++++++++++++++-
.../studio_server/utils/copilot_utils.py | 44 ++-
app/web_ui/src/lib/api_schema.d.ts | 142 +++++++++-
4 files changed, 611 insertions(+), 79 deletions(-)
diff --git a/app/desktop/studio_server/copilot_api.py b/app/desktop/studio_server/copilot_api.py
index 32aa20d359..78b4982d28 100644
--- a/app/desktop/studio_server/copilot_api.py
+++ b/app/desktop/studio_server/copilot_api.py
@@ -44,8 +44,10 @@
)
from app.desktop.studio_server.utils.copilot_utils import (
create_dataset_task_runs,
+ find_multi_turn_chain_leaves,
generate_copilot_examples,
get_copilot_api_key,
+ tag_multi_turn_chains_for_eval,
)
from app.desktop.studio_server.utils.response_utils import unwrap_response
from fastapi import FastAPI, HTTPException, Path
@@ -60,7 +62,7 @@
SyntheticDataGenerationStepConfig,
TaskSample,
)
-from kiln_ai.datamodel.spec_properties import SpecProperties
+from kiln_ai.datamodel.spec_properties import SpecProperties, SpecType
from kiln_ai.utils.name_generator import generate_memorable_name
from kiln_server.task_api import task_from_id
from kiln_server.utils.spec_utils import (
@@ -76,25 +78,78 @@
SubmitAnswersRequest,
)
from kiln_server.utils.agent_checks.policy import agent_policy_require_approval
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, model_validator
+from typing_extensions import Self
logger = logging.getLogger(__name__)
+class ClassifySpecDescriptionInput(BaseModel):
+ """Free-text description of an eval the user wants to build. The
+ endpoint maps it to one of the 9 spec types and pre-fills the
+ property_values for that type so the v2 builder can skip the
+ template-carousel step entirely.
+ """
+
+ description: str = Field(
+ description="Free-text description of what the eval should check."
+ )
+ task_prompt: str | None = Field(
+ default=None,
+ description="Optional task prompt for context (improves classification "
+ "accuracy when the spec relates to a specific task).",
+ )
+
+
+class ClassifySpecDescriptionOutput(BaseModel):
+ """Classified spec type + suggested name + spec_type-specific property
+ values. The `property_values` dict keys match the FieldConfig keys
+ defined for the chosen spec_type (see
+ app/web_ui/.../select_template/spec_templates.ts).
+ """
+
+ spec_type: SpecType = Field(description="The classified spec type.")
+ suggested_name: str = Field(
+ description="A filename-safe name for the new spec, derived from the description."
+ )
+ property_values: dict[str, str] = Field(
+ description="Pre-filled property values for the chosen spec_type. "
+ "Keys correspond to the field_configs of that spec_type."
+ )
+
+
+class MultiTurnSaveInfo(BaseModel):
+ """Identifies an existing multi-turn synthetic-user batch to turn into an Eval.
+ The endpoint walks chains tagged with this batch_tag and applies eval/golden
+ filter tags instead of generating new examples.
+ """
+
+ batch_tag: str = Field(
+ description="The batch_tag emitted by the multi-turn synthetic-user runner "
+ "(see kiln_ai.synthetic_user.runner). Identifies the set of conversation "
+ "chains already persisted to disk that this Eval should evaluate."
+ )
+
+
class CreateSpecWithCopilotRequest(BaseModel):
"""Request model for creating a spec with Kiln Copilot.
- This endpoint uses Kiln Copilot to:
- - Generate batch examples for eval, train, and golden datasets
- - Create a judge eval config
- - Create an eval with appropriate template/output scores
- - Create and save the spec
+ Two synthesis paths are supported, exactly one must be set per request:
+
+ - **Single-turn:** caller supplies `sdg_session_config`. Endpoint calls
+ `generate_copilot_examples` for fresh I/O pairs, splits them into
+ eval/train/golden datasets, and tags new TaskRuns.
- If you don't want to use copilot, use the regular POST /spec endpoint instead.
+ - **Multi-turn:** caller supplies `multi_turn` with a `batch_tag` pointing
+ at chains already on disk (created earlier by the synthetic-user runner).
+ Endpoint tags the existing chain leaves with eval/golden filter tags;
+ no new TaskRuns are created. `evaluate_full_trace` must be True.
+
+ If you don't want copilot at all, use POST /spec instead.
The client is responsible for building:
- - definition: The spec definition string (use buildSpecDefinition on client)
- - properties: The spec properties object (filtered, with spec_type included)
+ - definition: the spec definition string (buildSpecDefinition on client)
+ - properties: the spec properties object (filtered, with spec_type included)
"""
name: FilenameString
@@ -108,13 +163,53 @@ class CreateSpecWithCopilotRequest(BaseModel):
evaluate_full_trace: bool = False
reviewed_examples: list[ReviewedExample] = Field(default_factory=list)
judge_info: SyntheticDataGenerationStepConfigApi
- sdg_session_config: SyntheticDataGenerationSessionConfigApi
+ sdg_session_config: SyntheticDataGenerationSessionConfigApi | None = None
+ multi_turn: MultiTurnSaveInfo | None = None
task_description: str = ""
task_prompt_with_example: str = ""
task_sample: TaskSample | None = None
+ @model_validator(mode="after")
+ def validate_synthesis_path(self) -> Self:
+ if self.multi_turn is not None and self.sdg_session_config is not None:
+ raise ValueError(
+ "Pass exactly one of `multi_turn` or `sdg_session_config` — not both."
+ )
+ if self.multi_turn is None and self.sdg_session_config is None:
+ raise ValueError(
+ "Must pass one of `multi_turn` (for multi-turn chains already on "
+ "disk) or `sdg_session_config` (for fresh single-turn synthesis)."
+ )
+ if self.multi_turn is not None and not self.evaluate_full_trace:
+ raise ValueError(
+ "Multi-turn save requires `evaluate_full_trace=True` — the eval "
+ "evaluates full conversation traces, not single I/O pairs."
+ )
+ return self
+
def connect_copilot_api(app: FastAPI):
+ @app.post(
+ "/api/copilot/classify_spec_description",
+ tags=["Copilot"],
+ openapi_extra=agent_policy_require_approval(
+ "Classify a free-text spec description?"
+ ),
+ )
+ async def classify_spec_description(
+ input: ClassifySpecDescriptionInput,
+ ) -> ClassifySpecDescriptionOutput:
+ """Classify a free-text spec description into one of the 9 spec types.
+
+ Not implemented yet — the underlying kiln_server classifier hasn't
+ shipped. Returns 501 so callers can detect this and fall back to
+ manual spec-type selection.
+ """
+ raise HTTPException(
+ status_code=501,
+ detail="Spec classification isn't implemented yet.",
+ )
+
@app.post(
"/api/copilot/clarify_spec",
tags=["Copilot"],
@@ -315,10 +410,27 @@ async def create_spec_with_copilot(
spec_type, request.evaluate_full_trace
)
+ # Multi-turn path: find existing chain leaves up front so we 404 before
+ # creating any models if the batch_tag matches nothing.
+ multi_turn_leaves: list[TaskRun] = []
+ if request.multi_turn is not None:
+ multi_turn_leaves = find_multi_turn_chain_leaves(
+ task, request.multi_turn.batch_tag
+ )
+ if not multi_turn_leaves:
+ raise HTTPException(
+ status_code=404,
+ detail=(
+ f"No multi-turn chains found for batch_tag "
+ f"'{request.multi_turn.batch_tag}'."
+ ),
+ )
+
# Build models but don't save yet, collect all models first
models_to_save: list[Eval | EvalConfig | TaskRun | Spec] = []
- # 1. Create the Eval
+ # 1. Create the Eval. Multi-turn has no train set (small datasets,
+ # not used for fine-tuning) so train_set_filter_id is None.
eval = Eval(
parent=task,
name=request.name,
@@ -326,14 +438,16 @@ async def create_spec_with_copilot(
template=template,
output_scores=output_scores,
eval_set_filter_id=eval_set_filter_id,
- train_set_filter_id=train_set_filter_id,
+ train_set_filter_id=(
+ None if request.multi_turn is not None else train_set_filter_id
+ ),
eval_configs_filter_id=eval_configs_filter_id,
template_properties=None,
evaluation_data_type=evaluation_data_type,
)
models_to_save.append(eval)
- # 2. Create judge eval config
+ # 2. Create judge eval config (same in both paths)
eval_config = EvalConfig(
parent=eval,
name=generate_memorable_name(),
@@ -350,44 +464,68 @@ async def create_spec_with_copilot(
# Set as default config after ID is assigned
eval.current_config_id = eval_config.id
- # 3. Generate examples via copilot API
- api_key = get_copilot_api_key()
- task_input_schema = (
- str(task.input_json_schema) if task.input_json_schema else ""
- )
- task_output_schema = (
- str(task.output_json_schema) if task.output_json_schema else ""
- )
- all_examples = await generate_copilot_examples(
- api_key=api_key,
- target_task_info=TaskInfoApi(
- task_prompt=request.task_prompt_with_example,
- task_input_schema=task_input_schema,
- task_output_schema=task_output_schema,
- ),
- sdg_session_config=request.sdg_session_config,
- spec_definition=request.definition,
- )
-
- # 4. Create TaskRuns for eval, train, and golden datasets
- dataset_runs = create_dataset_task_runs(
- all_examples=all_examples,
- reviewed_examples=request.reviewed_examples,
- eval_tag=eval_tag,
- train_tag=train_tag,
- golden_tag=golden_tag,
- spec_name=request.name,
- )
- task_runs = dataset_runs.task_runs
- for run in task_runs:
- run.parent = task
- models_to_save.extend(task_runs)
+ # 3+4. Single-turn: synthesise examples + create TaskRuns.
+ # Multi-turn: skipped — chains already exist on disk.
+ task_runs: list[TaskRun] = []
+ dataset_runs = None
+ sdg_session_config_for_spec: SyntheticDataGenerationSessionConfig | None = None
+ if request.multi_turn is None:
+ assert request.sdg_session_config is not None # validator guarantees
+ api_key = get_copilot_api_key()
+ task_input_schema = (
+ str(task.input_json_schema) if task.input_json_schema else ""
+ )
+ task_output_schema = (
+ str(task.output_json_schema) if task.output_json_schema else ""
+ )
+ all_examples = await generate_copilot_examples(
+ api_key=api_key,
+ target_task_info=TaskInfoApi(
+ task_prompt=request.task_prompt_with_example,
+ task_input_schema=task_input_schema,
+ task_output_schema=task_output_schema,
+ ),
+ sdg_session_config=request.sdg_session_config,
+ spec_definition=request.definition,
+ )
- # 5. Create the Spec using pre-computed definition and properties from client
- topic_generation_config = request.sdg_session_config.topic_generation_config
- input_generation_config = request.sdg_session_config.input_generation_config
- output_generation_config = request.sdg_session_config.output_generation_config
+ dataset_runs = create_dataset_task_runs(
+ all_examples=all_examples,
+ reviewed_examples=request.reviewed_examples,
+ eval_tag=eval_tag,
+ train_tag=train_tag,
+ golden_tag=golden_tag,
+ spec_name=request.name,
+ )
+ task_runs = dataset_runs.task_runs
+ for run in task_runs:
+ run.parent = task
+ models_to_save.extend(task_runs)
+
+ # Snapshot the generation config on the Spec (single-turn only).
+ topic_cfg = request.sdg_session_config.topic_generation_config
+ input_cfg = request.sdg_session_config.input_generation_config
+ output_cfg = request.sdg_session_config.output_generation_config
+ sdg_session_config_for_spec = SyntheticDataGenerationSessionConfig(
+ topic_generation_config=SyntheticDataGenerationStepConfig(
+ model_name=topic_cfg.task_metadata.model_name,
+ provider_name=topic_cfg.task_metadata.model_provider_name,
+ prompt=topic_cfg.prompt,
+ ),
+ input_generation_config=SyntheticDataGenerationStepConfig(
+ model_name=input_cfg.task_metadata.model_name,
+ provider_name=input_cfg.task_metadata.model_provider_name,
+ prompt=input_cfg.prompt,
+ ),
+ output_generation_config=SyntheticDataGenerationStepConfig(
+ model_name=output_cfg.task_metadata.model_name,
+ provider_name=output_cfg.task_metadata.model_provider_name,
+ prompt=output_cfg.prompt,
+ ),
+ )
+ # 5. Create the Spec. Multi-turn leaves sdg_session_config unset —
+ # the operational state lives on the Eval (full_trace + filter_ids).
spec = Spec(
parent=task,
name=request.name,
@@ -398,23 +536,7 @@ async def create_spec_with_copilot(
tags=[],
eval_id=eval.id,
task_sample=request.task_sample,
- synthetic_data_generation_session_config=SyntheticDataGenerationSessionConfig(
- topic_generation_config=SyntheticDataGenerationStepConfig(
- model_name=topic_generation_config.task_metadata.model_name,
- provider_name=topic_generation_config.task_metadata.model_provider_name,
- prompt=topic_generation_config.prompt,
- ),
- input_generation_config=SyntheticDataGenerationStepConfig(
- model_name=input_generation_config.task_metadata.model_name,
- provider_name=input_generation_config.task_metadata.model_provider_name,
- prompt=input_generation_config.prompt,
- ),
- output_generation_config=SyntheticDataGenerationStepConfig(
- model_name=output_generation_config.task_metadata.model_name,
- provider_name=output_generation_config.task_metadata.model_provider_name,
- prompt=output_generation_config.prompt,
- ),
- ),
+ synthetic_data_generation_session_config=sdg_session_config_for_spec,
)
models_to_save.append(spec)
@@ -431,10 +553,19 @@ async def create_spec_with_copilot(
for run in task_runs:
run.save_to_file()
saved_models.append(run)
- dataset_runs.save_pending_feedback(run)
+ if dataset_runs is not None:
+ dataset_runs.save_pending_feedback(run)
spec.save_to_file()
saved_models.append(spec)
+
+ # Multi-turn: tag existing chain leaves with eval/golden filter
+ # tags AFTER spec has saved. Tag-add is idempotent (set semantics);
+ # leaves persisted to disk pick up the new tags on next read.
+ # Failure here would leave the spec without a populated dataset —
+ # surface it so the cleanup path runs.
+ if request.multi_turn is not None:
+ tag_multi_turn_chains_for_eval(multi_turn_leaves, eval_tag, golden_tag)
except Exception:
# Clean up any models that were successfully saved before the error
for model in reversed(saved_models):
diff --git a/app/desktop/studio_server/test_copilot_api.py b/app/desktop/studio_server/test_copilot_api.py
index a7983c6e70..d54ba81894 100644
--- a/app/desktop/studio_server/test_copilot_api.py
+++ b/app/desktop/studio_server/test_copilot_api.py
@@ -14,8 +14,10 @@
from app.desktop.studio_server.utils.copilot_utils import DatasetTaskRuns
from fastapi import FastAPI
from fastapi.testclient import TestClient
-from kiln_ai.datamodel import Project, Task
+from kiln_ai.datamodel import Project, Task, TaskRun
+from kiln_ai.datamodel.eval import EvalDataType
from kiln_ai.datamodel.spec_properties import SpecType
+from kiln_ai.datamodel.task_output import DataSource, DataSourceType, TaskOutput
from kiln_server.custom_errors import connect_custom_errors
@@ -445,3 +447,236 @@ def test_create_spec_with_copilot_success(
specs = task.specs()
assert len(specs) == 1
assert specs[0].eval_id == evals[0].id
+
+
+class TestClassifySpecDescription:
+ """Stub endpoint. Returns 501 until kiln_server ships the real classifier."""
+
+ def test_returns_501(self, client):
+ response = client.post(
+ "/api/copilot/classify_spec_description",
+ json={"description": "A classification request"},
+ )
+
+ assert response.status_code == 501
+
+
+class TestCreateSpecWithCopilotMultiTurn:
+ """Multi-turn save path: tag existing chain leaves instead of synthesising
+ new examples. See specs/projects/eval_builder_v2/design.md for context.
+ """
+
+ BATCH_TAG = "abc123def456"
+
+ @pytest.fixture
+ def project_and_task(self, tmp_path):
+ project_path = tmp_path / "test_project" / "project.kiln"
+ project_path.parent.mkdir()
+ project = Project(name="Test Project", path=project_path)
+ project.save_to_file()
+ task = Task(
+ name="Test Task",
+ instruction="Test instruction",
+ description="Test task",
+ parent=project,
+ )
+ task.save_to_file()
+ return project, task
+
+ @pytest.fixture
+ def synthetic_chain_leaves(self, project_and_task):
+ """Persist three single-run "chains" tagged like the multi-turn runner
+ leaves them. Single TaskRuns (no actual multi-turn parents) are
+ sufficient: the endpoint only cares about the leaf tag.
+ """
+ _, task = project_and_task
+ source = DataSource(
+ type=DataSourceType.synthetic,
+ properties={
+ "model_name": "haiku",
+ "model_provider": "openrouter",
+ "adapter_name": "kiln_synthetic_user_runner",
+ },
+ )
+ leaves = []
+ for i in range(3):
+ run = TaskRun(
+ parent=task,
+ input=f"input {i}",
+ input_source=source,
+ output=TaskOutput(output=f"output {i}", source=source),
+ tags=[
+ "synthetic_user_case",
+ f"synthetic_user_batch:{TestCreateSpecWithCopilotMultiTurn.BATCH_TAG}",
+ ],
+ )
+ run.save_to_file()
+ leaves.append(run)
+ return leaves
+
+ @pytest.fixture
+ def multi_turn_request_data(self):
+ step_config = {
+ "task_metadata": {
+ "model_name": "gpt-4",
+ "model_provider_name": "openai",
+ },
+ "prompt": "Test prompt",
+ }
+ return {
+ "name": "Multi Turn Spec",
+ "definition": "The agent should not fabricate policies",
+ "properties": {
+ "spec_type": SpecType.issue.value,
+ "issue_description": "Don't make stuff up",
+ },
+ "evaluate_full_trace": True,
+ "judge_info": step_config,
+ "multi_turn": {"batch_tag": TestCreateSpecWithCopilotMultiTurn.BATCH_TAG},
+ "task_description": "Test task",
+ "task_prompt_with_example": "Test prompt",
+ }
+
+ def test_multi_turn_save_success_tags_chains_and_creates_eval(
+ self,
+ client,
+ project_and_task,
+ synthetic_chain_leaves,
+ multi_turn_request_data,
+ ):
+ project, task = project_and_task
+
+ with (
+ patch(
+ "app.desktop.studio_server.copilot_api.task_from_id",
+ return_value=task,
+ ),
+ patch(
+ "app.desktop.studio_server.copilot_api.generate_memorable_name",
+ return_value="multi-turn-judge",
+ ),
+ ):
+ response = client.post(
+ f"/api/projects/{project.id}/tasks/{task.id}/spec_with_copilot",
+ json=multi_turn_request_data,
+ )
+
+ assert response.status_code == 200, response.text
+ res = response.json()
+ assert res["name"] == "Multi Turn Spec"
+ assert res["eval_id"] is not None
+ # Multi-turn doesn't snapshot a generation config on the spec —
+ # the operational state lives on the Eval.
+ assert res["synthetic_data_generation_session_config"] is None
+
+ # Eval: full_trace data type + judge config attached.
+ # Note: train_set_filter_id is saved as None on disk but a
+ # backward-compat migration in Eval (libs/core/.../eval.py
+ # migrate_train_set_filter_id) auto-populates it to
+ # tag::train_ on read for legacy evals. The functional
+ # invariant for multi-turn is "no runs tagged with train_*",
+ # checked below — the field value itself is incidental.
+ evals = task.evals()
+ assert len(evals) == 1
+ eval_obj = evals[0]
+ assert eval_obj.evaluation_data_type == EvalDataType.full_trace
+ assert eval_obj.eval_set_filter_id == "tag::eval_multi_turn_spec"
+ assert eval_obj.current_config_id is not None
+
+ # Leaves got the eval + golden tags applied on top of their existing
+ # synthetic_user_* tags. No train tag (multi-turn has no train set).
+ expected_eval_tag = "eval_multi_turn_spec"
+ expected_golden_tag = "eval_golden_multi_turn_spec"
+ train_tag = "train_multi_turn_spec"
+ for leaf in task.runs():
+ assert expected_eval_tag in leaf.tags
+ assert expected_golden_tag in leaf.tags
+ assert train_tag not in leaf.tags
+ assert "synthetic_user_case" in leaf.tags
+
+ def test_multi_turn_save_404_when_batch_tag_matches_nothing(
+ self, client, project_and_task, multi_turn_request_data
+ ):
+ project, task = project_and_task
+
+ with patch(
+ "app.desktop.studio_server.copilot_api.task_from_id",
+ return_value=task,
+ ):
+ response = client.post(
+ f"/api/projects/{project.id}/tasks/{task.id}/spec_with_copilot",
+ json=multi_turn_request_data,
+ )
+
+ assert response.status_code == 404
+ assert "batch_tag" in response.json()["message"]
+ # No models created when the lookup fails up front.
+ assert len(task.evals()) == 0
+ assert len(task.specs()) == 0
+
+ def test_validator_rejects_both_multi_turn_and_sdg_config(
+ self, client, project_and_task, multi_turn_request_data
+ ):
+ project, task = project_and_task
+ step_config = {
+ "task_metadata": {
+ "model_name": "gpt-4",
+ "model_provider_name": "openai",
+ },
+ "prompt": "Test prompt",
+ }
+ multi_turn_request_data["sdg_session_config"] = {
+ "topic_generation_config": step_config,
+ "input_generation_config": step_config,
+ "output_generation_config": step_config,
+ }
+
+ with patch(
+ "app.desktop.studio_server.copilot_api.task_from_id",
+ return_value=task,
+ ):
+ response = client.post(
+ f"/api/projects/{project.id}/tasks/{task.id}/spec_with_copilot",
+ json=multi_turn_request_data,
+ )
+
+ assert response.status_code == 422
+ body = response.json()
+ # Pydantic surfaces the validator message somewhere in the response.
+ assert "multi_turn" in str(body) and "sdg_session_config" in str(body)
+
+ def test_validator_rejects_neither_multi_turn_nor_sdg_config(
+ self, client, project_and_task, multi_turn_request_data
+ ):
+ project, task = project_and_task
+ del multi_turn_request_data["multi_turn"]
+
+ with patch(
+ "app.desktop.studio_server.copilot_api.task_from_id",
+ return_value=task,
+ ):
+ response = client.post(
+ f"/api/projects/{project.id}/tasks/{task.id}/spec_with_copilot",
+ json=multi_turn_request_data,
+ )
+
+ assert response.status_code == 422
+ assert "multi_turn" in str(response.json())
+
+ def test_validator_rejects_multi_turn_without_evaluate_full_trace(
+ self, client, project_and_task, multi_turn_request_data
+ ):
+ project, task = project_and_task
+ multi_turn_request_data["evaluate_full_trace"] = False
+
+ with patch(
+ "app.desktop.studio_server.copilot_api.task_from_id",
+ return_value=task,
+ ):
+ response = client.post(
+ f"/api/projects/{project.id}/tasks/{task.id}/spec_with_copilot",
+ json=multi_turn_request_data,
+ )
+
+ assert response.status_code == 422
+ assert "evaluate_full_trace" in str(response.json())
diff --git a/app/desktop/studio_server/utils/copilot_utils.py b/app/desktop/studio_server/utils/copilot_utils.py
index 5aef604565..fc7f71f31e 100644
--- a/app/desktop/studio_server/utils/copilot_utils.py
+++ b/app/desktop/studio_server/utils/copilot_utils.py
@@ -25,7 +25,7 @@
)
from app.desktop.studio_server.utils.response_utils import unwrap_response
from fastapi import HTTPException
-from kiln_ai.datamodel import Feedback, FeedbackSource, TaskRun
+from kiln_ai.datamodel import Feedback, FeedbackSource, Task, TaskRun
from kiln_ai.datamodel.datamodel_enums import TaskOutputRatingType
from kiln_ai.datamodel.task_output import (
DataSource,
@@ -36,6 +36,12 @@
)
from kiln_ai.utils.config import Config
+# Tag prefix the multi-turn synthetic-user runner stamps on each chain's leaf
+# TaskRun — see kiln_ai.synthetic_user.runner._TAG_PREFIX_SU_BATCH. Kept in
+# sync manually; if the runner ever changes its tag scheme this constant
+# moves too.
+_TAG_PREFIX_SU_BATCH = "synthetic_user_batch:"
+
# Constants for copilot spec creation
KILN_COPILOT_MODEL_NAME = "kiln-copilot"
KILN_COPILOT_MODEL_PROVIDER = "kiln"
@@ -298,3 +304,39 @@ def create_dataset_task_runs(
result.add_run(create_task_run_from_sample(example, train_tag, extra_tags))
return result
+
+
+def find_multi_turn_chain_leaves(task: Task, batch_tag: str) -> list[TaskRun]:
+ """Return the leaf TaskRuns of all chains tagged with the given batch_tag.
+
+ The multi-turn runner tags only the leaf of each chain with
+ "synthetic_user_batch:{batch_tag}". Walking parent_task_run_id from the
+ leaf reconstructs the full conversation if a caller needs it; for eval
+ purposes the leaf alone is enough because its `.trace` field already
+ holds the cumulative OpenAI-format conversation.
+ """
+ target_tag = f"{_TAG_PREFIX_SU_BATCH}{batch_tag}"
+ return [run for run in task.runs() if target_tag in (run.tags or [])]
+
+
+def tag_multi_turn_chains_for_eval(
+ leaves: list[TaskRun],
+ eval_tag: str,
+ golden_tag: str,
+) -> None:
+ """Add eval and golden filter tags to existing chain leaves.
+
+ Tags are deduplicated. We tag every leaf with BOTH eval and golden tags
+ so the chain is part of the eval dataset AND the golden ratings set —
+ multi-turn batches are small (≤10 chains) so there's no meaningful
+ split. No train tag is applied (multi-turn evals don't have a train
+ set in MVP — see specs/projects/eval_builder_v2/design.md).
+
+ Mutates each leaf in place and persists via save_to_file.
+ """
+ for leaf in leaves:
+ tags = set(leaf.tags or [])
+ tags.add(eval_tag)
+ tags.add(golden_tag)
+ leaf.tags = sorted(tags)
+ leaf.save_to_file()
diff --git a/app/web_ui/src/lib/api_schema.d.ts b/app/web_ui/src/lib/api_schema.d.ts
index f05dd26d78..60effa1a29 100644
--- a/app/web_ui/src/lib/api_schema.d.ts
+++ b/app/web_ui/src/lib/api_schema.d.ts
@@ -2644,6 +2644,30 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/copilot/classify_spec_description": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Classify Spec Description
+ * @description Classify a free-text spec description into one of the 9 spec types.
+ *
+ * Not implemented yet — the underlying kiln_server classifier hasn't
+ * shipped. Returns 501 so callers can detect this and fall back to
+ * manual spec-type selection.
+ */
+ post: operations["classify_spec_description_api_copilot_classify_spec_description_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/copilot/clarify_spec": {
parameters: {
query?: never;
@@ -4106,6 +4130,48 @@ export interface components {
judge_result: components["schemas"]["SyntheticDataGenerationStepConfigApi-Output"];
sdg_session_config: components["schemas"]["SyntheticDataGenerationSessionConfigApi-Output"];
};
+ /**
+ * ClassifySpecDescriptionInput
+ * @description Free-text description of an eval the user wants to build. The
+ * endpoint maps it to one of the 9 spec types and pre-fills the
+ * property_values for that type so the v2 builder can skip the
+ * template-carousel step entirely.
+ */
+ ClassifySpecDescriptionInput: {
+ /**
+ * Description
+ * @description Free-text description of what the eval should check.
+ */
+ description: string;
+ /**
+ * Task Prompt
+ * @description Optional task prompt for context (improves classification accuracy when the spec relates to a specific task).
+ */
+ task_prompt?: string | null;
+ };
+ /**
+ * ClassifySpecDescriptionOutput
+ * @description Classified spec type + suggested name + spec_type-specific property
+ * values. The `property_values` dict keys match the FieldConfig keys
+ * defined for the chosen spec_type (see
+ * app/web_ui/.../select_template/spec_templates.ts).
+ */
+ ClassifySpecDescriptionOutput: {
+ /** @description The classified spec type. */
+ spec_type: components["schemas"]["SpecType"];
+ /**
+ * Suggested Name
+ * @description A filename-safe name for the new spec, derived from the description.
+ */
+ suggested_name: string;
+ /**
+ * Property Values
+ * @description Pre-filled property values for the chosen spec_type. Keys correspond to the field_configs of that spec_type.
+ */
+ property_values: {
+ [key: string]: string;
+ };
+ };
/**
* CloneRequest
* @description Request to clone a git repository into a temporary directory.
@@ -4522,17 +4588,22 @@ export interface components {
* CreateSpecWithCopilotRequest
* @description Request model for creating a spec with Kiln Copilot.
*
- * This endpoint uses Kiln Copilot to:
- * - Generate batch examples for eval, train, and golden datasets
- * - Create a judge eval config
- * - Create an eval with appropriate template/output scores
- * - Create and save the spec
+ * Two synthesis paths are supported, exactly one must be set per request:
+ *
+ * - **Single-turn:** caller supplies `sdg_session_config`. Endpoint calls
+ * `generate_copilot_examples` for fresh I/O pairs, splits them into
+ * eval/train/golden datasets, and tags new TaskRuns.
*
- * If you don't want to use copilot, use the regular POST /spec endpoint instead.
+ * - **Multi-turn:** caller supplies `multi_turn` with a `batch_tag` pointing
+ * at chains already on disk (created earlier by the synthetic-user runner).
+ * Endpoint tags the existing chain leaves with eval/golden filter tags;
+ * no new TaskRuns are created. `evaluate_full_trace` must be True.
+ *
+ * If you don't want copilot at all, use POST /spec instead.
*
* The client is responsible for building:
- * - definition: The spec definition string (use buildSpecDefinition on client)
- * - properties: The spec properties object (filtered, with spec_type included)
+ * - definition: the spec definition string (buildSpecDefinition on client)
+ * - properties: the spec properties object (filtered, with spec_type included)
*/
CreateSpecWithCopilotRequest: {
/** Name */
@@ -4555,7 +4626,8 @@ export interface components {
/** Reviewed Examples */
reviewed_examples?: components["schemas"]["ReviewedExample"][];
judge_info: components["schemas"]["SyntheticDataGenerationStepConfigApi-Input"];
- sdg_session_config: components["schemas"]["SyntheticDataGenerationSessionConfigApi-Input"];
+ sdg_session_config?: components["schemas"]["SyntheticDataGenerationSessionConfigApi-Input"] | null;
+ multi_turn?: components["schemas"]["MultiTurnSaveInfo"] | null;
/**
* Task Description
* @default
@@ -7430,6 +7502,19 @@ export interface components {
* @enum {string}
*/
ModelProviderName: "openai" | "groq" | "amazon_bedrock" | "ollama" | "openrouter" | "fireworks_ai" | "kiln_fine_tune" | "kiln_custom_registry" | "openai_compatible" | "anthropic" | "gemini_api" | "azure_openai" | "huggingface" | "vertex" | "together_ai" | "siliconflow_cn" | "cerebras" | "docker_model_runner";
+ /**
+ * MultiTurnSaveInfo
+ * @description Identifies an existing multi-turn synthetic-user batch to turn into an Eval.
+ * The endpoint walks chains tagged with this batch_tag and applies eval/golden
+ * filter tags instead of generating new examples.
+ */
+ MultiTurnSaveInfo: {
+ /**
+ * Batch Tag
+ * @description The batch_tag emitted by the multi-turn synthetic-user runner (see kiln_ai.synthetic_user.runner). Identifies the set of conversation chains already persisted to disk that this Eval should evaluate.
+ */
+ batch_tag: string;
+ };
/**
* NewProposedSpecEditApi
* @description A proposed edit to a spec field.
@@ -9216,6 +9301,12 @@ export interface components {
* @enum {string}
*/
SpecStatus: "active" | "future" | "deprecated" | "archived";
+ /**
+ * SpecType
+ * @description Defines the type of spec.
+ * @enum {string}
+ */
+ SpecType: "desired_behaviour" | "issue" | "tone" | "formatting" | "localization" | "appropriate_tool_use" | "reference_answer_accuracy" | "factual_correctness" | "hallucinations" | "completeness" | "toxicity" | "bias" | "maliciousness" | "nsfw" | "taboo" | "jailbreak" | "prompt_leakage";
/**
* SpecificationInput
* @description The specification to refine.
@@ -16835,6 +16926,39 @@ export interface operations {
};
};
};
+ classify_spec_description_api_copilot_classify_spec_description_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ClassifySpecDescriptionInput"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ClassifySpecDescriptionOutput"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
clarify_spec_api_copilot_clarify_spec_post: {
parameters: {
query?: never;
From 4cb609f83488bb4b9c110a3519eebece6c2858f7 Mon Sep 17 00:00:00 2001
From: aiyakitori <16633065+chiang-daniel@users.noreply.github.com>
Date: Thu, 4 Jun 2026 13:04:01 -0700
Subject: [PATCH 18/36] feat(specs-v2): mock-first eval builder UI (single-turn
+ multi-turn)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the v2 eval builder at /specs_v2/{project_id}/{task_id} — a single-
page wizard that simplifies v1's 9-template carousel + multi-field form
into one description box → Q&A → editable refine → generate → review →
save flow. Sidebar entry under "Evals V2 (Beta)".
Reuses v1 spec_builder components (Questions, RefineSpec, ReviewExamples)
on the shared screens so the look-and-feel stays in sync with v1. The
multi-turn branch at Step 4 + 5 + 6 has its own custom UI for chat-trace
review and ties into the new spec_with_copilot multi-turn save path.
Step 1 calls classify_spec_description (currently 501) with a graceful
fallback to "issue" defaults. Multi-turn generation (Step 4) is a stub —
real run_cases_batch SSE wiring is still pending; surface a clear error
until that lands.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
app/web_ui/src/lib/ui/section.ts | 1 +
app/web_ui/src/routes/(app)/+layout.svelte | 16 +
.../[project_id]/[task_id]/+page.svelte | 863 ++++++++++++++++++
.../specs_v2/[project_id]/[task_id]/+page.ts | 1 +
4 files changed, 881 insertions(+)
create mode 100644 app/web_ui/src/routes/(app)/specs_v2/[project_id]/[task_id]/+page.svelte
create mode 100644 app/web_ui/src/routes/(app)/specs_v2/[project_id]/[task_id]/+page.ts
diff --git a/app/web_ui/src/lib/ui/section.ts b/app/web_ui/src/lib/ui/section.ts
index 0dd7728472..437ec26a2e 100644
--- a/app/web_ui/src/lib/ui/section.ts
+++ b/app/web_ui/src/lib/ui/section.ts
@@ -4,6 +4,7 @@ export enum Section {
Settings,
Prompts,
Specs,
+ SpecsV2,
Generate,
Run,
FineTune,
diff --git a/app/web_ui/src/routes/(app)/+layout.svelte b/app/web_ui/src/routes/(app)/+layout.svelte
index ac367455bb..80971d46e8 100644
--- a/app/web_ui/src/routes/(app)/+layout.svelte
+++ b/app/web_ui/src/routes/(app)/+layout.svelte
@@ -104,6 +104,8 @@
section = Section.Documents
} else if (path_start("/models", $page.url.pathname)) {
section = Section.Models
+ } else if (path_start("/specs_v2", $page.url.pathname)) {
+ section = Section.SpecsV2
} else if (path_start("/specs", $page.url.pathname)) {
section = Section.Specs
} else if (path_start("/optimize", $page.url.pathname)) {
@@ -276,6 +278,20 @@
>
+
+
+