Skip to content

Commit 92076e0

Browse files
authored
Merge pull request #1176 from Kiln-AI/leonard/kil-490-refactor-unnest-taskruns
2 parents 7415b2a + 12d345d commit 92076e0

File tree

9 files changed

+261
-802
lines changed

9 files changed

+261
-802
lines changed

app/web_ui/src/lib/api_schema.d.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7048,11 +7048,6 @@ export interface components {
70487048
*
70497049
* Contains the input used, its source, the output produced, and optional
70507050
* repair information if the output needed correction.
7051-
*
7052-
* Can be nested under another TaskRun; nested runs are stored as child runs
7053-
* in a "runs" subfolder (same relationship name as Task's runs).
7054-
*
7055-
* Accepts both Task and TaskRun as parents (polymorphic).
70567051
*/
70577052
"TaskRun-Input": {
70587053
/**
@@ -7108,18 +7103,18 @@ export interface components {
71087103
* @description The trace of the task run in OpenAI format. This is the list of messages that were sent to/from the model.
71097104
*/
71107105
trace?: (components["schemas"]["ChatCompletionDeveloperMessageParam"] | components["schemas"]["ChatCompletionSystemMessageParam"] | components["schemas"]["ChatCompletionUserMessageParam-Input"] | components["schemas"]["ChatCompletionAssistantMessageParamWrapper-Input"] | components["schemas"]["ChatCompletionToolMessageParamWrapper"] | components["schemas"]["ChatCompletionFunctionMessageParam"])[] | null;
7106+
/**
7107+
* Parent Task Run Id
7108+
* @description The ID of the parent task run. This is the ID of the task run that contains this task run.
7109+
*/
7110+
parent_task_run_id?: string | null;
71117111
};
71127112
/**
71137113
* TaskRun
71147114
* @description Represents a single execution of a Task.
71157115
*
71167116
* Contains the input used, its source, the output produced, and optional
71177117
* repair information if the output needed correction.
7118-
*
7119-
* Can be nested under another TaskRun; nested runs are stored as child runs
7120-
* in a "runs" subfolder (same relationship name as Task's runs).
7121-
*
7122-
* Accepts both Task and TaskRun as parents (polymorphic).
71237118
*/
71247119
"TaskRun-Output": {
71257120
/**
@@ -7174,6 +7169,11 @@ export interface components {
71747169
* @description The trace of the task run in OpenAI format. This is the list of messages that were sent to/from the model.
71757170
*/
71767171
trace?: (components["schemas"]["ChatCompletionDeveloperMessageParam"] | components["schemas"]["ChatCompletionSystemMessageParam"] | components["schemas"]["ChatCompletionUserMessageParam-Output"] | components["schemas"]["ChatCompletionAssistantMessageParamWrapper-Output"] | components["schemas"]["ChatCompletionToolMessageParamWrapper"] | components["schemas"]["ChatCompletionFunctionMessageParam"])[] | null;
7172+
/**
7173+
* Parent Task Run Id
7174+
* @description The ID of the parent task run. This is the ID of the task run that contains this task run.
7175+
*/
7176+
parent_task_run_id?: string | null;
71777177
/** Model Type */
71787178
readonly model_type: string;
71797179
};

libs/core/kiln_ai/adapters/model_adapters/base_adapter.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -605,8 +605,18 @@ def generate_run(
605605
properties={"created_by": Config.shared().user_id},
606606
)
607607

608+
parent_task_run_id: str | None = None
609+
if parent_task_run is not None:
610+
if parent_task_run.id is None:
611+
raise ValueError(
612+
"parent_task_run must be persisted before using as parent: save the parent "
613+
"TaskRun (e.g. save_to_file()) so it has a stable id."
614+
)
615+
parent_task_run_id = parent_task_run.id
616+
608617
return TaskRun(
609-
parent=parent_task_run if parent_task_run is not None else self.task,
618+
parent=self.task,
619+
parent_task_run_id=parent_task_run_id,
610620
input=input_str,
611621
input_source=input_source,
612622
output=new_output,

libs/core/kiln_ai/adapters/model_adapters/test_saving_adapter_results.py

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pytest
44

55
from kiln_ai.adapters.model_adapters.base_adapter import BaseAdapter, RunOutput
6-
from kiln_ai.datamodel import DataSource, DataSourceType, Project, Task, TaskRun, Usage
6+
from kiln_ai.datamodel import DataSource, DataSourceType, Project, Task, Usage
77
from kiln_ai.datamodel.datamodel_enums import InputType
88
from kiln_ai.datamodel.run_config import KilnAgentRunConfigProperties
99
from kiln_ai.utils.config import Config
@@ -427,15 +427,15 @@ def test_properties_for_task_output_custom_values(test_task):
427427
assert output.source.properties["top_p"] == 0.9
428428

429429

430-
def test_generate_run_with_parent_task_run_sets_parent(test_task, adapter):
431-
"""Test that generate_run with parent_task_run uses it as parent instead of the task."""
430+
def test_generate_run_with_parent_task_run_sets_parent_task_run_id(test_task, adapter):
432431
prior_run = adapter.generate_run(
433432
input="prior input",
434433
input_source=None,
435434
run_output=RunOutput(output="prior output", intermediate_outputs=None),
436435
)
437436
prior_run.save_to_file()
438437
assert prior_run.id is not None
438+
assert prior_run.parent_task_run_id is None
439439

440440
new_run = adapter.generate_run(
441441
input="new input",
@@ -444,41 +444,55 @@ def test_generate_run_with_parent_task_run_sets_parent(test_task, adapter):
444444
parent_task_run=prior_run,
445445
)
446446

447-
assert new_run.parent == prior_run
448-
447+
assert new_run.parent_task_run_id == prior_run.id
449448
new_run.save_to_file()
450449

451-
reloaded_prior_run = TaskRun.load_from_file(prior_run.path)
452-
child_runs = reloaded_prior_run.runs()
453-
assert len(child_runs) == 1
454-
assert child_runs[0].output.output == "new output"
455-
456-
# The task should only have the prior run as a direct child
457450
reloaded_task = Task.load_from_file(test_task.path)
458451
task_runs = reloaded_task.runs()
459-
assert len(task_runs) == 1
460-
assert task_runs[0].id == prior_run.id
452+
assert len(task_runs) == 2
453+
by_id = {r.id: r for r in task_runs}
454+
assert by_id[prior_run.id].parent_task_run_id is None
455+
assert by_id[new_run.id].parent_task_run_id == prior_run.id
456+
assert by_id[new_run.id].output.output == "new output"
461457

462458

463459
def test_generate_run_without_parent_task_run_defaults_to_task(test_task, adapter):
464-
"""Test that generate_run without parent_task_run defaults to using the task as parent."""
460+
"""Test that generate_run without parent_task_run leaves parent_task_run_id unset."""
465461
run = adapter.generate_run(
466462
input="input",
467463
input_source=None,
468464
run_output=RunOutput(output="output", intermediate_outputs=None),
469465
)
466+
assert run.parent_task_run_id is None
470467
assert run.parent == test_task
471468

472469

470+
def test_generate_run_with_unsaved_parent_task_run_raises(adapter):
471+
prior_run = adapter.generate_run(
472+
input="prior input",
473+
input_source=None,
474+
run_output=RunOutput(output="prior output", intermediate_outputs=None),
475+
)
476+
prior_run.id = None
477+
478+
with pytest.raises(ValueError, match="parent_task_run must be persisted"):
479+
adapter.generate_run(
480+
input="new input",
481+
input_source=None,
482+
run_output=RunOutput(output="new output", intermediate_outputs=None),
483+
parent_task_run=prior_run,
484+
)
485+
486+
473487
@pytest.mark.asyncio
474-
async def test_invoke_with_parent_task_run_saves_as_child(test_task, adapter):
475-
"""Test that invoke with parent_task_run saves the new run as a child of that run."""
488+
async def test_invoke_with_parent_task_run_saves_under_task_with_link(
489+
test_task, adapter
490+
):
476491
trace = [
477492
{"role": "user", "content": "Hello"},
478493
{"role": "assistant", "content": "Hi there!"},
479494
]
480495

481-
# Create and save a prior run to act as parent
482496
prior_run = adapter.generate_run(
483497
input="Hello",
484498
input_source=None,
@@ -531,16 +545,11 @@ async def test_invoke_with_parent_task_run_saves_as_child(test_task, adapter):
531545
)
532546

533547
assert new_run.id is not None
534-
assert new_run.parent == prior_run
535-
536-
# The prior run should have the new run as a child
537-
reloaded_prior_run = TaskRun.load_from_file(prior_run.path)
538-
child_runs = reloaded_prior_run.runs()
539-
assert len(child_runs) == 1
540-
assert child_runs[0].output.output == "More details!"
548+
assert new_run.parent_task_run_id == prior_run.id
541549

542-
# The task should only have the prior run as a direct child
543550
reloaded_task = Task.load_from_file(test_task.path)
544551
task_runs = reloaded_task.runs()
545-
assert len(task_runs) == 1
546-
assert task_runs[0].id == prior_run.id
552+
assert len(task_runs) == 2
553+
by_id = {r.id: r for r in task_runs}
554+
assert by_id[new_run.id].output.output == "More details!"
555+
assert by_id[new_run.id].parent_task_run_id == prior_run.id

libs/core/kiln_ai/datamodel/basemodel.py

Lines changed: 5 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -545,36 +545,10 @@ def relationship_name(cls) -> str:
545545
def parent_type(cls) -> Type[KilnBaseModel]:
546546
raise NotImplementedError("Parent type must be implemented")
547547

548-
@classmethod
549-
def _parent_types(cls) -> List[Type["KilnBaseModel"]] | None:
550-
"""Return accepted parent types. This must be implemented by the subclass if
551-
the model can have multiple parent types.
552-
553-
Return None (default) to use the single parent_type() check.
554-
Override and return a list of parent types for models that can be nested
555-
under more than one parent type (e.g. TaskRun can be nested under Task or TaskRun).
556-
"""
557-
return None
558-
559-
def _check_parent_type(
560-
self,
561-
expected_parent_types: List[Type[KilnBaseModel]] | None = None,
562-
) -> Self:
548+
@model_validator(mode="after")
549+
def check_parent_type(self) -> Self:
563550
cached_parent = self.cached_parent()
564-
if cached_parent is None:
565-
return self
566-
567-
# some models support having multiple parent types, so we allow overriding the expected parent
568-
if expected_parent_types is not None:
569-
if not any(
570-
isinstance(cached_parent, expected_parent_type)
571-
for expected_parent_type in expected_parent_types
572-
):
573-
raise ValueError(
574-
f"Parent must be one of {expected_parent_types}, but was {type(cached_parent)}"
575-
)
576-
else:
577-
# default case where we expect a single parent type to be valid
551+
if cached_parent is not None:
578552
expected_parent_type = self.__class__.parent_type()
579553
if not isinstance(cached_parent, expected_parent_type):
580554
raise ValueError(
@@ -583,11 +557,6 @@ def _check_parent_type(
583557

584558
return self
585559

586-
@model_validator(mode="after")
587-
def check_parent_type(self) -> Self:
588-
"""Default validation for parent type. Can be overridden by subclasses - for example if the parent is polymorphic."""
589-
return self._check_parent_type()
590-
591560
def build_child_dirname(self) -> Path:
592561
# Default implementation for readable folder names.
593562
# {id} - {name}/{type}.kiln
@@ -642,39 +611,10 @@ def iterate_children_paths_of_parent_path(cls: Type[PT], parent_path: Path | Non
642611
else:
643612
parent_folder = parent_path
644613

645-
if not parent_path.exists():
614+
parent = cls.parent_type().load_from_file(parent_path)
615+
if parent is None:
646616
raise ValueError("Parent must be set to load children")
647617

648-
# Validate the parent file's declared type so we fail fast when the caller
649-
# passes a wrong path. For polymorphic children (e.g. TaskRun) the
650-
# subclass overrides _accepted_parent_types() to broaden the check to all
651-
# accepted parent types
652-
parent_types_override = cls._parent_types()
653-
if parent_types_override is None:
654-
# Default: single expected parent type — original behaviour
655-
parent = cls.parent_type().load_from_file(parent_path)
656-
if parent is None:
657-
raise ValueError("Parent must be set to load children")
658-
else:
659-
# Polymorphic parent: read only the model_type field to avoid a full load.
660-
with open(parent_path, "r", encoding="utf-8") as fh:
661-
actual_parent_type_name = json.loads(fh.read()).get("model_type", "")
662-
parent_type_names = {t.type_name() for t in parent_types_override}
663-
if actual_parent_type_name not in parent_type_names:
664-
raise ValueError(
665-
f"Parent model_type '{actual_parent_type_name}' is not one of "
666-
f"{parent_type_names}"
667-
)
668-
669-
parent_type = next(
670-
t
671-
for t in parent_types_override
672-
if t.type_name() == actual_parent_type_name
673-
)
674-
parent = parent_type.load_from_file(parent_path)
675-
if parent is None:
676-
raise ValueError("Parent must be set to load children")
677-
678618
# Ignore type error: this is abstract base class, but children must implement relationship_name
679619
relationship_folder = parent_folder / Path(cls.relationship_name()) # type: ignore
680620

libs/core/kiln_ai/datamodel/task.py

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -203,21 +203,3 @@ def parent_project(self) -> Union["Project", None]:
203203
if self.parent is None or self.parent.__class__.__name__ != "Project":
204204
return None
205205
return self.parent # type: ignore
206-
207-
def find_task_run_by_id_dfs(
208-
self, task_run_id: str, readonly: bool = False
209-
) -> TaskRun | None:
210-
"""
211-
Find a task run by id in the entire task run tree. This is an expensive DFS
212-
traversal of the file system so do not use too willy nilly.
213-
214-
If you already know the root task run, you can use the same method on
215-
the root TaskRun instead - that will save a bunch of subtree traversals.
216-
"""
217-
stack: List[TaskRun] = list(self.runs(readonly=readonly))
218-
while stack:
219-
run = stack.pop()
220-
if run.id == task_run_id:
221-
return run
222-
stack.extend(run.runs(readonly=readonly))
223-
return None

0 commit comments

Comments
 (0)