kiln_ai.datamodel
See our docs for details about our datamodel classes and hierarchy:
Developer docs: https://kiln-ai.github.io/Kiln/kiln_core_docs/kiln_ai.html
1""" 2See our docs for details about our datamodel classes and hierarchy: 3 4Developer docs: https://kiln-ai.github.io/Kiln/kiln_core_docs/kiln_ai.html 5 6User docs: https://docs.kiln.tech/developers/kiln-datamodel 7""" 8 9# This component uses "flat" imports so we don't have too much internal structure exposed in the API. 10# for example you can just `from datamodel import Task, Project` instead of `from datamodel.task import Task; from datamodel.project import Project` 11 12from __future__ import annotations 13 14from kiln_ai.datamodel import ( 15 chunk, 16 dataset_split, 17 embedding, 18 eval, 19 extraction, 20 rag, 21 reranker, 22 strict_mode, 23) 24from kiln_ai.datamodel.basemodel import generate_model_id 25from kiln_ai.datamodel.claim_review import ClaimReview, GradedClaim 26from kiln_ai.datamodel.code_tool import CodeTool 27from kiln_ai.datamodel.data_guide import DataGuide 28from kiln_ai.datamodel.datamodel_enums import ( 29 FeedbackSource, 30 FineTuneStatusType, 31 Priority, 32 StructuredOutputMode, 33 TaskOutputRatingType, 34) 35from kiln_ai.datamodel.dataset_split import DatasetSplit, DatasetSplitDefinition 36from kiln_ai.datamodel.external_tool_server import ExternalToolServer 37from kiln_ai.datamodel.feedback import Feedback 38from kiln_ai.datamodel.finetune import Finetune 39from kiln_ai.datamodel.project import Project 40from kiln_ai.datamodel.prompt import BasePrompt, Prompt 41from kiln_ai.datamodel.prompt_id import ( 42 PromptGenerators, 43 PromptId, 44 prompt_generator_values, 45) 46from kiln_ai.datamodel.prompt_optimization_job import PromptOptimizationJob 47from kiln_ai.datamodel.skill import Skill 48from kiln_ai.datamodel.task import Task, TaskRequirement 49from kiln_ai.datamodel.task_output import ( 50 DataSource, 51 DataSourceProperty, 52 DataSourceType, 53 RequirementRating, 54 TaskOutput, 55 TaskOutputRating, 56) 57from kiln_ai.datamodel.task_run import ( 58 EvalItemSource, 59 MessageUsage, 60 TaskRun, 61 Usage, 62) 63 64__all__ = [ 65 "BasePrompt", 66 "ClaimReview", 67 "CodeTool", 68 "DataGuide", 69 "DataSource", 70 "DataSourceProperty", 71 "DataSourceType", 72 "DatasetSplit", 73 "DatasetSplitDefinition", 74 "EvalItemSource", 75 "ExternalToolServer", 76 "Feedback", 77 "FeedbackSource", 78 "FineTuneStatusType", 79 "Finetune", 80 "GradedClaim", 81 "MessageUsage", 82 "Priority", 83 "Project", 84 "Prompt", 85 "PromptGenerators", 86 "PromptId", 87 "PromptOptimizationJob", 88 "RequirementRating", 89 "Skill", 90 "StructuredOutputMode", 91 "Task", 92 "TaskOutput", 93 "TaskOutputRating", 94 "TaskOutputRatingType", 95 "TaskRequirement", 96 "TaskRun", 97 "Usage", 98 "chunk", 99 "dataset_split", 100 "embedding", 101 "eval", 102 "extraction", 103 "generate_model_id", 104 "prompt_generator_values", 105 "rag", 106 "reranker", 107 "strict_mode", 108]
7class BasePrompt(BaseModel): 8 """ 9 A prompt for a task. This is the basic data storage format which can be used throughout a project. 10 11 The "Prompt" model name is reserved for the custom prompts parented by a task. 12 """ 13 14 name: FilenameString = Field(description="The name of the prompt.") 15 description: str | None = Field( 16 default=None, 17 description="A more detailed description of the prompt.", 18 ) 19 generator_id: str | None = Field( 20 default=None, 21 description="The id of the generator that created this prompt.", 22 ) 23 prompt: str = Field( 24 description="The prompt for the task.", 25 min_length=1, 26 ) 27 chain_of_thought_instructions: str | None = Field( 28 default=None, 29 description="Instructions for the model 'thinking' about the requirement prior to answering. Used for chain of thought style prompting. COT will not be used unless this is provided.", 30 )
A prompt for a task. This is the basic data storage format which can be used throughout a project.
The "Prompt" model name is reserved for the custom prompts parented by a task.
28class ClaimReview(KilnParentedModel): 29 """A human's grades on the claim summary of one task run. 30 31 Persisted alongside the run's rating so consumers (e.g. judge-prompt 32 refinement) can use the full review, which claims were agreed or 33 disagreed with and why, not just the final pass/fail. Every claim the 34 reviewer saw is recorded with its grade, and the overview is kept so a 35 stored review reads on its own. 36 """ 37 38 judge_score: Literal["pass", "fail"] = Field( 39 description="The judge's binary verdict on this run." 40 ) 41 judge_reasoning: str = Field(description="The judge's explanation for its verdict.") 42 overview: str = Field( 43 description="The neutral summary of the run the reviewer read before " 44 "grading the claims." 45 ) 46 claims: list[GradedClaim] = Field( 47 description="Every graded claim, in the order the reviewer saw them.", 48 ) 49 human_verdict: Literal["pass", "fail"] = Field( 50 description="The reviewer's overall call on this run. Derived from " 51 "their grade on the verdict claim when the summary carried one, " 52 "otherwise asked directly." 53 )
A human's grades on the claim summary of one task run.
Persisted alongside the run's rating so consumers (e.g. judge-prompt refinement) can use the full review, which claims were agreed or disagreed with and why, not just the final pass/fail. Every claim the reviewer saw is recorded with its grade, and the overview is kept so a stored review reads on its own.
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
39class CodeTool(KilnParentedModel): 40 """A user-authored Python function that runs as a tool inside the agent harness. 41 42 Functional content (code, schema, allowlist, etc.) is immutable post-create 43 — the API enforces this; changing code means cloning into a new tool. 44 """ 45 46 # Editable metadata 47 name: FilenameString = Field(description="User-facing display name.") 48 description: str | None = Field( 49 default=None, 50 description="User-facing notes shown in the UI. Not shown to models.", 51 ) 52 is_archived: bool = Field( 53 default=False, 54 description="Archived tools are hidden from pickers but still resolve if referenced.", 55 ) 56 57 # Functional content — immutable post-create (enforced at the API layer) 58 tool_function_name: str = Field( 59 description="The function name exposed to the model. Snake_case identifier." 60 ) 61 tool_description: str = Field( 62 min_length=1, 63 description="Shown to agents as the tool description.", 64 ) 65 parameters_schema: dict[str, Any] = Field( 66 description="JSON Schema for the tool's parameters. Root must be type: object.", 67 ) 68 code: str = Field( 69 description="Python source, stored in a sibling tool.py file (in memory as a string). Validated for syntax and entry-point presence.", 70 ) 71 timeout_seconds: int = Field( 72 default=60, 73 ge=1, 74 description="Wall-clock timeout for one invocation, including nested tool calls.", 75 ) 76 tool_allowlist: list[ToolId] = Field( 77 default_factory=list, 78 description="Explicit per-tool allowlist of tools this code tool may call.", 79 ) 80 81 @model_validator(mode="before") 82 @classmethod 83 def _read_code_file(cls, data: Any, info: ValidationInfo) -> Any: 84 """When loading from disk, inject `code` from the sibling tool.py. 85 86 The source is stored in tool.py beside code_tool.kiln, not inline in the 87 JSON. On load the base model puts the artifact folder in the validation 88 context (`source_dir`); the shared helper reads the file here, before 89 field validation, so the existing validate_code trio runs against the 90 loaded string unchanged. 91 """ 92 return read_code_from_sibling_file( 93 data, 94 info.context or {}, 95 filename=TOOL_CODE_FILENAME, 96 kiln_filename="code_tool.kiln", 97 model_label="CodeTool", 98 ) 99 100 @model_serializer(mode="wrap") 101 def _serialize( 102 self, handler: SerializerFunctionWrapHandler, info: SerializationInfo 103 ) -> dict[str, Any]: 104 """On disk-save, write `code` to tool.py and omit it from the .kiln JSON. 105 106 Delegates to the shared sibling-file helper, which uses the same save 107 context attachments use (`save_attachments` + `dest_path`). Without that 108 context — normal model_dump / API responses — `code` is left in the 109 output and no file is written, so the API contract is unchanged. 110 111 Trade-off: a custom model_serializer collapses the *serialization-mode* 112 JSON schema to an untyped object (`model_json_schema(mode="serialization")` 113 loses per-field typing). This is acceptable and consistent with the 114 existing KilnAttachmentModel precedent, which uses the same pattern: 115 - Validation-mode schema is unaffected, so request bodies stay fully typed. 116 - No endpoint uses `response_model=CodeTool`; every code-tool endpoint 117 returns a dedicated response model, and the generated web schema never 118 references CodeTool's serialization schema. 119 If a typed serialization schema is ever needed off this model, add a 120 `__get_pydantic_json_schema__` override rather than removing this 121 serializer. (CodeEvalProperties keeps exactly such an override because it 122 IS a FastAPI response_model member.) 123 """ 124 return write_code_to_sibling_file( 125 handler(self), 126 info.context or {}, 127 filename=TOOL_CODE_FILENAME, 128 code=self.code, 129 ) 130 131 @field_validator("tool_function_name") 132 @classmethod 133 def validate_function_name(cls, v: str) -> str: 134 if not _FUNCTION_NAME_RE.fullmatch(v): 135 raise ValueError( 136 f"tool_function_name must match ^[a-z][a-z0-9_]{{0,63}}$, got: '{v}'" 137 ) 138 return v 139 140 @model_validator(mode="after") 141 def validate_parameters_schema(self, info: ValidationInfo) -> Self: 142 validate_schema_dict(self.parameters_schema, require_object=True) 143 144 # Parameters are passed to run() as keyword arguments keyed by these names, 145 # and a Python reserved word can never be declared as a run() parameter, so 146 # reject it at authoring. Loaded files are exempt so old projects still open. 147 # Soft keywords (match/case/type/_) are valid identifiers and stay allowed. 148 if not self.loaded_from_file(info): 149 for param_name in self.parameters_schema.get("properties", {}): 150 if keyword.iskeyword(param_name): 151 raise ValueError( 152 f"Parameter name '{param_name}' is a reserved Python keyword " 153 "and cannot be used as a run() parameter. Choose a different name." 154 ) 155 return self 156 157 @model_validator(mode="after") 158 def validate_code(self) -> Self: 159 code_bytes = self.code.encode("utf-8") 160 if len(code_bytes) > 64 * 1024: 161 raise ValueError( 162 f"Code is too large ({len(code_bytes)} bytes). Maximum size is 64KB." 163 ) 164 165 try: 166 compile(self.code, "<code_tool>", "exec") 167 except SyntaxError as e: 168 raise ValueError(f"Code has a syntax error: {e}") from e 169 170 tree = ast.parse(self.code) 171 has_run_fn = any( 172 isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) 173 and node.name == "run" 174 for node in ast.iter_child_nodes(tree) 175 ) 176 if not has_run_fn: 177 raise ValueError( 178 "Code must define a module-level 'run' function (def run(...) or async def run(...))." 179 ) 180 181 return self 182 183 @model_validator(mode="after") 184 def validate_allowlist(self) -> Self: 185 validate_tool_allowlist( 186 self.tool_allowlist, 187 caller="code tools", 188 self_tool_id=build_code_tool_id(self.id) if self.id is not None else None, 189 ) 190 return self
A user-authored Python function that runs as a tool inside the agent harness.
Functional content (code, schema, allowlist, etc.) is immutable post-create — the API enforces this; changing code means cloning into a new tool.
140 @model_validator(mode="after") 141 def validate_parameters_schema(self, info: ValidationInfo) -> Self: 142 validate_schema_dict(self.parameters_schema, require_object=True) 143 144 # Parameters are passed to run() as keyword arguments keyed by these names, 145 # and a Python reserved word can never be declared as a run() parameter, so 146 # reject it at authoring. Loaded files are exempt so old projects still open. 147 # Soft keywords (match/case/type/_) are valid identifiers and stay allowed. 148 if not self.loaded_from_file(info): 149 for param_name in self.parameters_schema.get("properties", {}): 150 if keyword.iskeyword(param_name): 151 raise ValueError( 152 f"Parameter name '{param_name}' is a reserved Python keyword " 153 "and cannot be used as a run() parameter. Choose a different name." 154 ) 155 return self
157 @model_validator(mode="after") 158 def validate_code(self) -> Self: 159 code_bytes = self.code.encode("utf-8") 160 if len(code_bytes) > 64 * 1024: 161 raise ValueError( 162 f"Code is too large ({len(code_bytes)} bytes). Maximum size is 64KB." 163 ) 164 165 try: 166 compile(self.code, "<code_tool>", "exec") 167 except SyntaxError as e: 168 raise ValueError(f"Code has a syntax error: {e}") from e 169 170 tree = ast.parse(self.code) 171 has_run_fn = any( 172 isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) 173 and node.name == "run" 174 for node in ast.iter_child_nodes(tree) 175 ) 176 if not has_run_fn: 177 raise ValueError( 178 "Code must define a module-level 'run' function (def run(...) or async def run(...))." 179 ) 180 181 return self
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
11class DataGuide(KilnParentedModel): 12 """Persistent input data guide for synthetic data generation, stored as a 13 child of a Task. 14 15 The guide describes what realistic *inputs* to this task look like — input 16 shape, format, distribution, and the kinds of values inputs contain. It is 17 consumed at the topic and input generation stages of synthetic data 18 generation, never at the output stage. Output behavior is the job of the 19 task's system prompt, not this guide. 20 21 The guide is a single markdown body. Two canonical shapes are supported 22 depending on origin: 23 24 - **Manual flow** (user-authored examples): leads with `# Reference Inputs` 25 (user-owned `## Example N` blocks), followed by `# Semantics`, `# Style`, 26 `# Presentation Defaults`. 27 - **Kiln Pro / Copilot flow** (analyze pipeline): only `# Semantics`, 28 `# Style`, `# Presentation Defaults` — the analyze prompt derives rules 29 from input documents rather than quoting them. 30 31 The metaprompter treats the whole body as one editable artifact and returns 32 a refined version on each refine pass; refine auto-detects which shape it 33 is working on by checking for a `# Reference Inputs` heading. 34 """ 35 36 guide: str = Field( 37 default="", 38 description="Markdown body of the input data guide. Manual-flow guides start with `# Reference Inputs`; Kiln Pro / Copilot-flow guides have only `# Semantics`, `# Style`, `# Presentation Defaults`.", 39 ) 40 41 source: DataGuideSource = Field( 42 default="manual", 43 description="Which flow created this guide. Refine + verify pipelines branch on this to choose the right metaprompter (manual = user-curated examples + edits all sections; kiln_pro = LLM-derived, refine is feedback-only surgical edits).", 44 )
Persistent input data guide for synthetic data generation, stored as a child of a Task.
The guide describes what realistic inputs to this task look like — input shape, format, distribution, and the kinds of values inputs contain. It is consumed at the topic and input generation stages of synthetic data generation, never at the output stage. Output behavior is the job of the task's system prompt, not this guide.
The guide is a single markdown body. Two canonical shapes are supported depending on origin:
- Manual flow (user-authored examples): leads with
# Reference Inputs(user-owned## Example Nblocks), followed by# Semantics,# Style,# Presentation Defaults. - Kiln Pro / Copilot flow (analyze pipeline): only
# Semantics,# Style,# Presentation Defaults— the analyze prompt derives rules from input documents rather than quoting them.
The metaprompter treats the whole body as one editable artifact and returns
a refined version on each refine pass; refine auto-detects which shape it
is working on by checking for a # Reference Inputs heading.
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
204class DataSource(BaseModel): 205 """ 206 Represents the origin of data, either human, synthetic, file import, or tool call, with associated properties. 207 208 Properties vary based on the source type - for synthetic/tool_call sources this includes 209 model information, for human sources this includes creator information, for file imports 210 this includes file information. 211 """ 212 213 type: DataSourceType = Field(description="The type of data source.") 214 properties: Dict[str, str | int | float] = Field( 215 default={}, 216 description="Properties describing the data source. For synthetic things like model. For human: the human's name. For file_import: file information.", 217 ) 218 run_config_id: Optional[str] = Field( 219 default=None, 220 description="The ID of the saved TaskRunConfig used to produce this data, if any. Only present when the run was initiated from a saved TaskRunConfig (e.g. via the saved-config dropdown or a tool call); runs configured ad-hoc from the run page leave this unset. Not validated against the file system: a TaskRunConfig may be deleted without invalidating historical runs that reference it.", 221 ) 222 run_config: Optional[RunConfigProperties] = Field( 223 default=None, 224 description="The run config used to generate the data, if generated by a running a model in Kiln (only true for type=synthetic).", 225 ) 226 227 @model_validator(mode="after") 228 def normalize_empty_run_config_id(self) -> Self: 229 # Some callers (e.g. tool wrappers reading from external properties) 230 # default missing IDs to "". Coerce to None so this field is either a 231 # real ID or unset — never an ambiguous empty string on disk. 232 if self.run_config_id == "": 233 self.run_config_id = None 234 return self 235 236 _data_source_properties = [ 237 DataSourceProperty( 238 name="created_by", 239 type=str, 240 required_for=[DataSourceType.human], 241 not_allowed_for=[ 242 DataSourceType.synthetic, 243 DataSourceType.file_import, 244 DataSourceType.tool_call, 245 ], 246 ), 247 DataSourceProperty( 248 name="model_name", 249 type=str, 250 required_for=[DataSourceType.synthetic], 251 not_allowed_for=[ 252 DataSourceType.human, 253 DataSourceType.file_import, 254 DataSourceType.tool_call, 255 ], 256 ), 257 DataSourceProperty( 258 name="model_provider", 259 type=str, 260 required_for=[DataSourceType.synthetic], 261 not_allowed_for=[ 262 DataSourceType.human, 263 DataSourceType.file_import, 264 DataSourceType.tool_call, 265 ], 266 ), 267 DataSourceProperty( 268 name="adapter_name", 269 type=str, 270 required_for=[DataSourceType.synthetic], 271 not_allowed_for=[ 272 DataSourceType.human, 273 DataSourceType.file_import, 274 DataSourceType.tool_call, 275 ], 276 ), 277 DataSourceProperty( 278 # Legacy field -- allow loading from old runs, but we shouldn't be setting it. 279 name="prompt_builder_name", 280 type=str, 281 not_allowed_for=[ 282 DataSourceType.human, 283 DataSourceType.file_import, 284 DataSourceType.tool_call, 285 ], 286 ), 287 DataSourceProperty( 288 # The PromptId of the prompt. Can be a saved prompt, fine-tune, generator name, etc. See PromptId type for more details. 289 name="prompt_id", 290 type=str, 291 not_allowed_for=[ 292 DataSourceType.human, 293 DataSourceType.file_import, 294 DataSourceType.tool_call, 295 ], 296 ), 297 DataSourceProperty( 298 name="file_name", 299 type=str, 300 required_for=[DataSourceType.file_import], 301 not_allowed_for=[ 302 DataSourceType.human, 303 DataSourceType.synthetic, 304 DataSourceType.tool_call, 305 ], 306 ), 307 ] 308 309 @model_validator(mode="after") 310 def validate_type(self) -> "DataSource": 311 if self.type not in DataSourceType: 312 raise ValueError(f"Invalid data source type: {self.type}") 313 return self 314 315 @model_validator(mode="after") 316 def validate_properties(self) -> "DataSource": 317 for prop in self._data_source_properties: 318 # Check the property type is correct 319 if prop.name in self.properties: 320 if not isinstance(self.properties[prop.name], prop.type): 321 raise ValueError( 322 f"'{prop.name}' must be of type {prop.type.__name__} for {self.type} data source" 323 ) 324 # Check the property is required for the data source type 325 if self.type in prop.required_for: 326 if prop.name not in self.properties: 327 raise ValueError( 328 f"'{prop.name}' is required for {self.type} data source" 329 ) 330 # Check the property is not allowed for the data source type 331 elif self.type in prop.not_allowed_for and prop.name in self.properties: 332 raise ValueError( 333 f"'{prop.name}' is not allowed for {self.type} data source" 334 ) 335 return self 336 337 @model_validator(mode="after") 338 def validate_no_empty_properties(self) -> Self: 339 for prop, value in self.properties.items(): 340 if isinstance(value, str) and value == "": 341 raise ValueError( 342 f"Property '{prop}' must be a non-empty string for {self.type} data source" 343 ) 344 return self
Represents the origin of data, either human, synthetic, file import, or tool call, with associated properties.
Properties vary based on the source type - for synthetic/tool_call sources this includes model information, for human sources this includes creator information, for file imports this includes file information.
227 @model_validator(mode="after") 228 def normalize_empty_run_config_id(self) -> Self: 229 # Some callers (e.g. tool wrappers reading from external properties) 230 # default missing IDs to "". Coerce to None so this field is either a 231 # real ID or unset — never an ambiguous empty string on disk. 232 if self.run_config_id == "": 233 self.run_config_id = None 234 return self
315 @model_validator(mode="after") 316 def validate_properties(self) -> "DataSource": 317 for prop in self._data_source_properties: 318 # Check the property type is correct 319 if prop.name in self.properties: 320 if not isinstance(self.properties[prop.name], prop.type): 321 raise ValueError( 322 f"'{prop.name}' must be of type {prop.type.__name__} for {self.type} data source" 323 ) 324 # Check the property is required for the data source type 325 if self.type in prop.required_for: 326 if prop.name not in self.properties: 327 raise ValueError( 328 f"'{prop.name}' is required for {self.type} data source" 329 ) 330 # Check the property is not allowed for the data source type 331 elif self.type in prop.not_allowed_for and prop.name in self.properties: 332 raise ValueError( 333 f"'{prop.name}' is not allowed for {self.type} data source" 334 ) 335 return self
337 @model_validator(mode="after") 338 def validate_no_empty_properties(self) -> Self: 339 for prop, value in self.properties.items(): 340 if isinstance(value, str) and value == "": 341 raise ValueError( 342 f"Property '{prop}' must be a non-empty string for {self.type} data source" 343 ) 344 return self
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
190class DataSourceProperty(BaseModel): 191 """ 192 Defines a property that can be associated with a data source. 193 194 Includes validation rules for when properties are required or not allowed 195 based on the data source type. 196 """ 197 198 name: str 199 type: Type[Union[str, int, float]] 200 required_for: List[DataSourceType] = [] 201 not_allowed_for: List[DataSourceType] = []
Defines a property that can be associated with a data source.
Includes validation rules for when properties are required or not allowed based on the data source type.
176class DataSourceType(str, Enum): 177 """ 178 The source type of a piece of data. 179 180 Human: a human created the data 181 Synthetic: a model created the data 182 """ 183 184 human = "human" 185 synthetic = "synthetic" 186 file_import = "file_import" 187 tool_call = "tool_call"
The source type of a piece of data.
Human: a human created the data Synthetic: a model created the data
83class DatasetSplit(KilnParentedModel): 84 """ 85 A collection of task runs, with optional splits (train, test, validation). 86 87 Used to freeze a dataset into train/test/validation splits for repeatable fine-tuning or other tasks. 88 89 Maintains a list of IDs for each split, to avoid data duplication. 90 """ 91 92 name: FilenameString = Field(description="The name of the dataset split.") 93 description: str | None = Field( 94 default=None, 95 description="A description of the dataset for you and your team. Not used in training.", 96 ) 97 splits: list[DatasetSplitDefinition] = Field( 98 default_factory=list, 99 description="The splits in the dataset.", 100 ) 101 split_contents: dict[str, list[str]] = Field( 102 description="The contents of each split in the dataset. The key is the split name, and the value is a list of task run IDs.", 103 ) 104 filter: DatasetFilterId | None = Field( 105 default=None, 106 description="The filter used to build the dataset.", 107 ) 108 109 @model_validator(mode="after") 110 def validate_split_percentages(self) -> "DatasetSplit": 111 total = sum(split.percentage for split in self.splits) 112 if not math.isclose(total, 1.0, rel_tol=1e-9): 113 raise ValueError(f"The sum of split percentages must be 1.0 (got {total})") 114 return self 115 116 @classmethod 117 def from_task( 118 cls, 119 name: str, 120 task: "Task", 121 splits: list[DatasetSplitDefinition], 122 filter_id: DatasetFilterId = "all", 123 description: str | None = None, 124 ): 125 """ 126 Build a dataset split from a task. 127 """ 128 filter = dataset_filter_from_id(filter_id) 129 split_contents = cls.build_split_contents(task, splits, filter) 130 return cls( 131 parent=task, 132 name=name, 133 description=description, 134 splits=splits, 135 split_contents=split_contents, 136 filter=filter_id, 137 ) 138 139 @classmethod 140 def build_split_contents( 141 cls, 142 task: "Task", 143 splits: list[DatasetSplitDefinition], 144 filter: DatasetFilter, 145 ) -> dict[str, list[str]]: 146 # Datasets must not include intermediate multiturn turns — only leaves. 147 runs = list(task.runs()) 148 valid_ids = [] 149 for task_run in runs: 150 if filter(task_run): 151 valid_ids.append(task_run.id) 152 153 # Shuffle and split by split percentage 154 random.shuffle(valid_ids) 155 split_contents = {} 156 start_idx = 0 157 remaining_items = len(valid_ids) 158 159 # Handle all splits except the last one 160 for split in splits[:-1]: 161 split_size = round(len(valid_ids) * split.percentage) 162 split_contents[split.name] = valid_ids[start_idx : start_idx + split_size] 163 start_idx += split_size 164 remaining_items -= split_size 165 166 # Last split gets all remaining items (for rounding) 167 if splits: 168 split_contents[splits[-1].name] = valid_ids[start_idx:] 169 170 return split_contents 171 172 def parent_task(self) -> "Task | None": 173 # inline import to avoid circular import 174 from kiln_ai.datamodel import Task 175 176 if not isinstance(self.parent, Task): 177 return None 178 return self.parent 179 180 def missing_count(self) -> int: 181 """ 182 Returns: 183 int: the number of task runs that have an ID persisted in this dataset split, but no longer exist in the dataset 184 """ 185 parent = self.parent_task() 186 if parent is None: 187 raise ValueError("DatasetSplit has no parent task") 188 189 # Include intermediate runs: a split snapshots run IDs at creation 190 # time (leaves-only), but the user can later extend a multiturn 191 # conversation - turning a run that was a leaf when the split was 192 # built into an intermediate. The run is still on disk, so it 193 # shouldn't be reported as "missing". 194 runs = parent.runs(include_intermediate_runs=True, readonly=True) 195 all_ids = set(run.id for run in runs) 196 all_ids_in_splits = set() 197 for ids in self.split_contents.values(): 198 all_ids_in_splits.update(ids) 199 missing = all_ids_in_splits - all_ids 200 return len(missing) 201 202 def _get_runs(self) -> list[TaskRun]: 203 """ 204 Get all task runs referenced in this dataset split. 205 206 Returns: 207 list[TaskRun]: list of task runs in this dataset split 208 """ 209 parent = self.parent_task() 210 if parent is None: 211 return [] 212 213 runs = [] 214 all_run_ids = set() 215 for run_ids in self.split_contents.values(): 216 all_run_ids.update(run_ids) 217 218 # Include intermediate runs: a split snapshots run IDs at creation 219 # time (leaves-only), but the user can later extend a multiturn 220 # conversation - turning a run that was a leaf when the split was 221 # built into an intermediate. We still want to resolve it. 222 for task_run in parent.runs(include_intermediate_runs=True, readonly=True): 223 if task_run.id in all_run_ids: 224 runs.append(task_run) 225 226 return runs 227 228 @staticmethod 229 def compute_tool_info(runs: list[TaskRun]) -> DatasetToolInfo: 230 """ 231 Compute tool info from a list of task runs. 232 233 Args: 234 runs: list of task runs to analyze 235 236 Returns: 237 DatasetToolInfo: information about tools used across the task runs 238 """ 239 240 has_tool_mismatch = False 241 tools: set[str] | None = None 242 243 for run in runs: 244 # Extract tools from run config, treating missing source/run_config/tools_config as empty tools 245 run_tools: set[str] = set() 246 source = run.output.source if run.output else None 247 if source is not None and isinstance( 248 source.run_config, KilnAgentRunConfigProperties 249 ): 250 tools_config = source.run_config.tools_config 251 if tools_config is not None: 252 run_tools = set(tools_config.tools) 253 254 # First run establishes the expected tool set (including empty) 255 if tools is None: 256 tools = run_tools 257 elif run_tools != tools: 258 # Mismatch found 259 has_tool_mismatch = True 260 tools = None 261 break 262 263 # If no valid runs were processed, return empty tools 264 if tools is None: 265 if not has_tool_mismatch: 266 tools = set() 267 268 return DatasetToolInfo( 269 has_tool_mismatch=has_tool_mismatch, 270 tools=None if tools is None else sorted(tools), 271 ) 272 273 def tool_info(self) -> DatasetToolInfo: 274 """ 275 Helper method to compute tool info for the dataset split. Iterate through all runs in the dataset split and check the tools used in each run config. 276 277 Returns: 278 DatasetToolInfo: information about tools used across task runs in this dataset split 279 """ 280 runs = self._get_runs() 281 tool_info = self.compute_tool_info(runs) 282 return tool_info
A collection of task runs, with optional splits (train, test, validation).
Used to freeze a dataset into train/test/validation splits for repeatable fine-tuning or other tasks.
Maintains a list of IDs for each split, to avoid data duplication.
109 @model_validator(mode="after") 110 def validate_split_percentages(self) -> "DatasetSplit": 111 total = sum(split.percentage for split in self.splits) 112 if not math.isclose(total, 1.0, rel_tol=1e-9): 113 raise ValueError(f"The sum of split percentages must be 1.0 (got {total})") 114 return self
116 @classmethod 117 def from_task( 118 cls, 119 name: str, 120 task: "Task", 121 splits: list[DatasetSplitDefinition], 122 filter_id: DatasetFilterId = "all", 123 description: str | None = None, 124 ): 125 """ 126 Build a dataset split from a task. 127 """ 128 filter = dataset_filter_from_id(filter_id) 129 split_contents = cls.build_split_contents(task, splits, filter) 130 return cls( 131 parent=task, 132 name=name, 133 description=description, 134 splits=splits, 135 split_contents=split_contents, 136 filter=filter_id, 137 )
Build a dataset split from a task.
139 @classmethod 140 def build_split_contents( 141 cls, 142 task: "Task", 143 splits: list[DatasetSplitDefinition], 144 filter: DatasetFilter, 145 ) -> dict[str, list[str]]: 146 # Datasets must not include intermediate multiturn turns — only leaves. 147 runs = list(task.runs()) 148 valid_ids = [] 149 for task_run in runs: 150 if filter(task_run): 151 valid_ids.append(task_run.id) 152 153 # Shuffle and split by split percentage 154 random.shuffle(valid_ids) 155 split_contents = {} 156 start_idx = 0 157 remaining_items = len(valid_ids) 158 159 # Handle all splits except the last one 160 for split in splits[:-1]: 161 split_size = round(len(valid_ids) * split.percentage) 162 split_contents[split.name] = valid_ids[start_idx : start_idx + split_size] 163 start_idx += split_size 164 remaining_items -= split_size 165 166 # Last split gets all remaining items (for rounding) 167 if splits: 168 split_contents[splits[-1].name] = valid_ids[start_idx:] 169 170 return split_contents
180 def missing_count(self) -> int: 181 """ 182 Returns: 183 int: the number of task runs that have an ID persisted in this dataset split, but no longer exist in the dataset 184 """ 185 parent = self.parent_task() 186 if parent is None: 187 raise ValueError("DatasetSplit has no parent task") 188 189 # Include intermediate runs: a split snapshots run IDs at creation 190 # time (leaves-only), but the user can later extend a multiturn 191 # conversation - turning a run that was a leaf when the split was 192 # built into an intermediate. The run is still on disk, so it 193 # shouldn't be reported as "missing". 194 runs = parent.runs(include_intermediate_runs=True, readonly=True) 195 all_ids = set(run.id for run in runs) 196 all_ids_in_splits = set() 197 for ids in self.split_contents.values(): 198 all_ids_in_splits.update(ids) 199 missing = all_ids_in_splits - all_ids 200 return len(missing)
Returns: int: the number of task runs that have an ID persisted in this dataset split, but no longer exist in the dataset
228 @staticmethod 229 def compute_tool_info(runs: list[TaskRun]) -> DatasetToolInfo: 230 """ 231 Compute tool info from a list of task runs. 232 233 Args: 234 runs: list of task runs to analyze 235 236 Returns: 237 DatasetToolInfo: information about tools used across the task runs 238 """ 239 240 has_tool_mismatch = False 241 tools: set[str] | None = None 242 243 for run in runs: 244 # Extract tools from run config, treating missing source/run_config/tools_config as empty tools 245 run_tools: set[str] = set() 246 source = run.output.source if run.output else None 247 if source is not None and isinstance( 248 source.run_config, KilnAgentRunConfigProperties 249 ): 250 tools_config = source.run_config.tools_config 251 if tools_config is not None: 252 run_tools = set(tools_config.tools) 253 254 # First run establishes the expected tool set (including empty) 255 if tools is None: 256 tools = run_tools 257 elif run_tools != tools: 258 # Mismatch found 259 has_tool_mismatch = True 260 tools = None 261 break 262 263 # If no valid runs were processed, return empty tools 264 if tools is None: 265 if not has_tool_mismatch: 266 tools = set() 267 268 return DatasetToolInfo( 269 has_tool_mismatch=has_tool_mismatch, 270 tools=None if tools is None else sorted(tools), 271 )
Compute tool info from a list of task runs.
Args: runs: list of task runs to analyze
Returns: DatasetToolInfo: information about tools used across the task runs
273 def tool_info(self) -> DatasetToolInfo: 274 """ 275 Helper method to compute tool info for the dataset split. Iterate through all runs in the dataset split and check the tools used in each run config. 276 277 Returns: 278 DatasetToolInfo: information about tools used across task runs in this dataset split 279 """ 280 runs = self._get_runs() 281 tool_info = self.compute_tool_info(runs) 282 return tool_info
Helper method to compute tool info for the dataset split. Iterate through all runs in the dataset split and check the tools used in each run config.
Returns: DatasetToolInfo: information about tools used across task runs in this dataset split
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
39class DatasetSplitDefinition(BaseModel): 40 """ 41 A definition of a split in a dataset. 42 43 Example: name="train", description="The training set", percentage=0.8 (80% of the dataset) 44 """ 45 46 name: FilenameString = Field( 47 description="The name of the dataset split definition." 48 ) 49 description: str | None = Field( 50 default=None, 51 description="A description of the dataset for you and your team. Not used in training.", 52 ) 53 percentage: float = Field( 54 ge=0.0, 55 le=1.0, 56 description="The percentage of the dataset that this split represents (between 0 and 1).", 57 )
A definition of a split in a dataset.
Example: name="train", description="The training set", percentage=0.8 (80% of the dataset)
31class EvalItemSource(BaseModel): 32 """The eval dataset item a TaskRun was generated for. 33 34 Not the run config — that lives on the same TaskRun at 35 `output.source.run_config_id`. 36 """ 37 38 source_type: Literal["eval_input", "task_run"] = Field( 39 description="Which store the dataset item came from: an EvalInput (V2) or a TaskRun (V1-backed split)." 40 ) 41 # `str`, not the usual `ID_TYPE` (`Optional[str]`): an id-less source is a trace-index 42 # key that collides with every other id-less source. Absence is already expressed by 43 # `TaskRun.eval_source` itself being None, so the inner id has no legitimate None 44 # state. `ItemKey`'s own nullability is inherited from `KilnBaseModel.id` rather than 45 # chosen, and a narrower id still satisfies it. 46 source_id: str = Field( 47 min_length=1, 48 description="The id of the dataset item this run was generated for. Interpreted within the store named by source_type — ids are only unique within a store.", 49 )
The eval dataset item a TaskRun was generated for.
Not the run config — that lives on the same TaskRun at
output.source.run_config_id.
64class ExternalToolServer(KilnParentedModel): 65 """ 66 Configuration for communicating with a external MCP (Model Context Protocol) Server for LLM tool calls. External tool servers can be remote or local. 67 68 This model stores the necessary configuration to connect to and authenticate with 69 external MCP servers that provide tools for LLM interactions. 70 """ 71 72 name: FilenameString = Field(description="The name of the external tool.") 73 type: ToolServerType = Field( 74 description="The type of external tool server. Remote tools are hosted on a remote server", 75 ) 76 description: str | None = Field( 77 default=None, 78 description="A description of the external tool for you and your team. Will not be used in prompts/training/validation.", 79 ) 80 81 properties: ( 82 LocalServerProperties | RemoteServerProperties | KilnTaskServerProperties 83 ) = Field( 84 description="Configuration properties specific to the tool type.", 85 ) 86 87 # Private variable to store unsaved secrets 88 _unsaved_secrets: dict[str, str] = PrivateAttr(default_factory=dict) 89 90 def model_post_init(self, __context: Any) -> None: 91 # Process secrets after initialization (pydantic v2 hook) 92 self._process_secrets_from_properties() 93 94 def _process_secrets_from_properties(self) -> None: 95 """ 96 Extract secrets from properties and move them to _unsaved_secrets. 97 This removes secrets from the properties dict so they aren't saved to file. 98 Clears existing _unsaved_secrets first to handle property updates correctly. 99 """ 100 # Clear existing unsaved secrets since we're reprocessing 101 self._unsaved_secrets.clear() 102 103 secret_keys = self.get_secret_keys() 104 105 if not secret_keys: 106 return 107 108 # Extract secret values from properties based on server type 109 match self.type: 110 case ToolServerType.remote_mcp: 111 headers = self.properties.get("headers", {}) 112 for key_name in secret_keys: 113 if key_name in headers: 114 self._unsaved_secrets[key_name] = headers[key_name] 115 # Remove from headers immediately so they are not saved to file 116 del headers[key_name] 117 118 case ToolServerType.local_mcp: 119 env_vars = self.properties.get("env_vars", {}) 120 for key_name in secret_keys: 121 if key_name in env_vars: 122 self._unsaved_secrets[key_name] = env_vars[key_name] 123 # Remove from env_vars immediately so they are not saved to file 124 del env_vars[key_name] 125 126 case ToolServerType.kiln_task: 127 pass 128 129 case _: 130 raise_exhaustive_enum_error(self.type) 131 132 def __setattr__(self, name: str, value: Any) -> None: 133 """ 134 Override __setattr__ to process secrets whenever properties are updated. 135 """ 136 super().__setattr__(name, value) 137 138 # Process secrets whenever properties are updated 139 if name == "properties": 140 self._process_secrets_from_properties() 141 142 # Validation Helpers 143 144 @classmethod 145 def check_server_url(cls, server_url: str) -> None: 146 """Validate Server URL""" 147 if not isinstance(server_url, str): 148 raise ValueError("Server URL must be a string") 149 150 # Check for leading whitespace in URL 151 if server_url != server_url.lstrip(): 152 raise ValueError("Server URL must not have leading whitespace") 153 154 parsed_url = urlparse(server_url) 155 if not parsed_url.netloc: 156 raise ValueError("Server URL is not a valid URL") 157 if parsed_url.scheme not in ["http", "https"]: 158 raise ValueError("Server URL must start with http:// or https://") 159 160 @classmethod 161 def check_headers(cls, headers: dict) -> None: 162 """Validate Headers""" 163 if not isinstance(headers, dict): 164 raise ValueError("headers must be a dictionary") 165 166 for key, value in headers.items(): 167 if not key: 168 raise ValueError("Header name is required") 169 if not value: 170 raise ValueError("Header value is required") 171 172 # Reject invalid header names and CR/LF in names/values 173 token_re = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") 174 if not token_re.match(key): 175 raise ValueError(f'Invalid header name: "{key}"') 176 if re.search(r"\r|\n", key) or re.search(r"\r|\n", value): 177 raise ValueError( 178 "Header names/values must not contain invalid characters" 179 ) 180 181 @classmethod 182 def check_secret_keys( 183 cls, secret_keys: list, key_type: str, tool_type: str 184 ) -> None: 185 """Validate Secret Keys (generic method for both header and env var keys)""" 186 if not isinstance(secret_keys, list): 187 raise ValueError( 188 f"{key_type} must be a list for external tools of type '{tool_type}'" 189 ) 190 if not all(isinstance(k, str) for k in secret_keys): 191 raise ValueError(f"{key_type} must contain only strings") 192 if not all(key for key in secret_keys): 193 raise ValueError("Secret key is required") 194 195 @classmethod 196 def check_env_vars(cls, env_vars: dict) -> None: 197 """Validate Environment Variables""" 198 if not isinstance(env_vars, dict): 199 raise ValueError("environment variables must be a dictionary") 200 201 # Validate env_vars keys are in the correct format for Environment Variables 202 # According to POSIX specification, environment variable names must: 203 # - Start with a letter (a-z, A-Z) or underscore (_) 204 # - Contain only ASCII letters, digits, and underscores 205 for key, _ in env_vars.items(): 206 if not key or not ( 207 key[0].isascii() and (key[0].isalpha() or key[0] == "_") 208 ): 209 raise ValueError( 210 f"Invalid environment variable key: {key}. Must start with a letter or underscore." 211 ) 212 213 if not all(c.isascii() and (c.isalnum() or c == "_") for c in key): 214 raise ValueError( 215 f"Invalid environment variable key: {key}. Can only contain letters, digits, and underscores." 216 ) 217 218 @classmethod 219 def type_from_data(cls, data: dict) -> ToolServerType: 220 """Get the tool server type from the data for the the validators""" 221 raw_type = data.get("type") 222 if raw_type is None: 223 raise ValueError("type is required") 224 try: 225 return ToolServerType(raw_type) 226 except ValueError: 227 valid_types = ", ".join(type.value for type in ToolServerType) 228 raise ValueError(f"type must be one of: {valid_types}") 229 230 @model_validator(mode="before") 231 def upgrade_old_properties(cls, data: dict) -> dict: 232 """ 233 Upgrade properties for backwards compatibility. 234 """ 235 properties = data.get("properties") 236 if properties is not None and "is_archived" not in properties: 237 # Add is_archived field with default value back to data 238 properties["is_archived"] = False 239 data["properties"] = properties 240 return data 241 242 @model_validator(mode="before") 243 def validate_required_fields(cls, data: dict) -> dict: 244 """Validate that each tool type has the required configuration.""" 245 server_type = ExternalToolServer.type_from_data(data) 246 properties = data.get("properties", {}) 247 248 match server_type: 249 case ToolServerType.remote_mcp: 250 server_url = properties.get("server_url", None) 251 if server_url is None: 252 raise ValueError( 253 "Server URL is required to connect to a remote MCP server" 254 ) 255 ExternalToolServer.check_server_url(server_url) 256 257 case ToolServerType.local_mcp: 258 command = properties.get("command", None) 259 if command is None: 260 raise ValueError("command is required to start a local MCP server") 261 if not isinstance(command, str): 262 raise ValueError( 263 "command must be a string to start a local MCP server" 264 ) 265 # Reject empty/whitespace-only command strings 266 if command.strip() == "": 267 raise ValueError("command must be a non-empty string") 268 269 args = properties.get("args", None) 270 if args is not None: 271 if not isinstance(args, list): 272 raise ValueError( 273 "arguments must be a list to start a local MCP server" 274 ) 275 276 case ToolServerType.kiln_task: 277 tool_name_validator(properties.get("name", "")) 278 err_msg_prefix = "Kiln task server properties:" 279 validate_return_dict_prop( 280 properties, "description", str, err_msg_prefix 281 ) 282 description = properties.get("description", "") 283 if len(description) > 128: 284 raise ValueError("description must be 128 characters or less") 285 validate_return_dict_prop( 286 properties, "is_archived", bool, err_msg_prefix 287 ) 288 validate_return_dict_prop(properties, "task_id", str, err_msg_prefix) 289 validate_return_dict_prop( 290 properties, "run_config_id", str, err_msg_prefix 291 ) 292 293 case _: 294 # Type checking will catch missing cases 295 raise_exhaustive_enum_error(server_type) 296 return data 297 298 @model_validator(mode="before") 299 def validate_headers_and_env_vars(cls, data: dict) -> dict: 300 """ 301 Validate secrets, these needs to be validated before model initlization because secrets will be processed and stripped 302 """ 303 type = ExternalToolServer.type_from_data(data) 304 305 properties = data.get("properties", {}) 306 if properties is None: 307 raise ValueError("properties is required") 308 309 match type: 310 case ToolServerType.remote_mcp: 311 # Validate headers 312 headers = properties.get("headers", None) 313 if headers is not None: 314 ExternalToolServer.check_headers(headers) 315 316 # Secret header keys are optional, validate if they are set 317 secret_header_keys = properties.get("secret_header_keys", None) 318 if secret_header_keys is not None: 319 ExternalToolServer.check_secret_keys( 320 secret_header_keys, "secret_header_keys", "remote_mcp" 321 ) 322 323 case ToolServerType.local_mcp: 324 # Validate secret environment variable keys 325 env_vars = properties.get("env_vars", {}) 326 if env_vars is not None: 327 ExternalToolServer.check_env_vars(env_vars) 328 329 # Secret env var keys are optional, but if they are set, they must be a list of strings 330 secret_env_var_keys = properties.get("secret_env_var_keys", None) 331 if secret_env_var_keys is not None: 332 ExternalToolServer.check_secret_keys( 333 secret_env_var_keys, "secret_env_var_keys", "local_mcp" 334 ) 335 336 case ToolServerType.kiln_task: 337 pass 338 339 case _: 340 raise_exhaustive_enum_error(type) 341 342 return data 343 344 def get_secret_keys(self) -> list[str]: 345 """ 346 Get the list of secret key names based on server type. 347 348 Returns: 349 List of secret key names (header names for remote, env var names for local) 350 """ 351 match self.type: 352 case ToolServerType.remote_mcp: 353 return self.properties.get("secret_header_keys", []) 354 case ToolServerType.local_mcp: 355 return self.properties.get("secret_env_var_keys", []) 356 case ToolServerType.kiln_task: 357 return [] 358 case _: 359 raise_exhaustive_enum_error(self.type) 360 361 def retrieve_secrets(self) -> tuple[dict[str, str], list[str]]: 362 """ 363 Retrieve secrets from configuration system or in-memory storage. 364 Automatically determines which secret keys to retrieve based on the server type. 365 Config secrets take precedence over unsaved secrets. 366 367 Returns: 368 Tuple of (secrets_dict, missing_secrets_list) where: 369 - secrets_dict: Dictionary mapping key names to their secret values 370 - missing_secrets_list: List of secret key names that are missing values 371 """ 372 secrets = {} 373 missing_secrets = [] 374 secret_keys = self.get_secret_keys() 375 376 if secret_keys and len(secret_keys) > 0: 377 config = Config.shared() 378 mcp_secrets = config.get_value(MCP_SECRETS_KEY) 379 380 for key_name in secret_keys: 381 secret_value = None 382 383 # First check config secrets (persistent storage), key is mcp_server_id::key_name 384 secret_key = self._config_secret_key(key_name) 385 secret_value = mcp_secrets.get(secret_key) if mcp_secrets else None 386 387 # Fall back to unsaved secrets (in-memory storage) 388 if ( 389 not secret_value 390 and hasattr(self, "_unsaved_secrets") 391 and key_name in self._unsaved_secrets 392 ): 393 secret_value = self._unsaved_secrets[key_name] 394 395 if secret_value: 396 secrets[key_name] = secret_value 397 else: 398 missing_secrets.append(key_name) 399 400 return secrets, missing_secrets 401 402 def _save_secrets(self) -> None: 403 """ 404 Save unsaved secrets to the configuration system. 405 """ 406 secret_keys = self.get_secret_keys() 407 408 # No secrets to save 409 if not secret_keys: 410 return 411 412 if self.id is None: 413 raise ValueError("Server ID cannot be None when saving secrets") 414 415 # Check if secrets are already saved 416 if not hasattr(self, "_unsaved_secrets") or not self._unsaved_secrets: 417 return 418 419 config = Config.shared() 420 mcp_secrets: dict[str, str] = config.get_value(MCP_SECRETS_KEY) or {} 421 422 # Store secrets with the pattern: mcp_server_id::key_name 423 for key_name, secret_value in self._unsaved_secrets.items(): 424 secret_key = self._config_secret_key(key_name) 425 mcp_secrets[secret_key] = secret_value 426 427 config.update_settings({MCP_SECRETS_KEY: mcp_secrets}) 428 429 # Clear unsaved secrets after saving 430 self._unsaved_secrets.clear() 431 432 def delete_secrets(self) -> None: 433 """ 434 Delete all secrets for this tool server from the configuration system. 435 """ 436 secret_keys = self.get_secret_keys() 437 438 config = Config.shared() 439 mcp_secrets = config.get_value(MCP_SECRETS_KEY) or dict[str, str]() 440 441 # Remove secrets with the pattern: mcp_server_id::key_name 442 for key_name in secret_keys: 443 secret_key = self._config_secret_key(key_name) 444 if secret_key in mcp_secrets: 445 del mcp_secrets[secret_key] 446 447 # Always call update_settings to maintain consistency with the old behavior 448 config.update_settings({MCP_SECRETS_KEY: mcp_secrets}) 449 450 def save_to_file(self) -> None: 451 """ 452 Override save_to_file to automatically save any unsaved secrets before saving to file. 453 454 This ensures that secrets are always saved when the object is saved, 455 preventing the issue where secrets could be lost if save_to_file is called 456 without explicitly saving secrets first. 457 """ 458 # Save any unsaved secrets first 459 if hasattr(self, "_unsaved_secrets") and self._unsaved_secrets: 460 self._save_secrets() 461 462 # Call the parent save_to_file method 463 super().save_to_file() 464 465 # Internal helpers 466 467 def _config_secret_key(self, key_name: str) -> str: 468 """ 469 Generate the secret key pattern for storing/retrieving secrets. 470 471 Args: 472 key_name: The name of the secret key 473 474 Returns: 475 The formatted secret key: "{server_id}::{key_name}" 476 """ 477 return f"{self.id}::{key_name}"
Configuration for communicating with a external MCP (Model Context Protocol) Server for LLM tool calls. External tool servers can be remote or local.
This model stores the necessary configuration to connect to and authenticate with external MCP servers that provide tools for LLM interactions.
90 def model_post_init(self, __context: Any) -> None: 91 # Process secrets after initialization (pydantic v2 hook) 92 self._process_secrets_from_properties()
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
144 @classmethod 145 def check_server_url(cls, server_url: str) -> None: 146 """Validate Server URL""" 147 if not isinstance(server_url, str): 148 raise ValueError("Server URL must be a string") 149 150 # Check for leading whitespace in URL 151 if server_url != server_url.lstrip(): 152 raise ValueError("Server URL must not have leading whitespace") 153 154 parsed_url = urlparse(server_url) 155 if not parsed_url.netloc: 156 raise ValueError("Server URL is not a valid URL") 157 if parsed_url.scheme not in ["http", "https"]: 158 raise ValueError("Server URL must start with http:// or https://")
Validate Server URL
160 @classmethod 161 def check_headers(cls, headers: dict) -> None: 162 """Validate Headers""" 163 if not isinstance(headers, dict): 164 raise ValueError("headers must be a dictionary") 165 166 for key, value in headers.items(): 167 if not key: 168 raise ValueError("Header name is required") 169 if not value: 170 raise ValueError("Header value is required") 171 172 # Reject invalid header names and CR/LF in names/values 173 token_re = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") 174 if not token_re.match(key): 175 raise ValueError(f'Invalid header name: "{key}"') 176 if re.search(r"\r|\n", key) or re.search(r"\r|\n", value): 177 raise ValueError( 178 "Header names/values must not contain invalid characters" 179 )
Validate Headers
181 @classmethod 182 def check_secret_keys( 183 cls, secret_keys: list, key_type: str, tool_type: str 184 ) -> None: 185 """Validate Secret Keys (generic method for both header and env var keys)""" 186 if not isinstance(secret_keys, list): 187 raise ValueError( 188 f"{key_type} must be a list for external tools of type '{tool_type}'" 189 ) 190 if not all(isinstance(k, str) for k in secret_keys): 191 raise ValueError(f"{key_type} must contain only strings") 192 if not all(key for key in secret_keys): 193 raise ValueError("Secret key is required")
Validate Secret Keys (generic method for both header and env var keys)
195 @classmethod 196 def check_env_vars(cls, env_vars: dict) -> None: 197 """Validate Environment Variables""" 198 if not isinstance(env_vars, dict): 199 raise ValueError("environment variables must be a dictionary") 200 201 # Validate env_vars keys are in the correct format for Environment Variables 202 # According to POSIX specification, environment variable names must: 203 # - Start with a letter (a-z, A-Z) or underscore (_) 204 # - Contain only ASCII letters, digits, and underscores 205 for key, _ in env_vars.items(): 206 if not key or not ( 207 key[0].isascii() and (key[0].isalpha() or key[0] == "_") 208 ): 209 raise ValueError( 210 f"Invalid environment variable key: {key}. Must start with a letter or underscore." 211 ) 212 213 if not all(c.isascii() and (c.isalnum() or c == "_") for c in key): 214 raise ValueError( 215 f"Invalid environment variable key: {key}. Can only contain letters, digits, and underscores." 216 )
Validate Environment Variables
218 @classmethod 219 def type_from_data(cls, data: dict) -> ToolServerType: 220 """Get the tool server type from the data for the the validators""" 221 raw_type = data.get("type") 222 if raw_type is None: 223 raise ValueError("type is required") 224 try: 225 return ToolServerType(raw_type) 226 except ValueError: 227 valid_types = ", ".join(type.value for type in ToolServerType) 228 raise ValueError(f"type must be one of: {valid_types}")
Get the tool server type from the data for the the validators
230 @model_validator(mode="before") 231 def upgrade_old_properties(cls, data: dict) -> dict: 232 """ 233 Upgrade properties for backwards compatibility. 234 """ 235 properties = data.get("properties") 236 if properties is not None and "is_archived" not in properties: 237 # Add is_archived field with default value back to data 238 properties["is_archived"] = False 239 data["properties"] = properties 240 return data
Upgrade properties for backwards compatibility.
242 @model_validator(mode="before") 243 def validate_required_fields(cls, data: dict) -> dict: 244 """Validate that each tool type has the required configuration.""" 245 server_type = ExternalToolServer.type_from_data(data) 246 properties = data.get("properties", {}) 247 248 match server_type: 249 case ToolServerType.remote_mcp: 250 server_url = properties.get("server_url", None) 251 if server_url is None: 252 raise ValueError( 253 "Server URL is required to connect to a remote MCP server" 254 ) 255 ExternalToolServer.check_server_url(server_url) 256 257 case ToolServerType.local_mcp: 258 command = properties.get("command", None) 259 if command is None: 260 raise ValueError("command is required to start a local MCP server") 261 if not isinstance(command, str): 262 raise ValueError( 263 "command must be a string to start a local MCP server" 264 ) 265 # Reject empty/whitespace-only command strings 266 if command.strip() == "": 267 raise ValueError("command must be a non-empty string") 268 269 args = properties.get("args", None) 270 if args is not None: 271 if not isinstance(args, list): 272 raise ValueError( 273 "arguments must be a list to start a local MCP server" 274 ) 275 276 case ToolServerType.kiln_task: 277 tool_name_validator(properties.get("name", "")) 278 err_msg_prefix = "Kiln task server properties:" 279 validate_return_dict_prop( 280 properties, "description", str, err_msg_prefix 281 ) 282 description = properties.get("description", "") 283 if len(description) > 128: 284 raise ValueError("description must be 128 characters or less") 285 validate_return_dict_prop( 286 properties, "is_archived", bool, err_msg_prefix 287 ) 288 validate_return_dict_prop(properties, "task_id", str, err_msg_prefix) 289 validate_return_dict_prop( 290 properties, "run_config_id", str, err_msg_prefix 291 ) 292 293 case _: 294 # Type checking will catch missing cases 295 raise_exhaustive_enum_error(server_type) 296 return data
Validate that each tool type has the required configuration.
298 @model_validator(mode="before") 299 def validate_headers_and_env_vars(cls, data: dict) -> dict: 300 """ 301 Validate secrets, these needs to be validated before model initlization because secrets will be processed and stripped 302 """ 303 type = ExternalToolServer.type_from_data(data) 304 305 properties = data.get("properties", {}) 306 if properties is None: 307 raise ValueError("properties is required") 308 309 match type: 310 case ToolServerType.remote_mcp: 311 # Validate headers 312 headers = properties.get("headers", None) 313 if headers is not None: 314 ExternalToolServer.check_headers(headers) 315 316 # Secret header keys are optional, validate if they are set 317 secret_header_keys = properties.get("secret_header_keys", None) 318 if secret_header_keys is not None: 319 ExternalToolServer.check_secret_keys( 320 secret_header_keys, "secret_header_keys", "remote_mcp" 321 ) 322 323 case ToolServerType.local_mcp: 324 # Validate secret environment variable keys 325 env_vars = properties.get("env_vars", {}) 326 if env_vars is not None: 327 ExternalToolServer.check_env_vars(env_vars) 328 329 # Secret env var keys are optional, but if they are set, they must be a list of strings 330 secret_env_var_keys = properties.get("secret_env_var_keys", None) 331 if secret_env_var_keys is not None: 332 ExternalToolServer.check_secret_keys( 333 secret_env_var_keys, "secret_env_var_keys", "local_mcp" 334 ) 335 336 case ToolServerType.kiln_task: 337 pass 338 339 case _: 340 raise_exhaustive_enum_error(type) 341 342 return data
Validate secrets, these needs to be validated before model initlization because secrets will be processed and stripped
344 def get_secret_keys(self) -> list[str]: 345 """ 346 Get the list of secret key names based on server type. 347 348 Returns: 349 List of secret key names (header names for remote, env var names for local) 350 """ 351 match self.type: 352 case ToolServerType.remote_mcp: 353 return self.properties.get("secret_header_keys", []) 354 case ToolServerType.local_mcp: 355 return self.properties.get("secret_env_var_keys", []) 356 case ToolServerType.kiln_task: 357 return [] 358 case _: 359 raise_exhaustive_enum_error(self.type)
Get the list of secret key names based on server type.
Returns: List of secret key names (header names for remote, env var names for local)
361 def retrieve_secrets(self) -> tuple[dict[str, str], list[str]]: 362 """ 363 Retrieve secrets from configuration system or in-memory storage. 364 Automatically determines which secret keys to retrieve based on the server type. 365 Config secrets take precedence over unsaved secrets. 366 367 Returns: 368 Tuple of (secrets_dict, missing_secrets_list) where: 369 - secrets_dict: Dictionary mapping key names to their secret values 370 - missing_secrets_list: List of secret key names that are missing values 371 """ 372 secrets = {} 373 missing_secrets = [] 374 secret_keys = self.get_secret_keys() 375 376 if secret_keys and len(secret_keys) > 0: 377 config = Config.shared() 378 mcp_secrets = config.get_value(MCP_SECRETS_KEY) 379 380 for key_name in secret_keys: 381 secret_value = None 382 383 # First check config secrets (persistent storage), key is mcp_server_id::key_name 384 secret_key = self._config_secret_key(key_name) 385 secret_value = mcp_secrets.get(secret_key) if mcp_secrets else None 386 387 # Fall back to unsaved secrets (in-memory storage) 388 if ( 389 not secret_value 390 and hasattr(self, "_unsaved_secrets") 391 and key_name in self._unsaved_secrets 392 ): 393 secret_value = self._unsaved_secrets[key_name] 394 395 if secret_value: 396 secrets[key_name] = secret_value 397 else: 398 missing_secrets.append(key_name) 399 400 return secrets, missing_secrets
Retrieve secrets from configuration system or in-memory storage. Automatically determines which secret keys to retrieve based on the server type. Config secrets take precedence over unsaved secrets.
Returns: Tuple of (secrets_dict, missing_secrets_list) where: - secrets_dict: Dictionary mapping key names to their secret values - missing_secrets_list: List of secret key names that are missing values
432 def delete_secrets(self) -> None: 433 """ 434 Delete all secrets for this tool server from the configuration system. 435 """ 436 secret_keys = self.get_secret_keys() 437 438 config = Config.shared() 439 mcp_secrets = config.get_value(MCP_SECRETS_KEY) or dict[str, str]() 440 441 # Remove secrets with the pattern: mcp_server_id::key_name 442 for key_name in secret_keys: 443 secret_key = self._config_secret_key(key_name) 444 if secret_key in mcp_secrets: 445 del mcp_secrets[secret_key] 446 447 # Always call update_settings to maintain consistency with the old behavior 448 config.update_settings({MCP_SECRETS_KEY: mcp_secrets})
Delete all secrets for this tool server from the configuration system.
450 def save_to_file(self) -> None: 451 """ 452 Override save_to_file to automatically save any unsaved secrets before saving to file. 453 454 This ensures that secrets are always saved when the object is saved, 455 preventing the issue where secrets could be lost if save_to_file is called 456 without explicitly saving secrets first. 457 """ 458 # Save any unsaved secrets first 459 if hasattr(self, "_unsaved_secrets") and self._unsaved_secrets: 460 self._save_secrets() 461 462 # Call the parent save_to_file method 463 super().save_to_file()
Override save_to_file to automatically save any unsaved secrets before saving to file.
This ensures that secrets are always saved when the object is saved, preventing the issue where secrets could be lost if save_to_file is called without explicitly saving secrets first.
8class Feedback(KilnParentedModel): 9 """Feedback on a task run. 10 11 Supports multi-source feedback: different users, automated systems, and 12 different locations in the UI can each contribute independent feedback 13 entries on the same task run. 14 """ 15 16 feedback: str = Field( 17 min_length=1, 18 description="Free-form text feedback on the task run.", 19 ) 20 source: FeedbackSource = Field( 21 description="Where this feedback originated, e.g. 'run-page' or 'spec-feedback'.", 22 )
Feedback on a task run.
Supports multi-source feedback: different users, automated systems, and different locations in the UI can each contribute independent feedback entries on the same task run.
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
101class FeedbackSource(str, Enum): 102 """Where a piece of feedback originated. 103 104 This is an append-only enum: new sources can be added freely, but existing 105 values must never be removed or renamed so that older persisted data 106 continues to load. 107 """ 108 109 run_page = "run-page" 110 spec_feedback = "spec-feedback"
Where a piece of feedback originated.
This is an append-only enum: new sources can be added freely, but existing values must never be removed or renamed so that older persisted data continues to load.
62class FineTuneStatusType(str, Enum): 63 """ 64 The status type of a fine-tune job. 65 """ 66 67 unknown = "unknown" 68 pending = "pending" 69 running = "running" 70 completed = "completed" 71 failed = "failed"
The status type of a fine-tune job.
24class Finetune(KilnParentedModel): 25 """ 26 The Kiln fine-tune datamodel. 27 28 Initially holds a reference to a training job, with needed identifiers to update the status. When complete, contains the new model ID. 29 """ 30 31 name: FilenameString = Field(description="The name of the fine-tune.") 32 description: str | None = Field( 33 default=None, 34 description="A description of the fine-tune for you and your team. Not used in training.", 35 ) 36 structured_output_mode: StructuredOutputMode | None = Field( 37 default=None, 38 description="Legacy field -- replaced by run_config.structured_output_mode. The mode to use to train the model for structured output, if it was trained with structured output. We should call the tuned model with this mode if set.", 39 ) 40 provider: str = Field( 41 description="The provider to use for the fine-tune (e.g. 'openai')." 42 ) 43 base_model_id: str = Field( 44 description="The id of the base model to use for the fine-tune. This string relates to the provider's IDs for their own models, not Kiln IDs." 45 ) 46 provider_id: str | None = Field( 47 default=None, 48 description="The ID of the fine-tune job on the provider's side. May not be the same as the fine_tune_model_id.", 49 ) 50 fine_tune_model_id: str | None = Field( 51 default=None, 52 description="The ID of the fine-tuned model on the provider's side. May not be the same as the provider_id.", 53 ) 54 dataset_split_id: str = Field( 55 description="The ID of the dataset split to use for this fine-tune.", 56 ) 57 train_split_name: str = Field( 58 default="train", 59 description="The name of the training split to use for this fine-tune.", 60 ) 61 validation_split_name: str | None = Field( 62 default=None, 63 description="The name of the validation split to use for this fine-tune. Optional.", 64 ) 65 parameters: dict[str, str | int | float | bool] = Field( 66 default={}, 67 description="The parameters to use for this fine-tune. These are provider-specific.", 68 ) 69 # These two fields are saved exactly used for training. Even if they map exactly to a custom prompt or generator, those can change, so we want to keep a record of the training prompt. 70 system_message: str = Field( 71 description="The system message to use for this fine-tune.", 72 ) 73 thinking_instructions: str | None = Field( 74 default=None, 75 description="The thinking instructions to use for this fine-tune. Only used when data_strategy is final_and_intermediate.", 76 ) 77 latest_status: FineTuneStatusType = Field( 78 default=FineTuneStatusType.unknown, 79 description="The latest known status of this fine-tune. Not updated in real time.", 80 ) 81 properties: Dict[str, str | int | float] = Field( 82 default={}, 83 description="Properties of the fine-tune. Different providers may use different properties.", 84 ) 85 data_strategy: ChatStrategy = Field( 86 default=ChatStrategy.single_turn, 87 description="The strategy to use for training the model. 'final_only' will only train on the final response. 'final_and_intermediate' will train on the final response and intermediate outputs (chain of thought or reasoning).", 88 ) 89 run_config: KilnAgentRunConfigProperties | None = Field( 90 default=None, 91 description="The run configuration for this fine-tune.", 92 ) 93 94 # Workaround to return typed parent without importing Task 95 def parent_task(self) -> Union["Task", None]: 96 if self.parent is None or self.parent.__class__.__name__ != "Task": 97 return None 98 return self.parent # type: ignore 99 100 def nested_id(self) -> str: 101 """ 102 Build the nested ID for this finetune in the format: project_id::task_id::finetune_id 103 """ 104 task = self.parent_task() 105 if task is None: 106 raise ValueError("Finetune must have a parent task") 107 project = task.parent_project() 108 if project is None: 109 raise ValueError("Finetune must have a parent project") 110 return f"{project.id}::{task.id}::{self.id}" 111 112 @model_validator(mode="after") 113 def validate_thinking_instructions(self) -> Self: 114 if ( 115 self.thinking_instructions is not None 116 and self.data_strategy not in DATA_STRATEGIES_REQUIRED_THINKING_INSTRUCTIONS 117 ): 118 raise ValueError( 119 f"Thinking instructions can only be used when data_strategy is one of the following: {DATA_STRATEGIES_REQUIRED_THINKING_INSTRUCTIONS}" 120 ) 121 if ( 122 self.thinking_instructions is None 123 and self.data_strategy in DATA_STRATEGIES_REQUIRED_THINKING_INSTRUCTIONS 124 ): 125 raise ValueError( 126 f"Thinking instructions are required when data_strategy is one of the following: {DATA_STRATEGIES_REQUIRED_THINKING_INSTRUCTIONS}" 127 ) 128 return self
The Kiln fine-tune datamodel.
Initially holds a reference to a training job, with needed identifiers to update the status. When complete, contains the new model ID.
100 def nested_id(self) -> str: 101 """ 102 Build the nested ID for this finetune in the format: project_id::task_id::finetune_id 103 """ 104 task = self.parent_task() 105 if task is None: 106 raise ValueError("Finetune must have a parent task") 107 project = task.parent_project() 108 if project is None: 109 raise ValueError("Finetune must have a parent project") 110 return f"{project.id}::{task.id}::{self.id}"
Build the nested ID for this finetune in the format: project_id::task_id::finetune_id
112 @model_validator(mode="after") 113 def validate_thinking_instructions(self) -> Self: 114 if ( 115 self.thinking_instructions is not None 116 and self.data_strategy not in DATA_STRATEGIES_REQUIRED_THINKING_INSTRUCTIONS 117 ): 118 raise ValueError( 119 f"Thinking instructions can only be used when data_strategy is one of the following: {DATA_STRATEGIES_REQUIRED_THINKING_INSTRUCTIONS}" 120 ) 121 if ( 122 self.thinking_instructions is None 123 and self.data_strategy in DATA_STRATEGIES_REQUIRED_THINKING_INSTRUCTIONS 124 ): 125 raise ValueError( 126 f"Thinking instructions are required when data_strategy is one of the following: {DATA_STRATEGIES_REQUIRED_THINKING_INSTRUCTIONS}" 127 ) 128 return self
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
9class GradedClaim(BaseModel): 10 """One claim with a human grade on it. 11 12 A claim is one decision the judge made, written so the reviewer can vote 13 on it from the card alone. Grades have one direction on every claim: 14 agree means the judge got that decision right, disagree means it got it 15 wrong. The claim text carries its own evidence and citation markers. 16 """ 17 18 text: str = Field(description="The claim as shown to the reviewer.") 19 human_grade: Literal["agree", "disagree"] = Field( 20 description="The human's grade on this claim." 21 ) 22 human_feedback: str | None = Field( 23 default=None, 24 description="Optional plaintext reason for the grade.", 25 )
One claim with a human grade on it.
A claim is one decision the judge made, written so the reviewer can vote on it from the card alone. Grades have one direction on every claim: agree means the judge got that decision right, disagree means it got it wrong. The claim text carries its own evidence and citation markers.
57class MessageUsage(BaseModel): 58 """Token usage and cost for a single LLM call or a multi-message sum. 59 60 Carries only the fields that are meaningfully aggregatable across 61 messages: token counts and cost. Per-call latency lives on the 62 individual message's ``latency_ms`` field; aggregating it across the 63 full trace would mix latencies from different points in time, so 64 ``MessageUsage`` does NOT carry ``total_llm_latency_ms``. 65 66 The :class:`Usage` subclass adds ``total_llm_latency_ms`` for the 67 in-flight per-run accumulator that tracks how long this run spent 68 waiting on LLM calls. 69 """ 70 71 input_tokens: int | None = Field( 72 default=None, 73 description="The number of input tokens used.", 74 ge=0, 75 ) 76 output_tokens: int | None = Field( 77 default=None, 78 description="The number of output tokens used.", 79 ge=0, 80 ) 81 total_tokens: int | None = Field( 82 default=None, 83 description="The total number of tokens used.", 84 ge=0, 85 ) 86 cost: float | None = Field( 87 default=None, 88 description="The cost in US dollars, saved at runtime (prices can change over time).", 89 ge=0, 90 ) 91 cached_tokens: int | None = Field( 92 default=None, 93 description="Number of tokens served from prompt cache. None if not reported.", 94 ge=0, 95 ) 96 97 def __add__(self, other: "MessageUsage") -> "MessageUsage": 98 """Add two MessageUsage objects together, handling None values gracefully. 99 100 None + None = None 101 None + value = value 102 value + None = value 103 value1 + value2 = value1 + value2 104 """ 105 if not isinstance(other, MessageUsage): 106 raise TypeError(f"Cannot add MessageUsage with {type(other).__name__}") 107 108 return MessageUsage( 109 input_tokens=_add_optional_int(self.input_tokens, other.input_tokens), 110 output_tokens=_add_optional_int(self.output_tokens, other.output_tokens), 111 total_tokens=_add_optional_int(self.total_tokens, other.total_tokens), 112 cost=_add_optional_float(self.cost, other.cost), 113 cached_tokens=_add_optional_int(self.cached_tokens, other.cached_tokens), 114 ) 115 116 @staticmethod 117 def from_trace( 118 trace: "list[ChatCompletionMessageParam] | None", 119 ) -> "MessageUsage": 120 """Sum per-message usage across all assistant messages in a trace. 121 122 Returns MessageUsage() (all fields None) when trace is None/empty 123 or no assistant message has a `usage` field. Skips non-assistant 124 messages and messages where `usage` is missing or None. Always 125 returns a MessageUsage instance — never None. 126 127 Accepts per-message `usage` values that are either MessageUsage 128 instances (including the Usage subclass) or plain dicts (e.g. 129 from JSON round-trips); dicts are validated to MessageUsage 130 before summing. 131 """ 132 total: MessageUsage = MessageUsage() 133 if not trace: 134 return total 135 136 for message in trace: 137 if not isinstance(message, dict): 138 continue 139 if message.get("role") != "assistant": 140 continue 141 raw_usage = message.get("usage") 142 if raw_usage is None: 143 continue 144 if isinstance(raw_usage, MessageUsage): 145 total = total + raw_usage 146 elif isinstance(raw_usage, dict): 147 total = total + MessageUsage.model_validate(raw_usage) 148 return total
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 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.
116 @staticmethod 117 def from_trace( 118 trace: "list[ChatCompletionMessageParam] | None", 119 ) -> "MessageUsage": 120 """Sum per-message usage across all assistant messages in a trace. 121 122 Returns MessageUsage() (all fields None) when trace is None/empty 123 or no assistant message has a `usage` field. Skips non-assistant 124 messages and messages where `usage` is missing or None. Always 125 returns a MessageUsage instance — never None. 126 127 Accepts per-message `usage` values that are either MessageUsage 128 instances (including the Usage subclass) or plain dicts (e.g. 129 from JSON round-trips); dicts are validated to MessageUsage 130 before summing. 131 """ 132 total: MessageUsage = MessageUsage() 133 if not trace: 134 return total 135 136 for message in trace: 137 if not isinstance(message, dict): 138 continue 139 if message.get("role") != "assistant": 140 continue 141 raw_usage = message.get("usage") 142 if raw_usage is None: 143 continue 144 if isinstance(raw_usage, MessageUsage): 145 total = total + raw_usage 146 elif isinstance(raw_usage, dict): 147 total = total + MessageUsage.model_validate(raw_usage) 148 return total
Sum per-message usage across all assistant messages in a trace.
Returns MessageUsage() (all fields None) when trace is None/empty
or no assistant message has a usage field. Skips non-assistant
messages and messages where usage is missing or None. Always
returns a MessageUsage instance — never None.
Accepts per-message usage values that are either MessageUsage
instances (including the Usage subclass) or plain dicts (e.g.
from JSON round-trips); dicts are validated to MessageUsage
before summing.
9class Priority(IntEnum): 10 """Priority levels, where P0 is highest priority.""" 11 12 p0 = 0 13 p1 = 1 14 p2 = 2 15 p3 = 3
Priority levels, where P0 is highest priority.
17class Project( 18 KilnParentModel, 19 parent_of={ 20 "tasks": Task, 21 "documents": Document, 22 "extractor_configs": ExtractorConfig, 23 "chunker_configs": ChunkerConfig, 24 "embedding_configs": EmbeddingConfig, 25 "rag_configs": RagConfig, 26 "vector_store_configs": VectorStoreConfig, 27 "external_tool_servers": ExternalToolServer, 28 "reranker_configs": RerankerConfig, 29 "skills": Skill, 30 "code_tools": CodeTool, 31 }, 32): 33 """ 34 A collection of related tasks. 35 36 Projects organize tasks into logical groups and provide high-level descriptions 37 of the overall goals. 38 """ 39 40 name: FilenameString = Field(description="The name of the project.") 41 description: str | None = Field( 42 default=None, 43 description="A description of the project for you and your team. Will not be used in prompts/training/validation.", 44 ) 45 46 # Needed for typechecking. We should fix this in KilnParentModel 47 def tasks(self, readonly: bool = False) -> list[Task]: 48 return super().tasks(readonly=readonly) # type: ignore 49 50 def documents(self, readonly: bool = False) -> list[Document]: 51 return super().documents(readonly=readonly) # type: ignore 52 53 def extractor_configs(self, readonly: bool = False) -> list[ExtractorConfig]: 54 return super().extractor_configs(readonly=readonly) # type: ignore 55 56 def chunker_configs(self, readonly: bool = False) -> list[ChunkerConfig]: 57 return super().chunker_configs(readonly=readonly) # type: ignore 58 59 def embedding_configs(self, readonly: bool = False) -> list[EmbeddingConfig]: 60 return super().embedding_configs(readonly=readonly) # type: ignore 61 62 def vector_store_configs(self, readonly: bool = False) -> list[VectorStoreConfig]: 63 return super().vector_store_configs(readonly=readonly) # type: ignore 64 65 def rag_configs(self, readonly: bool = False) -> list[RagConfig]: 66 return super().rag_configs(readonly=readonly) # type: ignore 67 68 def external_tool_servers(self, readonly: bool = False) -> list[ExternalToolServer]: 69 return super().external_tool_servers(readonly=readonly) # type: ignore 70 71 def reranker_configs(self, readonly: bool = False) -> list[RerankerConfig]: 72 return super().reranker_configs(readonly=readonly) # type: ignore 73 74 def skills(self, readonly: bool = False) -> list[Skill]: 75 return super().skills(readonly=readonly) # type: ignore 76 77 def code_tools(self, readonly: bool = False) -> list[CodeTool]: 78 return super().code_tools(readonly=readonly) # type: ignore
A collection of related tasks.
Projects organize tasks into logical groups and provide high-level descriptions of the overall goals.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
33class Prompt(KilnParentedModel, BasePrompt): 34 """ 35 A prompt for a task. This is the custom prompt parented by a task. 36 """ 37 38 pass
A prompt for a task. This is the custom prompt parented by a task.
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
9class PromptGenerators(str, Enum): 10 """Built-in prompt generators that can construct a prompt from a task definition.""" 11 12 SIMPLE = "simple_prompt_builder" 13 MULTI_SHOT = "multi_shot_prompt_builder" 14 FEW_SHOT = "few_shot_prompt_builder" 15 REPAIRS = "repairs_prompt_builder" 16 SIMPLE_CHAIN_OF_THOUGHT = "simple_chain_of_thought_prompt_builder" 17 FEW_SHOT_CHAIN_OF_THOUGHT = "few_shot_chain_of_thought_prompt_builder" 18 MULTI_SHOT_CHAIN_OF_THOUGHT = "multi_shot_chain_of_thought_prompt_builder"
Built-in prompt generators that can construct a prompt from a task definition.
12class PromptOptimizationJob(KilnParentedModel): 13 """ 14 The Kiln prompt optimization job datamodel. 15 """ 16 17 name: FilenameString = Field(description="The name of the prompt optimization job.") 18 description: str | None = Field( 19 default=None, 20 description="A description of the prompt optimization job for you and your team.", 21 ) 22 job_id: str = Field(description="The ID of the job on the remote Kiln server.") 23 target_run_config_id: str = Field( 24 description="The ID of the run configuration used for this job." 25 ) 26 latest_status: str = Field( 27 default="pending", 28 description="The latest known status of this prompt optimization job (pending, running, succeeded, failed, cancelled). Not updated in real time.", 29 ) 30 optimized_prompt: str | None = Field( 31 default=None, 32 description="The optimized prompt result when the job succeeds.", 33 ) 34 created_prompt_id: str | None = Field( 35 default=None, 36 description="The ID of the prompt created from this job's result, if any.", 37 ) 38 created_run_config_id: str | None = Field( 39 default=None, 40 description="The ID of the run config created from this job's result, if any.", 41 ) 42 eval_ids: list[str] = Field( 43 default_factory=list, 44 description="List of eval IDs used for this job.", 45 ) 46 47 def parent_task(self) -> "Task | None": 48 """Get the parent task, with proper typing.""" 49 if self.parent is None or self.parent.__class__.__name__ != "Task": 50 return None 51 return self.parent # type: ignore
The Kiln prompt optimization job datamodel.
47 def parent_task(self) -> "Task | None": 48 """Get the parent task, with proper typing.""" 49 if self.parent is None or self.parent.__class__.__name__ != "Task": 50 return None 51 return self.parent # type: ignore
Get the parent task, with proper typing.
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
29class RequirementRating(BaseModel): 30 """Rating for a specific requirement within a task output.""" 31 32 value: float = Field( 33 description="The rating value. Interpretation depends on rating type" 34 ) 35 type: TaskOutputRatingType = Field(description="The type of rating")
Rating for a specific requirement within a task output.
20class Skill(KilnParentedModel): 21 """A Skill represents reusable agent instructions following the agentskills.io specification. 22 23 Skills are project-level resources that can be attached to run configs. 24 The agent discovers available skills via the skill tool description, then 25 loads a skill's body on demand by calling skill(name="skill_name"). 26 27 The skill's body (markdown instructions) is stored in a SKILL.md sidecar file 28 rather than in skill.kiln, following the agentskills.io spec. 29 """ 30 31 name: SkillNameString = Field( 32 description="Skill name. Kebab-case: lowercase alphanumeric with hyphens.", 33 ) 34 description: str = Field( 35 description="Description of what the skill does and when to use it.", 36 min_length=1, 37 max_length=1024, 38 ) 39 is_archived: bool = Field( 40 default=False, 41 description="Whether the skill is archived. Archived skills are hidden from the UI and not available for use.", 42 ) 43 44 def parent_project(self) -> Union["Project", None]: 45 if self.parent is None or self.parent.__class__.__name__ != "Project": 46 return None 47 return self.parent # type: ignore 48 49 def skill_md_path(self) -> Path: 50 """Path to the SKILL.md sidecar file (sibling of skill.kiln).""" 51 if self.path is None: 52 raise ValueError("Skill must be saved before accessing SKILL.md path") 53 return self.path.parent / SKILL_MD_FILENAME 54 55 def skill_md_raw(self) -> str: 56 """Read the full SKILL.md file content (frontmatter + body).""" 57 md_path = self.skill_md_path() 58 if not md_path.exists(): 59 raise FileNotFoundError(f"SKILL.md not found at {md_path}") 60 if md_path.is_dir(): 61 raise FileNotFoundError(f"SKILL.md path is a folder, not a file: {md_path}") 62 return md_path.read_text(encoding="utf-8") 63 64 def body(self) -> str: 65 """Read the markdown body from SKILL.md (content after YAML frontmatter).""" 66 return _parse_skill_md_body(self.skill_md_raw()) 67 68 # -- Resources (references & assets) -- 69 70 def references_dir(self) -> Path: 71 if self.path is None: 72 raise ValueError( 73 "Skill must be saved before accessing references directory" 74 ) 75 return self.path.parent / "references" 76 77 def assets_dir(self) -> Path: 78 if self.path is None: 79 raise ValueError("Skill must be saved before accessing assets directory") 80 return self.path.parent / "assets" 81 82 def read_reference(self, relative_path: str) -> str: 83 """Read a reference file. Raises ValueError for path traversal, non-text, or if the path is a folder, FileNotFoundError if missing.""" 84 return self._read_resource(self.references_dir(), relative_path) 85 86 def read_asset(self, relative_path: str) -> str: 87 """Read an asset file. Raises ValueError for path traversal, non-text, or if the path is a folder, FileNotFoundError if missing.""" 88 return self._read_resource(self.assets_dir(), relative_path) 89 90 def _read_resource(self, base_dir: Path, relative_path: str) -> str: 91 """Read a resource file, validating it resolves within base_dir and is readable text.""" 92 if not relative_path or not relative_path.strip(): 93 raise ValueError("Path cannot be empty") 94 95 target = base_dir / relative_path 96 try: 97 resolved = target.resolve() 98 resolved.relative_to(base_dir.resolve()) 99 except ValueError: 100 raise ValueError("Path traversal is not allowed") from None 101 102 if resolved.is_dir(): 103 raise ValueError(f"Path is a folder, not a file: {relative_path}") 104 105 try: 106 return resolved.read_text(encoding="utf-8") 107 except FileNotFoundError: 108 raise FileNotFoundError( 109 f"Resource file not found: {relative_path}" 110 ) from None 111 except UnicodeDecodeError: 112 raise ValueError( 113 f"File is not a readable text file: {relative_path}" 114 ) from None 115 116 def save_skill_md(self, body: str) -> None: 117 """Write SKILL.md with YAML frontmatter (name, description) + markdown body. 118 119 Reads name and description from self to keep SKILL.md in sync with skill.kiln. 120 """ 121 if not body or not body.strip(): 122 raise ValueError("body must be non-empty") 123 frontmatter = yaml.dump( 124 {"name": self.name, "description": self.description}, 125 default_flow_style=False, 126 allow_unicode=True, 127 sort_keys=False, 128 ).rstrip("\n") 129 content = f"---\n{frontmatter}\n---\n\n{body}" 130 self.skill_md_path().write_text(content, encoding="utf-8") 131 self.references_dir().mkdir(exist_ok=True) 132 self.assets_dir().mkdir(exist_ok=True)
A Skill represents reusable agent instructions following the agentskills.io specification.
Skills are project-level resources that can be attached to run configs. The agent discovers available skills via the skill tool description, then loads a skill's body on demand by calling skill(name="skill_name").
The skill's body (markdown instructions) is stored in a SKILL.md sidecar file rather than in skill.kiln, following the agentskills.io spec.
49 def skill_md_path(self) -> Path: 50 """Path to the SKILL.md sidecar file (sibling of skill.kiln).""" 51 if self.path is None: 52 raise ValueError("Skill must be saved before accessing SKILL.md path") 53 return self.path.parent / SKILL_MD_FILENAME
Path to the SKILL.md sidecar file (sibling of skill.kiln).
55 def skill_md_raw(self) -> str: 56 """Read the full SKILL.md file content (frontmatter + body).""" 57 md_path = self.skill_md_path() 58 if not md_path.exists(): 59 raise FileNotFoundError(f"SKILL.md not found at {md_path}") 60 if md_path.is_dir(): 61 raise FileNotFoundError(f"SKILL.md path is a folder, not a file: {md_path}") 62 return md_path.read_text(encoding="utf-8")
Read the full SKILL.md file content (frontmatter + body).
64 def body(self) -> str: 65 """Read the markdown body from SKILL.md (content after YAML frontmatter).""" 66 return _parse_skill_md_body(self.skill_md_raw())
Read the markdown body from SKILL.md (content after YAML frontmatter).
82 def read_reference(self, relative_path: str) -> str: 83 """Read a reference file. Raises ValueError for path traversal, non-text, or if the path is a folder, FileNotFoundError if missing.""" 84 return self._read_resource(self.references_dir(), relative_path)
Read a reference file. Raises ValueError for path traversal, non-text, or if the path is a folder, FileNotFoundError if missing.
86 def read_asset(self, relative_path: str) -> str: 87 """Read an asset file. Raises ValueError for path traversal, non-text, or if the path is a folder, FileNotFoundError if missing.""" 88 return self._read_resource(self.assets_dir(), relative_path)
Read an asset file. Raises ValueError for path traversal, non-text, or if the path is a folder, FileNotFoundError if missing.
116 def save_skill_md(self, body: str) -> None: 117 """Write SKILL.md with YAML frontmatter (name, description) + markdown body. 118 119 Reads name and description from self to keep SKILL.md in sync with skill.kiln. 120 """ 121 if not body or not body.strip(): 122 raise ValueError("body must be non-empty") 123 frontmatter = yaml.dump( 124 {"name": self.name, "description": self.description}, 125 default_flow_style=False, 126 allow_unicode=True, 127 sort_keys=False, 128 ).rstrip("\n") 129 content = f"---\n{frontmatter}\n---\n\n{body}" 130 self.skill_md_path().write_text(content, encoding="utf-8") 131 self.references_dir().mkdir(exist_ok=True) 132 self.assets_dir().mkdir(exist_ok=True)
Write SKILL.md with YAML frontmatter (name, description) + markdown body.
Reads name and description from self to keep SKILL.md in sync with skill.kiln.
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
37class StructuredOutputMode(str, Enum): 38 """ 39 Enumeration of supported structured output modes. 40 41 - json_schema: request json using API capabilities for json_schema 42 - function_calling: request json using API capabilities for function calling 43 - json_mode: request json using API's JSON mode, which should return valid JSON, but isn't checking/passing the schema 44 - json_instructions: append instructions to the prompt to request json matching the schema. No API capabilities are used. You should have a custom parser on these models as they will be returning strings. 45 - json_instruction_and_object: append instructions to the prompt to request json matching the schema. Also request the response as json_mode via API capabilities (returning dictionaries). 46 - json_custom_instructions: The model should output JSON, but custom instructions are already included in the system prompt. Don't append additional JSON instructions. 47 - default: let the adapter decide (legacy, do not use for new use cases) 48 - unknown: used for cases where the structured output mode is not known (on old models where it wasn't saved). Should lookup best option at runtime. 49 """ 50 51 default = "default" 52 json_schema = "json_schema" 53 function_calling_weak = "function_calling_weak" 54 function_calling = "function_calling" 55 json_mode = "json_mode" 56 json_instructions = "json_instructions" 57 json_instruction_and_object = "json_instruction_and_object" 58 json_custom_instructions = "json_custom_instructions" 59 unknown = "unknown"
Enumeration of supported structured output modes.
- json_schema: request json using API capabilities for json_schema
- function_calling: request json using API capabilities for function calling
- json_mode: request json using API's JSON mode, which should return valid JSON, but isn't checking/passing the schema
- json_instructions: append instructions to the prompt to request json matching the schema. No API capabilities are used. You should have a custom parser on these models as they will be returning strings.
- json_instruction_and_object: append instructions to the prompt to request json matching the schema. Also request the response as json_mode via API capabilities (returning dictionaries).
- json_custom_instructions: The model should output JSON, but custom instructions are already included in the system prompt. Don't append additional JSON instructions.
- default: let the adapter decide (legacy, do not use for new use cases)
- unknown: used for cases where the structured output mode is not known (on old models where it wasn't saved). Should lookup best option at runtime.
129class Task( 130 KilnParentedModel, 131 KilnParentModel, 132 parent_of={ 133 "_runs": ParentOfRelationship(model=TaskRun, filesystem_name="runs"), 134 "dataset_splits": DatasetSplit, 135 "finetunes": Finetune, 136 "prompt_optimization_jobs": PromptOptimizationJob, 137 "prompts": Prompt, 138 "evals": Eval, 139 "eval_inputs": EvalInput, 140 "specs": Spec, 141 "run_configs": TaskRunConfig, 142 "data_guides": DataGuide, 143 }, 144): 145 """ 146 Represents a specific task to be performed, with associated requirements and validation rules. 147 148 Contains the task definition, requirements, input/output schemas, and maintains 149 a collection of task runs. 150 """ 151 152 name: FilenameString = Field(description="The name of the task.") 153 description: str | None = Field( 154 default=None, 155 description="A description of the task for you and your team. Will not be used in prompts/training/validation.", 156 ) 157 instruction: str = Field( 158 min_length=1, 159 description="The instructions for the task. Will be used in prompts/training/validation.", 160 ) 161 requirements: List[TaskRequirement] = Field( 162 default=[], 163 description="Deprecated: Use specs and prompts instead.", 164 ) 165 output_json_schema: JsonObjectSchema | None = Field( 166 default=None, 167 description="JSON schema for structured task output. Must be an object schema.", 168 ) 169 input_json_schema: JsonSchema | None = Field( 170 default=None, 171 description="JSON schema for structured task input. Can be an object or array schema.", 172 ) 173 thinking_instruction: str | None = Field( 174 default=None, 175 description="Instructions for the model 'thinking' about the requirement prior to answering. Used for chain of thought style prompting.", 176 ) 177 178 default_run_config_id: ID_TYPE | None = Field( 179 default=None, 180 description="ID of the run config to use for this task by default. Must exist in saved run configs for this task.", 181 ) 182 183 turn_mode: TurnMode = Field( 184 default=TurnMode.single_turn, 185 frozen=True, 186 description="Whether this task is single-turn (each run independent) or multi-turn (runs continue prior runs). Immutable after construction: changing it would invalidate existing TaskRuns. To change, clone the task.", 187 ) 188 189 @model_validator(mode="after") 190 def validate_turn_mode_compatibility(self) -> "Task": 191 if self.turn_mode == TurnMode.multiturn: 192 if self.input_json_schema is not None: 193 raise ValueError( 194 "Multi-turn tasks cannot have a structured input schema. " 195 "Use plaintext input for multi-turn tasks." 196 ) 197 if self.output_json_schema is not None: 198 raise ValueError( 199 "Multi-turn tasks do not support structured output yet. " 200 "Use plaintext output, or set turn_mode to single_turn." 201 ) 202 return self 203 204 def output_schema(self) -> Dict | None: 205 if self.output_json_schema is None: 206 return None 207 return schema_from_json_str(self.output_json_schema) 208 209 def input_schema(self) -> Dict | None: 210 if self.input_json_schema is None: 211 return None 212 # Allow arrays, not just objects 213 return schema_from_json_str(self.input_json_schema, require_object=False) 214 215 def runs( 216 self, 217 readonly: bool = False, 218 include_intermediate_runs: bool = False, 219 include_eval_generated: bool = False, 220 ) -> list[TaskRun]: 221 """Return TaskRuns for this task with leaf-only, dataset-only filtering by default. 222 223 For multiturn tasks, child TaskRuns reference their parent via 224 ``parent_task_run_id``. By default we return only the leaves of those 225 chains - the runs that aren't a parent of any other run - because that 226 is the right view for the vast majority of consumers iterating runs 227 in-process (dataset/eval/finetune sample iteration, summary lists, 228 statistics, tag counts, etc.). 229 230 Pass ``include_intermediate_runs=True`` to get every run regardless of 231 position in the chain. That is only correct for code that needs the 232 complete on-disk set (e.g. walking ancestors, diagnostics). For 233 single-turn tasks the two modes are equivalent. 234 235 ``runs/`` also holds eval-generated traces (``eval_source`` set) alongside the 236 dataset corpus. Those are excluded by default: they are a byproduct of running 237 an eval, not data the user curated, and leaking them into fine-tune sets or 238 few-shot prompts would feed a model its own eval outputs. Default-exclude rather 239 than an opt-out filter is deliberate - forgetting to handle eval traces then 240 fails visibly (missing data) instead of silently (contaminated data). 241 ``include_eval_generated=True`` is meant for the eval runner alone, which needs 242 to find the traces it can reuse; nothing else should pass it. 243 244 The two filters compose: a run must pass both to be returned. 245 246 Note: these filters only affect in-process iteration. Filesystem-level 247 operations that copy the ``runs/`` directory (e.g. project export) 248 copy every run regardless. 249 """ 250 runs = self._runs(readonly=readonly) # type: ignore[attr-defined] 251 # Eval filter first: an excluded eval-generated run must not count as a parent 252 # either, or an eval child chained onto a dataset run would hide that dataset 253 # run from the default view while itself being filtered out. 254 if not include_eval_generated: 255 runs = [r for r in runs if r.eval_source is None] 256 if not include_intermediate_runs: 257 parent_ids = {r.parent_task_run_id for r in runs if r.parent_task_run_id} 258 runs = [r for r in runs if r.id not in parent_ids] 259 return runs 260 261 # These wrappers help for typechecking. We should fix this in KilnParentModel 262 def dataset_splits(self, readonly: bool = False) -> list[DatasetSplit]: 263 return super().dataset_splits(readonly=readonly) # type: ignore 264 265 def finetunes(self, readonly: bool = False) -> list[Finetune]: 266 return super().finetunes(readonly=readonly) # type: ignore 267 268 def prompts(self, readonly: bool = False) -> list[Prompt]: 269 return super().prompts(readonly=readonly) # type: ignore 270 271 def evals(self, readonly: bool = False) -> list[Eval]: 272 return super().evals(readonly=readonly) # type: ignore 273 274 def eval_inputs(self, readonly: bool = False) -> list[EvalInput]: 275 return super().eval_inputs(readonly=readonly) # type: ignore 276 277 def run_configs(self, readonly: bool = False) -> list[TaskRunConfig]: 278 return super().run_configs(readonly=readonly) # type: ignore 279 280 def specs(self, readonly: bool = False) -> list[Spec]: 281 return super().specs(readonly=readonly) # type: ignore 282 283 def data_guides(self, readonly: bool = False) -> list[DataGuide]: 284 return super().data_guides(readonly=readonly) # type: ignore 285 286 def current_data_guide(self, readonly: bool = False) -> DataGuide | None: 287 # By design there is at most one DataGuide per task — saves overwrite 288 # the existing one in place rather than creating a new file. If the 289 # folder somehow ends up with multiple (e.g. an older import), return 290 # the first; cleanup is up to the caller. 291 guides = self.data_guides(readonly=readonly) 292 return guides[0] if guides else None 293 294 def prompt_optimization_jobs( 295 self, readonly: bool = False 296 ) -> list[PromptOptimizationJob]: 297 return super().prompt_optimization_jobs(readonly=readonly) # type: ignore 298 299 # Workaround to return typed parent without importing Task 300 def parent_project(self) -> Union["Project", None]: 301 if self.parent is None or self.parent.__class__.__name__ != "Project": 302 return None 303 return self.parent # type: ignore
Represents a specific task to be performed, with associated requirements and validation rules.
Contains the task definition, requirements, input/output schemas, and maintains a collection of task runs.
189 @model_validator(mode="after") 190 def validate_turn_mode_compatibility(self) -> "Task": 191 if self.turn_mode == TurnMode.multiturn: 192 if self.input_json_schema is not None: 193 raise ValueError( 194 "Multi-turn tasks cannot have a structured input schema. " 195 "Use plaintext input for multi-turn tasks." 196 ) 197 if self.output_json_schema is not None: 198 raise ValueError( 199 "Multi-turn tasks do not support structured output yet. " 200 "Use plaintext output, or set turn_mode to single_turn." 201 ) 202 return self
215 def runs( 216 self, 217 readonly: bool = False, 218 include_intermediate_runs: bool = False, 219 include_eval_generated: bool = False, 220 ) -> list[TaskRun]: 221 """Return TaskRuns for this task with leaf-only, dataset-only filtering by default. 222 223 For multiturn tasks, child TaskRuns reference their parent via 224 ``parent_task_run_id``. By default we return only the leaves of those 225 chains - the runs that aren't a parent of any other run - because that 226 is the right view for the vast majority of consumers iterating runs 227 in-process (dataset/eval/finetune sample iteration, summary lists, 228 statistics, tag counts, etc.). 229 230 Pass ``include_intermediate_runs=True`` to get every run regardless of 231 position in the chain. That is only correct for code that needs the 232 complete on-disk set (e.g. walking ancestors, diagnostics). For 233 single-turn tasks the two modes are equivalent. 234 235 ``runs/`` also holds eval-generated traces (``eval_source`` set) alongside the 236 dataset corpus. Those are excluded by default: they are a byproduct of running 237 an eval, not data the user curated, and leaking them into fine-tune sets or 238 few-shot prompts would feed a model its own eval outputs. Default-exclude rather 239 than an opt-out filter is deliberate - forgetting to handle eval traces then 240 fails visibly (missing data) instead of silently (contaminated data). 241 ``include_eval_generated=True`` is meant for the eval runner alone, which needs 242 to find the traces it can reuse; nothing else should pass it. 243 244 The two filters compose: a run must pass both to be returned. 245 246 Note: these filters only affect in-process iteration. Filesystem-level 247 operations that copy the ``runs/`` directory (e.g. project export) 248 copy every run regardless. 249 """ 250 runs = self._runs(readonly=readonly) # type: ignore[attr-defined] 251 # Eval filter first: an excluded eval-generated run must not count as a parent 252 # either, or an eval child chained onto a dataset run would hide that dataset 253 # run from the default view while itself being filtered out. 254 if not include_eval_generated: 255 runs = [r for r in runs if r.eval_source is None] 256 if not include_intermediate_runs: 257 parent_ids = {r.parent_task_run_id for r in runs if r.parent_task_run_id} 258 runs = [r for r in runs if r.id not in parent_ids] 259 return runs
Return TaskRuns for this task with leaf-only, dataset-only filtering by default.
For multiturn tasks, child TaskRuns reference their parent via
parent_task_run_id. By default we return only the leaves of those
chains - the runs that aren't a parent of any other run - because that
is the right view for the vast majority of consumers iterating runs
in-process (dataset/eval/finetune sample iteration, summary lists,
statistics, tag counts, etc.).
Pass include_intermediate_runs=True to get every run regardless of
position in the chain. That is only correct for code that needs the
complete on-disk set (e.g. walking ancestors, diagnostics). For
single-turn tasks the two modes are equivalent.
runs/ also holds eval-generated traces (eval_source set) alongside the
dataset corpus. Those are excluded by default: they are a byproduct of running
an eval, not data the user curated, and leaking them into fine-tune sets or
few-shot prompts would feed a model its own eval outputs. Default-exclude rather
than an opt-out filter is deliberate - forgetting to handle eval traces then
fails visibly (missing data) instead of silently (contaminated data).
include_eval_generated=True is meant for the eval runner alone, which needs
to find the traces it can reuse; nothing else should pass it.
The two filters compose: a run must pass both to be returned.
Note: these filters only affect in-process iteration. Filesystem-level
operations that copy the runs/ directory (e.g. project export)
copy every run regardless.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
286 def current_data_guide(self, readonly: bool = False) -> DataGuide | None: 287 # By design there is at most one DataGuide per task — saves overwrite 288 # the existing one in place rather than creating a new file. If the 289 # folder somehow ends up with multiple (e.g. an older import), return 290 # the first; cleanup is up to the caller. 291 guides = self.data_guides(readonly=readonly) 292 return guides[0] if guides else None
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
347class TaskOutput(KilnBaseModel): 348 """ 349 An output for a specific task run. 350 351 Contains the actual output content, its source (human or synthetic), 352 and optional rating information. 353 """ 354 355 output: str = Field( 356 description="The output of the task. JSON formatted for structured output, plaintext for unstructured output." 357 ) 358 source: DataSource | None = Field( 359 description="The source of the output: human or synthetic.", 360 default=None, 361 ) 362 rating: TaskOutputRating | None = Field( 363 default=None, description="The rating of the output" 364 ) 365 366 def validate_output_format(self, task: "Task") -> Self: 367 # validate output 368 if task.output_json_schema is not None: 369 try: 370 output_parsed = json.loads(self.output) 371 except json.JSONDecodeError: 372 raise ValueError("Output is not a valid JSON object") 373 374 validate_schema_with_value_error( 375 output_parsed, 376 task.output_json_schema, 377 TASK_OUTPUT_SCHEMA_ERROR_PREFIX, 378 ) 379 return self 380 381 @model_validator(mode="after") 382 def validate_output_source(self, info: ValidationInfo) -> Self: 383 # On strict mode and not loaded from file, we validate output_source is not None. 384 # We want to be able to load any data, even if it's not perfect. But we want to create perfect data when adding new data. 385 if not strict_mode(): 386 return self 387 if self.loaded_from_file(info): 388 return self 389 if self.source is None: 390 raise ValueError("Output source is required when strict mode is enabled") 391 return self
An output for a specific task run.
Contains the actual output content, its source (human or synthetic), and optional rating information.
366 def validate_output_format(self, task: "Task") -> Self: 367 # validate output 368 if task.output_json_schema is not None: 369 try: 370 output_parsed = json.loads(self.output) 371 except json.JSONDecodeError: 372 raise ValueError("Output is not a valid JSON object") 373 374 validate_schema_with_value_error( 375 output_parsed, 376 task.output_json_schema, 377 TASK_OUTPUT_SCHEMA_ERROR_PREFIX, 378 ) 379 return self
381 @model_validator(mode="after") 382 def validate_output_source(self, info: ValidationInfo) -> Self: 383 # On strict mode and not loaded from file, we validate output_source is not None. 384 # We want to be able to load any data, even if it's not perfect. But we want to create perfect data when adding new data. 385 if not strict_mode(): 386 return self 387 if self.loaded_from_file(info): 388 return self 389 if self.source is None: 390 raise ValueError("Output source is required when strict mode is enabled") 391 return self
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
59class TaskOutputRating(KilnBaseModel): 60 """ 61 A rating for a task output, including an overall rating and ratings for each requirement. 62 63 Supports: 64 - five_star: 1-5 star ratings 65 - pass_fail: boolean pass/fail (1.0 = pass, 0.0 = fail) 66 - pass_fail_critical: tri-state (1.0 = pass, 0.0 = fail, -1.0 = critical fail) 67 """ 68 69 type: TaskOutputRatingType = Field( 70 default=TaskOutputRatingType.five_star, 71 description="The rating system used for this rating.", 72 ) 73 value: float | None = Field( 74 description="The rating value. Interpretation depends on rating type:\n- five_star: 1-5 stars\n- pass_fail: 1.0 (pass) or 0.0 (fail)\n- pass_fail_critical: 1.0 (pass), 0.0 (fail), or -1.0 (critical fail)", 75 default=None, 76 ) 77 requirement_ratings: Dict[ID_TYPE, RequirementRating] = Field( 78 default={}, 79 description="The ratings of the requirements of the task. The ID can be either a task_requirement_id or a named rating for an eval_output_score name (in format 'named::<name>').", 80 ) 81 82 # Previously we stored rating values as a dict of floats, but now we store them as RequirementRating objects. 83 @model_validator(mode="before") 84 def upgrade_old_format(cls, data: dict) -> dict: 85 if not isinstance(data, dict): 86 return data 87 88 # Check if we have the old format (dict of floats) 89 req_ratings = data.get("requirement_ratings", {}) 90 if req_ratings and all( 91 isinstance(v, (int, float)) for v in req_ratings.values() 92 ): 93 # Convert each float to a RequirementRating object 94 # all ratings are five star at the point we used this format 95 data["requirement_ratings"] = { 96 k: {"value": v, "type": TaskOutputRatingType.five_star} 97 for k, v in req_ratings.items() 98 } 99 100 return data 101 102 # Used to select high quality outputs for example selection (MultiShotPromptBuilder, etc) 103 def is_high_quality(self) -> bool: 104 if self.value is None: 105 return False 106 107 if self.type == TaskOutputRatingType.five_star: 108 return self.value >= 4 109 elif self.type == TaskOutputRatingType.pass_fail: 110 return self.value == 1.0 111 elif self.type == TaskOutputRatingType.pass_fail_critical: 112 return self.value == 1.0 113 return False 114 115 @model_validator(mode="after") 116 def validate_rating(self) -> Self: 117 if self.type not in TaskOutputRatingType: 118 raise ValueError(f"Invalid rating type: {self.type}") 119 120 # Overall rating is optional 121 if self.value is not None: 122 self._validate_rating(self.type, self.value, "overall rating") 123 124 for req_id, req_rating in self.requirement_ratings.items(): 125 self._validate_rating( 126 req_rating.type, 127 req_rating.value, 128 f"requirement rating for req ID: {req_id}", 129 ) 130 131 return self 132 133 def _validate_rating( 134 self, type: TaskOutputRatingType, rating: float | None, rating_name: str 135 ) -> None: 136 if type == TaskOutputRatingType.five_star: 137 self._validate_five_star(rating, rating_name) 138 elif type == TaskOutputRatingType.pass_fail: 139 self._validate_pass_fail(rating, rating_name) 140 elif type == TaskOutputRatingType.pass_fail_critical: 141 self._validate_pass_fail_critical(rating, rating_name) 142 143 def _validate_five_star(self, rating: float | None, rating_name: str) -> None: 144 if rating is None or not isinstance(rating, float) or not rating.is_integer(): 145 raise ValueError( 146 f"{rating_name.capitalize()} of type five_star must be an integer value (1-5)" 147 ) 148 if rating < 1 or rating > 5: 149 raise ValueError( 150 f"{rating_name.capitalize()} of type five_star must be between 1 and 5 stars" 151 ) 152 153 def _validate_pass_fail(self, rating: float | None, rating_name: str) -> None: 154 if rating is None or not isinstance(rating, float) or not rating.is_integer(): 155 raise ValueError( 156 f"{rating_name.capitalize()} of type pass_fail must be an integer value (0 or 1)" 157 ) 158 if rating not in [0, 1]: 159 raise ValueError( 160 f"{rating_name.capitalize()} of type pass_fail must be 0 (fail) or 1 (pass)" 161 ) 162 163 def _validate_pass_fail_critical( 164 self, rating: float | None, rating_name: str 165 ) -> None: 166 if rating is None or not isinstance(rating, float) or not rating.is_integer(): 167 raise ValueError( 168 f"{rating_name.capitalize()} of type pass_fail_critical must be an integer value (-1, 0, or 1)" 169 ) 170 if rating not in [-1, 0, 1]: 171 raise ValueError( 172 f"{rating_name.capitalize()} of type pass_fail_critical must be -1 (critical fail), 0 (fail), or 1 (pass)" 173 )
A rating for a task output, including an overall rating and ratings for each requirement.
Supports:
- five_star: 1-5 star ratings
- pass_fail: boolean pass/fail (1.0 = pass, 0.0 = fail)
- pass_fail_critical: tri-state (1.0 = pass, 0.0 = fail, -1.0 = critical fail)
83 @model_validator(mode="before") 84 def upgrade_old_format(cls, data: dict) -> dict: 85 if not isinstance(data, dict): 86 return data 87 88 # Check if we have the old format (dict of floats) 89 req_ratings = data.get("requirement_ratings", {}) 90 if req_ratings and all( 91 isinstance(v, (int, float)) for v in req_ratings.values() 92 ): 93 # Convert each float to a RequirementRating object 94 # all ratings are five star at the point we used this format 95 data["requirement_ratings"] = { 96 k: {"value": v, "type": TaskOutputRatingType.five_star} 97 for k, v in req_ratings.items() 98 } 99 100 return data
103 def is_high_quality(self) -> bool: 104 if self.value is None: 105 return False 106 107 if self.type == TaskOutputRatingType.five_star: 108 return self.value >= 4 109 elif self.type == TaskOutputRatingType.pass_fail: 110 return self.value == 1.0 111 elif self.type == TaskOutputRatingType.pass_fail_critical: 112 return self.value == 1.0 113 return False
115 @model_validator(mode="after") 116 def validate_rating(self) -> Self: 117 if self.type not in TaskOutputRatingType: 118 raise ValueError(f"Invalid rating type: {self.type}") 119 120 # Overall rating is optional 121 if self.value is not None: 122 self._validate_rating(self.type, self.value, "overall rating") 123 124 for req_id, req_rating in self.requirement_ratings.items(): 125 self._validate_rating( 126 req_rating.type, 127 req_rating.value, 128 f"requirement rating for req ID: {req_id}", 129 ) 130 131 return self
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
28class TaskOutputRatingType(str, Enum): 29 """Defines the types of rating systems available for task outputs.""" 30 31 five_star = "five_star" 32 pass_fail = "pass_fail" 33 pass_fail_critical = "pass_fail_critical" 34 custom = "custom"
Defines the types of rating systems available for task outputs.
40class TaskRequirement(BaseModel): 41 """ 42 Defines a specific requirement that should be met by task outputs. 43 44 Includes an identifier, name, description, instruction for meeting the requirement, 45 priority level, and rating type (five_star, pass_fail, pass_fail_critical, custom). 46 """ 47 48 id: ID_TYPE = ID_FIELD 49 name: FilenameStringShort = Field(description="The name of the task requirement.") 50 description: str | None = Field( 51 default=None, 52 description="Optional elaboration on the requirement's purpose.", 53 ) 54 instruction: str = Field( 55 min_length=1, description="Instructions for meeting the requirement." 56 ) 57 priority: Priority = Field( 58 default=Priority.p2, description="The priority level of the requirement." 59 ) 60 type: TaskOutputRatingType = Field( 61 default=TaskOutputRatingType.five_star, 62 description="The rating type used to evaluate this requirement.", 63 )
Defines a specific requirement that should be met by task outputs.
Includes an identifier, name, description, instruction for meeting the requirement, priority level, and rating type (five_star, pass_fail, pass_fail_critical, custom).
61class TaskRun( 62 KilnParentedModel, 63 KilnParentModel, 64 parent_of={ 65 "feedback": Feedback, 66 "claim_reviews": ClaimReview, 67 }, 68): 69 """ 70 Represents a single execution of a Task. 71 72 Contains the input used, its source, the output produced, and optional 73 repair information if the output needed correction. 74 """ 75 76 input: str = Field( 77 description="The inputs to the task. JSON formatted for structured input, plaintext for unstructured input." 78 ) 79 input_source: DataSource | None = Field( 80 default=None, description="The source of the input: human or synthetic." 81 ) 82 83 output: TaskOutput = Field(description="The output of the task run.") 84 repair_instructions: str | None = Field( 85 default=None, 86 description="Instructions for fixing the output. Should define what is wrong, and how to fix it. Will be used by models for both generating a fixed output, and evaluating future models.", 87 ) 88 repaired_output: TaskOutput | None = Field( 89 default=None, 90 description="An version of the output with issues fixed. This must be a 'fixed' version of the existing output, and not an entirely new output. If you wish to generate an ideal curatorial output for this task unrelated to this output, generate a new TaskOutput with type 'human' instead of using this field.", 91 ) 92 intermediate_outputs: Dict[str, str] | None = Field( 93 default=None, 94 description="Intermediate outputs from the task run. Keys are the names of the intermediate output steps (cot=chain of thought, etc), values are the output data.", 95 ) 96 tags: List[str] = Field( 97 default=[], 98 description="Tags for the task run. Tags are used to categorize task runs for filtering and reporting.", 99 ) 100 usage: Usage | None = Field( 101 default=None, 102 description="Usage information for the task run. This includes the number of input tokens, output tokens, and total tokens used.", 103 ) 104 cumulative_usage: MessageUsage | None = Field( 105 default=None, 106 description=( 107 "Sum of per-message token usage and cost across the entire trace, " 108 "including any seeded prior trace. None on records created before " 109 "this field existed. For a fresh (non-seeded) run, the token / " 110 "cost fields equal those of `usage`." 111 ), 112 ) 113 synthetic_user_usage: Usage | None = Field( 114 default=None, 115 description=( 116 "The synthetic-user driver model's spend for an eval-driven " 117 "conversation, recorded beside the assistant's own usage so `usage` " 118 "stays assistant-only. None for ordinary runs, and for migrated " 119 "legacy traces whose driver cost is fused into `usage`." 120 ), 121 ) 122 trace: list[ChatCompletionMessageParam] | None = Field( 123 default=None, 124 description="The trace of the task run in OpenAI format. This is the list of messages that were sent to/from the model.", 125 ) 126 parent_task_run_id: str | None = Field( 127 default=None, 128 description="The ID of the parent task run. This is the ID of the task run that contains this task run.", 129 ) 130 eval_source: EvalItemSource | None = Field( 131 default=None, 132 description="Set when this run was generated by an eval. Names the eval dataset item it was generated for. None for ordinary dataset runs. Runs with this set are excluded from Task.runs() by default, so they do not appear on dataset surfaces.", 133 ) 134 135 @model_validator(mode="after") 136 def materialize_trace_content(self) -> Self: 137 # Pydantic validates the trace wrappers' Iterable-typed `content` into a lazy, 138 # single-use iterator that cannot be deepcopied. Materialize it here so any 139 # later copy of the run (e.g. the model cache's mutable copies) is safe. 140 if self.trace is not None: 141 materialize_lazy_content(self.trace) 142 return self 143 144 @property 145 def is_toolcall_pending(self) -> bool: 146 """True if the trace ends with an assistant message awaiting client tool execution.""" 147 return trace_has_pending_client_tool_calls(self.trace) 148 149 def thinking_training_data(self) -> str | None: 150 """ 151 Get the thinking training data from the task run. 152 """ 153 if self.intermediate_outputs is None: 154 return None 155 return self.intermediate_outputs.get( 156 "reasoning" 157 ) or self.intermediate_outputs.get("chain_of_thought") 158 159 def has_thinking_training_data(self) -> bool: 160 """ 161 Does this run have thinking data that we can use to train a thinking model? 162 """ 163 return self.thinking_training_data() is not None 164 165 def feedback(self, readonly: bool = False) -> list[Feedback]: 166 return super().feedback(readonly=readonly) # type: ignore 167 168 def claim_reviews(self, readonly: bool = False) -> list[ClaimReview]: 169 return super().claim_reviews(readonly=readonly) # type: ignore 170 171 # Workaround to return typed parent without importing Task 172 def parent_task(self) -> Union["Task", None]: 173 if self.parent is None or self.parent.__class__.__name__ != "Task": 174 return None 175 return self.parent # type: ignore 176 177 @model_validator(mode="after") 178 def validate_input_format(self, info: ValidationInfo) -> Self: 179 # Don't validate if loading from file (not new). Too slow. 180 # We don't allow changing task schema, so this is redundant validation. 181 # Note: we still validate if editing a loaded model 182 if self.loading_from_file(info): 183 # Consider loading an existing model as validated. 184 self._last_validated_input = self.input 185 return self 186 187 # Don't validate if input has not changed. Too slow to run this every time. 188 if ( 189 hasattr(self, "_last_validated_input") 190 and self.input == self._last_validated_input 191 ): 192 return self 193 194 task = self.parent_task() 195 if task is None: 196 # don't validate this relationship until we have a path or parent. Give them time to build it (but will catch it before saving) 197 return self 198 199 # validate input 200 if task.input_json_schema is not None: 201 try: 202 input_parsed = json.loads(self.input) 203 except json.JSONDecodeError: 204 raise ValueError("Input is not a valid JSON object") 205 206 validate_schema_with_value_error( 207 input_parsed, 208 task.input_json_schema, 209 "Input does not match task input schema.", 210 require_object=False, 211 ) 212 213 self._last_validated_input = self.input 214 return self 215 216 @model_validator(mode="after") 217 def validate_output_format(self, info: ValidationInfo) -> Self: 218 # Don't validate if loading from file (not new). Too slow. 219 # Note: we still validate if editing a loaded model's output. 220 if self.loading_from_file(info): 221 # Consider loading an existing model as validated. 222 self._last_validated_output = self.output.output if self.output else None 223 return self 224 225 # Skip output validation when the run is waiting for tool call results. 226 # The output field is empty/partial in this state. 227 if self.is_toolcall_pending: 228 self._last_validated_output = self.output.output if self.output else None 229 return self 230 231 # Don't validate unless output has changed since last validation. 232 # The validator is slow and costly, don't want it running when setting other fields. 233 if ( 234 hasattr(self, "_last_validated_output") 235 and self.output is not None 236 and self.output.output == self._last_validated_output 237 ): 238 return self 239 240 task = self.parent_task() 241 if task is None: 242 return self 243 244 self.output.validate_output_format(task) 245 self._last_validated_output = self.output.output if self.output else None 246 return self 247 248 @model_validator(mode="after") 249 def validate_parent_task_run_id_for_turn_mode(self, info: ValidationInfo) -> Self: 250 # Single-turn tasks must not have a parent_task_run_id - the leaf 251 # filter in Task.runs() would silently drop the parent from 252 # iteration, producing phantom runs in evals/finetunes/dataset 253 # splits. Skip on load so legacy files don't fail to deserialize. 254 if self.loading_from_file(info): 255 return self 256 if self.parent_task_run_id is None: 257 return self 258 task = self.parent_task() 259 if task is None: 260 # Not yet attached - defer; revalidates when attached/saved. 261 return self 262 # Avoid circular import at module load. 263 from kiln_ai.datamodel.datamodel_enums import TurnMode 264 265 if task.turn_mode != TurnMode.multiturn: 266 raise ValueError( 267 "parent_task_run_id is only valid on multi-turn tasks. " 268 "This task's turn_mode is single_turn." 269 ) 270 return self 271 272 @model_validator(mode="after") 273 def validate_repaired_output(self) -> Self: 274 if self.repaired_output is not None: 275 if self.repaired_output.rating is not None: 276 raise ValueError( 277 "Repaired output rating must be None. Repaired outputs are assumed to have a perfect rating, as they have been fixed." 278 ) 279 280 task = self.parent_task() 281 if ( 282 task is not None 283 and self.repaired_output.output is not None 284 and task.output_json_schema is not None 285 ): 286 try: 287 output_parsed = json.loads(self.repaired_output.output) 288 except json.JSONDecodeError: 289 raise ValueError("Repaired output is not a valid JSON object") 290 291 validate_schema_with_value_error( 292 output_parsed, 293 task.output_json_schema, 294 "Repaired output does not match task output schema.", 295 ) 296 297 if self.repair_instructions is None and self.repaired_output is not None: 298 raise ValueError( 299 "Repair instructions are required if providing a repaired output." 300 ) 301 if self.repair_instructions is not None and self.repaired_output is None: 302 raise ValueError( 303 "A repaired output is required if providing repair instructions." 304 ) 305 306 return self 307 308 @model_validator(mode="after") 309 def validate_input_source(self, info: ValidationInfo) -> Self: 310 # On strict mode and not loaded from file, we validate input_source is not None. 311 # We want to be able to load any data, even if it's not perfect. But we want to create perfect data when adding new data. 312 if not strict_mode(): 313 return self 314 if self.loaded_from_file(info): 315 return self 316 if self.input_source is None: 317 raise ValueError("input_source is required when strict mode is enabled") 318 return self 319 320 @model_validator(mode="after") 321 def validate_tags(self) -> Self: 322 for tag in self.tags: 323 if not tag: 324 raise ValueError("Tags cannot be empty strings") 325 if " " in tag: 326 raise ValueError("Tags cannot contain spaces. Try underscores.") 327 328 return self
Represents a single execution of a Task.
Contains the input used, its source, the output produced, and optional repair information if the output needed correction.
135 @model_validator(mode="after") 136 def materialize_trace_content(self) -> Self: 137 # Pydantic validates the trace wrappers' Iterable-typed `content` into a lazy, 138 # single-use iterator that cannot be deepcopied. Materialize it here so any 139 # later copy of the run (e.g. the model cache's mutable copies) is safe. 140 if self.trace is not None: 141 materialize_lazy_content(self.trace) 142 return self
144 @property 145 def is_toolcall_pending(self) -> bool: 146 """True if the trace ends with an assistant message awaiting client tool execution.""" 147 return trace_has_pending_client_tool_calls(self.trace)
True if the trace ends with an assistant message awaiting client tool execution.
149 def thinking_training_data(self) -> str | None: 150 """ 151 Get the thinking training data from the task run. 152 """ 153 if self.intermediate_outputs is None: 154 return None 155 return self.intermediate_outputs.get( 156 "reasoning" 157 ) or self.intermediate_outputs.get("chain_of_thought")
Get the thinking training data from the task run.
159 def has_thinking_training_data(self) -> bool: 160 """ 161 Does this run have thinking data that we can use to train a thinking model? 162 """ 163 return self.thinking_training_data() is not None
Does this run have thinking data that we can use to train a thinking model?
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
841 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 842 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
177 @model_validator(mode="after") 178 def validate_input_format(self, info: ValidationInfo) -> Self: 179 # Don't validate if loading from file (not new). Too slow. 180 # We don't allow changing task schema, so this is redundant validation. 181 # Note: we still validate if editing a loaded model 182 if self.loading_from_file(info): 183 # Consider loading an existing model as validated. 184 self._last_validated_input = self.input 185 return self 186 187 # Don't validate if input has not changed. Too slow to run this every time. 188 if ( 189 hasattr(self, "_last_validated_input") 190 and self.input == self._last_validated_input 191 ): 192 return self 193 194 task = self.parent_task() 195 if task is None: 196 # don't validate this relationship until we have a path or parent. Give them time to build it (but will catch it before saving) 197 return self 198 199 # validate input 200 if task.input_json_schema is not None: 201 try: 202 input_parsed = json.loads(self.input) 203 except json.JSONDecodeError: 204 raise ValueError("Input is not a valid JSON object") 205 206 validate_schema_with_value_error( 207 input_parsed, 208 task.input_json_schema, 209 "Input does not match task input schema.", 210 require_object=False, 211 ) 212 213 self._last_validated_input = self.input 214 return self
216 @model_validator(mode="after") 217 def validate_output_format(self, info: ValidationInfo) -> Self: 218 # Don't validate if loading from file (not new). Too slow. 219 # Note: we still validate if editing a loaded model's output. 220 if self.loading_from_file(info): 221 # Consider loading an existing model as validated. 222 self._last_validated_output = self.output.output if self.output else None 223 return self 224 225 # Skip output validation when the run is waiting for tool call results. 226 # The output field is empty/partial in this state. 227 if self.is_toolcall_pending: 228 self._last_validated_output = self.output.output if self.output else None 229 return self 230 231 # Don't validate unless output has changed since last validation. 232 # The validator is slow and costly, don't want it running when setting other fields. 233 if ( 234 hasattr(self, "_last_validated_output") 235 and self.output is not None 236 and self.output.output == self._last_validated_output 237 ): 238 return self 239 240 task = self.parent_task() 241 if task is None: 242 return self 243 244 self.output.validate_output_format(task) 245 self._last_validated_output = self.output.output if self.output else None 246 return self
248 @model_validator(mode="after") 249 def validate_parent_task_run_id_for_turn_mode(self, info: ValidationInfo) -> Self: 250 # Single-turn tasks must not have a parent_task_run_id - the leaf 251 # filter in Task.runs() would silently drop the parent from 252 # iteration, producing phantom runs in evals/finetunes/dataset 253 # splits. Skip on load so legacy files don't fail to deserialize. 254 if self.loading_from_file(info): 255 return self 256 if self.parent_task_run_id is None: 257 return self 258 task = self.parent_task() 259 if task is None: 260 # Not yet attached - defer; revalidates when attached/saved. 261 return self 262 # Avoid circular import at module load. 263 from kiln_ai.datamodel.datamodel_enums import TurnMode 264 265 if task.turn_mode != TurnMode.multiturn: 266 raise ValueError( 267 "parent_task_run_id is only valid on multi-turn tasks. " 268 "This task's turn_mode is single_turn." 269 ) 270 return self
272 @model_validator(mode="after") 273 def validate_repaired_output(self) -> Self: 274 if self.repaired_output is not None: 275 if self.repaired_output.rating is not None: 276 raise ValueError( 277 "Repaired output rating must be None. Repaired outputs are assumed to have a perfect rating, as they have been fixed." 278 ) 279 280 task = self.parent_task() 281 if ( 282 task is not None 283 and self.repaired_output.output is not None 284 and task.output_json_schema is not None 285 ): 286 try: 287 output_parsed = json.loads(self.repaired_output.output) 288 except json.JSONDecodeError: 289 raise ValueError("Repaired output is not a valid JSON object") 290 291 validate_schema_with_value_error( 292 output_parsed, 293 task.output_json_schema, 294 "Repaired output does not match task output schema.", 295 ) 296 297 if self.repair_instructions is None and self.repaired_output is not None: 298 raise ValueError( 299 "Repair instructions are required if providing a repaired output." 300 ) 301 if self.repair_instructions is not None and self.repaired_output is None: 302 raise ValueError( 303 "A repaired output is required if providing repair instructions." 304 ) 305 306 return self
308 @model_validator(mode="after") 309 def validate_input_source(self, info: ValidationInfo) -> Self: 310 # On strict mode and not loaded from file, we validate input_source is not None. 311 # We want to be able to load any data, even if it's not perfect. But we want to create perfect data when adding new data. 312 if not strict_mode(): 313 return self 314 if self.loaded_from_file(info): 315 return self 316 if self.input_source is None: 317 raise ValueError("input_source is required when strict mode is enabled") 318 return self
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
151class Usage(MessageUsage): 152 """Token usage, cost, and aggregate LLM latency for a per-run accumulator. 153 154 Extends :class:`MessageUsage` with ``total_llm_latency_ms``, which is 155 only meaningful while a single run is in flight (its model calls run 156 sequentially in real time). For per-message records and full-trace 157 sums use :class:`MessageUsage` — those values would mix latencies 158 from different points in time, so the field doesn't apply. 159 """ 160 161 total_llm_latency_ms: int | None = Field( 162 default=None, 163 description="Total time spent waiting on LLM API calls in milliseconds. Sum of per-call latencies, excludes tool execution time.", 164 ge=0, 165 ) 166 167 def __add__(self, other: "MessageUsage | Usage") -> "Usage": 168 """Add Usage to either Usage or MessageUsage. 169 170 Token / cost fields sum the same way as :meth:`MessageUsage.__add__`. 171 172 ``Usage + Usage`` sums both ``total_llm_latency_ms`` values 173 (None-graceful). ``Usage + MessageUsage`` carries ``self``'s 174 ``total_llm_latency_ms`` through unchanged — the right-hand side 175 has no latency to contribute. 176 177 Always returns a :class:`Usage` so chained ``usage += msg_usage`` 178 keeps the latency on the accumulator. 179 """ 180 if not isinstance(other, MessageUsage): 181 raise TypeError(f"Cannot add Usage with {type(other).__name__}") 182 183 other_latency = other.total_llm_latency_ms if isinstance(other, Usage) else None 184 185 return Usage( 186 input_tokens=_add_optional_int(self.input_tokens, other.input_tokens), 187 output_tokens=_add_optional_int(self.output_tokens, other.output_tokens), 188 total_tokens=_add_optional_int(self.total_tokens, other.total_tokens), 189 cost=_add_optional_float(self.cost, other.cost), 190 cached_tokens=_add_optional_int(self.cached_tokens, other.cached_tokens), 191 total_llm_latency_ms=_add_optional_int( 192 self.total_llm_latency_ms, other_latency 193 ), 194 )
Token usage, cost, and aggregate LLM latency for a per-run accumulator.
Extends 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 MessageUsage — those values would mix latencies
from different points in time, so the field doesn't apply.