diff --git a/lumen/ai/report.py b/lumen/ai/report.py index 802affb46..374852900 100644 --- a/lumen/ai/report.py +++ b/lumen/ai/report.py @@ -27,6 +27,7 @@ SpeedDial, TextAreaInput, TextInput, Typography, ) +from ..util import resolve_module_reference from ..views.base import Panel, View from .actor import ( Actor, ContextProvider, NullStep, TContext, @@ -48,6 +49,28 @@ ) +def _task_type_spec(obj: Any) -> str: + return f"{type(obj).__module__}.{type(obj).__name__}" + + +def _task_type_from_spec(type_spec: str) -> type[Actor]: + return resolve_module_reference(type_spec, component_type=Task) + + +def _ensure_jsonable(value: Any) -> Any: + """ + Best-effort conversion of values to JSON-serializable structures. + Intended primarily for chat `history` payloads. + """ + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, dict): + return {str(k): _ensure_jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_ensure_jsonable(v) for v in value] + return str(value) + + class Task(Viewer): """ A `Task` defines a single unit of work that can be executed and rendered. @@ -241,6 +264,88 @@ def reset(self): self._view[:] = [] self.out_context.clear() + def to_spec( + self, + *, + include_views: bool = False, + include_context: bool = False, + include_artifacts: bool = False, + ) -> dict[str, Any]: + """ + Serialize a Task to a declarative spec. + + By default, this only captures the task definition (and `history`) and + excludes rendered `views`, execution state, and generated artifacts. + """ + params: dict[str, Any] = {} + for k in ("title", "instruction", "history", "abort_on_error"): + v = getattr(self, k) + pobj = self.param[k] + try: + equal = is_equal(v, pobj.default) + except Exception: + try: + equal = v == pobj.default + except Exception: + equal = False + if equal: + continue + if k == "history": + v = _ensure_jsonable(v) + params[k] = v + + if include_context and "context" in self.param: + params["context"] = _ensure_jsonable(self.context) + + if include_artifacts: + # Explicit opt-in only + params["status"] = self.status + params["out_context"] = _ensure_jsonable(self.out_context) + + if include_views: + params["views"] = None + + return {"type": _task_type_spec(self), **params} + + @classmethod + def from_spec( + cls, + spec: dict[str, Any], + *, + actor_lookup: dict[str, Actor | Tool] | None = None, + **params + ) -> Task: + """ + Materialize a Task from a spec. + + Parameters + ---------- + actor_lookup: + Mapping used to resolve `ActorTask.actor` keys. + """ + spec = dict(spec) + type_spec = spec.get("type") + tasks = spec.get("tasks") + if type_spec: + task_type = _task_type_from_spec(type_spec) + else: + task_type = cls + + if tasks is not None: + # Delegate to TaskGroup-like materialization + if hasattr(task_type, "_tasks"): + return task_type.from_spec(spec, actor_lookup=actor_lookup, **params) # type: ignore[misc] + + if hasattr(task_type, "from_spec") and task_type is not cls: + return task_type.from_spec(spec, actor_lookup=actor_lookup, **params) # type: ignore[misc] + + base_params = {k: v for k, v in spec.items() if k not in ("type", "tasks", "params")} + if "params" in spec and isinstance(spec["params"], dict): + # Backward compatibility with nested param format. + base_params = dict(spec["params"], **base_params) + materialized_params = dict(base_params, **params) + return task_type(**materialized_params) # type: ignore[call-arg] + class TaskGroup(Task): @@ -606,6 +711,55 @@ def reset(self, start: int = 0): task.reset() self._populate_view() + def to_spec( + self, + *, + include_views: bool = False, + include_context: bool = False, + include_artifacts: bool = False, + ) -> dict[str, Any]: + spec = super().to_spec( + include_views=include_views, + include_context=include_context, + include_artifacts=include_artifacts, + ) + spec["tasks"] = [ + t.to_spec( + include_views=include_views, + include_context=include_context, + include_artifacts=include_artifacts, + ) + for t in self._tasks + ] + return spec + + @classmethod + def from_spec( + cls, + spec: dict[str, Any], + *, + actor_lookup: dict[str, Actor | Tool] | None = None, + **params + ) -> TaskGroup: + spec = dict(spec) + type_spec = spec.get("type") + base_params = {k: v for k, v in spec.items() if k not in ("type", "tasks", "params")} + if "params" in spec and isinstance(spec["params"], dict): + # Backward compatibility with nested param format. + base_params = dict(spec["params"], **base_params) + params = dict(base_params, **params) + tasks_spec = list(spec.get("tasks") or []) + + group_type = _task_type_from_spec(type_spec) if type_spec else cls + tasks: list[Task] = [] + for ts in tasks_spec: + tasks.append(Task.from_spec(ts, actor_lookup=actor_lookup, context=params.get("context", {}))) + + obj = group_type(*tasks, **params) # type: ignore[call-arg] + for t in obj: + t.parent = obj + return obj + def to_notebook(self): """ Returns the notebook representation of the tasks. @@ -814,6 +968,22 @@ def __init__(self, *tasks, **params): if self.auto_execute: pn.state.execute(self.execute) + def to_spec( + self, + *, + include_views: bool = False, + include_context: bool = False, + include_artifacts: bool = False, + ) -> dict[str, Any]: + spec = super().to_spec( + include_views=include_views, + include_context=include_context, + include_artifacts=include_artifacts, + ) + if self.auto_execute != self.param.auto_execute.default: + spec["auto_execute"] = self.auto_execute + return spec + def _init_view(self): self._header_title = Typography( self.param.title, variant="h1", margin=(0, 0, 0, 10) @@ -1116,6 +1286,49 @@ def __init__(self, actor: Actor, **params): if views and out_context: self._add_outputs(views, out_context) + def to_spec( + self, + *, + include_views: bool = False, + include_context: bool = False, + include_artifacts: bool = False, + actor_key: str | None = None, + ) -> dict[str, Any]: + spec = super().to_spec( + include_views=include_views, + include_context=include_context, + include_artifacts=include_artifacts, + ) + # Store actor by lookup key; reconstruction requires an explicit mapping. + spec["actor"] = actor_key or type(self.actor).__name__ + return spec + + @classmethod + def from_spec( + cls, + spec: dict[str, Any], + *, + actor_lookup: dict[str, Actor | Tool] | None = None, + **params + ) -> ActorTask: + spec = dict(spec) + base_params = {k: v for k, v in spec.items() if k not in ("type", "tasks", "params")} + if "params" in spec and isinstance(spec["params"], dict): + # Backward compatibility with nested param format. + base_params = dict(spec["params"], **base_params) + params = dict(base_params, **params) + actor_key = params.pop("actor", None) + if actor_key is None: + raise ValueError("ActorTask spec did not declare required 'actor' key.") + if actor_lookup is None or actor_key not in actor_lookup: + known = [] if actor_lookup is None else sorted(actor_lookup) + raise ValueError( + f"ActorTask spec references unknown actor {actor_key!r}. " + f"Provide actor_lookup with this key. Known keys: {known!r}" + ) + actor = actor_lookup[actor_key] + return cls(actor, **params) # type: ignore[arg-type] + @property def input_schema(self): return self.actor.input_schema diff --git a/lumen/tests/ai/test_report.py b/lumen/tests/ai/test_report.py index dddefe884..4567e3116 100644 --- a/lumen/tests/ai/test_report.py +++ b/lumen/tests/ai/test_report.py @@ -16,7 +16,7 @@ from lumen.ai.actor import ContextModel from lumen.ai.report import ( - Action, Report, Section, TaskGroup, Typography, + Action, Actor, ActorTask, Report, Section, Task, TaskGroup, Typography, ) @@ -369,3 +369,54 @@ async def test_report_to_notebook(): assert cell3['cell_type'] == 'markdown' assert cell3['source'] == ["**Hello**"] + + +def test_taskgroup_spec_roundtrip_preserves_structure(): + tg = TaskGroup(A(title="A1"), B(title="B1"), title="Seq", instruction="Do it") + tg.history = [{"role": "user", "content": "hi"}] + spec = tg.to_spec() + + assert "tasks" in spec + assert "views" not in spec + assert "out_context" not in spec + + new = Task.from_spec(spec) + assert isinstance(new, TaskGroup) + assert new.title == "Seq" + assert new.instruction == "Do it" + assert new.history == [{"role": "user", "content": "hi"}] + assert len(new) == 2 + assert type(new[0]) is A + assert type(new[1]) is B + + +def test_report_spec_roundtrip_preserves_sections_and_title(): + report = Report(Section(HelloAction(title="Hello"), title="S1"), title="MyReport") + report.history = [{"role": "user", "content": "build report"}] + spec = report.to_spec() + + new = Task.from_spec(spec) + assert isinstance(new, Report) + assert new.title == "MyReport" + assert new.history == [{"role": "user", "content": "build report"}] + assert len(new) == 1 + assert isinstance(new[0], Section) + assert new[0].title == "S1" + + +def test_actortask_spec_roundtrip_uses_actor_lookup(): + class DummyActor(Actor): + async def respond(self, messages, context, **kwargs): + return [], {} + + actor = DummyActor() + task = ActorTask(actor, title="T1", instruction="Run") + task.history = [{"role": "user", "content": "do"}] + + spec = task.to_spec() + new = Task.from_spec(spec, actor_lookup={"DummyActor": actor}) + assert isinstance(new, ActorTask) + assert new.actor is actor + assert new.title == "T1" + assert new.instruction == "Run" + assert new.history == [{"role": "user", "content": "do"}]